From 417c076465913d7d7ce75c028d4bad4e341af5a3 Mon Sep 17 00:00:00 2001 From: jzf Date: Wed, 11 Nov 2026 18:25:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=AC=E5=AF=93=E7=AB=AF=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E6=8E=A8=E8=BF=9B=E5=88=B0=E7=AC=AC=E4=BA=94=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=EF=BC=8Cami=E4=BA=8B=E4=BB=B6=E5=B7=B2=E7=BB=8F=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AptJcxxController.java | 126 +++ .../dispatch/domain/AptCorrectCommand.java | 148 ++++ .../apt/dispatch/domain/AptDutyCommand.java | 58 ++ .../dispatch/domain/AptDutyCommandResult.java | 76 ++ .../apt/dispatch/domain/AptJcxxQuery.java | 145 ++++ .../apt/dispatch/domain/AptStateSnapshot.java | 104 +++ .../dispatch/mapper/AptDutyStateMapper.java | 57 ++ .../apt/dispatch/mapper/AptJcxxMapper.java | 82 ++ .../service/AptDispatchScanServiceImpl.java | 100 +++ .../service/AptDutyCommandServiceImpl.java | 330 ++++++++ .../service/AptDutyStateServiceImpl.java | 126 +++ .../dispatch/service/AptJcxxServiceImpl.java | 307 +++++++ .../service/IAptDispatchScanService.java | 16 + .../service/IAptDutyCommandService.java | 18 + .../service/IAptDutyStateService.java | 40 + .../apt/dispatch/service/IAptJcxxService.java | 27 + .../controller/AptTabletOpenController.java | 82 ++ .../tablet/domain/AptTabletResultCommand.java | 103 +++ .../dispatch/tablet/domain/AptTabletTask.java | 169 ++++ .../tablet/mapper/AptTabletMapper.java | 74 ++ .../service/AptTabletDispatchServiceImpl.java | 206 +++++ .../service/AptTabletResultServiceImpl.java | 333 ++++++++ .../service/IAptTabletDispatchService.java | 23 + .../service/IAptTabletResultService.java | 15 + .../controller/AptDutyFaceRegController.java | 84 ++ .../controller/AptFaceOpenController.java | 82 ++ .../ruoyi/apt/face/domain/AptFacePerson.java | 142 ++++ .../com/ruoyi/apt/face/domain/AptFaceReg.java | 290 +++++++ .../ruoyi/apt/face/domain/AptFaceRegAck.java | 141 ++++ .../apt/face/domain/AptFaceRegCommand.java | 205 +++++ .../face/domain/AptFaceRegConfirmCommand.java | 80 ++ .../face/domain/AptFaceRegRejectCommand.java | 41 + .../apt/face/domain/AptFaceVerifyCommand.java | 119 +++ .../apt/face/domain/AptFaceVerifyResult.java | 174 ++++ .../apt/face/mapper/AptFaceRegMapper.java | 75 ++ .../apt/face/mapper/AptFaceVerifyMapper.java | 33 + .../face/service/AptFaceRegServiceImpl.java | 507 +++++++++++ .../face/service/AptFaceSyncDispatcher.java | 98 +++ .../face/service/AptFaceVerdictService.java | 98 +++ .../service/AptFaceVerifyServiceImpl.java | 171 ++++ .../apt/face/service/IAptFaceRegService.java | 33 + .../face/service/IAptFaceVerifyService.java | 18 + .../apt/framework/device/AptDeviceSigner.java | 229 +++++ .../controller/AptCheckinOpenController.java | 72 ++ .../controller/AptCheckinQueryController.java | 39 + .../controller/AptDutyCheckinController.java | 93 ++ .../checkin/domain/AptCallTimeResult.java | 125 +++ .../checkin/domain/AptCheckinApplication.java | 702 ++++++++++++++++ .../domain/AptCheckinApplyCommand.java | 181 ++++ .../checkin/domain/AptCheckinApplyResult.java | 121 +++ .../checkin/domain/AptCheckinCallTask.java | 269 ++++++ .../checkin/domain/AptCheckinMatchResult.java | 145 ++++ .../domain/AptCheckinMatchVerdict.java | 138 +++ .../checkin/domain/AptCheckinOptions.java | 313 +++++++ .../domain/AptCheckinReviewCommand.java | 98 +++ .../domain/AptCheckinReviewResult.java | 172 ++++ .../domain/AptCheckinRoomAdjustCommand.java | 69 ++ .../domain/AptCheckinRoomAdjustResult.java | 145 ++++ .../checkin/domain/AptCheckinTimeline.java | 172 ++++ .../apt/stay/checkin/domain/AptOutboxRow.java | 107 +++ .../stay/checkin/mapper/AptCheckinMapper.java | 132 +++ .../service/AptCallTimeCalculator.java | 125 +++ .../service/AptCheckinMatchServiceImpl.java | 213 +++++ .../service/AptCheckinServiceImpl.java | 795 ++++++++++++++++++ .../service/IAptCheckinMatchService.java | 18 + .../checkin/service/IAptCheckinService.java | 53 ++ .../apt/voice/domain/AptSipEventCommand.java | 70 ++ .../service/AptVoiceEventServiceImpl.java | 155 ++++ .../voice/service/IAptVoiceEventService.java | 21 + .../ruoyi/quartz/task/AptDispatchTask.java | 35 + .../resources/mapper/apt/AptCheckinMapper.xml | 418 +++++++++ .../mapper/apt/AptDutyStateMapper.xml | 121 +++ .../resources/mapper/apt/AptFaceRegMapper.xml | 193 +++++ .../mapper/apt/AptFaceVerifyMapper.xml | 27 + .../resources/mapper/apt/AptJcxxMapper.xml | 300 +++++++ .../resources/mapper/apt/AptTabletMapper.xml | 150 ++++ .../dispatch/AptDutyCommandServiceTest.java | 171 ++++ .../apt/dispatch/AptDutyStateServiceTest.java | 105 +++ .../apt/dispatch/AptJcxxServiceTest.java | 206 +++++ .../dispatch/AptTabletResultServiceTest.java | 238 ++++++ .../ruoyi/apt/face/AptDeviceSignerTest.java | 142 ++++ .../ruoyi/apt/face/AptFaceRegServiceTest.java | 314 +++++++ .../apt/face/AptFaceVerdictServiceTest.java | 99 +++ .../apt/face/AptFaceVerifyServiceTest.java | 164 ++++ .../AptDutyStateSingleWriterArchTest.java | 133 +++ .../apt/framework/AptIt3SqlContractTest.java | 133 +++ .../apt/framework/AptIt4SqlContractTest.java | 115 +++ .../apt/framework/AptIt5SqlContractTest.java | 110 +++ .../checkin/AptCallTimeCalculatorTest.java | 105 +++ .../checkin/AptCheckinMatchServiceTest.java | 226 +++++ .../stay/checkin/AptCheckinServiceTest.java | 485 +++++++++++ dev/scripts/apply-it3-sql.ps1 | 110 +++ dev/scripts/apply-it4-sql.ps1 | 110 +++ dev/scripts/apply-it5-sql.ps1 | 110 +++ dev/scripts/verify-it2-m1m2.ps1 | 373 ++++++++ 95 files changed, 14621 insertions(+) create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/controller/AptJcxxController.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptCorrectCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommandResult.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptJcxxQuery.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptStateSnapshot.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyCommandServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptJcxxServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDispatchScanService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyCommandService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyStateService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptJcxxService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/controller/AptTabletOpenController.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/domain/AptTabletResultCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/domain/AptTabletTask.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/mapper/AptTabletMapper.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletDispatchServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletResultServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptTabletDispatchService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptTabletResultService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/controller/AptDutyFaceRegController.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/controller/AptFaceOpenController.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFacePerson.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceReg.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegAck.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegConfirmCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegRejectCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceVerifyCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceVerifyResult.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/mapper/AptFaceRegMapper.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/mapper/AptFaceVerifyMapper.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceRegServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceSyncDispatcher.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceVerdictService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceVerifyServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/IAptFaceRegService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/IAptFaceVerifyService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/device/AptDeviceSigner.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptCheckinOpenController.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptCheckinQueryController.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptDutyCheckinController.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCallTimeResult.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplication.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplyCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplyResult.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinCallTask.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinMatchResult.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinMatchVerdict.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinOptions.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinReviewCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinReviewResult.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinRoomAdjustCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinRoomAdjustResult.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinTimeline.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptOutboxRow.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/mapper/AptCheckinMapper.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCallTimeCalculator.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinMatchServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinMatchService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptSipEventCommand.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptVoiceEventServiceImpl.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptVoiceEventService.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptDispatchTask.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptCheckinMapper.xml create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceRegMapper.xml create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceVerifyMapper.xml create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptTabletMapper.xml create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptJcxxServiceTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletResultServiceTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptDeviceSignerTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceRegServiceTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerdictServiceTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerifyServiceTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptDutyStateSingleWriterArchTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt3SqlContractTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt4SqlContractTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt5SqlContractTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCallTimeCalculatorTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinMatchServiceTest.java create mode 100644 dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinServiceTest.java create mode 100644 dev/scripts/apply-it3-sql.ps1 create mode 100644 dev/scripts/apply-it4-sql.ps1 create mode 100644 dev/scripts/apply-it5-sql.ps1 create mode 100644 dev/scripts/verify-it2-m1m2.ps1 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 new file mode 100644 index 0000000..adb2088 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/controller/AptJcxxController.java @@ -0,0 +1,126 @@ +package com.ruoyi.apt.dispatch.controller; + +import java.util.Date; +import java.util.List; +import com.ruoyi.apt.common.audit.AptAudit; +import com.ruoyi.apt.common.page.AptTableDataInfo; +import com.ruoyi.apt.dispatch.domain.AptCorrectCommand; +import com.ruoyi.apt.dispatch.domain.AptDutyCommand; +import com.ruoyi.apt.dispatch.domain.AptDutyCommandResult; +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.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; + +/** + * 叫乘信息管理(IT-5 / M3,公寓端/04 表 4-9 接口 1~6)。 + * + *

刻意不提供 {@code PUT /apt/jcxx/{id}}:状态字段只能经 + * {@code POST /apt/jcxx/{id}/commands} 提交命令,由 {@code apt.duty.state} 落库; + * 非状态字段走 {@code /correct} 白名单。

+ * + * @author apt-rev01 + */ +@RestController +@RequestMapping(value = "/apt/jcxx", produces = "application/json;charset=UTF-8") +public class AptJcxxController extends AptBaseController +{ + private final IAptJcxxService jcxxService; + private final IAptDutyCommandService commandService; + + public AptJcxxController(IAptJcxxService jcxxService, IAptDutyCommandService commandService) + { + this.jcxxService = jcxxService; + this.commandService = commandService; + } + + /** 表 4-9 #1:在寓/叫乘记录列表 */ + @PreAuthorize("@ss.hasPermi('apt:jcxx:list')") + @AptAudit(module = "DISPATCH", action = "JCXX_LIST") + @GetMapping("/list") + public AptTableDataInfo list( + @RequestParam(required = false) String ryid, + @RequestParam(required = false) String rymc, + @RequestParam(required = false) String fjh, + @RequestParam(required = false) String cc, + @RequestParam(required = false) String jlid, + @RequestParam(required = false) List ryzt, + @RequestParam(required = false) String finishJc, + @RequestParam(required = false) String finishLy, + @RequestParam(required = false) String stoped, + @RequestParam(required = false) String isTempCall, + @RequestParam(required = false) + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date jcsjFrom, + @RequestParam(required = false) + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date jcsjTo) + { + AptJcxxQuery query = new AptJcxxQuery(); + query.setRyid(ryid); + query.setRymc(rymc); + query.setFjh(fjh); + query.setCc(cc); + query.setJlid(jlid); + query.setRyzt(ryzt); + query.setFinishJc(finishJc); + query.setFinishLy(finishLy); + query.setStoped(stoped); + query.setIsTempCall(isTempCall); + query.setJcsjFrom(jcsjFrom); + query.setJcsjTo(jcsjTo); + + startAptPage(); + return getAptDataTable(jcxxService.list(query)); + } + + /** 表 4-9 #6:状态分布(看板) */ + @PreAuthorize("@ss.hasPermi('apt:jcxx:list')") + @GetMapping("/statistics") + public AjaxResult statistics(@RequestParam(required = false) String date) + { + return AjaxResult.success(jcxxService.statistics(date)); + } + + /** 表 4-9 #2:详情 */ + @PreAuthorize("@ss.hasPermi('apt:jcxx:query')") + @AptAudit(module = "DISPATCH", action = "JCXX_DETAIL") + @GetMapping("/{id}") + public AjaxResult detail(@PathVariable Long id) + { + return AjaxResult.success(jcxxService.detail(id)); + } + + /** 表 4-9 #3:事件时间线(排障) */ + @PreAuthorize("@ss.hasPermi('apt:jcxx:query')") + @GetMapping("/{id}/timeline") + public AjaxResult timeline(@PathVariable Long id) + { + return AjaxResult.success(jcxxService.timeline(id)); + } + + /** 表 4-9 #4:非状态字段修正(白名单) */ + @PreAuthorize("@ss.hasPermi('apt:jcxx:correct')") + @PostMapping("/{id}/correct") + public AjaxResult correct(@PathVariable Long id, @RequestBody AptCorrectCommand command) + { + return AjaxResult.success(jcxxService.correct(id, command)); + } + + /** 表 4-9 #5:状态机命令(全部转 duty_manual_action 再由 apt.duty.state 执行) */ + @PreAuthorize("@ss.hasPermi('apt:jcxx:changeStatus')") + @PostMapping("/{id}/commands") + public AjaxResult commands(@PathVariable Long id, @RequestBody AptDutyCommand command) + { + AptDutyCommandResult result = commandService.execute(id, command); + return AjaxResult.success(result); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptCorrectCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptCorrectCommand.java new file mode 100644 index 0000000..80b734c --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptCorrectCommand.java @@ -0,0 +1,148 @@ +package com.ruoyi.apt.dispatch.domain; + +/** + * 非状态字段人工修正(公寓端/04 表 4-9 第 4 个接口)。 + * + *

白名单只有 fjh / jlid / cc / jch / leaveRemark / isTempCall(仅置 1)。 + * 为让「改 ryzt」明确返回 40001 而不是被静默忽略,状态字段单独声明为 + * {@code rejected*}:只要请求体带了任一状态字段,直接拒绝。

+ * + * @author apt-rev01 + */ +public class AptCorrectCommand +{ + private String commandId; + private String reason; + private String fjh; + private String jlid; + private String cc; + private String jch; + private String leaveRemark; + private Boolean isTempCall; + + /** 以下字段不参与写入,仅用于识别越权改状态(40001) */ + private String ryzt; + private String finishJc; + private String finishLy; + private String stoped; + + public String getCommandId() + { + return commandId; + } + + public void setCommandId(String commandId) + { + this.commandId = commandId; + } + + public String getReason() + { + return reason; + } + + public void setReason(String reason) + { + this.reason = reason; + } + + public String getFjh() + { + return fjh; + } + + public void setFjh(String fjh) + { + this.fjh = fjh; + } + + public String getJlid() + { + return jlid; + } + + public void setJlid(String jlid) + { + this.jlid = jlid; + } + + public String getCc() + { + return cc; + } + + public void setCc(String cc) + { + this.cc = cc; + } + + public String getJch() + { + return jch; + } + + public void setJch(String jch) + { + this.jch = jch; + } + + public String getLeaveRemark() + { + return leaveRemark; + } + + public void setLeaveRemark(String leaveRemark) + { + this.leaveRemark = leaveRemark; + } + + public Boolean getIsTempCall() + { + return isTempCall; + } + + public void setIsTempCall(Boolean isTempCall) + { + this.isTempCall = isTempCall; + } + + public String getRyzt() + { + return ryzt; + } + + public void setRyzt(String ryzt) + { + this.ryzt = ryzt; + } + + public String getFinishJc() + { + return finishJc; + } + + public void setFinishJc(String finishJc) + { + this.finishJc = finishJc; + } + + public String getFinishLy() + { + return finishLy; + } + + public void setFinishLy(String finishLy) + { + this.finishLy = finishLy; + } + + public String getStoped() + { + return stoped; + } + + public void setStoped(String stoped) + { + this.stoped = stoped; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommand.java new file mode 100644 index 0000000..603cba2 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommand.java @@ -0,0 +1,58 @@ +package com.ruoyi.apt.dispatch.domain; + +/** + * 状态机命令请求(公寓端/04 表 4-9 第 5 个接口)。 + * + *

command 只允许 STOP / RE_CALL / FORCE_FINISH / MARK_LEAVE / CANCEL; + * commandId 是 L2 幂等键,落 duty_manual_action.uk_manual_gyid_action。

+ * + * @author apt-rev01 + */ +public class AptDutyCommand +{ + private String commandId; + private String command; + private String reason; + /** MARK_LEAVE 专用:离寓类型,缺省 MANUAL */ + private String leaveType; + + public String getCommandId() + { + return commandId; + } + + public void setCommandId(String commandId) + { + this.commandId = commandId; + } + + public String getCommand() + { + return command; + } + + public void setCommand(String command) + { + this.command = command; + } + + public String getReason() + { + return reason; + } + + public void setReason(String reason) + { + this.reason = reason; + } + + public String getLeaveType() + { + return leaveType; + } + + public void setLeaveType(String leaveType) + { + this.leaveType = leaveType; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommandResult.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommandResult.java new file mode 100644 index 0000000..d359b2c --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptDutyCommandResult.java @@ -0,0 +1,76 @@ +package com.ruoyi.apt.dispatch.domain; + +/** + * 状态机命令结果(公寓端/04 表 4-9 第 5 个接口出参)。 + * + * @author apt-rev01 + */ +public class AptDutyCommandResult +{ + private String commandId; + private String command; + private boolean accepted; + private boolean duplicated; + private String stateAfter; + private String actionId; + + public String getCommandId() + { + return commandId; + } + + public void setCommandId(String commandId) + { + this.commandId = commandId; + } + + public String getCommand() + { + return command; + } + + public void setCommand(String command) + { + this.command = command; + } + + public boolean isAccepted() + { + return accepted; + } + + public void setAccepted(boolean accepted) + { + this.accepted = accepted; + } + + public boolean isDuplicated() + { + return duplicated; + } + + public void setDuplicated(boolean duplicated) + { + this.duplicated = duplicated; + } + + public String getStateAfter() + { + return stateAfter; + } + + public void setStateAfter(String stateAfter) + { + this.stateAfter = stateAfter; + } + + public String getActionId() + { + return actionId; + } + + public void setActionId(String actionId) + { + this.actionId = actionId; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptJcxxQuery.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptJcxxQuery.java new file mode 100644 index 0000000..2b2e366 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptJcxxQuery.java @@ -0,0 +1,145 @@ +package com.ruoyi.apt.dispatch.domain; + +import java.util.Date; +import java.util.List; + +/** + * 叫乘列表查询条件(公寓端/04 表 4-9 第 1 个接口)。 + * + * @author apt-rev01 + */ +public class AptJcxxQuery +{ + private String ryid; + private String rymc; + private String fjh; + private String cc; + private String jlid; + private List ryzt; + private String finishJc; + private String finishLy; + private String stoped; + private Date jcsjFrom; + private Date jcsjTo; + private String isTempCall; + + public String getRyid() + { + return ryid; + } + + public void setRyid(String ryid) + { + this.ryid = ryid; + } + + public String getRymc() + { + return rymc; + } + + public void setRymc(String rymc) + { + this.rymc = rymc; + } + + public String getFjh() + { + return fjh; + } + + public void setFjh(String fjh) + { + this.fjh = fjh; + } + + public String getCc() + { + return cc; + } + + public void setCc(String cc) + { + this.cc = cc; + } + + public String getJlid() + { + return jlid; + } + + public void setJlid(String jlid) + { + this.jlid = jlid; + } + + public List getRyzt() + { + return ryzt; + } + + public void setRyzt(List ryzt) + { + this.ryzt = ryzt; + } + + public String getFinishJc() + { + return finishJc; + } + + public void setFinishJc(String finishJc) + { + this.finishJc = finishJc; + } + + public String getFinishLy() + { + return finishLy; + } + + public void setFinishLy(String finishLy) + { + this.finishLy = finishLy; + } + + public String getStoped() + { + return stoped; + } + + public void setStoped(String stoped) + { + this.stoped = stoped; + } + + public Date getJcsjFrom() + { + return jcsjFrom; + } + + public void setJcsjFrom(Date jcsjFrom) + { + this.jcsjFrom = jcsjFrom; + } + + public Date getJcsjTo() + { + return jcsjTo; + } + + public void setJcsjTo(Date jcsjTo) + { + this.jcsjTo = jcsjTo; + } + + public String getIsTempCall() + { + return isTempCall; + } + + public void setIsTempCall(String isTempCall) + { + this.isTempCall = isTempCall; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptStateSnapshot.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptStateSnapshot.java new file mode 100644 index 0000000..1eed01f --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/domain/AptStateSnapshot.java @@ -0,0 +1,104 @@ +package com.ruoyi.apt.dispatch.domain; + +/** + * gy_jcxx 状态快照(只读,用于命令前置校验与结果去重判定)。 + * + * @author apt-rev01 + */ +public class AptStateSnapshot +{ + private Long id; + private String jcid; + private String ryzt; + private String finishJc; + private String finishLy; + private String stoped; + private String callMode; + private Long bizVersion; + + /** 终态判定:已完成或已离寓,任何迟到结果都不再回写(表 3-10 保护语义 1/2) */ + public boolean isTerminal() + { + return "1".equals(finishJc) || "1".equals(finishLy); + } + + public Long getId() + { + return id; + } + + public void setId(Long id) + { + this.id = id; + } + + public String getJcid() + { + return jcid; + } + + public void setJcid(String jcid) + { + this.jcid = jcid; + } + + public String getRyzt() + { + return ryzt; + } + + public void setRyzt(String ryzt) + { + this.ryzt = ryzt; + } + + public String getFinishJc() + { + return finishJc; + } + + public void setFinishJc(String finishJc) + { + this.finishJc = finishJc; + } + + public String getFinishLy() + { + return finishLy; + } + + public void setFinishLy(String finishLy) + { + this.finishLy = finishLy; + } + + public String getStoped() + { + return stoped; + } + + public void setStoped(String stoped) + { + this.stoped = stoped; + } + + public String getCallMode() + { + return callMode; + } + + public void setCallMode(String callMode) + { + this.callMode = callMode; + } + + public Long getBizVersion() + { + return bizVersion; + } + + public void setBizVersion(Long bizVersion) + { + this.bizVersion = bizVersion; + } +} 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 new file mode 100644 index 0000000..76fae0c --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptDutyStateMapper.java @@ -0,0 +1,57 @@ +package com.ruoyi.apt.dispatch.mapper; + +import java.util.List; +import com.ruoyi.apt.dispatch.domain.AptStateSnapshot; +import org.apache.ibatis.annotations.Param; + +/** + * {@code apt.duty.state} 唯一写入口的数据访问(公寓端/03 表 3-10)。 + * + *

硬约束:全仓 `UPDATE gy_jcxx SET ryzt / finish_jc / finish_ly / stoped` + * 只允许出现在本 Mapper 的 XML({@code AptDutyStateMapper.xml}), + * 每条 UPDATE 必须同时带 {@code gyid}、{@code jcid} 与期望态条件, + * 由 {@code AptDutyStateServiceImpl} 独占调用;管理端只提交命令。

+ * + * @author apt-rev01 + */ +public interface AptDutyStateMapper +{ + /** 状态快照(命令前置校验 + 结果去重判定) */ + AptStateSnapshot selectSnapshot(@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); + + /** -> 3 已应答待离寓:SIP ConfbridgeJoin 或平板 ANSWERED+VERIFIED */ + int updateAnswered(@Param("gyid") String gyid, @Param("jcid") String jcid, + @Param("fromStates") List fromStates); + + /** 1 叫乘中 -> 2 叫乘报警(30s 无入会 / 平板 NO_ANSWER / FAILED) */ + int updateCallFailed(@Param("gyid") String gyid, @Param("jcid") String jcid); + + /** 人工「停止叫乘」:ryzt 保持,stoped=1 */ + int updateStop(@Param("gyid") String gyid, @Param("jcid") String jcid); + + /** 人工「复叫」:清 stoped、ryzt->1、记 fjsj_s */ + int updateRecall(@Param("gyid") String gyid, @Param("jcid") String jcid); + + /** 人工「强制结束」:finish_jc=1,不动 finish_ly(不置离寓) */ + int updateForceFinish(@Param("gyid") String gyid, @Param("jcid") String jcid); + + /** 人工「登记离寓」:finish_ly=1 + sjlysj */ + int updateMarkLeave(@Param("gyid") String gyid, @Param("jcid") String jcid, + @Param("leaveType") String leaveType, @Param("eventId") String eventId, + @Param("remark") String remark); + + /** 人工「取消未开始的叫乘」:ryzt=0 保持,stoped=1 */ + int updateCancel(@Param("gyid") String gyid, @Param("jcid") String jcid); + + /** 复叫新增一次会话(表 3-10:RE_CALL 生成新会话) */ + int insertCallSession(@Param("gyid") String gyid, @Param("sessionId") String sessionId, + @Param("jcid") String jcid, @Param("callMode") String callMode); + + /** 登记离寓写 gygl_ly_event(leave_type=MANUAL 等),由 gy_jcxx 行带出人员房间 */ + int insertLeaveEvent(@Param("gyid") String gyid, @Param("eventId") String eventId, + @Param("jcid") String jcid, @Param("leaveType") String leaveType); +} 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 new file mode 100644 index 0000000..450cf23 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/mapper/AptJcxxMapper.java @@ -0,0 +1,82 @@ +package com.ruoyi.apt.dispatch.mapper; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import com.ruoyi.apt.dispatch.domain.AptCorrectCommand; +import com.ruoyi.apt.dispatch.domain.AptJcxxQuery; +import org.apache.ibatis.annotations.Param; + +/** + * 叫乘信息读侧 + 白名单修正 + 命令流水(IT-5 / M3)。 + * + *

本 Mapper 只写 {@code duty_manual_action}、{@code duty_operation_log} + * 关联的非状态字段与 {@code gy_jcxx} 白名单列(fjh/jlid/cc/jch/leave_remark/is_temp_call), + * 不写 ryzt / finish_jc / finish_ly / stoped(那些只在 AptDutyStateMapper)。

+ * + * @author apt-rev01 + */ +public interface AptJcxxMapper +{ + List> selectJcxxList(@Param("gyid") String gyid, @Param("q") AptJcxxQuery query); + + Map selectJcxxById(@Param("gyid") String gyid, @Param("id") Long id); + + Map selectLatestSession(@Param("gyid") String gyid, @Param("jcid") String jcid); + + List> selectRecordings(@Param("gyid") String gyid, @Param("jcid") String jcid); + + List> selectManualActions(@Param("gyid") String gyid, @Param("jcid") String jcid); + + /** 时间线:状态时间戳(表 3-10 的「同表写入」列) */ + List> selectTimelineStates(@Param("gyid") String gyid, @Param("jcid") String jcid); + + /** 时间线:AMI 事件 */ + List> selectTimelineEvents(@Param("gyid") String gyid, @Param("jcid") String jcid); + + /** 时间线:人工命令(duty_manual_action) */ + List> selectTimelineActions(@Param("gyid") String gyid, @Param("jcid") String jcid); + + /** 时间线:审计(duty_operation_log,detail JSON 兜底 jcid) */ + List> selectTimelineAudits(@Param("gyid") String gyid, @Param("jcid") String jcid); + + /** 时间线:平板结果(TABLET_RESULT 落地行) */ + List> selectTimelineTabletResults(@Param("gyid") String gyid, @Param("jcid") String jcid); + + Map selectStatistics(@Param("gyid") String gyid, @Param("statDate") String statDate); + + /** SIP 30s 无入会扫描(表 3-10:1 叫乘中 -> 2 叫乘报警) */ + List> selectStaleCalling(@Param("gyid") String gyid, + @Param("deadline") Date deadline, + @Param("limit") int limit); + + /** 命令幂等键查询(duty_manual_action) */ + Map selectManualAction(@Param("gyid") String gyid, @Param("actionId") String actionId); + + int insertManualAction(@Param("gyid") String gyid, @Param("actionId") String actionId, + @Param("jcid") String jcid, @Param("actionType") String actionType, + @Param("state") String state, @Param("reason") String reason, + @Param("requestedBy") String requestedBy, + @Param("occurredAt") Date occurredAt); + + int updateManualActionResult(@Param("gyid") String gyid, @Param("actionId") String actionId, + @Param("state") String state, @Param("resultCode") String resultCode); + + int markManualActionDuplicated(@Param("gyid") String gyid, @Param("actionId") String actionId); + + /** 白名单修正:只允许 fjh / jlid / cc / jch / leave_remark / is_temp_call */ + int updateJcxxCorrect(@Param("gyid") String gyid, @Param("id") Long id, + @Param("command") AptCorrectCommand command); + + int countRoomByNo(@Param("gyid") String gyid, @Param("roomNo") String roomNo); + + int countEnabledRoute(@Param("gyid") String gyid, @Param("jlid") String jlid); + + /** AMI 事件落库(只追加) */ + int insertCallEvent(@Param("gyid") String gyid, @Param("eventId") String eventId, + @Param("sessionId") String sessionId, @Param("eventType") String eventType, + @Param("payload") String payload, @Param("occurredAt") Date occurredAt, + @Param("eventSeq") long eventSeq); + + Long selectNextEventSeq(@Param("gyid") String gyid, @Param("sessionId") String sessionId); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanServiceImpl.java new file mode 100644 index 0000000..5b2da49 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDispatchScanServiceImpl.java @@ -0,0 +1,100 @@ +package com.ruoyi.apt.dispatch.service; + +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper; +import com.ruoyi.apt.dispatch.tablet.service.IAptTabletDispatchService; +import com.ruoyi.apt.framework.config.AptProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * 叫乘扫描实现(IT-5 / M3)。 + * + *

调度线程没有 HTTP 上下文,本类在进入时用部署态 {@code apt.gy-id} + * 显式建立 {@link AptContext}(一进程一 gyid,D5),结束时清理。

+ * + * @author apt-rev01 + */ +@Service +public class AptDispatchScanServiceImpl implements IAptDispatchScanService +{ + private static final Logger log = LoggerFactory.getLogger(AptDispatchScanServiceImpl.class); + + private final IAptTabletDispatchService tabletDispatchService; + private final IAptDutyStateService stateService; + private final AptJcxxMapper jcxxMapper; + private final AptProperties properties; + + public AptDispatchScanServiceImpl(IAptTabletDispatchService tabletDispatchService, + IAptDutyStateService stateService, + AptJcxxMapper jcxxMapper, + AptProperties properties) + { + this.tabletDispatchService = tabletDispatchService; + this.stateService = stateService; + this.jcxxMapper = jcxxMapper; + this.properties = properties; + } + + @Override + public Map scan() + { + boolean bound = AptContext.getGyIdOrNull() == null; + if (bound) + { + AptContext.setGyId(properties.getGyId()); + AptContext.initTraceId(); + } + try + { + int expired = tabletDispatchService.expireTasks(); + int generated = tabletDispatchService.generateTasks(); + + int sipTimeoutSeconds = properties.getDispatch().getSipNoAnswerSeconds(); + Date deadline = new Date(System.currentTimeMillis() - sipTimeoutSeconds * 1000L); + List> stale = jcxxMapper.selectStaleCalling( + AptContext.getGyId(), deadline, properties.getDispatch().getScanBatchSize()); + int timedOut = 0; + for (Map row : stale == null ? List.>of() : stale) + { + String jcid = String.valueOf(row.get("jcid")); + try + { + stateService.callFailed(jcid); + timedOut++; + } + catch (AptException e) + { + if (e.getErrorCode() != AptErrorCode.STATE_ILLEGAL) + { + throw e; + } + log.debug("叫乘 {} 已被其他事件迁移,跳过 SIP 超时判定", jcid); + } + } + + Map result = new LinkedHashMap<>(); + result.put("expiredTasks", expired); + result.put("generatedTasks", generated); + result.put("sipTimeouts", timedOut); + result.put("scannedAt", new Date()); + log.info("叫乘扫描完成:过期任务 {},新生成 {},SIP 超时 {}", + expired, generated, timedOut); + return result; + } + finally + { + if (bound) + { + AptContext.clear(); + } + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyCommandServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyCommandServiceImpl.java new file mode 100644 index 0000000..aac0e98 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyCommandServiceImpl.java @@ -0,0 +1,330 @@ +package com.ruoyi.apt.dispatch.service; + +import java.util.Date; +import java.util.LinkedHashMap; +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.dispatch.domain.AptDutyCommand; +import com.ruoyi.apt.dispatch.domain.AptDutyCommandResult; +import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper; +import com.ruoyi.apt.framework.audit.AptAuditWriter; +import com.ruoyi.apt.ops.domain.AptOperationLog; +import com.ruoyi.common.utils.SecurityUtils; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 受控命令实现(IT-5 / M3,公寓端/04 表 4-9 命令语义)。 + * + *

命令矩阵(与表 4-9 逐条对齐):

+ *
+ * STOP          ryzt in (1,2) 且 finish_ly=0     -> stoped=1
+ * RE_CALL       stoped=1 或 ryzt=2               -> 清 stoped、ryzt=1、新增会话
+ * FORCE_FINISH  finish_ly=0                      -> finish_jc=1(不置离寓)
+ * MARK_LEAVE    finish_ly=0                      -> finish_ly=1 + gygl_ly_event
+ * CANCEL        ryzt=0                           -> stoped=1
+ * 
+ * + *

幂等:commandId 落 duty_manual_action 唯一键;重复命令只置 duplicated=1, + * 绝不产生第二次状态迁移(表 7-3 M3「同 commandId 重复 STOP 只生效一次」)。

+ * + * @author apt-rev01 + */ +@Service +public class AptDutyCommandServiceImpl implements IAptDutyCommandService +{ + private static final String STOP = "STOP"; + private static final String RE_CALL = "RE_CALL"; + private static final String FORCE_FINISH = "FORCE_FINISH"; + private static final String MARK_LEAVE = "MARK_LEAVE"; + private static final String CANCEL = "CANCEL"; + + private final AptJcxxMapper jcxxMapper; + private final IAptDutyStateService stateService; + private final AptAuditWriter auditWriter; + + public AptDutyCommandServiceImpl(AptJcxxMapper jcxxMapper, IAptDutyStateService stateService, + AptAuditWriter auditWriter) + { + this.jcxxMapper = jcxxMapper; + this.stateService = stateService; + this.auditWriter = auditWriter; + } + + @Override + @Transactional + public AptDutyCommandResult execute(Long id, AptDutyCommand command) + { + String action = validate(command); + String actionId = actionId(command); + + Map existing = jcxxMapper.selectManualAction(gyid(), actionId); + if (existing != null && !existing.isEmpty()) + { + jcxxMapper.markManualActionDuplicated(gyid(), actionId); + return duplicated(id, action, actionId, "DUPLICATED_COMMAND_ID"); + } + + Map row = jcxxMapper.selectJcxxById(gyid(), id); + if (row == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "叫乘记录不存在"); + } + String jcid = String.valueOf(row.get("jcid")); + String ryzt = str(row.get("ryzt")); + String finishJc = str(row.get("finishJc")); + String finishLy = str(row.get("finishLy")); + String stoped = str(row.get("stoped")); + String before = stateLabel(ryzt, finishJc, finishLy, stoped); + checkAllowed(action, ryzt, finishJc, finishLy, stoped); + + try + { + jcxxMapper.insertManualAction(gyid(), actionId, jcid, action, "PENDING", + command.getReason().trim(), operatorRef(), new Date()); + } + catch (DuplicateKeyException e) + { + jcxxMapper.markManualActionDuplicated(gyid(), actionId); + return duplicated(id, action, actionId, "DUPLICATED_COMMAND_ID"); + } + + try + { + dispatch(action, jcid, command); + } + catch (AptException e) + { + jcxxMapper.updateManualActionResult(gyid(), actionId, "FAILED", e.getReason()); + throw e; + } + + Map after = jcxxMapper.selectJcxxById(gyid(), id); + String afterLabel = stateLabel(str(after.get("ryzt")), str(after.get("finishJc")), + str(after.get("finishLy")), str(after.get("stoped"))); + jcxxMapper.updateManualActionResult(gyid(), actionId, "DONE", afterLabel); + writeAudit(id, jcid, action, actionId, command.getReason(), before, afterLabel, false); + + AptDutyCommandResult result = new AptDutyCommandResult(); + result.setCommandId(actionId); + result.setCommand(action); + result.setAccepted(true); + result.setDuplicated(false); + result.setStateAfter(afterLabel); + result.setActionId(actionId); + return result; + } + + private void dispatch(String action, String jcid, AptDutyCommand command) + { + switch (action) + { + case STOP: + stateService.stop(jcid); + break; + case RE_CALL: + stateService.recall(jcid); + break; + case FORCE_FINISH: + stateService.forceFinish(jcid); + break; + case MARK_LEAVE: + String leaveType = command.getLeaveType() == null || command.getLeaveType().isBlank() + ? "MANUAL" : command.getLeaveType().trim().toUpperCase(Locale.ROOT); + stateService.markLeave(jcid, leaveType, command.getReason().trim()); + break; + case CANCEL: + stateService.cancel(jcid); + break; + default: + throw new AptException(AptErrorCode.BAD_REQUEST, "COMMAND_UNSUPPORTED", + "command 仅允许 STOP / RE_CALL / FORCE_FINISH / MARK_LEAVE / CANCEL"); + } + } + + /** 前置状态校验(表 4-9「允许的当前状态」列,逐条对应) */ + private void checkAllowed(String action, String ryzt, String finishJc, String finishLy, String stoped) + { + boolean callingOrAlarm = "1".equals(ryzt) || "2".equals(ryzt); + boolean inHouse = "0".equals(finishLy); + switch (action) + { + case STOP: + if (!callingOrAlarm || !inHouse) + { + throw stateIllegal("STOP 仅允许 ryzt in (1,2) 且未离寓的记录"); + } + if ("1".equals(finishJc)) + { + throw stateIllegal("该叫乘已完成,不可再停止"); + } + break; + case RE_CALL: + if (!("1".equals(stoped) || "2".equals(ryzt)) || !inHouse) + { + throw stateIllegal("RE_CALL 仅允许已停止或叫乘报警的记录"); + } + if ("1".equals(finishJc)) + { + throw stateIllegal("该叫乘已完成,不可复叫"); + } + break; + case FORCE_FINISH: + if (!inHouse || "1".equals(finishJc)) + { + throw stateIllegal("FORCE_FINISH 仅允许未完成且未离寓的记录"); + } + break; + case MARK_LEAVE: + if (!inHouse) + { + throw stateIllegal("该人员已离寓,不可重复登记离寓"); + } + break; + case CANCEL: + if (!"0".equals(ryzt) || !inHouse) + { + throw stateIllegal("CANCEL 仅允许 ryzt=0 的未开始叫乘"); + } + break; + default: + throw stateIllegal("未知命令"); + } + } + + private AptDutyCommandResult duplicated(Long id, String action, String actionId, String reason) + { + Map row = jcxxMapper.selectJcxxById(gyid(), id); + AptDutyCommandResult result = new AptDutyCommandResult(); + result.setCommandId(actionId); + result.setCommand(action); + result.setAccepted(true); + result.setDuplicated(true); + result.setActionId(actionId); + if (row != null) + { + result.setStateAfter(stateLabel(str(row.get("ryzt")), str(row.get("finishJc")), + str(row.get("finishLy")), str(row.get("stoped")))); + writeAudit(id, String.valueOf(row.get("jcid")), action, actionId, reason, + result.getStateAfter(), result.getStateAfter(), true); + } + return result; + } + + private String validate(AptDutyCommand command) + { + if (command == null) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_BODY", "请求体不能为空"); + } + if (command.getCommand() == null || command.getCommand().isBlank()) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_COMMAND", "command 不能为空"); + } + if (command.getReason() == null || command.getReason().isBlank()) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_REASON", "reason 不能为空"); + } + if (command.getReason().length() > 256) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "REASON_TOO_LONG", "reason 长度不能超过 256"); + } + if (command.getCommandId() != null && command.getCommandId().length() > 64) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "COMMAND_ID_TOO_LONG", "commandId 长度不能超过 64"); + } + String action = command.getCommand().trim().toUpperCase(Locale.ROOT); + if (!STOP.equals(action) && !RE_CALL.equals(action) && !FORCE_FINISH.equals(action) + && !MARK_LEAVE.equals(action) && !CANCEL.equals(action)) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "COMMAND_UNSUPPORTED", + "command 仅允许 STOP / RE_CALL / FORCE_FINISH / MARK_LEAVE / CANCEL"); + } + return action; + } + + private String actionId(AptDutyCommand command) + { + if (command.getCommandId() == null || command.getCommandId().isBlank()) + { + return "CMD-" + UUID.randomUUID().toString().replace("-", ""); + } + return command.getCommandId().trim(); + } + + private void writeAudit(Long id, String jcid, String action, String actionId, String reason, + String before, String after, boolean duplicated) + { + Map detail = new LinkedHashMap<>(); + detail.put("jcid", jcid); + detail.put("jcxxId", id); + detail.put("command", action); + detail.put("commandId", actionId); + detail.put("reason", reason); + detail.put("stateBefore", before); + detail.put("stateAfter", after); + detail.put("duplicated", duplicated); + detail.put("status", duplicated ? "DUPLICATED" : "SUCCESS"); + detail.put("traceId", AptContext.getTraceId()); + + AptOperationLog entry = new AptOperationLog(); + entry.setLogId(UUID.randomUUID().toString().replace("-", "")); + entry.setGyId(AptContext.getGyIdOrNull()); + entry.setOperatorRef(operatorRef()); + entry.setModule("DISPATCH"); + entry.setAction("JCXX_" + action); + entry.setDetail(JSON.toJSONString(detail)); + entry.setTraceId(AptContext.getTraceId()); + entry.setOccurredAt(new Date()); + auditWriter.write(entry); + } + + private String stateLabel(String ryzt, String finishJc, String finishLy, String stoped) + { + String label; + switch (ryzt) + { + case "0": label = "WAITING"; break; + case "1": label = "CALLING"; break; + case "2": label = "ALARM"; break; + case "3": label = "ANSWERED"; break; + case "4": label = "LEAVE_ALARM"; break; + case "5": label = "TALKING"; break; + default: label = "UNKNOWN"; break; + } + return label + "|finishJc=" + finishJc + "|finishLy=" + finishLy + "|stoped=" + stoped; + } + + private AptException stateIllegal(String message) + { + return new AptException(AptErrorCode.STATE_ILLEGAL, "STATE_TRANSITION_ILLEGAL", message); + } + + private String str(Object value) + { + return value == null ? "" : String.valueOf(value); + } + + private String operatorRef() + { + try + { + return SecurityUtils.getLoginUser().getUsername(); + } + catch (Exception e) + { + return "ANONYMOUS"; + } + } + + private String gyid() + { + return AptContext.getGyId(); + } +} 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 new file mode 100644 index 0000000..d850efd --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java @@ -0,0 +1,126 @@ +package com.ruoyi.apt.dispatch.service; + +import java.util.List; +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.dispatch.domain.AptStateSnapshot; +import com.ruoyi.apt.dispatch.mapper.AptDutyStateMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 叫乘状态机唯一写入口(IT-5 / M3,公寓端/03 表 3-10)。 + * + *

实现要点:

+ *
    + *
  • 每条 UPDATE 都带 gyid + jcid + 期望态,未命中返回 0 行 → 40902;
  • + *
  • 不做任何「定时自动迁移」:平板链路的迁移只由 N-04 上报触发, + * 签到窗口超时同样由平板上报 NO_ANSWER(P2-2 裁决);
  • + *
  • SIP 30s 无入会由扫描任务调用 {@link #callFailed(String)}, + * 仍是「提交命令」而非直写。
  • + *
+ * + * @author apt-rev01 + */ +@Service +public class AptDutyStateServiceImpl implements IAptDutyStateService +{ + /** 应答允许的来源态:0 待叫乘 / 1 叫乘中 / 2 叫乘报警(含复叫入会) */ + private static final List ANSWERABLE = List.of("0", "1", "2"); + + private final AptDutyStateMapper stateMapper; + + public AptDutyStateServiceImpl(AptDutyStateMapper stateMapper) + { + this.stateMapper = stateMapper; + } + + @Override + public AptStateSnapshot snapshot(String jcid) + { + return stateMapper.selectSnapshot(gyid(), jcid); + } + + @Override + @Transactional + public int callStart(String jcid) + { + return require(stateMapper.updateCallStart(gyid(), jcid, false), jcid, "CALL_START"); + } + + @Override + @Transactional + public int answered(String jcid) + { + return require(stateMapper.updateAnswered(gyid(), jcid, ANSWERABLE), jcid, "ANSWERED"); + } + + @Override + @Transactional + public int callFailed(String jcid) + { + return require(stateMapper.updateCallFailed(gyid(), jcid), jcid, "CALL_FAILED"); + } + + @Override + @Transactional + public int stop(String jcid) + { + return require(stateMapper.updateStop(gyid(), jcid), jcid, "STOP"); + } + + @Override + @Transactional + public int recall(String jcid) + { + int rows = require(stateMapper.updateRecall(gyid(), jcid), jcid, "RE_CALL"); + AptStateSnapshot snapshot = stateMapper.selectSnapshot(gyid(), jcid); + String callMode = snapshot == null || snapshot.getCallMode() == null ? "SIP" : snapshot.getCallMode(); + // 表 3-10:RE_CALL 生成新会话,保证时间线与录音索引有独立 session_id + stateMapper.insertCallSession(gyid(), "CS-" + UUID.randomUUID().toString().replace("-", ""), + jcid, callMode); + return rows; + } + + @Override + @Transactional + public int forceFinish(String jcid) + { + return require(stateMapper.updateForceFinish(gyid(), jcid), jcid, "FORCE_FINISH"); + } + + @Override + @Transactional + public int markLeave(String jcid, String leaveType, String remark) + { + String eventId = "LY-" + UUID.randomUUID().toString().replace("-", ""); + int rows = require(stateMapper.updateMarkLeave(gyid(), jcid, leaveType, eventId, remark), + jcid, "MARK_LEAVE"); + stateMapper.insertLeaveEvent(gyid(), eventId, jcid, leaveType); + return rows; + } + + @Override + @Transactional + public int cancel(String jcid) + { + return require(stateMapper.updateCancel(gyid(), jcid), jcid, "CANCEL"); + } + + private int require(int rows, String jcid, String transition) + { + if (rows == 0) + { + throw new AptException(AptErrorCode.STATE_ILLEGAL, "STATE_TRANSITION_ILLEGAL", + "叫乘 " + jcid + " 当前状态不允许 " + transition + "(期望态条件更新未命中)"); + } + return rows; + } + + private String gyid() + { + return AptContext.getGyId(); + } +} 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 new file mode 100644 index 0000000..3c0106c --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptJcxxServiceImpl.java @@ -0,0 +1,307 @@ +package com.ruoyi.apt.dispatch.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.dispatch.domain.AptCorrectCommand; +import com.ruoyi.apt.dispatch.domain.AptJcxxQuery; +import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper; +import com.ruoyi.apt.framework.audit.AptAuditWriter; +import com.ruoyi.apt.ops.domain.AptOperationLog; +import com.ruoyi.common.utils.SecurityUtils; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 叫乘信息管理(IT-5 / M3)。 + * + *

口径:

+ *
    + *
  • 管理端只读 + 提交命令,不存在「编辑状态」的接口(PUT /apt/jcxx/{id} 不提供);
  • + *
  • correct 只改白名单列;请求体带 ryzt / finish_jc / finish_ly / stoped 直接 40001;
  • + *
  • 时间线五类来源合并后按 atMillis 升序,供排障与验收取证。
  • + *
+ * + * @author apt-rev01 + */ +@Service +public class AptJcxxServiceImpl implements IAptJcxxService +{ + private static final String ACTION_CORRECT = "CORRECT"; + + private final AptJcxxMapper jcxxMapper; + private final AptAuditWriter auditWriter; + + public AptJcxxServiceImpl(AptJcxxMapper jcxxMapper, AptAuditWriter auditWriter) + { + this.jcxxMapper = jcxxMapper; + this.auditWriter = auditWriter; + } + + @Override + public List> list(AptJcxxQuery query) + { + return jcxxMapper.selectJcxxList(gyid(), query == null ? new AptJcxxQuery() : query); + } + + @Override + public Map detail(Long id) + { + Map row = requireJcxx(id); + String jcid = str(row.get("jcid")); + Map detail = new LinkedHashMap<>(row); + detail.put("session", jcxxMapper.selectLatestSession(gyid(), jcid)); + detail.put("recordings", nonNull(jcxxMapper.selectRecordings(gyid(), jcid))); + detail.put("manualActions", nonNull(jcxxMapper.selectManualActions(gyid(), jcid))); + return detail; + } + + @Override + public List> timeline(Long id) + { + Map row = requireJcxx(id); + String jcid = str(row.get("jcid")); + List> events = new ArrayList<>(); + events.addAll(nonNull(jcxxMapper.selectTimelineStates(gyid(), jcid))); + events.addAll(nonNull(jcxxMapper.selectTimelineEvents(gyid(), jcid))); + events.addAll(nonNull(jcxxMapper.selectTimelineActions(gyid(), jcid))); + events.addAll(nonNull(jcxxMapper.selectTimelineAudits(gyid(), jcid))); + events.addAll(nonNull(jcxxMapper.selectTimelineTabletResults(gyid(), jcid))); + events.sort(Comparator.comparingLong(event -> toLong(event.get("atMillis")))); + return events; + } + + @Override + @Transactional + public Map correct(Long id, AptCorrectCommand command) + { + validateCorrect(id, command); + String actionId = command.getCommandId() == null || command.getCommandId().isBlank() + ? "COR-" + UUID.randomUUID().toString().replace("-", "") + : command.getCommandId().trim(); + + Map existing = jcxxMapper.selectManualAction(gyid(), actionId); + if (existing != null && !existing.isEmpty()) + { + jcxxMapper.markManualActionDuplicated(gyid(), actionId); + Map result = new LinkedHashMap<>(); + result.put("commandId", actionId); + result.put("duplicated", true); + result.put("jcid", str(existing.get("jcid"))); + return result; + } + + Map row = requireJcxx(id); + String jcid = str(row.get("jcid")); + if (command.getFjh() != null && !command.getFjh().isBlank() + && jcxxMapper.countRoomByNo(gyid(), command.getFjh().trim()) == 0) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "ROOM_NOT_FOUND", + "修正后的房间号不存在:" + command.getFjh()); + } + if (command.getJlid() != null && !command.getJlid().isBlank() + && jcxxMapper.countEnabledRoute(gyid(), command.getJlid().trim()) == 0) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "ROUTE_NOT_FOUND", + "修正后的交路不存在或已停用:" + command.getJlid()); + } + + try + { + jcxxMapper.insertManualAction(gyid(), actionId, jcid, ACTION_CORRECT, "PENDING", + command.getReason().trim(), operatorRef(), new Date()); + } + catch (DuplicateKeyException e) + { + jcxxMapper.markManualActionDuplicated(gyid(), actionId); + Map result = new LinkedHashMap<>(); + result.put("commandId", actionId); + result.put("duplicated", true); + result.put("jcid", jcid); + return result; + } + + int updated = jcxxMapper.updateJcxxCorrect(gyid(), id, command); + if (updated == 0) + { + jcxxMapper.updateManualActionResult(gyid(), actionId, "FAILED", "JCXX_NOT_FOUND"); + throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "叫乘记录不存在"); + } + jcxxMapper.updateManualActionResult(gyid(), actionId, "DONE", "OK"); + writeCorrectAudit(id, jcid, actionId, command); + + Map result = new LinkedHashMap<>(); + result.put("commandId", actionId); + result.put("duplicated", false); + result.put("jcid", jcid); + result.put("changedFields", changedFields(command)); + return result; + } + + @Override + public Map statistics(String statDate) + { + Map row = jcxxMapper.selectStatistics(gyid(), statDate); + Map result = new LinkedHashMap<>(); + result.put("date", statDate); + for (String key : List.of("waiting", "calling", "alarm", "answered", "leaveAlarm", + "talking", "finished", "total")) + { + result.put(key, row == null ? 0L : toLong(row.get(key))); + } + return result; + } + + private Map requireJcxx(Long id) + { + Map row = jcxxMapper.selectJcxxById(gyid(), id); + if (row == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "叫乘记录不存在"); + } + return row; + } + + /** 白名单校验:状态字段一律 40001;空修正 40001;已离寓记录禁止改房间/交路 */ + private void validateCorrect(Long id, AptCorrectCommand command) + { + if (command == null) + { + throw badRequest("MISSING_BODY", "请求体不能为空"); + } + if (notBlank(command.getRyzt()) || notBlank(command.getFinishJc()) + || notBlank(command.getFinishLy()) || notBlank(command.getStoped())) + { + throw badRequest("STATE_FIELD_READONLY", + "ryzt / finishJc / finishLy / stoped 为状态字段,只能经 /apt/jcxx/{id}/commands 变更"); + } + if (command.getReason() == null || command.getReason().isBlank()) + { + throw badRequest("MISSING_REASON", "reason 不能为空"); + } + if (command.getReason().length() > 256) + { + throw badRequest("REASON_TOO_LONG", "reason 长度不能超过 256"); + } + if (changedFields(command).isEmpty()) + { + throw badRequest("NO_FIELD_TO_CORRECT", + "白名单字段为空:仅允许 fjh / jlid / cc / jch / leaveRemark / isTempCall"); + } + Map row = requireJcxx(id); + boolean movesRoomOrRoute = notBlank(command.getFjh()) || notBlank(command.getJlid()); + if (movesRoomOrRoute && "1".equals(str(row.get("finishLy")))) + { + throw new AptException(AptErrorCode.STATE_ILLEGAL, "LEAVE_RECORD_READONLY", + "已离寓记录不可修正房间或交路"); + } + } + + private List changedFields(AptCorrectCommand command) + { + List fields = new ArrayList<>(); + if (notBlank(command.getFjh())) { fields.add("fjh"); } + if (notBlank(command.getJlid())) { fields.add("jlid"); } + if (notBlank(command.getCc())) { fields.add("cc"); } + if (notBlank(command.getJch())) { fields.add("jch"); } + if (notBlank(command.getLeaveRemark())) { fields.add("leaveRemark"); } + if (Boolean.TRUE.equals(command.getIsTempCall())) { fields.add("isTempCall"); } + return fields; + } + + private void writeCorrectAudit(Long id, String jcid, String actionId, AptCorrectCommand command) + { + Map detail = new LinkedHashMap<>(); + detail.put("jcid", jcid); + detail.put("jcxxId", id); + detail.put("commandId", actionId); + detail.put("reason", command.getReason()); + detail.put("changedFields", changedFields(command)); + detail.put("status", "SUCCESS"); + detail.put("traceId", AptContext.getTraceId()); + + AptOperationLog entry = new AptOperationLog(); + entry.setLogId(UUID.randomUUID().toString().replace("-", "")); + entry.setGyId(AptContext.getGyIdOrNull()); + entry.setOperatorRef(operatorRef()); + entry.setModule("DISPATCH"); + entry.setAction("JCXX_CORRECT"); + entry.setDetail(JSON.toJSONString(detail)); + entry.setTraceId(AptContext.getTraceId()); + entry.setOccurredAt(new Date()); + auditWriter.write(entry); + } + + private AptException badRequest(String reason, String message) + { + return new AptException(AptErrorCode.BAD_REQUEST, reason, message); + } + + private boolean notBlank(String value) + { + return value != null && !value.isBlank(); + } + + private List> nonNull(List> rows) + { + return rows == null ? List.of() : rows; + } + + private long toLong(Object value) + { + if (value instanceof Number) + { + return ((Number) value).longValue(); + } + if (value == null) + { + return 0L; + } + try + { + return Long.parseLong(String.valueOf(value)); + } + catch (NumberFormatException e) + { + return 0L; + } + } + + private String str(Object value) + { + return value == null ? "" : String.valueOf(value); + } + + @SuppressWarnings("unused") + private String upper(String value) + { + return value == null ? null : value.trim().toUpperCase(Locale.ROOT); + } + + private String operatorRef() + { + try + { + return SecurityUtils.getLoginUser().getUsername(); + } + catch (Exception e) + { + return "ANONYMOUS"; + } + } + + private String gyid() + { + return AptContext.getGyId(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDispatchScanService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDispatchScanService.java new file mode 100644 index 0000000..0d2a3f9 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDispatchScanService.java @@ -0,0 +1,16 @@ +package com.ruoyi.apt.dispatch.service; + +import java.util.Map; + +/** + * 叫乘扫描任务(IT-5 / M3):平板任务过期回收、任务生成、SIP 30s 无入会转报警。 + * + *

注意:扫描只「提交状态机命令」,不做任何直写; + * 平板签到窗口超时不由本任务推定(P2-2 裁决:由平板上报 NO_ANSWER)。

+ * + * @author apt-rev01 + */ +public interface IAptDispatchScanService +{ + Map scan(); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyCommandService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyCommandService.java new file mode 100644 index 0000000..3ef1218 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyCommandService.java @@ -0,0 +1,18 @@ +package com.ruoyi.apt.dispatch.service; + +import com.ruoyi.apt.dispatch.domain.AptDutyCommand; +import com.ruoyi.apt.dispatch.domain.AptDutyCommandResult; + +/** + * 受控命令层(公寓端/04 表 4-9 第 5 个接口)。 + * + *

职责边界:命令 → 幂等键(duty_manual_action)→ 前置状态校验 → + * 提交给 {@code apt.duty.state} 执行 → 结果回写 + 审计。 + * 本层不写 gy_jcxx 的任何状态字段。

+ * + * @author apt-rev01 + */ +public interface IAptDutyCommandService +{ + AptDutyCommandResult execute(Long id, AptDutyCommand command); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyStateService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyStateService.java new file mode 100644 index 0000000..af603b3 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptDutyStateService.java @@ -0,0 +1,40 @@ +package com.ruoyi.apt.dispatch.service; + +import com.ruoyi.apt.dispatch.domain.AptStateSnapshot; + +/** + * {@code apt.duty.state} 状态机(公寓端/03 表 3-10 的唯一写入口)。 + * + *

所有方法都是「提交命令」语义:条件更新未命中即抛 40902, + * 调用方(命令层 / 平板结果接入 / SIP 适配)不得自行 UPDATE gy_jcxx。

+ * + * @author apt-rev01 + */ +public interface IAptDutyStateService +{ + AptStateSnapshot snapshot(String jcid); + + /** 0 待叫乘 -> 1 叫乘中 */ + int callStart(String jcid); + + /** -> 3 已应答待离寓 + finish_jc=1(SIP 入会 / 平板 VERIFIED) */ + int answered(String jcid); + + /** 1 叫乘中 -> 2 叫乘报警 */ + int callFailed(String jcid); + + /** 人工停止叫乘:stoped=1 */ + int stop(String jcid); + + /** 人工复叫:清 stoped、ryzt->1、新增会话 */ + int recall(String jcid); + + /** 人工强制结束:finish_jc=1,不置离寓 */ + int forceFinish(String jcid); + + /** 人工登记离寓:finish_ly=1 + sjlysj + gygl_ly_event */ + int markLeave(String jcid, String leaveType, String remark); + + /** 人工取消未开始的叫乘:stoped=1 */ + int cancel(String jcid); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptJcxxService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptJcxxService.java new file mode 100644 index 0000000..f0583e2 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/IAptJcxxService.java @@ -0,0 +1,27 @@ +package com.ruoyi.apt.dispatch.service; + +import java.util.List; +import java.util.Map; +import com.ruoyi.apt.dispatch.domain.AptCorrectCommand; +import com.ruoyi.apt.dispatch.domain.AptJcxxQuery; + +/** + * 叫乘信息管理只读侧 + 白名单修正(公寓端/04 表 4-9 接口 1~4、6)。 + * + * @author apt-rev01 + */ +public interface IAptJcxxService +{ + List> list(AptJcxxQuery query); + + /** 详情:gy_jcxx 全字段 + 最近会话 + 录音索引 + 人工命令;不存在抛 40401 */ + Map detail(Long id); + + /** 时间线:状态迁移 + AMI 事件 + 人工命令 + 审计 + 平板结果,按 at 升序 */ + List> timeline(Long id); + + /** 非状态字段修正(白名单),改 ryzt 等一律 40001 */ + Map correct(Long id, AptCorrectCommand command); + + Map statistics(String statDate); +} 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 new file mode 100644 index 0000000..2e8f938 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/controller/AptTabletOpenController.java @@ -0,0 +1,82 @@ +package com.ruoyi.apt.dispatch.tablet.controller; + +import java.util.Locale; +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.IAptTabletResultService; +import com.ruoyi.common.core.domain.AjaxResult; +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; + +/** + * 设备侧平板接口(IT-5 / M3,公寓端/05 表 5-16 步骤 2~3、8)。 + * + *

鉴权:{@code /open/v1/**} 已由 {@code AptContextInterceptor} + + * {@code AptDeviceSigner} 完成「设备编码 + HMAC + 时间窗 + nonce」四要素校验, + * 本控制器不再放行任何匿名逻辑,也不接受请求参数携带 gyid。

+ * + *

N-03 拉取 / N-05 ACK / N-04 结果上报均为端内接口,不计入跨端 20 个编号; + * 平板链路不发 #24。

+ * + * @author apt-rev01 + */ +@RestController +@RequestMapping(value = "/open/v1/tablet", produces = "application/json;charset=UTF-8") +public class AptTabletOpenController +{ + private final IAptTabletDispatchService dispatchService; + private final IAptTabletResultService resultService; + + public AptTabletOpenController(IAptTabletDispatchService dispatchService, + IAptTabletResultService resultService) + { + this.dispatchService = dispatchService; + this.resultService = resultService; + } + + /** N-03:按 dispatch_seq 游标顺序拉取任务(拉取 ≠ 执行) */ + @GetMapping("/tasks") + public AjaxResult tasks( + @RequestHeader(value = "X-Device-Code", required = false) String deviceCode, + @RequestParam(required = false, defaultValue = "0") long cursor, + @RequestParam(required = false, defaultValue = "50") int limit) + { + return AjaxResult.success(dispatchService.pull(requireDevice(deviceCode), cursor, limit)); + } + + /** N-05:ACK 任务(WAITING -> PULLED;重复 / 过期返回 40916) */ + @PostMapping("/tasks/{taskId}/ack") + public AjaxResult ack( + @RequestHeader(value = "X-Device-Code", required = false) String deviceCode, + @PathVariable String taskId) + { + return AjaxResult.success(dispatchService.ack(requireDevice(deviceCode), taskId)); + } + + /** N-04:结果上报(resultId 幂等;只传识别结论,不传图像) */ + @PostMapping("/results") + public AjaxResult results( + @RequestHeader(value = "X-Device-Code", required = false) String deviceCode, + @RequestBody AptTabletResultCommand command) + { + return AjaxResult.success(resultService.accept(requireDevice(deviceCode), command)); + } + + 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/dispatch/tablet/domain/AptTabletResultCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/domain/AptTabletResultCommand.java new file mode 100644 index 0000000..11a9fb6 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/domain/AptTabletResultCommand.java @@ -0,0 +1,103 @@ +package com.ruoyi.apt.dispatch.tablet.domain; + +import java.util.Date; + +/** + * 平板结果上报(N-04 入参,CR-02 表 3-16)。 + * + *

只允许识别结论,不接收抓拍帧 / 人脸原图 / 比对帧; + * {@code resultId} 是幂等键(UK {@code (gyid, result_id)})。

+ * + * @author apt-rev01 + */ +public class AptTabletResultCommand +{ + private String resultId; + private String taskId; + private String jcid; + private String faceVerifyState; + private String verifiedRyid; + private String resultType; + private Integer personCount; + private Date occurredAt; + + public String getResultId() + { + return resultId; + } + + public void setResultId(String resultId) + { + this.resultId = resultId; + } + + public String getTaskId() + { + return taskId; + } + + public void setTaskId(String taskId) + { + this.taskId = taskId; + } + + public String getJcid() + { + return jcid; + } + + public void setJcid(String jcid) + { + this.jcid = jcid; + } + + public String getFaceVerifyState() + { + return faceVerifyState; + } + + public void setFaceVerifyState(String faceVerifyState) + { + this.faceVerifyState = faceVerifyState; + } + + public String getVerifiedRyid() + { + return verifiedRyid; + } + + public void setVerifiedRyid(String verifiedRyid) + { + this.verifiedRyid = verifiedRyid; + } + + public String getResultType() + { + return resultType; + } + + public void setResultType(String resultType) + { + this.resultType = resultType; + } + + public Integer getPersonCount() + { + return personCount; + } + + public void setPersonCount(Integer personCount) + { + this.personCount = personCount; + } + + 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/dispatch/tablet/domain/AptTabletTask.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/domain/AptTabletTask.java new file mode 100644 index 0000000..0737bc4 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/domain/AptTabletTask.java @@ -0,0 +1,169 @@ +package com.ruoyi.apt.dispatch.tablet.domain; + +import java.util.Date; + +/** + * 平板派发任务(N-03 出参,公寓端/05 表 5-15)。 + * + *

口径:{@code jcsj} 是平板本地定时播放的基准时刻,{@code dispatchSeq} 单调递增, + * {@code cursor} 即已拉取到的最大 {@code dispatchSeq};拉取 ≠ 执行。

+ * + * @author apt-rev01 + */ +public class AptTabletTask +{ + private String taskId; + private String jcid; + private Long dispatchSeq; + private Date jcsj; + private String roomNo; + private String ryid; + private String personName; + private String callMode; + private String pullState; + private Date expireAt; + private Boolean faceVerifyRequired; + private Integer signTimeoutSec; + private String ttsTemplate; + private String ttsText; + + public String getTaskId() + { + return taskId; + } + + public void setTaskId(String taskId) + { + this.taskId = taskId; + } + + public String getJcid() + { + return jcid; + } + + public void setJcid(String jcid) + { + this.jcid = jcid; + } + + public Long getDispatchSeq() + { + return dispatchSeq; + } + + public void setDispatchSeq(Long dispatchSeq) + { + this.dispatchSeq = dispatchSeq; + } + + public Date getJcsj() + { + return jcsj; + } + + public void setJcsj(Date jcsj) + { + this.jcsj = jcsj; + } + + public String getRoomNo() + { + return roomNo; + } + + public void setRoomNo(String roomNo) + { + this.roomNo = roomNo; + } + + public String getRyid() + { + return ryid; + } + + public void setRyid(String ryid) + { + this.ryid = ryid; + } + + public String getPersonName() + { + return personName; + } + + public void setPersonName(String personName) + { + this.personName = personName; + } + + public String getCallMode() + { + return callMode; + } + + public void setCallMode(String callMode) + { + this.callMode = callMode; + } + + public String getPullState() + { + return pullState; + } + + public void setPullState(String pullState) + { + this.pullState = pullState; + } + + public Date getExpireAt() + { + return expireAt; + } + + public void setExpireAt(Date expireAt) + { + this.expireAt = expireAt; + } + + public Boolean getFaceVerifyRequired() + { + return faceVerifyRequired; + } + + public void setFaceVerifyRequired(Boolean faceVerifyRequired) + { + this.faceVerifyRequired = faceVerifyRequired; + } + + public Integer getSignTimeoutSec() + { + return signTimeoutSec; + } + + public void setSignTimeoutSec(Integer signTimeoutSec) + { + this.signTimeoutSec = signTimeoutSec; + } + + public String getTtsTemplate() + { + return ttsTemplate; + } + + public void setTtsTemplate(String ttsTemplate) + { + this.ttsTemplate = ttsTemplate; + } + + public String getTtsText() + { + return ttsText; + } + + public void setTtsText(String ttsText) + { + this.ttsText = ttsText; + } +} 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 new file mode 100644 index 0000000..1498a0d --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/mapper/AptTabletMapper.java @@ -0,0 +1,74 @@ +package com.ruoyi.apt.dispatch.tablet.mapper; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import org.apache.ibatis.annotations.Param; + +/** + * 平板派发与结果接入(IT-5 / M3,N-03 / N-04 / N-05)。 + * + *

硬约束:每条 SQL 必带 gyid;结果表 {@code (gyid, result_id)} 唯一键做幂等; + * 本 Mapper 不写 gy_jcxx 的 ryzt / finish_jc / finish_ly / stoped + * (只写 dispatch_seq 这一派发序号列)。

+ * + * @author apt-rev01 + */ +public interface AptTabletMapper +{ + /** 按游标拉取(含 PULLED,便于断网重拉;拉取 ≠ 执行) */ + List> selectPullTasks(@Param("gyid") String gyid, + @Param("cursor") long cursor, + @Param("limit") int limit); + + Map selectTask(@Param("gyid") String gyid, @Param("taskId") String taskId); + + /** ACK:WAITING -> PULLED;未命中返回 0(重复 ACK / 已过期 → 40916) */ + int markPulled(@Param("gyid") String gyid, @Param("taskId") String taskId, + @Param("deviceCode") String deviceCode); + + int markTaskDone(@Param("gyid") String gyid, @Param("taskId") String taskId); + + /** 过期回收:WAITING 且 expire_at < now -> EXPIRED(不重派) */ + int expireTasks(@Param("gyid") String gyid, @Param("now") Date now); + + Long selectMaxDispatchSeq(@Param("gyid") String gyid); + + int insertTask(@Param("gyid") String gyid, @Param("taskId") String taskId, + @Param("jcid") String jcid, @Param("dispatchSeq") long dispatchSeq, + @Param("jcsj") Date jcsj, @Param("expireAt") Date expireAt); + + int updateJcxxDispatchSeq(@Param("gyid") String gyid, @Param("jcid") String jcid, + @Param("dispatchSeq") long dispatchSeq); + + /** 待派发候选:TABLET 模式、待叫乘、未完成未离寓、无既有任务行 */ + List> selectDispatchCandidates(@Param("gyid") String gyid, + @Param("limit") int limit); + + int countActiveTablet(@Param("gyid") String gyid); + + /** 平板注册态(ACTIVE / DISABLED / PENDING);未注册返回 null */ + String selectDeviceStatus(@Param("gyid") String gyid, @Param("deviceCode") String deviceCode); + + int countTaskByJcid(@Param("gyid") String gyid, @Param("jcid") String jcid); + + Map selectResult(@Param("gyid") String gyid, @Param("resultId") String resultId); + + int insertResult(@Param("gyid") String gyid, @Param("resultId") String resultId, + @Param("taskId") String taskId, @Param("jcid") String jcid, + @Param("faceVerifyState") String faceVerifyState, + @Param("verifiedRyid") String verifiedRyid, + @Param("resultType") String resultType, + @Param("personCount") int personCount, + @Param("deviceCode") String deviceCode, + @Param("occurredAt") Date occurredAt, + @Param("receivedAt") Date receivedAt); + + int updateResultApplied(@Param("gyid") String gyid, @Param("resultId") String resultId, + @Param("appliedState") String appliedState); + + int insertOutbox(@Param("gyid") String gyid, @Param("idempotencyKey") String idempotencyKey, + @Param("bizType") String bizType, @Param("bizId") String bizId, + @Param("bizVersion") long bizVersion, @Param("payload") String payload, + @Param("occurredAt") Date occurredAt); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletDispatchServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletDispatchServiceImpl.java new file mode 100644 index 0000000..a702d25 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletDispatchServiceImpl.java @@ -0,0 +1,206 @@ +package com.ruoyi.apt.dispatch.tablet.service; + +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.dispatch.tablet.domain.AptTabletTask; +import com.ruoyi.apt.dispatch.tablet.mapper.AptTabletMapper; +import com.ruoyi.apt.framework.config.AptProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 平板派发实现(IT-5 / M3,公寓端/05 表 5-16)。 + * + *

关键口径:

+ *
    + *
  • 拉取 ≠ 执行:服务端只生成 WAITING 任务与单调 dispatch_seq, + * 不向平板推送呼叫指令;到点播放由平板本地定时器完成;
  • + *
  • ACK 用条件更新 WAITING -> PULLED,未命中即 40916(重复 ACK / 已过期);
  • + *
  • 过期只置 EXPIRED,不重派(表 5-16 步骤 3 / 表 7-3 M3)。
  • + *
+ * + * @author apt-rev01 + */ +@Service +public class AptTabletDispatchServiceImpl implements IAptTabletDispatchService +{ + private static final Logger log = LoggerFactory.getLogger(AptTabletDispatchServiceImpl.class); + + private final AptTabletMapper tabletMapper; + private final AptProperties properties; + + public AptTabletDispatchServiceImpl(AptTabletMapper tabletMapper, AptProperties properties) + { + this.tabletMapper = tabletMapper; + this.properties = properties; + } + + @Override + @Transactional + public int generateTasks() + { + if (!properties.getFeatures().isTabletCall()) + { + log.info("apt.features.tablet-call=false:跳过平板任务生成,已有 TABLET 记录按 CR-C23 回落 SIP"); + return 0; + } + int batch = properties.getDispatch().getScanBatchSize(); + List> candidates = + tabletMapper.selectDispatchCandidates(gyid(), batch); + if (candidates == null || candidates.isEmpty()) + { + return 0; + } + long seq = nvl(tabletMapper.selectMaxDispatchSeq(gyid())); + long ttlMillis = properties.getDispatch().getTabletTaskTtlSeconds() * 1000L; + int generated = 0; + for (Map candidate : candidates) + { + String jcid = String.valueOf(candidate.get("jcid")); + Date jcsj = toDate(candidate.get("jcsj")); + if (jcsj == null) + { + continue; + } + seq = seq + 1; + String taskId = "TT-" + jcid + "-" + seq; + Date expireAt = new Date(jcsj.getTime() + ttlMillis); + tabletMapper.insertTask(gyid(), taskId, jcid, seq, jcsj, expireAt); + tabletMapper.updateJcxxDispatchSeq(gyid(), jcid, seq); + generated++; + } + log.info("平板任务生成 {} 条,最后 dispatch_seq={}", generated, seq); + return generated; + } + + @Override + public Map pull(String deviceCode, long cursor, int limit) + { + String status = tabletMapper.selectDeviceStatus(gyid(), deviceCode); + if (status == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "TABLET_DEVICE_NOT_FOUND", + "平板设备未注册:" + deviceCode); + } + if (!"ACTIVE".equals(status)) + { + throw new AptException(AptErrorCode.FORBIDDEN_DEVICE_GYID, "TABLET_DEVICE_DISABLED", + "平板设备已停用:" + deviceCode); + } + int pageSize = limit <= 0 ? 50 : Math.min(limit, 100); + List> rows = tabletMapper.selectPullTasks(gyid(), cursor, pageSize); + List tasks = new ArrayList<>(); + long nextCursor = cursor; + for (Map row : rows == null ? List.>of() : rows) + { + AptTabletTask task = new AptTabletTask(); + task.setTaskId(String.valueOf(row.get("taskId"))); + task.setJcid(String.valueOf(row.get("jcid"))); + task.setDispatchSeq(nvl(row.get("dispatchSeq"))); + task.setJcsj(toDate(row.get("jcsj"))); + task.setRoomNo(str(row.get("roomNo"))); + task.setRyid(str(row.get("ryid"))); + task.setPersonName(str(row.get("personName"))); + task.setCallMode("TABLET"); + task.setPullState(str(row.get("pullState"))); + task.setExpireAt(toDate(row.get("expireAt"))); + task.setFaceVerifyRequired(Boolean.TRUE); + task.setSignTimeoutSec(properties.getDispatch().getSignTimeoutSec()); + task.setTtsTemplate(properties.getDispatch().getTtsTemplate()); + task.setTtsText(properties.getDispatch().getTtsText()); + tasks.add(task); + if (task.getDispatchSeq() != null && task.getDispatchSeq() > nextCursor) + { + nextCursor = task.getDispatchSeq(); + } + } + + Map result = new LinkedHashMap<>(); + result.put("tasks", tasks); + result.put("cursor", nextCursor); + result.put("serverTime", new Date()); + // 平板需按 serverTime 校准本地时钟;服务端不回传设备偏差,避免伪造校准依据 + result.put("clockSkewMs", null); + result.put("clockSkewThresholdMs", properties.getDispatch().getClockSkewThresholdMs()); + result.put("signTimeoutSec", properties.getDispatch().getSignTimeoutSec()); + result.put("traceId", AptContext.getTraceId()); + return result; + } + + @Override + @Transactional + public Map ack(String deviceCode, String taskId) + { + Map task = tabletMapper.selectTask(gyid(), taskId); + if (task == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "TABLET_TASK_NOT_FOUND", + "平板任务不存在:" + taskId); + } + String bound = str(task.get("deviceCode")); + if (!bound.isEmpty() && !bound.equalsIgnoreCase(deviceCode)) + { + throw new AptException(AptErrorCode.STATE_ILLEGAL, "TABLET_TASK_DEVICE_MISMATCH", + "任务已绑定其他平板,不可重复 ACK"); + } + int updated = tabletMapper.markPulled(gyid(), taskId, deviceCode); + if (updated == 0) + { + throw new AptException(AptErrorCode.TABLET_TASK_EXPIRED, "TABLET_TASK_ALREADY_PULLED", + "任务已 ACK 或已过期,不可重复 ACK"); + } + Map result = new LinkedHashMap<>(); + result.put("taskId", taskId); + result.put("pullState", "PULLED"); + result.put("ackedAt", new Date()); + result.put("traceId", AptContext.getTraceId()); + return result; + } + + @Override + public int expireTasks() + { + return tabletMapper.expireTasks(gyid(), new Date()); + } + + private String gyid() + { + return AptContext.getGyId(); + } + + private long nvl(Long value) + { + return value == null ? 0L : value; + } + + private long nvl(Object value) + { + if (value instanceof Number) + { + return ((Number) value).longValue(); + } + return 0L; + } + + private Date toDate(Object value) + { + if (value instanceof Date) + { + return (Date) value; + } + return null; + } + + private String str(Object value) + { + return value == null ? "" : String.valueOf(value); + } +} 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 new file mode 100644 index 0000000..849cfc3 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletResultServiceImpl.java @@ -0,0 +1,333 @@ +package com.ruoyi.apt.dispatch.tablet.service; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +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.dispatch.domain.AptStateSnapshot; +import com.ruoyi.apt.dispatch.service.IAptDutyStateService; +import com.ruoyi.apt.dispatch.tablet.domain.AptTabletResultCommand; +import com.ruoyi.apt.dispatch.tablet.mapper.AptTabletMapper; +import com.ruoyi.apt.framework.device.AptDeviceSigner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 平板结果受理(IT-5 / M3,公寓端/05 表 5-20 终态矩阵)。 + * + *

映射规则(逐条对齐表 5-20):

+ *
+ * ANSWERED + VERIFIED            -> ANSWERED 命令 -> ryzt=3 / finish_jc=1 -> #9
+ * ANSWERED + UNVERIFIED / SKIPPED-> 保持待确认 + 复叫提示 + 告警 -> #9 + #12(不改 ryzt)
+ * NO_ANSWER / FAILED             -> CALL_FAILED 命令 -> ryzt=2 -> #9
+ * CANCELLED                      -> STOP(ryzt in 1,2)/ CANCEL(ryzt=0)-> #9
+ * 迟到 / 重复                    -> IGNORED,不回写、不重复上报
+ * 
+ * + *

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

+ * + * @author apt-rev01 + */ +@Service +public class AptTabletResultServiceImpl implements IAptTabletResultService +{ + private static final Logger log = LoggerFactory.getLogger(AptTabletResultServiceImpl.class); + + private static final String ANSWERED = "ANSWERED"; + private static final String NO_ANSWER = "NO_ANSWER"; + private static final String FAILED = "FAILED"; + private static final String CANCELLED = "CANCELLED"; + private static final String VERIFIED = "VERIFIED"; + private static final String UNVERIFIED = "UNVERIFIED"; + private static final String SKIPPED = "SKIPPED"; + + private static final String APPLIED = "APPLIED"; + private static final String IGNORED = "IGNORED"; + + private static final String BIZ_TABLET_RESULT = "TABLET_RESULT"; + private static final String BIZ_DEVICE_ALARM = "DEVICE_ALARM"; + + private final AptTabletMapper tabletMapper; + private final IAptDutyStateService stateService; + + public AptTabletResultServiceImpl(AptTabletMapper tabletMapper, IAptDutyStateService stateService) + { + this.tabletMapper = tabletMapper; + this.stateService = stateService; + } + + @Override + @Transactional + public Map accept(String deviceCode, AptTabletResultCommand command) + { + validate(command); + + Map existing = tabletMapper.selectResult(gyid(), command.getResultId()); + if (existing != null && !existing.isEmpty()) + { + return firstResult(existing, true); + } + + Map task = tabletMapper.selectTask(gyid(), command.getTaskId()); + if (task == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "TABLET_TASK_NOT_FOUND", + "平板任务不存在:" + command.getTaskId()); + } + String boundDevice = str(task.get("deviceCode")); + if (!boundDevice.isEmpty() && !boundDevice.equalsIgnoreCase(deviceCode)) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "TABLET_TASK_DEVICE_MISMATCH", + "任务不属于该平板:" + command.getTaskId()); + } + String jcid = str(task.get("jcid")); + if (command.getJcid() != null && !command.getJcid().isBlank() + && !command.getJcid().equals(jcid)) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "TABLET_RESULT_JCID_MISMATCH", + "jcid 与任务不一致"); + } + + Date occurredAt = command.getOccurredAt() == null ? new Date() : command.getOccurredAt(); + Date receivedAt = new Date(); + try + { + tabletMapper.insertResult(gyid(), command.getResultId(), command.getTaskId(), jcid, + command.getFaceVerifyState(), command.getVerifiedRyid(), + command.getResultType(), command.getPersonCount() == null ? 0 : command.getPersonCount(), + deviceCode, occurredAt, receivedAt); + } + catch (DuplicateKeyException e) + { + Map first = tabletMapper.selectResult(gyid(), command.getResultId()); + return firstResult(first, true); + } + + String appliedState = apply(command, jcid, task, deviceCode); + tabletMapper.updateResultApplied(gyid(), command.getResultId(), appliedState); + tabletMapper.markTaskDone(gyid(), command.getTaskId()); + + AptStateSnapshot after = stateService.snapshot(jcid); + Map result = new LinkedHashMap<>(); + result.put("resultId", command.getResultId()); + result.put("taskId", command.getTaskId()); + result.put("jcid", jcid); + result.put("faceVerifyState", command.getFaceVerifyState()); + result.put("resultType", command.getResultType()); + result.put("appliedState", appliedState); + result.put("duplicated", false); + result.put("ryzt", after == null ? null : after.getRyzt()); + result.put("occurredAt", occurredAt); + result.put("traceId", AptContext.getTraceId()); + if (UNVERIFIED.equals(command.getFaceVerifyState()) || SKIPPED.equals(command.getFaceVerifyState())) + { + List warnings = new ArrayList<>(); + warnings.add("未刷脸:保持待确认并提示复叫(Q-22),值班员确认前不判完成"); + result.put("warnings", warnings); + } + return result; + } + + /** 表 5-20:只提交命令,不改状态字段;终态冲突视为迟到并记 IGNORED */ + private String apply(AptTabletResultCommand command, String jcid, Map task, + String deviceCode) + { + AptStateSnapshot snapshot = stateService.snapshot(jcid); + if (snapshot == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "叫乘记录不存在:" + jcid); + } + if (snapshot.isTerminal() || "DONE".equals(str(task.get("pullState")))) + { + return IGNORED; + } + String type = command.getResultType(); + try + { + switch (type) + { + case ANSWERED: + if (VERIFIED.equals(command.getFaceVerifyState())) + { + stateService.answered(jcid); + enqueueResult(command, jcid, snapshot, deviceCode); + } + else + { + // 保持待确认:不提交状态命令,只上报与告警(复叫由值班台/应用端提交) + enqueueResult(command, jcid, snapshot, deviceCode); + enqueueAlarm(command, jcid, deviceCode); + } + return APPLIED; + case NO_ANSWER: + case FAILED: + stateService.callFailed(jcid); + enqueueResult(command, jcid, snapshot, deviceCode); + return APPLIED; + case CANCELLED: + if ("0".equals(snapshot.getRyzt())) + { + stateService.cancel(jcid); + } + else + { + stateService.stop(jcid); + } + enqueueResult(command, jcid, snapshot, deviceCode); + return APPLIED; + default: + throw new AptException(AptErrorCode.BAD_REQUEST, "TABLET_RESULT_TYPE_INVALID", + "resultType 仅允许 ANSWERED / NO_ANSWER / FAILED / CANCELLED"); + } + } + catch (AptException e) + { + if (e.getErrorCode() == AptErrorCode.STATE_ILLEGAL) + { + log.info("平板结果迟到,记 IGNORED:resultId={}, jcid={}, reason={}", + command.getResultId(), jcid, e.getReason()); + return IGNORED; + } + throw e; + } + } + + /** #9 TABLET_RESULT:平板链路唯一的结果上行;不带 #24 */ + private void enqueueResult(AptTabletResultCommand command, String jcid, + AptStateSnapshot snapshot, String deviceCode) + { + Map payload = new LinkedHashMap<>(); + payload.put("resultId", command.getResultId()); + payload.put("taskId", command.getTaskId()); + payload.put("jcid", jcid); + payload.put("deviceCode", deviceCode); + payload.put("callMode", "TABLET"); + payload.put("faceVerifyState", command.getFaceVerifyState()); + payload.put("verifiedRyid", command.getVerifiedRyid()); + payload.put("resultType", command.getResultType()); + payload.put("personCount", command.getPersonCount()); + payload.put("occurredAt", command.getOccurredAt()); + enqueue(BIZ_TABLET_RESULT, command.getResultId(), snapshot == null ? 1L : nvl(snapshot.getBizVersion()), + payload, command.getOccurredAt()); + } + + /** #12 DEVICE_ALARM(alarmSource=CALL):未刷脸告警,供值班台与平台统计 */ + private void enqueueAlarm(AptTabletResultCommand command, String jcid, String deviceCode) + { + Map payload = new LinkedHashMap<>(); + payload.put("alarmId", "AL-" + command.getResultId()); + payload.put("alarmEventKey", "UNVERIFIED:" + command.getResultId()); + payload.put("alarmSource", "CALL"); + payload.put("jcid", jcid); + payload.put("taskId", command.getTaskId()); + payload.put("deviceCode", deviceCode); + payload.put("faceVerifyState", command.getFaceVerifyState()); + payload.put("callMode", "TABLET"); + payload.put("occurredAt", command.getOccurredAt()); + enqueue(BIZ_DEVICE_ALARM, command.getResultId(), 1L, payload, command.getOccurredAt()); + } + + private void enqueue(String bizType, String bizId, long bizVersion, Map payload, + Date occurredAt) + { + String key = idempotencyKey(bizType, bizId, bizVersion); + tabletMapper.insertOutbox(gyid(), key, bizType, bizId, bizVersion, + JSON.toJSONString(payload), occurredAt == null ? new Date() : occurredAt); + } + + private String idempotencyKey(String bizType, String bizId, long bizVersion) + { + String raw = gyid() + "|" + bizType + "|" + bizId + "|" + bizVersion; + return AptDeviceSigner.sha256Hex(raw.getBytes(StandardCharsets.UTF_8)); + } + + private Map firstResult(Map row, boolean duplicated) + { + Map result = new LinkedHashMap<>(); + if (row == null || row.isEmpty()) + { + result.put("duplicated", duplicated); + return result; + } + result.put("resultId", row.get("resultId")); + result.put("taskId", row.get("taskId")); + result.put("jcid", row.get("jcid")); + result.put("faceVerifyState", row.get("faceVerifyState")); + result.put("resultType", row.get("resultType")); + result.put("appliedState", row.get("appliedState")); + result.put("occurredAt", row.get("occurredAt")); + result.put("duplicated", duplicated); + result.put("traceId", AptContext.getTraceId()); + return result; + } + + private void validate(AptTabletResultCommand command) + { + if (command == null) + { + throw badRequest("MISSING_BODY", "请求体不能为空"); + } + requireText(command.getResultId(), "resultId"); + requireText(command.getTaskId(), "taskId"); + requireText(command.getFaceVerifyState(), "faceVerifyState"); + requireText(command.getResultType(), "resultType"); + String face = command.getFaceVerifyState().trim().toUpperCase(Locale.ROOT); + if (!VERIFIED.equals(face) && !UNVERIFIED.equals(face) && !SKIPPED.equals(face)) + { + throw badRequest("TABLET_FACE_STATE_INVALID", + "faceVerifyState 仅允许 VERIFIED / UNVERIFIED / SKIPPED"); + } + command.setFaceVerifyState(face); + String type = command.getResultType().trim().toUpperCase(Locale.ROOT); + if (!ANSWERED.equals(type) && !NO_ANSWER.equals(type) && !FAILED.equals(type) + && !CANCELLED.equals(type)) + { + throw badRequest("TABLET_RESULT_TYPE_INVALID", + "resultType 仅允许 ANSWERED / NO_ANSWER / FAILED / CANCELLED"); + } + command.setResultType(type); + if (command.getResultId() != null && command.getResultId().length() > 64) + { + throw badRequest("RESULT_ID_TOO_LONG", "resultId 长度不能超过 64"); + } + } + + private void requireText(String value, String field) + { + if (value == null || value.isBlank()) + { + throw badRequest("MISSING_" + field.toUpperCase(Locale.ROOT), field + " 不能为空"); + } + } + + private AptException badRequest(String reason, String message) + { + return new AptException(AptErrorCode.BAD_REQUEST, reason, message); + } + + private long nvl(Long value) + { + return value == null ? 1L : value; + } + + 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/IAptTabletDispatchService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptTabletDispatchService.java new file mode 100644 index 0000000..284441e --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptTabletDispatchService.java @@ -0,0 +1,23 @@ +package com.ruoyi.apt.dispatch.tablet.service; + +import java.util.Map; + +/** + * 平板任务派发(N-03 拉取 / N-05 ACK,公寓端/05 表 5-16 步骤 1~3)。 + * + * @author apt-rev01 + */ +public interface IAptTabletDispatchService +{ + /** 为 TABLET 模式的待叫乘记录生成任务(WAITING + 单调 dispatch_seq) */ + int generateTasks(); + + /** 按游标拉取;返回 tasks / cursor / serverTime / clockSkewMs / signTimeoutSec */ + Map pull(String deviceCode, long cursor, int limit); + + /** ACK:WAITING -> PULLED;重复 / 过期返回 40916 */ + Map ack(String deviceCode, String taskId); + + /** 过期回收:WAITING/PULLED 且 expire_at < now -> EXPIRED(不重派) */ + int expireTasks(); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptTabletResultService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptTabletResultService.java new file mode 100644 index 0000000..625d42b --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/IAptTabletResultService.java @@ -0,0 +1,15 @@ +package com.ruoyi.apt.dispatch.tablet.service; + +import java.util.Map; +import com.ruoyi.apt.dispatch.tablet.domain.AptTabletResultCommand; + +/** + * 平板结果接入(N-04,CR-02 表 3-16 / 公寓端/05 表 5-20)。 + * + * @author apt-rev01 + */ +public interface IAptTabletResultService +{ + /** 幂等受理:同 resultId 返回首次结果;迟到 / 重复记 IGNORED,不产生第二次终态 */ + Map accept(String deviceCode, AptTabletResultCommand command); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/controller/AptDutyFaceRegController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/controller/AptDutyFaceRegController.java new file mode 100644 index 0000000..04e6ffb --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/controller/AptDutyFaceRegController.java @@ -0,0 +1,84 @@ +package com.ruoyi.apt.face.controller; + +import com.ruoyi.apt.common.page.AptTableDataInfo; +import com.ruoyi.apt.face.domain.AptFaceRegConfirmCommand; +import com.ruoyi.apt.face.domain.AptFaceRegRejectCommand; +import com.ruoyi.apt.face.service.IAptFaceRegService; +import com.ruoyi.apt.framework.web.AptBaseController; +import com.ruoyi.apt.common.audit.AptAudit; +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.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; + +/** + * 值班台人脸注册审核(N-35 / N-36 / N-37)。 + * + *

权限点 {@code duty:person:reg} 为确认 / 驳回的唯一凭据;N-36 / N-37 的 + * 「二次确认」由前端 {@code $modal.confirm} 承担,后端不做前后端二次校验。 + * 签到终端只有申请权(N-34),无权调用本组接口。

+ * + * @author apt-rev01 + */ +@RestController +@RequestMapping(value = "/duty/person/face-reg", produces = "application/json;charset=UTF-8") +public class AptDutyFaceRegController extends AptBaseController +{ + private final IAptFaceRegService faceRegService; + + public AptDutyFaceRegController(IAptFaceRegService faceRegService) + { + this.faceRegService = faceRegService; + } + + /** N-35:注册台账;regState 缺省 APPLIED(待审),可传 CONFIRMED / REJECTED 查看历史 */ + @PreAuthorize("@ss.hasPermi('duty:person:reg')") + @AptAudit(module = "FACE", action = "FACE_REG_LIST") + @GetMapping("/pending") + public AptTableDataInfo pending(@RequestParam(required = false) String regState, + @RequestParam(required = false) String workNo, + @RequestParam(required = false) String name) + { + startAptPage(); + String state = (regState == null || regState.isBlank()) ? "APPLIED" : regState.trim().toUpperCase(); + AptTableDataInfo data = getAptDataTable(faceRegService.list(state, workNo, name)); + data.getWarnings().add("当前注册态筛选:" + state + "(缺省只返回待审 APPLIED)"); + return data; + } + + @PreAuthorize("@ss.hasPermi('duty:person:reg')") + @AptAudit(module = "FACE", action = "FACE_REG_DETAIL") + @GetMapping("/{regId}") + public AjaxResult detail(@PathVariable String regId) + { + return AjaxResult.success(faceRegService.detail(regId)); + } + + /** N-36:注册生效点;重复确认按 regId 幂等返回原 ryid */ + @PreAuthorize("@ss.hasPermi('duty:person:reg')") + @AptAudit(module = "FACE", action = "FACE_REG_CONFIRM") + @PostMapping("/{regId}/confirm") + public AjaxResult confirm(@PathVariable String regId, + @RequestBody(required = false) AptFaceRegConfirmCommand command) + { + AptFaceRegConfirmCommand actual = command == null ? new AptFaceRegConfirmCommand() : command; + return AjaxResult.success(faceRegService.confirm(regId, actual, SecurityUtils.getUsername())); + } + + /** N-37:驳回为终态,不生成 ryid、不触发双路 */ + @PreAuthorize("@ss.hasPermi('duty:person:reg')") + @AptAudit(module = "FACE", action = "FACE_REG_REJECT") + @PostMapping("/{regId}/reject") + public AjaxResult reject(@PathVariable String regId, + @Valid @RequestBody AptFaceRegRejectCommand command) + { + return AjaxResult.success(faceRegService.reject(regId, command, SecurityUtils.getUsername())); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/controller/AptFaceOpenController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/controller/AptFaceOpenController.java new file mode 100644 index 0000000..7ad572c --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/controller/AptFaceOpenController.java @@ -0,0 +1,82 @@ +package com.ruoyi.apt.face.controller; + +import java.util.Locale; + +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.face.domain.AptFaceRegAck; +import com.ruoyi.apt.face.domain.AptFaceRegCommand; +import com.ruoyi.apt.face.domain.AptFaceVerifyCommand; +import com.ruoyi.apt.face.domain.AptFaceVerifyResult; +import com.ruoyi.apt.face.service.IAptFaceRegService; +import com.ruoyi.apt.face.service.IAptFaceVerifyService; +import com.ruoyi.apt.framework.web.AptBaseController; +import com.ruoyi.common.annotation.Anonymous; +import com.ruoyi.common.core.domain.AjaxResult; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * 设备侧人脸接口(N-34 注册申请、N-38 核验会话)。 + * + *

属 {@code /open/v1/**} 匿名分区:不校验登录态,但 必须通过 + * {@code AptContextInterceptor} 的「设备编码 + HMAC + 时间窗 + nonce」四要素校验; + * {@code gyid} 只从部署环境注入,不接受请求参数。设备只有申请权, + * 无权直写人员主档或触发下发(表 10-24 #9)。

+ * + * @author apt-rev01 + */ +@Anonymous +@RestController +public class AptFaceOpenController extends AptBaseController +{ + private final IAptFaceRegService faceRegService; + private final IAptFaceVerifyService faceVerifyService; + + public AptFaceOpenController(IAptFaceRegService faceRegService, + IAptFaceVerifyService faceVerifyService) + { + this.faceRegService = faceRegService; + this.faceVerifyService = faceVerifyService; + } + + /** N-34:签到终端发起注册申请,只落 APPLIED */ + @PostMapping(value = "/open/v1/face/reg-requests", produces = "application/json;charset=UTF-8") + public AjaxResult applyRegRequest(@Valid @RequestBody AptFaceRegCommand command) + { + AptFaceRegAck ack = faceRegService.apply(command, currentDeviceCode()); + return AjaxResult.success(ack); + } + + /** N-38:刷脸核验会话;APPLIED 命中 40914 语义(code=40914,仍回 regId 与 nextAction) */ + @PostMapping(value = "/open/v1/checkin/verify", produces = "application/json;charset=UTF-8") + public AjaxResult verify(@Valid @RequestBody AptFaceVerifyCommand command) + { + AptFaceVerifyResult result = faceVerifyService.verify(command, currentDeviceCode()); + if ("REG_PENDING".equals(result.getNextAction())) + { + AjaxResult ajax = AjaxResult.error(AptErrorCode.FACE_REG_PENDING.getCode(), + "注册申请未获值班员确认,禁止参与核验"); + ajax.put("data", result); + return ajax; + } + return AjaxResult.success(result); + } + + private String currentDeviceCode() + { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) + { + return null; + } + HttpServletRequest request = attributes.getRequest(); + String deviceCode = request.getHeader("X-Device-Code"); + return deviceCode == null ? null : deviceCode.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFacePerson.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFacePerson.java new file mode 100644 index 0000000..8cf19a0 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFacePerson.java @@ -0,0 +1,142 @@ +package com.ruoyi.apt.face.domain; + +/** + * 人员主档人脸字段投影(apt_master_person 子集,用于确认入档与核验查人)。 + * + * @author apt-rev01 + */ +public class AptFacePerson +{ + private String ryid; + private String xm; + private String workNo; + private String deptCode; + private String personCategory; + private String photoRef; + private String photoChecksum; + private String featureRef; + private String featureChecksum; + private String featureAlgo; + private Long featureVersion; + private String faceSyncState; + + public String getRyid() + { + return ryid; + } + + public void setRyid(String ryid) + { + this.ryid = ryid; + } + + public String getXm() + { + return xm; + } + + public void setXm(String xm) + { + this.xm = xm; + } + + public String getWorkNo() + { + return workNo; + } + + public void setWorkNo(String workNo) + { + this.workNo = workNo; + } + + public String getDeptCode() + { + return deptCode; + } + + public void setDeptCode(String deptCode) + { + this.deptCode = deptCode; + } + + public String getPersonCategory() + { + return personCategory; + } + + public void setPersonCategory(String personCategory) + { + this.personCategory = personCategory; + } + + public String getPhotoRef() + { + return photoRef; + } + + public void setPhotoRef(String photoRef) + { + this.photoRef = photoRef; + } + + public String getPhotoChecksum() + { + return photoChecksum; + } + + public void setPhotoChecksum(String photoChecksum) + { + this.photoChecksum = photoChecksum; + } + + public String getFeatureRef() + { + return featureRef; + } + + public void setFeatureRef(String featureRef) + { + this.featureRef = featureRef; + } + + public String getFeatureChecksum() + { + return featureChecksum; + } + + public void setFeatureChecksum(String featureChecksum) + { + this.featureChecksum = featureChecksum; + } + + public String getFeatureAlgo() + { + return featureAlgo; + } + + public void setFeatureAlgo(String featureAlgo) + { + this.featureAlgo = featureAlgo; + } + + public Long getFeatureVersion() + { + return featureVersion; + } + + public void setFeatureVersion(Long featureVersion) + { + this.featureVersion = featureVersion; + } + + public String getFaceSyncState() + { + return faceSyncState; + } + + public void setFaceSyncState(String faceSyncState) + { + this.faceSyncState = faceSyncState; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceReg.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceReg.java new file mode 100644 index 0000000..fe51870 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceReg.java @@ -0,0 +1,290 @@ +package com.ruoyi.apt.face.domain; + +import java.util.Date; + +/** + * 人脸注册申请(apt_person_face_reg)。 + * + *

媒体只存引用 + 校验和,本体不入库(CR-C24);{@code regState} 为唯一写入口 + * {@code apt.face.registry},{@code verdict} / {@code platformVersion} 只承载 #2 回执。

+ * + * @author apt-rev01 + */ +public class AptFaceReg +{ + private String regId; + private String workNo; + private String name; + private String unitCode; + private String personCategory; + private String regState; + private Integer localOnly; + private String verdict; + private Long verdictVersion; + private Long platformVersion; + private String uploadState; + private String dispatchState; + private String faceSyncState; + private String applyDeviceCode; + private String confirmedRyid; + private String confirmedBy; + private Date confirmedAt; + private String rejectReason; + private String featureAlgo; + private Long featureVersion; + private String photoRef; + private String photoChecksum; + private String featureRef; + private String featureChecksum; + private Date occurredAt; + + public String getRegId() + { + return regId; + } + + public void setRegId(String regId) + { + this.regId = regId; + } + + public String getWorkNo() + { + return workNo; + } + + public void setWorkNo(String workNo) + { + this.workNo = workNo; + } + + public String getName() + { + return name; + } + + public void setName(String name) + { + this.name = name; + } + + public String getUnitCode() + { + return unitCode; + } + + public void setUnitCode(String unitCode) + { + this.unitCode = unitCode; + } + + public String getPersonCategory() + { + return personCategory; + } + + public void setPersonCategory(String personCategory) + { + this.personCategory = personCategory; + } + + public String getRegState() + { + return regState; + } + + public void setRegState(String regState) + { + this.regState = regState; + } + + public Integer getLocalOnly() + { + return localOnly; + } + + public void setLocalOnly(Integer localOnly) + { + this.localOnly = localOnly; + } + + public String getVerdict() + { + return verdict; + } + + public void setVerdict(String verdict) + { + this.verdict = verdict; + } + + public Long getVerdictVersion() + { + return verdictVersion; + } + + public void setVerdictVersion(Long verdictVersion) + { + this.verdictVersion = verdictVersion; + } + + public Long getPlatformVersion() + { + return platformVersion; + } + + public void setPlatformVersion(Long platformVersion) + { + this.platformVersion = platformVersion; + } + + public String getUploadState() + { + return uploadState; + } + + public void setUploadState(String uploadState) + { + this.uploadState = uploadState; + } + + public String getDispatchState() + { + return dispatchState; + } + + public void setDispatchState(String dispatchState) + { + this.dispatchState = dispatchState; + } + + public String getFaceSyncState() + { + return faceSyncState; + } + + public void setFaceSyncState(String faceSyncState) + { + this.faceSyncState = faceSyncState; + } + + public String getApplyDeviceCode() + { + return applyDeviceCode; + } + + public void setApplyDeviceCode(String applyDeviceCode) + { + this.applyDeviceCode = applyDeviceCode; + } + + public String getConfirmedRyid() + { + return confirmedRyid; + } + + public void setConfirmedRyid(String confirmedRyid) + { + this.confirmedRyid = confirmedRyid; + } + + public String getConfirmedBy() + { + return confirmedBy; + } + + public void setConfirmedBy(String confirmedBy) + { + this.confirmedBy = confirmedBy; + } + + public Date getConfirmedAt() + { + return confirmedAt; + } + + public void setConfirmedAt(Date confirmedAt) + { + this.confirmedAt = confirmedAt; + } + + public String getRejectReason() + { + return rejectReason; + } + + public void setRejectReason(String rejectReason) + { + this.rejectReason = rejectReason; + } + + public String getFeatureAlgo() + { + return featureAlgo; + } + + public void setFeatureAlgo(String featureAlgo) + { + this.featureAlgo = featureAlgo; + } + + public Long getFeatureVersion() + { + return featureVersion; + } + + public void setFeatureVersion(Long featureVersion) + { + this.featureVersion = featureVersion; + } + + public String getPhotoRef() + { + return photoRef; + } + + public void setPhotoRef(String photoRef) + { + this.photoRef = photoRef; + } + + public String getPhotoChecksum() + { + return photoChecksum; + } + + public void setPhotoChecksum(String photoChecksum) + { + this.photoChecksum = photoChecksum; + } + + public String getFeatureRef() + { + return featureRef; + } + + public void setFeatureRef(String featureRef) + { + this.featureRef = featureRef; + } + + public String getFeatureChecksum() + { + return featureChecksum; + } + + public void setFeatureChecksum(String featureChecksum) + { + this.featureChecksum = featureChecksum; + } + + 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/face/domain/AptFaceRegAck.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegAck.java new file mode 100644 index 0000000..1fc6a97 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegAck.java @@ -0,0 +1,141 @@ +package com.ruoyi.apt.face.domain; + +import java.util.ArrayList; +import java.util.List; + +/** + * N-34 / N-36 / N-37 统一响应体。 + * + *

{@code uploadState} / {@code dispatchState} 在两路只入队、未投递时保持 + * {@code PENDING},并以 {@code reportOutboxId} / {@code dispatchCommandId} 证明已触发; + * 已投递才由后续投递器推进到 {@code SENT} / {@code ISSUED}。

+ * + * @author apt-rev01 + */ +public class AptFaceRegAck +{ + private String regId; + private String regState; + /** PENDING_REVIEW / CONFIRMED / REJECTED */ + private String nextAction; + private String confirmedRyid; + private String uploadState; + private String dispatchState; + private String reportOutboxId; + private String dispatchCommandId; + /** 重复确认 / 重复申请命中幂等时为 true */ + private Boolean duplicated; + /** 特征算法是否在白名单(false 时允许入档但不会下发设备) */ + private Boolean algoSupported; + private List warnings = new ArrayList<>(); + + public String getRegId() + { + return regId; + } + + public void setRegId(String regId) + { + this.regId = regId; + } + + public String getRegState() + { + return regState; + } + + public void setRegState(String regState) + { + this.regState = regState; + } + + public String getNextAction() + { + return nextAction; + } + + public void setNextAction(String nextAction) + { + this.nextAction = nextAction; + } + + public String getConfirmedRyid() + { + return confirmedRyid; + } + + public void setConfirmedRyid(String confirmedRyid) + { + this.confirmedRyid = confirmedRyid; + } + + public String getUploadState() + { + return uploadState; + } + + public void setUploadState(String uploadState) + { + this.uploadState = uploadState; + } + + public String getDispatchState() + { + return dispatchState; + } + + public void setDispatchState(String dispatchState) + { + this.dispatchState = dispatchState; + } + + public String getReportOutboxId() + { + return reportOutboxId; + } + + public void setReportOutboxId(String reportOutboxId) + { + this.reportOutboxId = reportOutboxId; + } + + public String getDispatchCommandId() + { + return dispatchCommandId; + } + + public void setDispatchCommandId(String dispatchCommandId) + { + this.dispatchCommandId = dispatchCommandId; + } + + public Boolean getDuplicated() + { + return duplicated; + } + + public void setDuplicated(Boolean duplicated) + { + this.duplicated = duplicated; + } + + public Boolean getAlgoSupported() + { + return algoSupported; + } + + public void setAlgoSupported(Boolean algoSupported) + { + this.algoSupported = algoSupported; + } + + public List getWarnings() + { + return warnings; + } + + public void setWarnings(List warnings) + { + this.warnings = warnings; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegCommand.java new file mode 100644 index 0000000..2a35e1a --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegCommand.java @@ -0,0 +1,205 @@ +package com.ruoyi.apt.face.domain; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * N-34 {@code POST /open/v1/face/reg-requests} 请求体(签到终端申请注册)。 + * + *

约束(公寓端/10 §4 / 表 10-25):

+ *
    + *
  • 只落 {@code APPLIED},不进主档、不下发、不上报;
  • + *
  • {@code photo} 与 {@code feature} 至少一项,否则 40919;
  • + *
  • {@code gyid} 不接受入参,只从设备凭据解析(表 10-24 #2);
  • + *
  • {@code occurredAt} 兼容 {@code yyyy-MM-dd HH:mm:ss} 与 ISO-8601,缺省取服务端时间。
  • + *
+ * + * @author apt-rev01 + */ +public class AptFaceRegCommand +{ + @NotBlank(message = "工号不能为空") + @Size(max = 64, message = "工号长度不能超过 64") + private String workNo; + + @NotBlank(message = "姓名不能为空") + @Size(max = 64, message = "姓名长度不能超过 64") + private String name; + + @NotBlank(message = "单位编码不能为空") + @Size(max = 64, message = "单位编码长度不能超过 64") + private String unitCode; + + /** 照片 base64 或内网引用;与 feature 至少一项 */ + @Size(max = 1048576, message = "照片入参长度超限") + private String photo; + + @Size(max = 128, message = "照片校验和长度不能超过 128") + private String photoChecksum; + + /** 特征值 base64;为空时以照片引用占位,不做本地特征提取 */ + @Size(max = 131072, message = "特征值入参长度超限") + private String feature; + + @Size(max = 128, message = "特征值校验和长度不能超过 128") + private String featureChecksum; + + @Size(max = 32, message = "特征算法长度不能超过 32") + private String featureAlgo; + + private Long featureVersion; + + @Size(max = 20, message = "人员类别长度不能超过 20") + private String personCategory; + + @Size(max = 64, message = "申请设备编码长度不能超过 64") + private String applyDeviceCode; + + private String occurredAt; + + @Size(max = 64, message = "nonce 长度不能超过 64") + private String nonce; + + @Size(max = 128, message = "signature 长度不能超过 128") + private String signature; + + public String getWorkNo() + { + return workNo; + } + + public void setWorkNo(String workNo) + { + this.workNo = workNo; + } + + public String getName() + { + return name; + } + + public void setName(String name) + { + this.name = name; + } + + public String getUnitCode() + { + return unitCode; + } + + public void setUnitCode(String unitCode) + { + this.unitCode = unitCode; + } + + public String getPhoto() + { + return photo; + } + + public void setPhoto(String photo) + { + this.photo = photo; + } + + public String getPhotoChecksum() + { + return photoChecksum; + } + + public void setPhotoChecksum(String photoChecksum) + { + this.photoChecksum = photoChecksum; + } + + public String getFeature() + { + return feature; + } + + public void setFeature(String feature) + { + this.feature = feature; + } + + public String getFeatureChecksum() + { + return featureChecksum; + } + + public void setFeatureChecksum(String featureChecksum) + { + this.featureChecksum = featureChecksum; + } + + public String getFeatureAlgo() + { + return featureAlgo; + } + + public void setFeatureAlgo(String featureAlgo) + { + this.featureAlgo = featureAlgo; + } + + public Long getFeatureVersion() + { + return featureVersion; + } + + public void setFeatureVersion(Long featureVersion) + { + this.featureVersion = featureVersion; + } + + public String getPersonCategory() + { + return personCategory; + } + + public void setPersonCategory(String personCategory) + { + this.personCategory = personCategory; + } + + public String getApplyDeviceCode() + { + return applyDeviceCode; + } + + public void setApplyDeviceCode(String applyDeviceCode) + { + this.applyDeviceCode = applyDeviceCode; + } + + public String getOccurredAt() + { + return occurredAt; + } + + public void setOccurredAt(String occurredAt) + { + this.occurredAt = occurredAt; + } + + public String getNonce() + { + return nonce; + } + + public void setNonce(String nonce) + { + this.nonce = nonce; + } + + public String getSignature() + { + return signature; + } + + public void setSignature(String signature) + { + this.signature = signature; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegConfirmCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegConfirmCommand.java new file mode 100644 index 0000000..05327a3 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegConfirmCommand.java @@ -0,0 +1,80 @@ +package com.ruoyi.apt.face.domain; + +import jakarta.validation.constraints.Size; + +/** + * N-36 {@code POST /duty/person/face-reg/{regId}/confirm} 请求体。 + * + *

注册生效点 = 值班员确认(CR-C25),不是平台回执。允许修正 {@code unitCode} / + * {@code personCategory},并可显式声明算法兼容性判定。

+ * + * @author apt-rev01 + */ +public class AptFaceRegConfirmCommand +{ + /** L2 幂等键(与 regId 双保险;重复确认一律返回原 ryid) */ + @Size(max = 64, message = "commandId 长度不能超过 64") + private String commandId; + + @Size(max = 64, message = "单位编码长度不能超过 64") + private String unitCode; + + @Size(max = 20, message = "人员类别长度不能超过 20") + private String personCategory; + + @Size(max = 32, message = "特征算法长度不能超过 32") + private String featureAlgo; + + @Size(max = 512, message = "确认说明长度不能超过 512") + private String reason; + + public String getCommandId() + { + return commandId; + } + + public void setCommandId(String commandId) + { + this.commandId = commandId; + } + + public String getUnitCode() + { + return unitCode; + } + + public void setUnitCode(String unitCode) + { + this.unitCode = unitCode; + } + + public String getPersonCategory() + { + return personCategory; + } + + public void setPersonCategory(String personCategory) + { + this.personCategory = personCategory; + } + + public String getFeatureAlgo() + { + return featureAlgo; + } + + public void setFeatureAlgo(String featureAlgo) + { + this.featureAlgo = featureAlgo; + } + + public String getReason() + { + return reason; + } + + public void setReason(String reason) + { + this.reason = reason; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegRejectCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegRejectCommand.java new file mode 100644 index 0000000..023f80f --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceRegRejectCommand.java @@ -0,0 +1,41 @@ +package com.ruoyi.apt.face.domain; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * N-37 {@code POST /duty/person/face-reg/{regId}/reject} 请求体。 + * + *

终态:不生成 ryid、不进主档、不触发双路同步。

+ * + * @author apt-rev01 + */ +public class AptFaceRegRejectCommand +{ + @NotBlank(message = "驳回原因不能为空") + @Size(max = 256, message = "驳回原因长度不能超过 256") + private String rejectReason; + + @Size(max = 64, message = "commandId 长度不能超过 64") + private String commandId; + + public String getRejectReason() + { + return rejectReason; + } + + public void setRejectReason(String rejectReason) + { + this.rejectReason = rejectReason; + } + + public String getCommandId() + { + return commandId; + } + + public void setCommandId(String commandId) + { + this.commandId = commandId; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceVerifyCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceVerifyCommand.java new file mode 100644 index 0000000..1fecf4b --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceVerifyCommand.java @@ -0,0 +1,119 @@ +package com.ruoyi.apt.face.domain; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * N-38 {@code POST /open/v1/checkin/verify} 请求体(签到终端刷脸核验会话)。 + * + *

{@code faceVerifyState} 取 {@code VERIFIED / UNVERIFIED / SKIPPED} + * (CR-02 表 3-21 / 表 3-66 唯一权威);抓拍帧、人脸原图、比对帧一律不上传。

+ * + * @author apt-rev01 + */ +public class AptFaceVerifyCommand +{ + @NotBlank(message = "deviceEventId 不能为空") + @Size(max = 64, message = "deviceEventId 长度不能超过 64") + private String deviceEventId; + + @NotBlank(message = "faceVerifyState 不能为空") + @Size(max = 20, message = "faceVerifyState 长度不能超过 20") + private String faceVerifyState; + + @Size(max = 64, message = "verifiedRyid 长度不能超过 64") + private String verifiedRyid; + + @Size(max = 64, message = "regId 长度不能超过 64") + private String regId; + + private Long featureVersion; + + private String occurredAt; + + @Size(max = 64, message = "nonce 长度不能超过 64") + private String nonce; + + @Size(max = 128, message = "signature 长度不能超过 128") + private String signature; + + public String getDeviceEventId() + { + return deviceEventId; + } + + public void setDeviceEventId(String deviceEventId) + { + this.deviceEventId = deviceEventId; + } + + public String getFaceVerifyState() + { + return faceVerifyState; + } + + public void setFaceVerifyState(String faceVerifyState) + { + this.faceVerifyState = faceVerifyState; + } + + public String getVerifiedRyid() + { + return verifiedRyid; + } + + public void setVerifiedRyid(String verifiedRyid) + { + this.verifiedRyid = verifiedRyid; + } + + public String getRegId() + { + return regId; + } + + public void setRegId(String regId) + { + this.regId = regId; + } + + public Long getFeatureVersion() + { + return featureVersion; + } + + public void setFeatureVersion(Long featureVersion) + { + this.featureVersion = featureVersion; + } + + public String getOccurredAt() + { + return occurredAt; + } + + public void setOccurredAt(String occurredAt) + { + this.occurredAt = occurredAt; + } + + public String getNonce() + { + return nonce; + } + + public void setNonce(String nonce) + { + this.nonce = nonce; + } + + public String getSignature() + { + return signature; + } + + public void setSignature(String signature) + { + this.signature = signature; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceVerifyResult.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceVerifyResult.java new file mode 100644 index 0000000..99869eb --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/domain/AptFaceVerifyResult.java @@ -0,0 +1,174 @@ +package com.ruoyi.apt.face.domain; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * N-38 核验会话响应。 + * + *

只回结论,不含任何图像(表 10-30 #8)。{@code nextAction} 取值: + * {@code PROCEED} / {@code REG_PENDING} / {@code REGISTER_REQUIRED} / {@code RETRY} / + * {@code MANUAL_CONFIRM}。

+ * + * @author apt-rev01 + */ +public class AptFaceVerifyResult +{ + private boolean personFound; + private String ryid; + private String workNo; + private String name; + private String unitCode; + private String personCategory; + /** CONFIRMED / APPLIED / NOT_FOUND */ + private String regState; + private String regId; + private String nextAction; + private String faceVerifyState; + /** 会话有效期(N-38 → N-40 间隔 ≤5min) */ + private Date sessionExpireAt; + private Date serverTime; + private Boolean duplicated; + private List warnings = new ArrayList<>(); + + public boolean isPersonFound() + { + return personFound; + } + + public void setPersonFound(boolean personFound) + { + this.personFound = personFound; + } + + public String getRyid() + { + return ryid; + } + + public void setRyid(String ryid) + { + this.ryid = ryid; + } + + public String getWorkNo() + { + return workNo; + } + + public void setWorkNo(String workNo) + { + this.workNo = workNo; + } + + public String getName() + { + return name; + } + + public void setName(String name) + { + this.name = name; + } + + public String getUnitCode() + { + return unitCode; + } + + public void setUnitCode(String unitCode) + { + this.unitCode = unitCode; + } + + public String getPersonCategory() + { + return personCategory; + } + + public void setPersonCategory(String personCategory) + { + this.personCategory = personCategory; + } + + public String getRegState() + { + return regState; + } + + public void setRegState(String regState) + { + this.regState = regState; + } + + public String getRegId() + { + return regId; + } + + public void setRegId(String regId) + { + this.regId = regId; + } + + public String getNextAction() + { + return nextAction; + } + + public void setNextAction(String nextAction) + { + this.nextAction = nextAction; + } + + public String getFaceVerifyState() + { + return faceVerifyState; + } + + public void setFaceVerifyState(String faceVerifyState) + { + this.faceVerifyState = faceVerifyState; + } + + public Date getSessionExpireAt() + { + return sessionExpireAt; + } + + public void setSessionExpireAt(Date sessionExpireAt) + { + this.sessionExpireAt = sessionExpireAt; + } + + public Date getServerTime() + { + return serverTime; + } + + public void setServerTime(Date serverTime) + { + this.serverTime = serverTime; + } + + public Boolean getDuplicated() + { + return duplicated; + } + + public void setDuplicated(Boolean duplicated) + { + this.duplicated = duplicated; + } + + public List getWarnings() + { + return warnings; + } + + public void setWarnings(List warnings) + { + this.warnings = warnings; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/mapper/AptFaceRegMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/mapper/AptFaceRegMapper.java new file mode 100644 index 0000000..4cc8022 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/mapper/AptFaceRegMapper.java @@ -0,0 +1,75 @@ +package com.ruoyi.apt.face.mapper; + +import java.util.Date; +import java.util.List; + +import com.ruoyi.apt.face.domain.AptFacePerson; +import com.ruoyi.apt.face.domain.AptFaceReg; +import org.apache.ibatis.annotations.Param; + +/** + * 人脸注册链路 Mapper。所有语句首参恒为服务端 gyid(不采信客户端)。 + * + * @author apt-rev01 + */ +public interface AptFaceRegMapper +{ + List selectFaceRegList(@Param("gyid") String gyid, + @Param("regState") String regState, + @Param("workNo") String workNo, + @Param("name") String name); + + AptFaceReg selectFaceRegByRegId(@Param("gyid") String gyid, @Param("regId") String regId); + + /** 未决重复申请:同 work_no 且 reg_state='APPLIED' */ + AptFaceReg selectPendingByWorkNo(@Param("gyid") String gyid, @Param("workNo") String workNo); + + int countConfirmedByWorkNo(@Param("gyid") String gyid, @Param("workNo") String workNo); + + int insertFaceReg(@Param("gyid") String gyid, @Param("reg") AptFaceReg reg); + + /** 条件更新:仅 reg_state='APPLIED' 可确认,返回 0 表示状态已变 */ + int confirmFaceReg(@Param("gyid") String gyid, + @Param("regId") String regId, + @Param("confirmedRyid") String confirmedRyid, + @Param("confirmedBy") String confirmedBy, + @Param("unitCode") String unitCode, + @Param("personCategory") String personCategory, + @Param("featureAlgo") String featureAlgo); + + /** 条件更新:仅 reg_state='APPLIED' 可驳回 */ + int rejectFaceReg(@Param("gyid") String gyid, + @Param("regId") String regId, + @Param("rejectReason") String rejectReason); + + /** + * 平台回执落库:#2 personRegVerdicts[],只改 verdict / platform_version, + * 绝不改写 reg_state(Q-35:REJECTED 不回滚、不自动擦除)。 + */ + int updateVerdict(@Param("gyid") String gyid, + @Param("regId") String regId, + @Param("verdict") String verdict, + @Param("platformVersion") long platformVersion); + + AptFacePerson selectPersonByRyid(@Param("gyid") String gyid, @Param("ryid") String ryid); + + AptFacePerson selectPersonByWorkNo(@Param("gyid") String gyid, @Param("workNo") String workNo); + + int insertPerson(@Param("gyid") String gyid, @Param("person") AptFacePerson person); + + int insertReportOutbox(@Param("gyid") String gyid, + @Param("idempotencyKey") String idempotencyKey, + @Param("bizId") String bizId, + @Param("payload") String payload, + @Param("occurredAt") Date occurredAt); + + int insertDeviceCommand(@Param("gyid") String gyid, + @Param("commandId") String commandId, + @Param("deviceCode") String deviceCode, + @Param("payload") String payload, + @Param("faceScopeRef") String faceScopeRef); + + int countDeviceByCode(@Param("gyid") String gyid, @Param("deviceCode") String deviceCode); + + String selectDeviceScope(@Param("gyid") String gyid, @Param("deviceCode") String deviceCode); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/mapper/AptFaceVerifyMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/mapper/AptFaceVerifyMapper.java new file mode 100644 index 0000000..cf7f69f --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/mapper/AptFaceVerifyMapper.java @@ -0,0 +1,33 @@ +package com.ruoyi.apt.face.mapper; + +import java.util.Date; + +import org.apache.ibatis.annotations.Param; + +/** + * 签到刷脸核验会话 Mapper。 + * + *

会话不新增表:以 {@code duty_operation_log} 承载({@code log_id = VS--} + * 作幂等键),detail 存 JSON 结论;Q-40 冻结前沿用 JSON 兜底口径。

+ * + * @author apt-rev01 + */ +public interface AptFaceVerifyMapper +{ + /** 幂等查询:命中即返回上次会话 detail(JSON 字符串) */ + String selectVerifySessionDetail(@Param("gyid") String gyid, @Param("logId") String logId); + + int insertVerifySession(@Param("gyid") String gyid, + @Param("logId") String logId, + @Param("operatorRef") String operatorRef, + @Param("detail") String detail, + @Param("traceId") String traceId, + @Param("occurredAt") Date occurredAt); + + /** 设备越权查询计数(表 10-30 #11 摘要口径,IT-3 只留痕) */ + int insertScopeDeniedLog(@Param("gyid") String gyid, + @Param("logId") String logId, + @Param("detail") String detail, + @Param("traceId") String traceId, + @Param("occurredAt") Date occurredAt); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceRegServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceRegServiceImpl.java new file mode 100644 index 0000000..6841d0a --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceRegServiceImpl.java @@ -0,0 +1,507 @@ +package com.ruoyi.apt.face.service; + +import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.face.domain.AptFacePerson; +import com.ruoyi.apt.face.domain.AptFaceReg; +import com.ruoyi.apt.face.domain.AptFaceRegAck; +import com.ruoyi.apt.face.domain.AptFaceRegCommand; +import com.ruoyi.apt.face.domain.AptFaceRegConfirmCommand; +import com.ruoyi.apt.face.domain.AptFaceRegRejectCommand; +import com.ruoyi.apt.face.mapper.AptFaceRegMapper; +import com.ruoyi.apt.framework.config.AptProperties; +import com.ruoyi.apt.framework.device.AptDeviceSigner; +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; + +/** + * 人脸注册链路实现(N-34 / N-35 / N-36 / N-37,DV-3)。 + * + *

三条硬规则:

+ *
    + *
  1. 申请只落 {@code APPLIED}:不进主档、不下发、不上报,参与匹配即 40914;
  2. + *
  3. 生效点 = 值班员确认,不是平台回执;
  4. + *
  5. 重复确认按 regId 幂等返回原 ryid,且幂等判断先于状态合法性判断 + * (与 IT-2 房态 commandId 同一教训)。
  6. + *
+ * + * @author apt-rev01 + */ +@Service +public class AptFaceRegServiceImpl implements IAptFaceRegService +{ + private static final Logger log = LoggerFactory.getLogger(AptFaceRegServiceImpl.class); + + private static final String STATE_APPLIED = "APPLIED"; + private static final String STATE_CONFIRMED = "CONFIRMED"; + private static final String STATE_REJECTED = "REJECTED"; + private static final String CATEGORY_TRANSIENT = "TRANSIENT"; + private static final String CATEGORY_LONG_TERM = "LONG_TERM"; + + private final AptFaceRegMapper faceRegMapper; + private final AptFaceSyncDispatcher syncDispatcher; + private final AptProperties properties; + + public AptFaceRegServiceImpl(AptFaceRegMapper faceRegMapper, + AptFaceSyncDispatcher syncDispatcher, + AptProperties properties) + { + this.faceRegMapper = faceRegMapper; + this.syncDispatcher = syncDispatcher; + this.properties = properties; + } + + @Override + public List list(String regState, String workNo, String name) + { + return faceRegMapper.selectFaceRegList(gyid(), regState, workNo, name); + } + + @Override + public AptFaceReg detail(String regId) + { + return mustExist(regId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public AptFaceRegAck apply(AptFaceRegCommand command, String deviceCode) + { + String gyid = gyid(); + if (!notBlank(command.getWorkNo()) || !notBlank(command.getName()) || !notBlank(command.getUnitCode())) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "PARAM_INVALID", + "workNo / name / unitCode 不能为空"); + } + String workNo = command.getWorkNo().trim(); + validateMedia(command); + + if (deviceCode == null || deviceCode.isBlank() + || faceRegMapper.countDeviceByCode(gyid, deviceCode) == 0) + { + throw new AptException(AptErrorCode.FORBIDDEN_DEVICE_GYID, "APPLY_DEVICE_UNKNOWN", + "申请设备未在公寓设备档案登记"); + } + if (command.getApplyDeviceCode() != null + && !command.getApplyDeviceCode().isBlank() + && !command.getApplyDeviceCode().trim().equalsIgnoreCase(deviceCode)) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "APPLY_DEVICE_MISMATCH", + "请求体设备编码与设备凭据不一致"); + } + + // 未决重复申请:返回原 regId(幂等,不报 40918) + AptFaceReg pending = faceRegMapper.selectPendingByWorkNo(gyid, workNo); + if (pending != null) + { + AptFaceRegAck duplicated = baseAck(pending); + duplicated.setDuplicated(true); + duplicated.setNextAction("PENDING_REVIEW"); + duplicated.getWarnings().add("该工号已有未决申请,已返回原 regId(幂等)"); + return duplicated; + } + if (faceRegMapper.countConfirmedByWorkNo(gyid, workNo) > 0) + { + throw new AptException(AptErrorCode.FACE_PERSON_DUPLICATE, "DUPLICATE_WORK_NO", + "该工号已存在已确认的人脸注册"); + } + + boolean hasFeature = notBlank(command.getFeature()); + String algo = hasFeature + ? (notBlank(command.getFeatureAlgo()) ? command.getFeatureAlgo().trim() : "FEAT_V1") + : "PHOTO_REF"; + boolean algoSupported = isAlgoSupported(algo); + + String regId = newId("FR"); + AptFaceReg reg = new AptFaceReg(); + reg.setRegId(regId); + reg.setRegState(STATE_APPLIED); + reg.setWorkNo(workNo); + reg.setName(command.getName().trim()); + reg.setUnitCode(command.getUnitCode().trim()); + reg.setPersonCategory(normalizeCategory(command.getPersonCategory())); + reg.setFeatureAlgo(algo); + reg.setFeatureVersion(command.getFeatureVersion() == null ? 1L : command.getFeatureVersion()); + reg.setPhotoRef(notBlank(command.getPhoto()) ? ref(regId, "photo") : null); + reg.setPhotoChecksum(trimToNull(command.getPhotoChecksum())); + reg.setFeatureRef(hasFeature ? ref(regId, "feature") : ref(regId, "photo")); + reg.setFeatureChecksum(trimToNull(hasFeature ? command.getFeatureChecksum() : command.getPhotoChecksum())); + reg.setApplyDeviceCode(deviceCode); + reg.setOccurredAt(parseTime(command.getOccurredAt())); + reg.setFaceSyncState(algoSupported ? "PENDING" : "FAILED"); + + faceRegMapper.insertFaceReg(gyid, reg); + + AptFaceRegAck ack = baseAck(reg); + ack.setDuplicated(false); + ack.setAlgoSupported(algoSupported); + ack.setNextAction("PENDING_REVIEW"); + ack.setUploadState("PENDING"); + ack.setDispatchState("PENDING"); + if (!algoSupported) + { + ack.getWarnings().add("特征算法 " + algo + " 不在白名单,允许入档但确认后不会下发设备(40921)"); + } + ack.getWarnings().add("申请已落 APPLIED:不进人员主档、不下发设备、不上报平台,需值班员确认后生效"); + return ack; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public AptFaceRegAck confirm(String regId, AptFaceRegConfirmCommand command, String operatorRef) + { + String gyid = gyid(); + AptFaceReg reg = mustExist(regId); + + // 幂等优先:重复确认返回原 ryid,不报 40924 + if (STATE_CONFIRMED.equals(reg.getRegState()) && notBlank(reg.getConfirmedRyid())) + { + AptFaceRegAck duplicated = baseAck(reg); + duplicated.setDuplicated(true); + duplicated.setNextAction(STATE_CONFIRMED); + duplicated.getWarnings().add("该申请已确认,已返回原 ryid(regId 幂等)"); + return duplicated; + } + if (!STATE_APPLIED.equals(reg.getRegState())) + { + throw new AptException(AptErrorCode.FACE_REG_STATE_INVALID, "REG_STATE_ILLEGAL", + "当前注册态 " + reg.getRegState() + " 不允许确认"); + } + + String unitCode = notBlank(command.getUnitCode()) ? command.getUnitCode().trim() : reg.getUnitCode(); + String category = notBlank(command.getPersonCategory()) + ? normalizeCategory(command.getPersonCategory()) : normalizeCategory(reg.getPersonCategory()); + String algo = notBlank(command.getFeatureAlgo()) ? command.getFeatureAlgo().trim() : reg.getFeatureAlgo(); + boolean algoSupported = isAlgoSupported(algo); + + AptFacePerson exist = faceRegMapper.selectPersonByWorkNo(gyid, reg.getWorkNo()); + if (exist != null) + { + throw new AptException(AptErrorCode.FACE_PERSON_DUPLICATE, "DUPLICATE_WORK_NO", + "该工号已存在人员主档,禁止重复注册"); + } + + String ryid = newId("RY"); + AptFacePerson person = new AptFacePerson(); + person.setRyid(ryid); + person.setXm(reg.getName()); + person.setWorkNo(reg.getWorkNo()); + person.setDeptCode(unitCode); + person.setPersonCategory(category); + person.setPhotoRef(reg.getPhotoRef()); + person.setPhotoChecksum(reg.getPhotoChecksum()); + person.setFeatureRef(reg.getFeatureRef()); + person.setFeatureChecksum(reg.getFeatureChecksum()); + person.setFeatureAlgo(algo); + person.setFeatureVersion(reg.getFeatureVersion() == null ? 1L : reg.getFeatureVersion()); + person.setFaceSyncState(algoSupported ? "PENDING" : "FAILED"); + faceRegMapper.insertPerson(gyid, person); + + int updated = faceRegMapper.confirmFaceReg(gyid, regId, ryid, operatorRef, + unitCode, category, algo); + if (updated == 0) + { + throw new AptException(AptErrorCode.FACE_REG_STATE_INVALID, "REG_STATE_ILLEGAL", + "注册态已被并发修改,确认未生效"); + } + + AptFaceReg snapshot = new AptFaceReg(); + snapshot.setRegId(reg.getRegId()); + snapshot.setWorkNo(reg.getWorkNo()); + snapshot.setName(reg.getName()); + snapshot.setUnitCode(unitCode); + snapshot.setPersonCategory(category); + snapshot.setPhotoRef(reg.getPhotoRef()); + snapshot.setPhotoChecksum(reg.getPhotoChecksum()); + snapshot.setFeatureRef(reg.getFeatureRef()); + snapshot.setFeatureChecksum(reg.getFeatureChecksum()); + snapshot.setFeatureAlgo(algo); + snapshot.setFeatureVersion(reg.getFeatureVersion()); + snapshot.setApplyDeviceCode(reg.getApplyDeviceCode()); + + AptFaceRegAck ack = new AptFaceRegAck(); + ack.setRegId(regId); + ack.setRegState(STATE_CONFIRMED); + ack.setConfirmedRyid(ryid); + ack.setNextAction(STATE_CONFIRMED); + ack.setDuplicated(false); + ack.setAlgoSupported(algoSupported); + ack.setUploadState("PENDING"); + ack.setDispatchState("PENDING"); + + // 事务提交后再触发双路,保证主事务失败整体回滚且 reg_state 保持 APPLIED + dispatchAfterCommit(gyid, snapshot, ryid, algoSupported, ack); + return ack; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public AptFaceRegAck reject(String regId, AptFaceRegRejectCommand command, String operatorRef) + { + String gyid = gyid(); + AptFaceReg reg = mustExist(regId); + if (!STATE_APPLIED.equals(reg.getRegState())) + { + throw new AptException(AptErrorCode.FACE_REG_STATE_INVALID, "REG_STATE_ILLEGAL", + "当前注册态 " + reg.getRegState() + " 不允许驳回"); + } + + int updated = faceRegMapper.rejectFaceReg(gyid, regId, command.getRejectReason().trim()); + if (updated == 0) + { + throw new AptException(AptErrorCode.FACE_REG_STATE_INVALID, "REG_STATE_ILLEGAL", + "注册态已被并发修改,驳回未生效"); + } + + AptFaceRegAck ack = new AptFaceRegAck(); + ack.setRegId(regId); + ack.setRegState(STATE_REJECTED); + ack.setNextAction(STATE_REJECTED); + ack.setDuplicated(false); + ack.getWarnings().add("驳回为终态:不生成 ryid、不进人员主档、不触发双路同步"); + return ack; + } + + private void dispatchAfterCommit(String gyid, AptFaceReg snapshot, String ryid, + boolean algoSupported, AptFaceRegAck ack) + { + Runnable task = () -> dispatchBoth(gyid, snapshot, ryid, algoSupported, ack); + if (TransactionSynchronizationManager.isSynchronizationActive()) + { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() + { + @Override + public void afterCommit() + { + task.run(); + } + }); + } + else + { + task.run(); + } + } + + /** 4a 与 4b 各自 try-catch:任一失败不回退另一路,也不回滚注册主事务 */ + private void dispatchBoth(String gyid, AptFaceReg snapshot, String ryid, + boolean algoSupported, AptFaceRegAck ack) + { + try + { + ack.setReportOutboxId(syncDispatcher.enqueuePlatformReport(gyid, snapshot, ryid)); + ack.setUploadState("PENDING"); + } + catch (Exception e) + { + log.warn("4a 平台上报入队失败(独立重试): regId={} {}", snapshot.getRegId(), e.getMessage()); + ack.setUploadState("FAILED"); + ack.getWarnings().add("平台上报入队失败,已独立重试,不影响设备下发"); + } + + if (!algoSupported) + { + ack.setDispatchState("PENDING"); + ack.getWarnings().add("特征算法不在白名单,已入档但不下发设备(40921)"); + return; + } + try + { + ack.setDispatchCommandId(syncDispatcher.enqueueDeviceIssue(gyid, snapshot, ryid)); + ack.setDispatchState("PENDING"); + } + catch (Exception e) + { + log.warn("4b 设备下发入队失败(独立重试): regId={} {}", snapshot.getRegId(), e.getMessage()); + ack.setDispatchState("FAILED"); + ack.getWarnings().add("设备下发入队失败,已独立重试,不影响平台上报"); + } + } + + private void validateMedia(AptFaceRegCommand command) + { + boolean hasPhoto = notBlank(command.getPhoto()); + boolean hasFeature = notBlank(command.getFeature()); + if (!hasPhoto && !hasFeature) + { + throw new AptException(AptFaceErrorCodeMedia.MISSING, "MEDIA_MISSING", + "照片与特征值至少提供一项"); + } + AptProperties.Device config = properties.getDevice(); + if (hasPhoto) + { + if (!notBlank(command.getPhotoChecksum())) + { + throw new AptException(AptFaceErrorCodeMedia.MISSING, "PHOTO_CHECKSUM_MISSING", + "提供照片时必须同时提供 photoChecksum"); + } + if (estimateBytes(command.getPhoto()) > config.getPhotoMaxBytes()) + { + throw new AptException(AptFaceErrorCodeMedia.TOO_LARGE, "PHOTO_TOO_LARGE", + "照片超过上限 " + config.getPhotoMaxBytes() + " 字节"); + } + assertChecksum(command.getPhoto(), command.getPhotoChecksum(), "PHOTO"); + } + if (hasFeature) + { + if (!notBlank(command.getFeatureChecksum())) + { + throw new AptException(AptFaceErrorCodeMedia.MISSING, "FEATURE_CHECKSUM_MISSING", + "提供特征值时必须同时提供 featureChecksum"); + } + if (estimateBytes(command.getFeature()) > config.getFeatureMaxBytes()) + { + throw new AptException(AptFaceErrorCodeMedia.TOO_LARGE, "FEATURE_TOO_LARGE", + "特征值超过上限 " + config.getFeatureMaxBytes() + " 字节"); + } + assertChecksum(command.getFeature(), command.getFeatureChecksum(), "FEATURE"); + } + } + + /** + * 媒体内部校验和口径:对上传串(base64 文本)本身做 SHA-256, + * 服务端不落媒体本体(CR-C24),因此不涉及解码与二次编码差异。 + */ + private void assertChecksum(String payload, String declared, String kind) + { + String actual = AptDeviceSigner.sha256Hex(payload.getBytes(StandardCharsets.UTF_8)); + if (!actual.equalsIgnoreCase(declared.trim())) + { + throw new AptException(AptFaceErrorCodeMedia.CHECKSUM_MISMATCH, kind + "_CHECKSUM_MISMATCH", + kind.toLowerCase() + " 校验和不符,整条申请拒收且不生成待办"); + } + } + + private long estimateBytes(String payload) + { + long length = payload.length(); + return length % 4 == 0 ? length / 4 * 3 : length / 4 * 3 + 1; + } + + private boolean isAlgoSupported(String algo) + { + if (!notBlank(algo)) + { + return false; + } + String[] whitelist = properties.getDevice().getFaceAlgoWhitelist(); + if (whitelist == null) + { + return false; + } + for (String item : whitelist) + { + if (algo.equalsIgnoreCase(item)) + { + return true; + } + } + return false; + } + + private AptFaceReg mustExist(String regId) + { + AptFaceReg reg = faceRegMapper.selectFaceRegByRegId(gyid(), regId); + if (reg == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "FACE_REG_NOT_FOUND", + "人脸注册申请不存在: " + regId); + } + return reg; + } + + private AptFaceRegAck baseAck(AptFaceReg reg) + { + AptFaceRegAck ack = new AptFaceRegAck(); + ack.setRegId(reg.getRegId()); + ack.setRegState(reg.getRegState()); + ack.setConfirmedRyid(reg.getConfirmedRyid()); + ack.setUploadState(reg.getUploadState()); + ack.setDispatchState(reg.getDispatchState()); + return ack; + } + + private String normalizeCategory(String category) + { + if (!notBlank(category)) + { + return CATEGORY_TRANSIENT; + } + String normalized = category.trim().toUpperCase(); + if (!CATEGORY_TRANSIENT.equals(normalized) && !CATEGORY_LONG_TERM.equals(normalized)) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "PERSON_CATEGORY_INVALID", + "人员类别只能是 TRANSIENT 或 LONG_TERM"); + } + return normalized; + } + + private Date parseTime(String raw) + { + if (!notBlank(raw)) + { + return new Date(); + } + String value = raw.trim().replace('T', ' '); + if (value.length() > 19) + { + value = value.substring(0, 19); + } + try + { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(value); + } + catch (ParseException e) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "OCCURRED_AT_INVALID", + "occurredAt 格式应为 yyyy-MM-dd HH:mm:ss 或 ISO-8601"); + } + } + + private String ref(String regId, String kind) + { + // 媒体范围只存引用占位,真实签名直传通道属 IT-7 + return "apt://face-reg/" + regId + "/" + kind; + } + + private String newId(String prefix) + { + return prefix + System.currentTimeMillis() + + String.format("%04d", ThreadLocalRandom.current().nextInt(10000)); + } + + private String trimToNull(String value) + { + return notBlank(value) ? value.trim() : null; + } + + private static boolean notBlank(String value) + { + return value != null && !value.isBlank(); + } + + private String gyid() + { + return AptContext.getGyId(); + } + + /** 媒体相关错误码集中引用,避免散落魔法值 */ + private static final class AptFaceErrorCodeMedia + { + static final AptErrorCode MISSING = AptErrorCode.FACE_MEDIA_MISSING; + static final AptErrorCode TOO_LARGE = AptErrorCode.FACE_MEDIA_TOO_LARGE; + static final AptErrorCode CHECKSUM_MISMATCH = AptErrorCode.FACE_MEDIA_CHECKSUM_MISMATCH; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceSyncDispatcher.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceSyncDispatcher.java new file mode 100644 index 0000000..53099da --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceSyncDispatcher.java @@ -0,0 +1,98 @@ +package com.ruoyi.apt.face.service; + +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.alibaba.fastjson2.JSON; +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.face.domain.AptFaceReg; +import com.ruoyi.apt.face.mapper.AptFaceRegMapper; +import com.ruoyi.apt.framework.device.AptDeviceSigner; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 注册确认后的双路同步(CR-C25 表 3-50 F-J / 图 10-2)。 + * + *

4a 上行(#9 PERSON_REG)与 4b 下发(FACE_ISSUE)各自独立事务: + * 任一路失败只置该路状态并告警,绝不把另一路回退为 PENDING,也不回滚注册主事务。 + * 两路都只入队,{@code protocol_confirmed} 恒为 0,禁止把入队当成功 + * ({@code upload_state} / {@code dispatch_state} 保持 PENDING,由后续投递器推进)。

+ * + * @author apt-rev01 + */ +@Service +public class AptFaceSyncDispatcher +{ + private final AptFaceRegMapper faceRegMapper; + + public AptFaceSyncDispatcher(AptFaceRegMapper faceRegMapper) + { + this.faceRegMapper = faceRegMapper; + } + + /** 4a:#9 PERSON_REG 上报入队,返回 idempotencyKey */ + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public String enqueuePlatformReport(String gyid, AptFaceReg reg, String confirmedRyid) + { + String idempotencyKey = AptDeviceSigner.sha256Hex( + (gyid + "|PERSON_REG|" + reg.getRegId() + "|1").getBytes(StandardCharsets.UTF_8)); + + Map payload = new LinkedHashMap<>(); + payload.put("ryid", confirmedRyid); + payload.put("regId", reg.getRegId()); + payload.put("regState", "CONFIRMED"); + payload.put("workNo", reg.getWorkNo()); + payload.put("name", reg.getName()); + payload.put("unitCode", reg.getUnitCode()); + payload.put("personCategory", reg.getPersonCategory()); + payload.put("photoRef", reg.getPhotoRef()); + payload.put("photoChecksum", reg.getPhotoChecksum()); + payload.put("featureRef", reg.getFeatureRef()); + payload.put("featureChecksum", reg.getFeatureChecksum()); + payload.put("featureAlgo", reg.getFeatureAlgo()); + payload.put("featureVersion", reg.getFeatureVersion()); + // CR-C24:媒体本体不上行,走短期签名直传 + payload.put("mediaTransport", "SIGNED_URL"); + payload.put("protocolConfirmed", false); + + faceRegMapper.insertReportOutbox(gyid, idempotencyKey, reg.getRegId(), + JSON.toJSONString(payload), new Date()); + return idempotencyKey; + } + + /** 4b:FACE_ISSUE 下发入队,返回 commandId */ + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public String enqueueDeviceIssue(String gyid, AptFaceReg reg, String confirmedRyid) + { + String deviceCode = reg.getApplyDeviceCode(); + if (deviceCode == null || deviceCode.isBlank()) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "APPLY_DEVICE_MISSING", + "注册申请缺少申请设备编码,无法生成下发任务"); + } + String commandId = AptDeviceSigner.sha256Hex( + (gyid + "|" + deviceCode + "|FACE_ISSUE|" + confirmedRyid + "|1") + .getBytes(StandardCharsets.UTF_8)); + + Map payload = new LinkedHashMap<>(); + payload.put("ryid", confirmedRyid); + payload.put("regId", reg.getRegId()); + payload.put("workNo", reg.getWorkNo()); + payload.put("featureAlgo", reg.getFeatureAlgo()); + payload.put("featureVersion", reg.getFeatureVersion()); + payload.put("featureRef", reg.getFeatureRef()); + payload.put("featureChecksum", reg.getFeatureChecksum()); + payload.put("photoRef", reg.getPhotoRef()); + payload.put("protocolConfirmed", false); + + String scope = faceRegMapper.selectDeviceScope(gyid, deviceCode); + faceRegMapper.insertDeviceCommand(gyid, commandId, deviceCode, + JSON.toJSONString(payload), scope); + return commandId; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceVerdictService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceVerdictService.java new file mode 100644 index 0000000..1065cd3 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceVerdictService.java @@ -0,0 +1,98 @@ +package com.ruoyi.apt.face.service; + +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +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.face.domain.AptFaceReg; +import com.ruoyi.apt.face.mapper.AptFaceRegMapper; +import com.ruoyi.apt.framework.audit.AptAuditWriter; +import com.ruoyi.apt.ops.domain.AptOperationLog; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * #2 {@code personRegVerdicts[]} 回执落库(CR-C25 / Q-35 默认口径)。 + * + *

回执只是「主档同步结果」,不决定公寓侧生效;平台 {@code REJECTED} 时 + * 不回滚本地注册、不撤销已下发设备、不自动擦除,只告警 + 留痕 + 暂停后续下发。

+ * + * @author apt-rev01 + */ +@Service +public class AptFaceVerdictService +{ + private static final Logger log = LoggerFactory.getLogger(AptFaceVerdictService.class); + + private final AptFaceRegMapper faceRegMapper; + private final AptAuditWriter auditWriter; + + public AptFaceVerdictService(AptFaceRegMapper faceRegMapper, AptAuditWriter auditWriter) + { + this.faceRegMapper = faceRegMapper; + this.auditWriter = auditWriter; + } + + /** + * 落库一条回执。 + * + * @param regId 注册申请 ID + * @param verdict ACCEPTED / REJECTED + * @param platformVersion 平台版本号 + * @param reason 平台返回原因(DUPLICATE_WORK_NO 等) + */ + @Transactional(rollbackFor = Exception.class) + public void applyVerdict(String regId, String verdict, long platformVersion, String reason) + { + String gyid = AptContext.getGyId(); + if (!"ACCEPTED".equals(verdict) && !"REJECTED".equals(verdict)) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "VERDICT_INVALID", + "回执只能是 ACCEPTED 或 REJECTED"); + } + AptFaceReg reg = faceRegMapper.selectFaceRegByRegId(gyid, regId); + if (reg == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "MASTER_VERSION_NOT_FOUND", + "注册申请不存在: " + regId); + } + + faceRegMapper.updateVerdict(gyid, regId, verdict, platformVersion); + + if ("REJECTED".equals(verdict)) + { + // Q-35:只告警与留痕,reg_state / 已下发设备一律保持原样 + log.warn("平台驳回人脸注册回执: gyid={} regId={} reason={} 本地注册保持 {},不回滚不擦除", + gyid, regId, reason, reg.getRegState()); + Map detail = new LinkedHashMap<>(); + detail.put("regId", regId); + detail.put("verdict", verdict); + detail.put("platformVersion", platformVersion); + detail.put("reason", reason); + detail.put("localRegState", reg.getRegState()); + detail.put("handling", "NO_ROLLBACK_ALERT_ONLY"); + detail.put("pendingDutyAction", "值班台人工归并,并暂停该人员后续下发"); + writeAudit(gyid, "PERSON_REG_REJECTED", detail); + } + } + + private void writeAudit(String gyid, String action, Map detail) + { + AptOperationLog entry = new AptOperationLog(); + entry.setGyId(gyid); + entry.setLogId("FRV-" + System.currentTimeMillis() + "-" + Math.abs(detail.hashCode())); + entry.setOperatorRef("PLATFORM"); + entry.setModule("FACE"); + entry.setAction(action); + entry.setDetail(JSON.toJSONString(detail)); + entry.setTraceId(AptContext.getTraceId()); + entry.setOccurredAt(new Date()); + auditWriter.write(entry); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceVerifyServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceVerifyServiceImpl.java new file mode 100644 index 0000000..33e828d --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/AptFaceVerifyServiceImpl.java @@ -0,0 +1,171 @@ +package com.ruoyi.apt.face.service; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; + +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.face.domain.AptFacePerson; +import com.ruoyi.apt.face.domain.AptFaceReg; +import com.ruoyi.apt.face.domain.AptFaceVerifyCommand; +import com.ruoyi.apt.face.domain.AptFaceVerifyResult; +import com.ruoyi.apt.face.mapper.AptFaceRegMapper; +import com.ruoyi.apt.face.mapper.AptFaceVerifyMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 核验会话实现(N-38,表 11-13 / 表 10-30 #8)。 + * + *

只回业务结论,不回任何图像;{@code faceVerifyState} 取 + * {@code VERIFIED / UNVERIFIED / SKIPPED}(CR-02 表 3-66 唯一权威)。 + * 会话以 {@code duty_operation_log} 承载,{@code log_id} 为幂等键, + * 有效期 5 分钟(N-38 → N-40 间隔约束,N-40 属 IT-4)。

+ * + * @author apt-rev01 + */ +@Service +public class AptFaceVerifyServiceImpl implements IAptFaceVerifyService +{ + private static final long SESSION_TTL_MILLIS = 5 * 60 * 1000L; + + private final AptFaceVerifyMapper verifyMapper; + private final AptFaceRegMapper faceRegMapper; + + public AptFaceVerifyServiceImpl(AptFaceVerifyMapper verifyMapper, AptFaceRegMapper faceRegMapper) + { + this.verifyMapper = verifyMapper; + this.faceRegMapper = faceRegMapper; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public AptFaceVerifyResult verify(AptFaceVerifyCommand command, String deviceCode) + { + String gyid = AptContext.getGyId(); + if (deviceCode == null || deviceCode.isBlank()) + { + throw new AptException(AptErrorCode.UNAUTHORIZED, "DEVICE_CREDENTIAL_MISSING", + "缺少设备凭据 X-Device-Code"); + } + + String state = command.getFaceVerifyState().trim().toUpperCase(); + if (!"VERIFIED".equals(state) && !"UNVERIFIED".equals(state) && !"SKIPPED".equals(state)) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "FACE_VERIFY_STATE_INVALID", + "faceVerifyState 只能是 VERIFIED / UNVERIFIED / SKIPPED"); + } + + String logId = "VS-" + deviceCode + "-" + command.getDeviceEventId().trim(); + String existing = verifyMapper.selectVerifySessionDetail(gyid, logId); + if (existing != null && !existing.isBlank()) + { + AptFaceVerifyResult replay = JSON.parseObject(existing, AptFaceVerifyResult.class); + replay.setDuplicated(true); + return replay; + } + + Date now = new Date(); + Date occurredAt = parseTime(command.getOccurredAt(), now); + + AptFaceVerifyResult result = new AptFaceVerifyResult(); + result.setFaceVerifyState(state); + result.setServerTime(now); + result.setSessionExpireAt(new Date(now.getTime() + SESSION_TTL_MILLIS)); + result.setDuplicated(false); + + if ("VERIFIED".equals(state)) + { + if (command.getVerifiedRyid() == null || command.getVerifiedRyid().isBlank()) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "VERIFIED_RYID_MISSING", + "faceVerifyState=VERIFIED 时必须提供 verifiedRyid"); + } + resolveVerified(gyid, command, result); + } + else if ("UNVERIFIED".equals(state)) + { + result.setRegState("NOT_FOUND"); + result.setNextAction("RETRY"); + result.getWarnings().add("比对未通过,请重新扫脸或转人工核验"); + } + else + { + result.setRegState("NOT_FOUND"); + result.setNextAction("MANUAL_CONFIRM"); + result.getWarnings().add("已跳过刷脸核验,转值班台人工确认"); + } + + verifyMapper.insertVerifySession(gyid, logId, deviceCode, + JSON.toJSONString(result), AptContext.getTraceId(), occurredAt); + return result; + } + + private void resolveVerified(String gyid, AptFaceVerifyCommand command, AptFaceVerifyResult result) + { + String ryid = command.getVerifiedRyid().trim(); + AptFacePerson person = faceRegMapper.selectPersonByRyid(gyid, ryid); + if (person != null) + { + result.setPersonFound(true); + result.setRyid(person.getRyid()); + result.setWorkNo(person.getWorkNo()); + result.setName(person.getXm()); + result.setUnitCode(person.getDeptCode()); + result.setPersonCategory(person.getPersonCategory()); + result.setRegState("CONFIRMED"); + result.setNextAction("PROCEED"); + return; + } + + // 未入档:若设备带回 regId,说明该申请仍处于 APPLIED(40914 语义) + AptFaceReg reg = null; + if (command.getRegId() != null && !command.getRegId().isBlank()) + { + reg = faceRegMapper.selectFaceRegByRegId(gyid, command.getRegId().trim()); + } + if (reg != null && "APPLIED".equals(reg.getRegState())) + { + result.setPersonFound(false); + result.setRegState("APPLIED"); + result.setRegId(reg.getRegId()); + result.setWorkNo(reg.getWorkNo()); + result.setName(reg.getName()); + result.setUnitCode(reg.getUnitCode()); + result.setPersonCategory(reg.getPersonCategory()); + result.setNextAction("REG_PENDING"); + result.getWarnings().add("注册申请未获值班员确认(40914),确认后需重新扫脸核验"); + return; + } + + result.setPersonFound(false); + result.setRegState("NOT_FOUND"); + result.setNextAction("REGISTER_REQUIRED"); + result.getWarnings().add("人员未注册,请先在签到终端发起人脸注册申请"); + } + + private Date parseTime(String raw, Date fallback) + { + if (raw == null || raw.isBlank()) + { + return fallback; + } + String value = raw.trim().replace('T', ' '); + if (value.length() > 19) + { + value = value.substring(0, 19); + } + try + { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(value); + } + catch (ParseException e) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "OCCURRED_AT_INVALID", + "occurredAt 格式应为 yyyy-MM-dd HH:mm:ss 或 ISO-8601"); + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/IAptFaceRegService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/IAptFaceRegService.java new file mode 100644 index 0000000..5353ea4 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/IAptFaceRegService.java @@ -0,0 +1,33 @@ +package com.ruoyi.apt.face.service; + +import java.util.List; + +import com.ruoyi.apt.face.domain.AptFaceReg; +import com.ruoyi.apt.face.domain.AptFaceRegAck; +import com.ruoyi.apt.face.domain.AptFaceRegCommand; +import com.ruoyi.apt.face.domain.AptFaceRegConfirmCommand; +import com.ruoyi.apt.face.domain.AptFaceRegRejectCommand; + +/** + * 人脸注册链路(N-34 → N-35 → N-36 / N-37)。 + * + *

签名一律不接受客户端 gyid;生效点恒为值班员确认,平台回执只改 verdict。

+ * + * @author apt-rev01 + */ +public interface IAptFaceRegService +{ + /** N-35 台账:regState 为空时返回全部状态(兼容待审页与台账页) */ + List list(String regState, String workNo, String name); + + AptFaceReg detail(String regId); + + /** N-34 签到终端申请:只落 APPLIED,不进主档、不下发、不上报 */ + AptFaceRegAck apply(AptFaceRegCommand command, String deviceCode); + + /** N-36 确认:注册生效点,事务内写主档,提交后双路并行触发 */ + AptFaceRegAck confirm(String regId, AptFaceRegConfirmCommand command, String operatorRef); + + /** N-37 驳回:终态,不生成 ryid、不触发双路 */ + AptFaceRegAck reject(String regId, AptFaceRegRejectCommand command, String operatorRef); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/IAptFaceVerifyService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/IAptFaceVerifyService.java new file mode 100644 index 0000000..214e1c2 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/face/service/IAptFaceVerifyService.java @@ -0,0 +1,18 @@ +package com.ruoyi.apt.face.service; + +import com.ruoyi.apt.face.domain.AptFaceVerifyCommand; +import com.ruoyi.apt.face.domain.AptFaceVerifyResult; + +/** + * 签到刷脸核验会话(N-38)。 + * + * @author apt-rev01 + */ +public interface IAptFaceVerifyService +{ + /** + * @param command 核验结论(只含 faceVerifyState / verifiedRyid / regId,不含任何图像) + * @param deviceCode 已通过 HMAC 校验的设备编码 + */ + AptFaceVerifyResult verify(AptFaceVerifyCommand command, String deviceCode); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/device/AptDeviceSigner.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/device/AptDeviceSigner.java new file mode 100644 index 0000000..7970f74 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/device/AptDeviceSigner.java @@ -0,0 +1,229 @@ +package com.ruoyi.apt.framework.device; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.framework.config.AptProperties; +import com.ruoyi.common.filter.RepeatedlyRequestWrapper; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * 设备侧四要素鉴权(IT-3 / DV-3,公寓端/10 表 10-24 #1)。 + * + *

仅作用于 {@code /open/v1/**}:设备编码 + HMAC-SHA256 签名 + ±时间窗 + nonce 去重。 + * 签名原文(LF 分隔):

+ *
+ * METHOD
+ * REQUEST-URI
+ * X-Device-Code
+ * X-Device-Timestamp
+ * X-Device-Nonce
+ * SHA-256(body) 十六进制小写(无请求体时为空串的 SHA-256)
+ * 
+ * + *

诚实边界:nonce 去重为单实例内存窗口({@link ConcurrentHashMap}), + * 多实例部署须换成 Redis 共享窗口;请求体依赖若依 {@code RepeatableFilter} + * 包装的 {@code RepeatedlyRequestWrapper},未包装时按空体计算,不会静默放行。

+ * + * @author apt-rev01 + */ +@Component +public class AptDeviceSigner +{ + private static final Logger log = LoggerFactory.getLogger(AptDeviceSigner.class); + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final String HEADER_DEVICE_CODE = "X-Device-Code"; + private static final String HEADER_TIMESTAMP = "X-Device-Timestamp"; + private static final String HEADER_NONCE = "X-Device-Nonce"; + private static final String HEADER_SIGNATURE = "X-Device-Signature"; + + private final AptProperties properties; + + /** nonce -> 过期时间(毫秒时间戳) */ + private final Map nonceWindow = new ConcurrentHashMap<>(); + + public AptDeviceSigner(AptProperties properties) + { + this.properties = properties; + } + + /** + * 校验并返回(大写后的)设备编码。 + * + * @throws AptException 40101 凭据缺失 / 时间窗越界 / 签名不符;40901 nonce 重放 + */ + public String verify(HttpServletRequest request) + { + String deviceCode = request.getHeader(HEADER_DEVICE_CODE); + if (deviceCode == null || deviceCode.isBlank()) + { + throw new AptException(AptErrorCode.UNAUTHORIZED, "DEVICE_CREDENTIAL_MISSING", + "缺少设备凭据 X-Device-Code"); + } + deviceCode = deviceCode.trim().toUpperCase(Locale.ROOT); + + AptProperties.Device config = properties.getDevice(); + if (!config.isHmacEnabled()) + { + log.warn("apt.device.hmac-enabled=false: /open/v1/** 仅校验 X-Device-Code,生产环境必须开启签名"); + return deviceCode; + } + + String secret = config.getHmacSecret(); + if (secret == null || secret.isBlank()) + { + throw new AptException(AptErrorCode.UNAUTHORIZED, "DEVICE_SECRET_UNCONFIGURED", + "设备签名已启用但未配置 apt.device.hmac-secret"); + } + + String timestamp = request.getHeader(HEADER_TIMESTAMP); + String nonce = request.getHeader(HEADER_NONCE); + String signature = request.getHeader(HEADER_SIGNATURE); + if (isBlank(timestamp) || isBlank(nonce) || isBlank(signature)) + { + throw new AptException(AptErrorCode.UNAUTHORIZED, "DEVICE_SIGNATURE_MISSING", + "缺少签名头 X-Device-Timestamp / X-Device-Nonce / X-Device-Signature"); + } + + long sentAt = parseTimestamp(timestamp); + long now = System.currentTimeMillis(); + long window = config.getTimeWindowSeconds() * 1000L; + if (Math.abs(now - sentAt) > window) + { + throw new AptException(AptErrorCode.UNAUTHORIZED, "DEVICE_TIMESTAMP_OUT_OF_WINDOW", + "设备时间戳超出允许偏移(±" + config.getTimeWindowSeconds() + "s)"); + } + + purgeExpired(now); + if (nonceWindow.containsKey(nonce)) + { + throw new AptException(AptErrorCode.DUPLICATE_KEY, "DEVICE_NONCE_REPLAY", + "设备请求 nonce 重复(疑似重放)"); + } + + String canonical = canonicalString(request, deviceCode, timestamp.trim(), nonce.trim()); + String expected = hmacHex(secret, canonical); + if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), + signature.trim().toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8))) + { + throw new AptException(AptErrorCode.UNAUTHORIZED, "DEVICE_SIGNATURE_INVALID", + "设备签名校验不通过"); + } + + nonceWindow.put(nonce.trim(), now + config.getNonceTtlSeconds() * 1000L); + return deviceCode; + } + + /** 签名原文,供端到端脚本与单测复用 */ + public String canonicalString(HttpServletRequest request, String deviceCode, String timestamp, String nonce) + { + String bodyHash = sha256Hex(readBody(request)); + return request.getMethod().toUpperCase(Locale.ROOT) + "\n" + + request.getRequestURI() + "\n" + + deviceCode + "\n" + + timestamp + "\n" + + nonce + "\n" + + bodyHash; + } + + private byte[] readBody(HttpServletRequest request) + { + if (!(request instanceof RepeatedlyRequestWrapper)) + { + return new byte[0]; + } + try + { + return request.getInputStream().readAllBytes(); + } + catch (IOException e) + { + log.warn("读取设备请求体失败,签名按空体计算: {}", e.getMessage()); + return new byte[0]; + } + } + + private long parseTimestamp(String raw) + { + try + { + long value = Long.parseLong(raw.trim()); + // 兼容秒级(10 位)与毫秒级(13 位)时间戳 + return value < 100000000000L ? value * 1000L : value; + } + catch (NumberFormatException e) + { + throw new AptException(AptErrorCode.UNAUTHORIZED, "DEVICE_TIMESTAMP_INVALID", + "设备时间戳格式非法,应为秒或毫秒整数"); + } + } + + private void purgeExpired(long now) + { + Iterator> iterator = nonceWindow.entrySet().iterator(); + while (iterator.hasNext()) + { + if (iterator.next().getValue() < now) + { + iterator.remove(); + } + } + } + + private static boolean isBlank(String value) + { + return value == null || value.isBlank(); + } + + private static String hmacHex(String secret, String payload) + { + try + { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + return toHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + } + catch (Exception e) + { + throw new AptException(AptErrorCode.INTERNAL_ERROR, "HMAC_INIT_FAILED", + "设备签名初始化失败", e); + } + } + + public static String sha256Hex(byte[] payload) + { + try + { + return toHex(MessageDigest.getInstance("SHA-256").digest(payload)); + } + catch (Exception e) + { + throw new AptException(AptErrorCode.INTERNAL_ERROR, "SHA256_FAILED", + "媒体摘要计算失败", e); + } + } + + private static String toHex(byte[] bytes) + { + StringBuilder builder = new StringBuilder(bytes.length * 2); + for (byte b : bytes) + { + builder.append(Character.forDigit((b >> 4) & 0xF, 16)); + builder.append(Character.forDigit(b & 0xF, 16)); + } + return builder.toString(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptCheckinOpenController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptCheckinOpenController.java new file mode 100644 index 0000000..97358c4 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptCheckinOpenController.java @@ -0,0 +1,72 @@ +package com.ruoyi.apt.stay.checkin.controller; + +import java.util.Locale; + +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyCommand; +import com.ruoyi.apt.stay.checkin.service.IAptCheckinService; +import com.ruoyi.common.annotation.Anonymous; +import com.ruoyi.common.core.domain.AjaxResult; +import jakarta.servlet.http.HttpServletRequest; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * 设备侧入住接口(N-39 选项 / N-40 申请 / N-41 查询)。 + * + *

属 {@code /open/v1/**} 匿名分区:不校验登录态,但必须通过设备四要素 + * (编码 + HMAC + 时间窗 + nonce);设备只能提交申请,不能审核、不能推动流程态。

+ * + * @author apt-rev01 + */ +@Anonymous +@RestController +public class AptCheckinOpenController +{ + private final IAptCheckinService checkinService; + + public AptCheckinOpenController(IAptCheckinService checkinService) + { + this.checkinService = checkinService; + } + + /** N-39:入住选项;version 与服务器一致时返回 notModified,选项体为空 */ + @GetMapping(value = "/open/v1/checkin/options", produces = "application/json;charset=UTF-8") + public AjaxResult options(@RequestParam(required = false) String version) + { + return AjaxResult.success(checkinService.options(version)); + } + + /** N-40:入住申请;同一设备 deviceEventId 幂等,重复提交返回原 applyId */ + @PostMapping(value = "/open/v1/checkin/applications", produces = "application/json;charset=UTF-8") + public AjaxResult apply(@Valid @RequestBody AptCheckinApplyCommand command) + { + return AjaxResult.success(checkinService.apply(command, currentDeviceCode())); + } + + /** N-41:按 applyId 查询申请单进度 */ + @GetMapping(value = "/open/v1/checkin/applications/{applyId}", produces = "application/json;charset=UTF-8") + public AjaxResult detail(@PathVariable String applyId) + { + return AjaxResult.success(checkinService.detail(applyId)); + } + + private String currentDeviceCode() + { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) + { + return null; + } + HttpServletRequest request = attributes.getRequest(); + String deviceCode = request.getHeader("X-Device-Code"); + return deviceCode == null ? null : deviceCode.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptCheckinQueryController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptCheckinQueryController.java new file mode 100644 index 0000000..595e1e0 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptCheckinQueryController.java @@ -0,0 +1,39 @@ +package com.ruoyi.apt.stay.checkin.controller; + +import com.ruoyi.apt.common.audit.AptAudit; +import com.ruoyi.apt.stay.checkin.service.IAptCheckinService; +import com.ruoyi.common.core.domain.AjaxResult; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 管理端入住与叫乘任务查询(N-47)。 + * + *

按 {@code jcid} 反查入住事实:{@code task=null} 表示任务未就绪(未匹配或未复算), + * 与「查无此入住」(40401)严格区分。

+ * + * @author apt-rev01 + */ +@RestController +@RequestMapping(value = "/apt/checkin", produces = "application/json;charset=UTF-8") +public class AptCheckinQueryController +{ + private final IAptCheckinService checkinService; + + public AptCheckinQueryController(IAptCheckinService checkinService) + { + this.checkinService = checkinService; + } + + /** N-47:入住 + 叫乘任务(管理端只读) */ + @PreAuthorize("@ss.hasPermi('apt:checkin:query')") + @AptAudit(module = "CHECKIN", action = "CHECKIN_CALL_TASK") + @GetMapping("/{jcid}/call-task") + public AjaxResult callTask(@PathVariable String jcid) + { + return AjaxResult.success(checkinService.callTask(jcid)); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptDutyCheckinController.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptDutyCheckinController.java new file mode 100644 index 0000000..4b7f1aa --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/controller/AptDutyCheckinController.java @@ -0,0 +1,93 @@ +package com.ruoyi.apt.stay.checkin.controller; + +import com.ruoyi.apt.common.audit.AptAudit; +import com.ruoyi.apt.common.page.AptTableDataInfo; +import com.ruoyi.apt.framework.web.AptBaseController; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewCommand; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinRoomAdjustCommand; +import com.ruoyi.apt.stay.checkin.service.IAptCheckinService; +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.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; + +/** + * 值班台入住审核(N-42 ~ N-46)。 + * + *

权限点分三段:列表 {@code duty:checkin:list}、审核 {@code duty:checkin:review}、 + * 换房 {@code duty:checkin:adjust}。审核与换房都必须带 {@code expectedBizVersion}, + * 版本不一致统一 40926,避免按过期页面处置。

+ * + * @author apt-rev01 + */ +@RestController +@RequestMapping(value = "/duty/checkin/applications", produces = "application/json;charset=UTF-8") +public class AptDutyCheckinController extends AptBaseController +{ + private final IAptCheckinService checkinService; + + public AptDutyCheckinController(IAptCheckinService checkinService) + { + this.checkinService = checkinService; + } + + /** N-42:入住待审列表;state 缺省 SUBMITTED */ + @PreAuthorize("@ss.hasPermi('duty:checkin:list')") + @AptAudit(module = "CHECKIN", action = "CHECKIN_LIST") + @GetMapping + public AptTableDataInfo list(@RequestParam(required = false) String state, + @RequestParam(required = false) String workNo, + @RequestParam(required = false) String name) + { + startAptPage(); + String flowState = (state == null || state.isBlank()) ? "SUBMITTED" : state.trim().toUpperCase(); + AptTableDataInfo data = getAptDataTable(checkinService.list(flowState, workNo, name)); + data.getWarnings().add("当前流程态筛选:" + flowState + "(缺省只看待审 SUBMITTED)"); + return data; + } + + /** N-43:审核详情;首次查看把 SUBMITTED 条件迁移为 REVIEWING(接单) */ + @PreAuthorize("@ss.hasPermi('duty:checkin:list')") + @AptAudit(module = "CHECKIN", action = "CHECKIN_DETAIL") + @GetMapping("/{applyId}") + public AjaxResult detail(@PathVariable String applyId) + { + return AjaxResult.success(checkinService.dutyDetail(applyId, SecurityUtils.getUsername())); + } + + /** N-44:审核(APPROVE / APPROVE_WITH_ROOM_CHANGE / REJECT),通过即同事务办理入住 */ + @PreAuthorize("@ss.hasPermi('duty:checkin:review')") + @AptAudit(module = "CHECKIN", action = "CHECKIN_REVIEW") + @PostMapping("/{applyId}/review") + public AjaxResult review(@PathVariable String applyId, + @Valid @RequestBody AptCheckinReviewCommand command) + { + return AjaxResult.success(checkinService.review(applyId, command, SecurityUtils.getUsername())); + } + + /** N-45:审核期换房(仅 APPROVED / ROOM_ADJUSTED;已入住返回 40926) */ + @PreAuthorize("@ss.hasPermi('duty:checkin:adjust')") + @AptAudit(module = "CHECKIN", action = "CHECKIN_ROOM_ADJUST") + @PostMapping("/{applyId}/room-adjust") + public AjaxResult roomAdjust(@PathVariable String applyId, + @Valid @RequestBody AptCheckinRoomAdjustCommand command) + { + return AjaxResult.success(checkinService.roomAdjust(applyId, command, SecurityUtils.getUsername())); + } + + /** N-46:审核时间线(流程态迁移 + 换房留痕) */ + @PreAuthorize("@ss.hasPermi('duty:checkin:list')") + @AptAudit(module = "CHECKIN", action = "CHECKIN_TIMELINE") + @GetMapping("/{applyId}/timeline") + public AjaxResult timeline(@PathVariable String applyId) + { + return AjaxResult.success(checkinService.timeline(applyId)); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCallTimeResult.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCallTimeResult.java new file mode 100644 index 0000000..d5688ba --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCallTimeResult.java @@ -0,0 +1,125 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import java.util.Date; + +/** + * 叫乘时刻复算结果(CR-02 表 3-57 备注 + 修订 R-48)。 + * + *

要素(kcsj / jcfs)来自平台匹配;执行时刻与派生一律本地计算: + * {@code jcsj = kcsj - advance}、{@code fjsj = jcsj + 10min}、 + * {@code jcbjsj = jcsj + 15min}、{@code ylysj = jcsj + release}。 + * 规则升级不回溯:{@code ruleVersion} 随结果留痕。

+ * + * @author apt-rev01 + */ +public class AptCallTimeResult +{ + private Date kcsj; + + private Date jcsj; + + private Date fjsj; + + private Date jcbjsj; + + private Date ylysj; + + /** 命中的规则版本;规则缺失时为 0(使用内置缺省) */ + private long ruleVersion; + + private boolean holiday; + + private int advanceMinutes; + + private int releaseMinutes; + + public Date getKcsj() + { + return kcsj; + } + + public void setKcsj(Date kcsj) + { + this.kcsj = kcsj; + } + + public Date getJcsj() + { + return jcsj; + } + + public void setJcsj(Date jcsj) + { + this.jcsj = jcsj; + } + + public Date getFjsj() + { + return fjsj; + } + + public void setFjsj(Date fjsj) + { + this.fjsj = fjsj; + } + + public Date getJcbjsj() + { + return jcbjsj; + } + + public void setJcbjsj(Date jcbjsj) + { + this.jcbjsj = jcbjsj; + } + + public Date getYlysj() + { + return ylysj; + } + + public void setYlysj(Date ylysj) + { + this.ylysj = ylysj; + } + + public long getRuleVersion() + { + return ruleVersion; + } + + public void setRuleVersion(long ruleVersion) + { + this.ruleVersion = ruleVersion; + } + + public boolean isHoliday() + { + return holiday; + } + + public void setHoliday(boolean holiday) + { + this.holiday = holiday; + } + + public int getAdvanceMinutes() + { + return advanceMinutes; + } + + public void setAdvanceMinutes(int advanceMinutes) + { + this.advanceMinutes = advanceMinutes; + } + + public int getReleaseMinutes() + { + return releaseMinutes; + } + + public void setReleaseMinutes(int releaseMinutes) + { + this.releaseMinutes = releaseMinutes; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplication.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplication.java new file mode 100644 index 0000000..ad82074 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplication.java @@ -0,0 +1,702 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * 入住申请单(N-41 / N-42 / N-43 共用投影)。 + * + *

来自 {@code apt_local_task}:申请期只有流程态(checkin_state / sync_state), + * 不写 {@code gy_jcxx};{@code ryzt} / {@code finish_ly} 只在审核通过后经 + * {@code apt.duty.state} 语义写入,本类不含这两个字段。

+ * + * @author apt-rev01 + */ +public class AptCheckinApplication +{ + private String applyId; + + private String jcid; + + private String taskId; + + private String deviceCode; + + private String deviceEventId; + + private String workNo; + + private String name; + + private String ryid; + + private String regId; + + private String regStateAtApply; + + private String fjh; + + private String fjhFinal; + + private int roomAdjustSeq; + + private String roomId; + + private String roomType; + + private String roomStatus; + + private String roomStatusLabel; + + private int bedCount; + + private int liveCount; + + private String jlid; + + private String cc; + + private String jch; + + private Date kcsj; + + private Date kcsjSnapshot; + + private long bizVersion; + + private String checkinState; + + private String syncState; + + private String matchState; + + private String matchedPlanKey; + + private String faceVerifyState; + + private String faceDeviceCode; + + private Date faceVerifiedAt; + + private String verifiedRyid; + + private Date verifiedAt; + + private String origin; + + private String source; + + private Date submittedAt; + + private Date occurredAt; + + private String reviewedBy; + + private Date reviewedAt; + + private String reviewNote; + + private String adjustedBy; + + private Date adjustedAt; + + private String adjustReason; + + private String rejectReason; + + private Date checkedInAt; + + private Date reportedAt; + + private Date matchedAt; + + private Date taskReadyAt; + + private Date jcsj; + + private String jcfs; + + private String jllx; + + private Long derivedRuleVersion; + + private Long featureVersion; + + /** 申请原始报文(JSON 兜底留痕,Q-40) */ + private String requestPayload; + + private List warnings = new ArrayList<>(); + + private List roomCandidates = new ArrayList<>(); + + public String getApplyId() + { + return applyId; + } + + public void setApplyId(String applyId) + { + this.applyId = applyId; + } + + public String getJcid() + { + return jcid; + } + + public void setJcid(String jcid) + { + this.jcid = jcid; + } + + public String getTaskId() + { + return taskId; + } + + public void setTaskId(String taskId) + { + this.taskId = taskId; + } + + public String getDeviceCode() + { + return deviceCode; + } + + public void setDeviceCode(String deviceCode) + { + this.deviceCode = deviceCode; + } + + public String getDeviceEventId() + { + return deviceEventId; + } + + public void setDeviceEventId(String deviceEventId) + { + this.deviceEventId = deviceEventId; + } + + public Long getFeatureVersion() + { + return featureVersion; + } + + public void setFeatureVersion(Long featureVersion) + { + this.featureVersion = featureVersion; + } + + public String getRequestPayload() + { + return requestPayload; + } + + public void setRequestPayload(String requestPayload) + { + this.requestPayload = requestPayload; + } + + public String getWorkNo() + { + return workNo; + } + + public void setWorkNo(String workNo) + { + this.workNo = workNo; + } + + public String getName() + { + return name; + } + + public void setName(String name) + { + this.name = name; + } + + public String getRyid() + { + return ryid; + } + + public void setRyid(String ryid) + { + this.ryid = ryid; + } + + public String getRegId() + { + return regId; + } + + public void setRegId(String regId) + { + this.regId = regId; + } + + public String getRegStateAtApply() + { + return regStateAtApply; + } + + public void setRegStateAtApply(String regStateAtApply) + { + this.regStateAtApply = regStateAtApply; + } + + public String getFjh() + { + return fjh; + } + + public void setFjh(String fjh) + { + this.fjh = fjh; + } + + public String getFjhFinal() + { + return fjhFinal; + } + + public void setFjhFinal(String fjhFinal) + { + this.fjhFinal = fjhFinal; + } + + public int getRoomAdjustSeq() + { + return roomAdjustSeq; + } + + public void setRoomAdjustSeq(int roomAdjustSeq) + { + this.roomAdjustSeq = roomAdjustSeq; + } + + public String getRoomId() + { + return roomId; + } + + public void setRoomId(String roomId) + { + this.roomId = roomId; + } + + public String getRoomType() + { + return roomType; + } + + public void setRoomType(String roomType) + { + this.roomType = roomType; + } + + public String getRoomStatus() + { + return roomStatus; + } + + public void setRoomStatus(String roomStatus) + { + this.roomStatus = roomStatus; + } + + public String getRoomStatusLabel() + { + return roomStatusLabel; + } + + public void setRoomStatusLabel(String roomStatusLabel) + { + this.roomStatusLabel = roomStatusLabel; + } + + public int getBedCount() + { + return bedCount; + } + + public void setBedCount(int bedCount) + { + this.bedCount = bedCount; + } + + public int getLiveCount() + { + return liveCount; + } + + public void setLiveCount(int liveCount) + { + this.liveCount = liveCount; + } + + public String getJlid() + { + return jlid; + } + + public void setJlid(String jlid) + { + this.jlid = jlid; + } + + public String getCc() + { + return cc; + } + + public void setCc(String cc) + { + this.cc = cc; + } + + public String getJch() + { + return jch; + } + + public void setJch(String jch) + { + this.jch = jch; + } + + public Date getKcsj() + { + return kcsj; + } + + public void setKcsj(Date kcsj) + { + this.kcsj = kcsj; + } + + public Date getKcsjSnapshot() + { + return kcsjSnapshot; + } + + public void setKcsjSnapshot(Date kcsjSnapshot) + { + this.kcsjSnapshot = kcsjSnapshot; + } + + public long getBizVersion() + { + return bizVersion; + } + + public void setBizVersion(long bizVersion) + { + this.bizVersion = bizVersion; + } + + public String getCheckinState() + { + return checkinState; + } + + public void setCheckinState(String checkinState) + { + this.checkinState = checkinState; + } + + public String getSyncState() + { + return syncState; + } + + public void setSyncState(String syncState) + { + this.syncState = syncState; + } + + public String getMatchState() + { + return matchState; + } + + public void setMatchState(String matchState) + { + this.matchState = matchState; + } + + public String getMatchedPlanKey() + { + return matchedPlanKey; + } + + public void setMatchedPlanKey(String matchedPlanKey) + { + this.matchedPlanKey = matchedPlanKey; + } + + public String getFaceVerifyState() + { + return faceVerifyState; + } + + public void setFaceVerifyState(String faceVerifyState) + { + this.faceVerifyState = faceVerifyState; + } + + public String getFaceDeviceCode() + { + return faceDeviceCode; + } + + public void setFaceDeviceCode(String faceDeviceCode) + { + this.faceDeviceCode = faceDeviceCode; + } + + public Date getFaceVerifiedAt() + { + return faceVerifiedAt; + } + + public void setFaceVerifiedAt(Date faceVerifiedAt) + { + this.faceVerifiedAt = faceVerifiedAt; + } + + public String getVerifiedRyid() + { + return verifiedRyid; + } + + public void setVerifiedRyid(String verifiedRyid) + { + this.verifiedRyid = verifiedRyid; + } + + public Date getVerifiedAt() + { + return verifiedAt; + } + + public void setVerifiedAt(Date verifiedAt) + { + this.verifiedAt = verifiedAt; + } + + public String getOrigin() + { + return origin; + } + + public void setOrigin(String origin) + { + this.origin = origin; + } + + public String getSource() + { + return source; + } + + public void setSource(String source) + { + this.source = source; + } + + public Date getSubmittedAt() + { + return submittedAt; + } + + public void setSubmittedAt(Date submittedAt) + { + this.submittedAt = submittedAt; + } + + public Date getOccurredAt() + { + return occurredAt; + } + + public void setOccurredAt(Date occurredAt) + { + this.occurredAt = occurredAt; + } + + public String getReviewedBy() + { + return reviewedBy; + } + + public void setReviewedBy(String reviewedBy) + { + this.reviewedBy = reviewedBy; + } + + public Date getReviewedAt() + { + return reviewedAt; + } + + public void setReviewedAt(Date reviewedAt) + { + this.reviewedAt = reviewedAt; + } + + public String getReviewNote() + { + return reviewNote; + } + + public void setReviewNote(String reviewNote) + { + this.reviewNote = reviewNote; + } + + public String getAdjustedBy() + { + return adjustedBy; + } + + public void setAdjustedBy(String adjustedBy) + { + this.adjustedBy = adjustedBy; + } + + public Date getAdjustedAt() + { + return adjustedAt; + } + + public void setAdjustedAt(Date adjustedAt) + { + this.adjustedAt = adjustedAt; + } + + public String getAdjustReason() + { + return adjustReason; + } + + public void setAdjustReason(String adjustReason) + { + this.adjustReason = adjustReason; + } + + public String getRejectReason() + { + return rejectReason; + } + + public void setRejectReason(String rejectReason) + { + this.rejectReason = rejectReason; + } + + public Date getCheckedInAt() + { + return checkedInAt; + } + + public void setCheckedInAt(Date checkedInAt) + { + this.checkedInAt = checkedInAt; + } + + public Date getReportedAt() + { + return reportedAt; + } + + public void setReportedAt(Date reportedAt) + { + this.reportedAt = reportedAt; + } + + public Date getMatchedAt() + { + return matchedAt; + } + + public void setMatchedAt(Date matchedAt) + { + this.matchedAt = matchedAt; + } + + public Date getTaskReadyAt() + { + return taskReadyAt; + } + + public void setTaskReadyAt(Date taskReadyAt) + { + this.taskReadyAt = taskReadyAt; + } + + public Date getJcsj() + { + return jcsj; + } + + public void setJcsj(Date jcsj) + { + this.jcsj = jcsj; + } + + public String getJcfs() + { + return jcfs; + } + + public void setJcfs(String jcfs) + { + this.jcfs = jcfs; + } + + public String getJllx() + { + return jllx; + } + + public void setJllx(String jllx) + { + this.jllx = jllx; + } + + public Long getDerivedRuleVersion() + { + return derivedRuleVersion; + } + + public void setDerivedRuleVersion(Long derivedRuleVersion) + { + this.derivedRuleVersion = derivedRuleVersion; + } + + public List getWarnings() + { + return warnings; + } + + public void setWarnings(List warnings) + { + this.warnings = warnings; + } + + public List getRoomCandidates() + { + return roomCandidates; + } + + public void setRoomCandidates(List roomCandidates) + { + this.roomCandidates = roomCandidates; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplyCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplyCommand.java new file mode 100644 index 0000000..aac95bd --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplyCommand.java @@ -0,0 +1,181 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import java.util.Date; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * N-40 入住申请命令(设备侧)。 + * + *

只接收入住要素,不接收 ryid 之外的任何主数据字段: + * 姓名 / 床位 / 房态一律服务端回查 {@code apt_master_*} 与 {@code gy_rooms}。 + * {@code deviceEventId} 是设备幂等键,同一设备重复提交不得产生第二条申请。

+ * + * @author apt-rev01 + */ +public class AptCheckinApplyCommand +{ + /** 设备幂等键(必填);与 X-Device-Code 组成唯一键 uk_lt_gyid_device_event */ + @NotBlank(message = "deviceEventId 不能为空") + @Size(max = 64, message = "deviceEventId 长度不能超过 64") + private String deviceEventId; + + /** N-38 核验通过的人员 */ + @NotBlank(message = "ryid 不能为空") + @Size(max = 64, message = "ryid 长度不能超过 64") + private String ryid; + + /** 可选:核验会话对应的注册单;缺省按 ryid 回查最近一条 CONFIRMED */ + @Size(max = 64, message = "regId 长度不能超过 64") + private String regId; + + @NotBlank(message = "房号不能为空") + @Size(max = 32, message = "房号长度不能超过 32") + private String fjh; + + @Size(max = 64, message = "jlid 长度不能超过 64") + private String jlid; + + @Size(max = 64, message = "车次长度不能超过 64") + private String cc; + + @Size(max = 32, message = "机次长度不能超过 32") + private String jch; + + /** 申请来源:DEVICE / DUTY / SELF(缺省 DEVICE) */ + @Size(max = 20, message = "source 长度不能超过 20") + private String source; + + /** 设备侧事件时间;缺省取服务端时间 */ + private Date occurredAt; + + /** N-38 核验时刻(服务端兜底校验 ≤5 min) */ + private Date verifiedAt; + + /** 核验所用特征版本;小于主档版本时返回 40927 */ + private Long featureVersion; + + @Size(max = 512, message = "备注长度不能超过 512") + private String remark; + + public String getDeviceEventId() + { + return deviceEventId; + } + + public void setDeviceEventId(String deviceEventId) + { + this.deviceEventId = deviceEventId; + } + + public String getRyid() + { + return ryid; + } + + public void setRyid(String ryid) + { + this.ryid = ryid; + } + + public String getRegId() + { + return regId; + } + + public void setRegId(String regId) + { + this.regId = regId; + } + + public String getFjh() + { + return fjh; + } + + public void setFjh(String fjh) + { + this.fjh = fjh; + } + + public String getJlid() + { + return jlid; + } + + public void setJlid(String jlid) + { + this.jlid = jlid; + } + + public String getCc() + { + return cc; + } + + public void setCc(String cc) + { + this.cc = cc; + } + + public String getJch() + { + return jch; + } + + public void setJch(String jch) + { + this.jch = jch; + } + + public String getSource() + { + return source; + } + + public void setSource(String source) + { + this.source = source; + } + + public Date getOccurredAt() + { + return occurredAt; + } + + public void setOccurredAt(Date occurredAt) + { + this.occurredAt = occurredAt; + } + + public Date getVerifiedAt() + { + return verifiedAt; + } + + public void setVerifiedAt(Date verifiedAt) + { + this.verifiedAt = verifiedAt; + } + + public Long getFeatureVersion() + { + return featureVersion; + } + + public void setFeatureVersion(Long featureVersion) + { + this.featureVersion = featureVersion; + } + + 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/stay/checkin/domain/AptCheckinApplyResult.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplyResult.java new file mode 100644 index 0000000..bc45517 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinApplyResult.java @@ -0,0 +1,121 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import java.util.ArrayList; +import java.util.List; + +/** + * N-40 入住申请回执。重复提交返回原 applyId({@code duplicated=true})。 + * + * @author apt-rev01 + */ +public class AptCheckinApplyResult +{ + private String applyId; + + private String jcid; + + private String checkinState; + + private String syncState; + + /** CHECKIN_ROUTE / CHECKIN_PENDING(表 11-9) */ + private String origin; + + private long bizVersion; + + private boolean duplicated; + + private String serverTime; + + private List warnings = new ArrayList<>(); + + public String getApplyId() + { + return applyId; + } + + public void setApplyId(String applyId) + { + this.applyId = applyId; + } + + public String getJcid() + { + return jcid; + } + + public void setJcid(String jcid) + { + this.jcid = jcid; + } + + public String getCheckinState() + { + return checkinState; + } + + public void setCheckinState(String checkinState) + { + this.checkinState = checkinState; + } + + public String getSyncState() + { + return syncState; + } + + public void setSyncState(String syncState) + { + this.syncState = syncState; + } + + public String getOrigin() + { + return origin; + } + + public void setOrigin(String origin) + { + this.origin = origin; + } + + public long getBizVersion() + { + return bizVersion; + } + + public void setBizVersion(long bizVersion) + { + this.bizVersion = bizVersion; + } + + public boolean isDuplicated() + { + return duplicated; + } + + public void setDuplicated(boolean duplicated) + { + this.duplicated = duplicated; + } + + public String getServerTime() + { + return serverTime; + } + + public void setServerTime(String serverTime) + { + this.serverTime = serverTime; + } + + public List getWarnings() + { + return warnings; + } + + public void setWarnings(List warnings) + { + this.warnings = warnings; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinCallTask.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinCallTask.java new file mode 100644 index 0000000..5e911b6 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinCallTask.java @@ -0,0 +1,269 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import java.util.Date; + +/** + * N-47 按 jcid 查入住与叫乘任务。 + * + *

{@code task=null} 表示任务未就绪(sync_state != TASK_READY),调用方应看 + * {@code syncState} / {@code matchState} 解释原因,而不是当成「无此入住」。

+ * + * @author apt-rev01 + */ +public class AptCheckinCallTask +{ + private String applyId; + + private String jcid; + + private String checkinState; + + private String syncState; + + private String matchState; + + private int roomAdjustSeq; + + private String fjh; + + private String fjhFinal; + + private Date jcsj; + + private String jcfs; + + private boolean taskReady; + + private String serverTime; + + private Task task; + + public String getApplyId() + { + return applyId; + } + + public void setApplyId(String applyId) + { + this.applyId = applyId; + } + + public String getJcid() + { + return jcid; + } + + public void setJcid(String jcid) + { + this.jcid = jcid; + } + + public String getCheckinState() + { + return checkinState; + } + + public void setCheckinState(String checkinState) + { + this.checkinState = checkinState; + } + + public String getSyncState() + { + return syncState; + } + + public void setSyncState(String syncState) + { + this.syncState = syncState; + } + + public String getMatchState() + { + return matchState; + } + + public void setMatchState(String matchState) + { + this.matchState = matchState; + } + + public int getRoomAdjustSeq() + { + return roomAdjustSeq; + } + + public void setRoomAdjustSeq(int roomAdjustSeq) + { + this.roomAdjustSeq = roomAdjustSeq; + } + + public String getFjh() + { + return fjh; + } + + public void setFjh(String fjh) + { + this.fjh = fjh; + } + + public String getFjhFinal() + { + return fjhFinal; + } + + public void setFjhFinal(String fjhFinal) + { + this.fjhFinal = fjhFinal; + } + + public Date getJcsj() + { + return jcsj; + } + + public void setJcsj(Date jcsj) + { + this.jcsj = jcsj; + } + + public String getJcfs() + { + return jcfs; + } + + public void setJcfs(String jcfs) + { + this.jcfs = jcfs; + } + + public boolean isTaskReady() + { + return taskReady; + } + + public void setTaskReady(boolean taskReady) + { + this.taskReady = taskReady; + } + + public String getServerTime() + { + return serverTime; + } + + public void setServerTime(String serverTime) + { + this.serverTime = serverTime; + } + + public Task getTask() + { + return task; + } + + public void setTask(Task task) + { + this.task = task; + } + + public static class Task + { + private String taskId; + + private String jlid; + + private String cc; + + private String jch; + + private Date kcsj; + + private Date jcsj; + + private String jcfs; + + private String state; + + public String getTaskId() + { + return taskId; + } + + public void setTaskId(String taskId) + { + this.taskId = taskId; + } + + public String getJlid() + { + return jlid; + } + + public void setJlid(String jlid) + { + this.jlid = jlid; + } + + public String getCc() + { + return cc; + } + + public void setCc(String cc) + { + this.cc = cc; + } + + public String getJch() + { + return jch; + } + + public void setJch(String jch) + { + this.jch = jch; + } + + public Date getKcsj() + { + return kcsj; + } + + public void setKcsj(Date kcsj) + { + this.kcsj = kcsj; + } + + public Date getJcsj() + { + return jcsj; + } + + public void setJcsj(Date jcsj) + { + this.jcsj = jcsj; + } + + public String getJcfs() + { + return jcfs; + } + + public void setJcfs(String jcfs) + { + this.jcfs = jcfs; + } + + public String getState() + { + return state; + } + + public void setState(String state) + { + this.state = state; + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinMatchResult.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinMatchResult.java new file mode 100644 index 0000000..f578d8d --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinMatchResult.java @@ -0,0 +1,145 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * #7 匹配结论落库结果(幂等:重复结论只更新版本与时间,不重复入队 #8)。 + * + * @author apt-rev01 + */ +public class AptCheckinMatchResult +{ + private String applyId; + + private String jcid; + + private String verdict; + + private String matchState; + + private String syncState; + + private Date jcsj; + + private Long derivedRuleVersion; + + private boolean taskReady; + + private boolean changed; + + private Long taskAckOutboxId; + + private List warnings = new ArrayList<>(); + + public String getApplyId() + { + return applyId; + } + + public void setApplyId(String applyId) + { + this.applyId = applyId; + } + + public String getJcid() + { + return jcid; + } + + public void setJcid(String jcid) + { + this.jcid = jcid; + } + + public String getVerdict() + { + return verdict; + } + + public void setVerdict(String verdict) + { + this.verdict = verdict; + } + + public String getMatchState() + { + return matchState; + } + + public void setMatchState(String matchState) + { + this.matchState = matchState; + } + + public String getSyncState() + { + return syncState; + } + + public void setSyncState(String syncState) + { + this.syncState = syncState; + } + + public Date getJcsj() + { + return jcsj; + } + + public void setJcsj(Date jcsj) + { + this.jcsj = jcsj; + } + + public Long getDerivedRuleVersion() + { + return derivedRuleVersion; + } + + public void setDerivedRuleVersion(Long derivedRuleVersion) + { + this.derivedRuleVersion = derivedRuleVersion; + } + + public boolean isTaskReady() + { + return taskReady; + } + + public void setTaskReady(boolean taskReady) + { + this.taskReady = taskReady; + } + + public boolean isChanged() + { + return changed; + } + + public void setChanged(boolean changed) + { + this.changed = changed; + } + + public Long getTaskAckOutboxId() + { + return taskAckOutboxId; + } + + public void setTaskAckOutboxId(Long taskAckOutboxId) + { + this.taskAckOutboxId = taskAckOutboxId; + } + + public List getWarnings() + { + return warnings; + } + + public void setWarnings(List warnings) + { + this.warnings = warnings; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinMatchVerdict.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinMatchVerdict.java new file mode 100644 index 0000000..e16b36f --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinMatchVerdict.java @@ -0,0 +1,138 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import java.util.Date; + +/** + * #7 匹配结论(matchVerdicts[] 单条)。 + * + *

本类由平台拉取器(IT-6)构造并调用 + * {@code IAptCheckinMatchService.applyVerdict}:{@code kcsj} / {@code jcfs} 属**要素**, + * 只用于复算叫乘时刻,绝不回写 {@code apt_master_route};{@code jcsj} 恒由公寓侧计算。

+ * + * @author apt-rev01 + */ +public class AptCheckinMatchVerdict +{ + private String applyId; + + private String jcid; + + /** MATCHED / UNMATCHED / LOCKED / REJECTED */ + private String verdict; + + private String matchedPlanKey; + + /** 平台补齐的开车时刻(要素) */ + private Date kcsj; + + /** 平台补齐的叫乘方式(要素) */ + private String jcfs; + + private String jlid; + + private String cc; + + private String jch; + + private String reason; + + public String getApplyId() + { + return applyId; + } + + public void setApplyId(String applyId) + { + this.applyId = applyId; + } + + public String getJcid() + { + return jcid; + } + + public void setJcid(String jcid) + { + this.jcid = jcid; + } + + public String getVerdict() + { + return verdict; + } + + public void setVerdict(String verdict) + { + this.verdict = verdict; + } + + public String getMatchedPlanKey() + { + return matchedPlanKey; + } + + public void setMatchedPlanKey(String matchedPlanKey) + { + this.matchedPlanKey = matchedPlanKey; + } + + public Date getKcsj() + { + return kcsj; + } + + public void setKcsj(Date kcsj) + { + this.kcsj = kcsj; + } + + public String getJcfs() + { + return jcfs; + } + + public void setJcfs(String jcfs) + { + this.jcfs = jcfs; + } + + public String getJlid() + { + return jlid; + } + + public void setJlid(String jlid) + { + this.jlid = jlid; + } + + public String getCc() + { + return cc; + } + + public void setCc(String cc) + { + this.cc = cc; + } + + public String getJch() + { + return jch; + } + + public void setJch(String jch) + { + this.jch = jch; + } + + public String getReason() + { + return reason; + } + + public void setReason(String reason) + { + this.reason = reason; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinOptions.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinOptions.java new file mode 100644 index 0000000..2139bc1 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinOptions.java @@ -0,0 +1,313 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import java.util.ArrayList; +import java.util.List; + +/** + * N-39 入住选项(房间 + 交路)。 + * + *

设备侧缓存 15 分钟:本类只做「版本比较」,服务端不维护设备缓存。 + * {@code notModified=true} 表示客户端版本仍然新鲜(HTTP 200 + 空选项,不返回 304)。

+ * + * @author apt-rev01 + */ +public class AptCheckinOptions +{ + private String version; + + private String serverTime; + + private boolean notModified; + + private int ttlSeconds = 900; + + /** 固定 LOCAL:选项只来自本公寓主数据,不与平台实时对账 */ + private String source = "LOCAL"; + + private List rooms = new ArrayList<>(); + + private List routes = new ArrayList<>(); + + public String getVersion() + { + return version; + } + + public void setVersion(String version) + { + this.version = version; + } + + public String getServerTime() + { + return serverTime; + } + + public void setServerTime(String serverTime) + { + this.serverTime = serverTime; + } + + public boolean isNotModified() + { + return notModified; + } + + public void setNotModified(boolean notModified) + { + this.notModified = notModified; + } + + public int getTtlSeconds() + { + return ttlSeconds; + } + + public void setTtlSeconds(int ttlSeconds) + { + this.ttlSeconds = ttlSeconds; + } + + public String getSource() + { + return source; + } + + public void setSource(String source) + { + this.source = source; + } + + public List getRooms() + { + return rooms; + } + + public void setRooms(List rooms) + { + this.rooms = rooms; + } + + public List getRoutes() + { + return routes; + } + + public void setRoutes(List routes) + { + this.routes = routes; + } + + /** 可用房间(床位未满且非「不能入住 / 保洁中」) */ + public static class Room + { + private String roomId; + + private String roomNo; + + private String roomType; + + private String roomFloor; + + private int bedCount; + + private int liveCount; + + private String roomStatus; + + private String roomStatusLabel; + + /** gy_rooms.state_version:条件更新房态用,不下发设备侧 */ + private long stateVersion; + + private boolean available; + + public String getRoomId() + { + return roomId; + } + + public void setRoomId(String roomId) + { + this.roomId = roomId; + } + + public String getRoomNo() + { + return roomNo; + } + + public void setRoomNo(String roomNo) + { + this.roomNo = roomNo; + } + + public String getRoomType() + { + return roomType; + } + + public void setRoomType(String roomType) + { + this.roomType = roomType; + } + + public String getRoomFloor() + { + return roomFloor; + } + + public void setRoomFloor(String roomFloor) + { + this.roomFloor = roomFloor; + } + + public int getBedCount() + { + return bedCount; + } + + public void setBedCount(int bedCount) + { + this.bedCount = bedCount; + } + + public int getLiveCount() + { + return liveCount; + } + + public void setLiveCount(int liveCount) + { + this.liveCount = liveCount; + } + + public String getRoomStatus() + { + return roomStatus; + } + + public void setRoomStatus(String roomStatus) + { + this.roomStatus = roomStatus; + } + + public String getRoomStatusLabel() + { + return roomStatusLabel; + } + + public void setRoomStatusLabel(String roomStatusLabel) + { + this.roomStatusLabel = roomStatusLabel; + } + + public long getStateVersion() + { + return stateVersion; + } + + public void setStateVersion(long stateVersion) + { + this.stateVersion = stateVersion; + } + + public boolean isAvailable() + { + return available; + } + + public void setAvailable(boolean available) + { + this.available = available; + } + } + + /** 交路选项:kcsj/jch 只作展示与快照,申请时仍由服务端回主数据校验 */ + public static class Route + { + private String jlid; + + private String routeName; + + private String cc; + + private String kcsj; + + private String jch; + + private String jcfs; + + private String jllx; + + public String getJlid() + { + return jlid; + } + + public void setJlid(String jlid) + { + this.jlid = jlid; + } + + public String getRouteName() + { + return routeName; + } + + public void setRouteName(String routeName) + { + this.routeName = routeName; + } + + public String getCc() + { + return cc; + } + + public void setCc(String cc) + { + this.cc = cc; + } + + public String getKcsj() + { + return kcsj; + } + + public void setKcsj(String kcsj) + { + this.kcsj = kcsj; + } + + public String getJch() + { + return jch; + } + + public void setJch(String jch) + { + this.jch = jch; + } + + public String getJcfs() + { + return jcfs; + } + + public void setJcfs(String jcfs) + { + this.jcfs = jcfs; + } + + public String getJllx() + { + return jllx; + } + + public void setJllx(String jllx) + { + this.jllx = jllx; + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinReviewCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinReviewCommand.java new file mode 100644 index 0000000..680a695 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinReviewCommand.java @@ -0,0 +1,98 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * N-44 审核命令(值班台)。 + * + *

{@code expectedBizVersion} 必填:审核是流程态迁移,必须与页面看到的版本一致, + * 否则返回 40926,避免「换房后仍按老房号通过」。

+ * + * @author apt-rev01 + */ +public class AptCheckinReviewCommand +{ + /** APPROVE / APPROVE_WITH_ROOM_CHANGE / REJECT */ + @NotBlank(message = "verdict 不能为空") + @Size(max = 32, message = "verdict 长度不能超过 32") + private String verdict; + + @NotNull(message = "expectedBizVersion 不能为空") + private Long expectedBizVersion; + + /** 换房目标房号(verdict=APPROVE_WITH_ROOM_CHANGE 时必填) */ + @Size(max = 32, message = "targetFjh 长度不能超过 32") + private String targetFjh; + + @Size(max = 256, message = "审核说明长度不能超过 256") + private String reviewNote; + + /** 换房理由(换房路径下建议填写,写入 timeline) */ + @Size(max = 256, message = "换房理由长度不能超过 256") + private String adjustReason; + + @Size(max = 64, message = "commandId 长度不能超过 64") + private String commandId; + + public String getVerdict() + { + return verdict; + } + + public void setVerdict(String verdict) + { + this.verdict = verdict; + } + + public Long getExpectedBizVersion() + { + return expectedBizVersion; + } + + public void setExpectedBizVersion(Long expectedBizVersion) + { + this.expectedBizVersion = expectedBizVersion; + } + + public String getTargetFjh() + { + return targetFjh; + } + + public void setTargetFjh(String targetFjh) + { + this.targetFjh = targetFjh; + } + + public String getReviewNote() + { + return reviewNote; + } + + public void setReviewNote(String reviewNote) + { + this.reviewNote = reviewNote; + } + + public String getAdjustReason() + { + return adjustReason; + } + + public void setAdjustReason(String adjustReason) + { + this.adjustReason = adjustReason; + } + + public String getCommandId() + { + return commandId; + } + + public void setCommandId(String commandId) + { + this.commandId = commandId; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinReviewResult.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinReviewResult.java new file mode 100644 index 0000000..526ec48 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinReviewResult.java @@ -0,0 +1,172 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * N-44 审核回执。 + * + *

{@code checkedIn=true} 表示本次审核在同一事务内完成「人员主档 + gy_jcxx + 住房事件 + 房态 + #9 入队」。 + * {@code uploadState} 只反映 #9 入队结果(PENDING),平台是否接收由 4a 链路后置决定。

+ * + * @author apt-rev01 + */ +public class AptCheckinReviewResult +{ + private String applyId; + + private String jcid; + + private String checkinState; + + private String syncState; + + private int roomAdjustSeq; + + private String fjh; + + private String fjhFinal; + + private long bizVersion; + + private Date reviewedAt; + + private boolean checkedIn; + + private String uploadState; + + private Long reportOutboxId; + + private List warnings = new ArrayList<>(); + + public String getApplyId() + { + return applyId; + } + + public void setApplyId(String applyId) + { + this.applyId = applyId; + } + + public String getJcid() + { + return jcid; + } + + public void setJcid(String jcid) + { + this.jcid = jcid; + } + + public String getCheckinState() + { + return checkinState; + } + + public void setCheckinState(String checkinState) + { + this.checkinState = checkinState; + } + + public String getSyncState() + { + return syncState; + } + + public void setSyncState(String syncState) + { + this.syncState = syncState; + } + + public int getRoomAdjustSeq() + { + return roomAdjustSeq; + } + + public void setRoomAdjustSeq(int roomAdjustSeq) + { + this.roomAdjustSeq = roomAdjustSeq; + } + + public String getFjh() + { + return fjh; + } + + public void setFjh(String fjh) + { + this.fjh = fjh; + } + + public String getFjhFinal() + { + return fjhFinal; + } + + public void setFjhFinal(String fjhFinal) + { + this.fjhFinal = fjhFinal; + } + + public long getBizVersion() + { + return bizVersion; + } + + public void setBizVersion(long bizVersion) + { + this.bizVersion = bizVersion; + } + + public Date getReviewedAt() + { + return reviewedAt; + } + + public void setReviewedAt(Date reviewedAt) + { + this.reviewedAt = reviewedAt; + } + + public boolean isCheckedIn() + { + return checkedIn; + } + + public void setCheckedIn(boolean checkedIn) + { + this.checkedIn = checkedIn; + } + + public String getUploadState() + { + return uploadState; + } + + public void setUploadState(String uploadState) + { + this.uploadState = uploadState; + } + + public Long getReportOutboxId() + { + return reportOutboxId; + } + + public void setReportOutboxId(Long reportOutboxId) + { + this.reportOutboxId = reportOutboxId; + } + + public List getWarnings() + { + return warnings; + } + + public void setWarnings(List warnings) + { + this.warnings = warnings; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinRoomAdjustCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinRoomAdjustCommand.java new file mode 100644 index 0000000..d19c04e --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinRoomAdjustCommand.java @@ -0,0 +1,69 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * N-45 换房命令(仅审核期:APPROVED / ROOM_ADJUSTED)。 + * + *

已 CHECKED_IN 的换房属退宿换房(IT-5),本接口返回 40926,避免在入住链路上 + * 改写 {@code gy_jcxx} 已产生的事实。

+ * + * @author apt-rev01 + */ +public class AptCheckinRoomAdjustCommand +{ + @NotBlank(message = "目标房号不能为空") + @Size(max = 32, message = "目标房号长度不能超过 32") + private String targetFjh; + + @Size(max = 256, message = "换房理由长度不能超过 256") + private String reason; + + @NotNull(message = "expectedBizVersion 不能为空") + private Long expectedBizVersion; + + @Size(max = 64, message = "commandId 长度不能超过 64") + private String commandId; + + public String getTargetFjh() + { + return targetFjh; + } + + public void setTargetFjh(String targetFjh) + { + this.targetFjh = targetFjh; + } + + public String getReason() + { + return reason; + } + + public void setReason(String reason) + { + this.reason = reason; + } + + public Long getExpectedBizVersion() + { + return expectedBizVersion; + } + + public void setExpectedBizVersion(Long expectedBizVersion) + { + this.expectedBizVersion = expectedBizVersion; + } + + public String getCommandId() + { + return commandId; + } + + public void setCommandId(String commandId) + { + this.commandId = commandId; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinRoomAdjustResult.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinRoomAdjustResult.java new file mode 100644 index 0000000..0ab08b8 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinRoomAdjustResult.java @@ -0,0 +1,145 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import java.util.ArrayList; +import java.util.List; + +/** + * N-45 换房回执。{@code sipBound} 反映「原房是否已有 SIP 绑定」: + * 审核期尚未入住时通常无绑定,绑定联动在入住后才产生意义。 + * + * @author apt-rev01 + */ +public class AptCheckinRoomAdjustResult +{ + private String applyId; + + private String jcid; + + private String checkinState; + + private String fjh; + + private String fjhFinal; + + private int roomAdjustSeq; + + private long bizVersion; + + private String originRoomStatus; + + private String targetRoomStatus; + + private boolean sipBound; + + private List warnings = new ArrayList<>(); + + public String getApplyId() + { + return applyId; + } + + public void setApplyId(String applyId) + { + this.applyId = applyId; + } + + public String getJcid() + { + return jcid; + } + + public void setJcid(String jcid) + { + this.jcid = jcid; + } + + public String getCheckinState() + { + return checkinState; + } + + public void setCheckinState(String checkinState) + { + this.checkinState = checkinState; + } + + public String getFjh() + { + return fjh; + } + + public void setFjh(String fjh) + { + this.fjh = fjh; + } + + public String getFjhFinal() + { + return fjhFinal; + } + + public void setFjhFinal(String fjhFinal) + { + this.fjhFinal = fjhFinal; + } + + public int getRoomAdjustSeq() + { + return roomAdjustSeq; + } + + public void setRoomAdjustSeq(int roomAdjustSeq) + { + this.roomAdjustSeq = roomAdjustSeq; + } + + public long getBizVersion() + { + return bizVersion; + } + + public void setBizVersion(long bizVersion) + { + this.bizVersion = bizVersion; + } + + public String getOriginRoomStatus() + { + return originRoomStatus; + } + + public void setOriginRoomStatus(String originRoomStatus) + { + this.originRoomStatus = originRoomStatus; + } + + public String getTargetRoomStatus() + { + return targetRoomStatus; + } + + public void setTargetRoomStatus(String targetRoomStatus) + { + this.targetRoomStatus = targetRoomStatus; + } + + public boolean isSipBound() + { + return sipBound; + } + + public void setSipBound(boolean sipBound) + { + this.sipBound = sipBound; + } + + public List getWarnings() + { + return warnings; + } + + public void setWarnings(List warnings) + { + this.warnings = warnings; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinTimeline.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinTimeline.java new file mode 100644 index 0000000..df00c23 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptCheckinTimeline.java @@ -0,0 +1,172 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import java.util.ArrayList; +import java.util.List; + +/** + * N-46 审核时间线。事件源为 {@code duty_operation_log}(module=CHECKIN, action=STATE_CHANGE), + * 只展示流程态迁移,不复制业务字段。 + * + * @author apt-rev01 + */ +public class AptCheckinTimeline +{ + private String applyId; + + private String jcid; + + private String checkinState; + + private String syncState; + + private String matchState; + + private String serverTime; + + private List events = new ArrayList<>(); + + public String getApplyId() + { + return applyId; + } + + public void setApplyId(String applyId) + { + this.applyId = applyId; + } + + public String getJcid() + { + return jcid; + } + + public void setJcid(String jcid) + { + this.jcid = jcid; + } + + public String getCheckinState() + { + return checkinState; + } + + public void setCheckinState(String checkinState) + { + this.checkinState = checkinState; + } + + public String getSyncState() + { + return syncState; + } + + public void setSyncState(String syncState) + { + this.syncState = syncState; + } + + public String getMatchState() + { + return matchState; + } + + public void setMatchState(String matchState) + { + this.matchState = matchState; + } + + public String getServerTime() + { + return serverTime; + } + + public void setServerTime(String serverTime) + { + this.serverTime = serverTime; + } + + public List getEvents() + { + return events; + } + + public void setEvents(List events) + { + this.events = events; + } + + public static class Event + { + private String state; + + private String fromState; + + private String at; + + private String actor; + + private String commandId; + + private String note; + + public String getState() + { + return state; + } + + public void setState(String state) + { + this.state = state; + } + + public String getFromState() + { + return fromState; + } + + public void setFromState(String fromState) + { + this.fromState = fromState; + } + + public String getAt() + { + return at; + } + + public void setAt(String at) + { + this.at = at; + } + + public String getActor() + { + return actor; + } + + public void setActor(String actor) + { + this.actor = actor; + } + + public String getCommandId() + { + return commandId; + } + + public void setCommandId(String commandId) + { + this.commandId = commandId; + } + + public String getNote() + { + return note; + } + + public void setNote(String note) + { + this.note = note; + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptOutboxRow.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptOutboxRow.java new file mode 100644 index 0000000..bdfd47c --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/domain/AptOutboxRow.java @@ -0,0 +1,107 @@ +package com.ruoyi.apt.stay.checkin.domain; + +import java.util.Date; + +/** + * #9 / #8 上报入队参数(useGeneratedKeys 回填 id,避免额外的 LAST_INSERT_ID 查询)。 + * + * @author apt-rev01 + */ +public class AptOutboxRow +{ + private Long id; + + private String gyid; + + private String idempotencyKey; + + private String bizType; + + private String bizId; + + private long bizVersion; + + private String payload; + + private Date occurredAt; + + public Long getId() + { + return id; + } + + public void setId(Long id) + { + this.id = id; + } + + public String getGyid() + { + return gyid; + } + + public void setGyid(String gyid) + { + this.gyid = gyid; + } + + 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 String getPayload() + { + return payload; + } + + public void setPayload(String 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/stay/checkin/mapper/AptCheckinMapper.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/mapper/AptCheckinMapper.java new file mode 100644 index 0000000..47d45d8 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/mapper/AptCheckinMapper.java @@ -0,0 +1,132 @@ +package com.ruoyi.apt.stay.checkin.mapper; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplication; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinCallTask; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinOptions; +import com.ruoyi.apt.stay.checkin.domain.AptOutboxRow; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 入住作业写入 / 查询(apt-stay 子域唯一写入口)。 + * + *

所有语句强制带 {@code gyid};房态、叫乘记录、住房事件、outbox 均与流程态在同一 + * 事务内写入,任一失败整体回滚(表 11-18)。

+ * + * @author apt-rev01 + */ +@Mapper +public interface AptCheckinMapper +{ + /** 选项版本:以房间主数据 + 交路的最后更新时刻为准 */ + String selectOptionsVersion(@Param("gyid") String gyid); + + List selectRoomOptions(@Param("gyid") String gyid); + + List selectRouteOptions(@Param("gyid") String gyid); + + AptCheckinApplication selectApplication(@Param("gyid") String gyid, @Param("applyId") String applyId); + + AptCheckinApplication selectApplicationByJcid(@Param("gyid") String gyid, @Param("jcid") String jcid); + + AptCheckinApplication selectByDeviceEvent(@Param("gyid") String gyid, + @Param("deviceCode") String deviceCode, + @Param("deviceEventId") String deviceEventId); + + List selectApplicationList(@Param("gyid") String gyid, + @Param("state") String state, + @Param("workNo") String workNo, + @Param("name") String name); + + int insertApplication(@Param("gyid") String gyid, @Param("app") AptCheckinApplication app); + + /** N-43 接单:SUBMITTED → REVIEWING(幂等,已是 REVIEWING 时返回 0 由服务层判定) */ + int claimReviewing(@Param("gyid") String gyid, @Param("applyId") String applyId, + @Param("actor") String actor, @Param("at") Date at); + + /** 通用流程态迁移:仅当当前态在 fromStates 且 biz_version 匹配时成功 */ + int transition(@Param("gyid") String gyid, @Param("applyId") String applyId, + @Param("fromStates") List fromStates, @Param("toState") String toState, + @Param("expectedBizVersion") Long expectedBizVersion, + @Param("actor") String actor, @Param("at") Date at, + @Param("note") String note, @Param("rejectReason") String rejectReason, + @Param("checkedInAt") Date checkedInAt, + @Param("fjhFinal") String fjhFinal, @Param("seqDelta") int seqDelta, + @Param("adjustedBy") String adjustedBy, @Param("adjustedAt") Date adjustedAt, + @Param("adjustReason") String adjustReason); + + int updateReported(@Param("gyid") String gyid, @Param("applyId") String applyId, @Param("at") Date at); + + int updateSyncState(@Param("gyid") String gyid, @Param("applyId") String applyId, + @Param("syncState") String syncState, @Param("at") Date at); + + int updateMatched(@Param("gyid") String gyid, @Param("applyId") String applyId, + @Param("matchState") String matchState, + @Param("matchedPlanKey") String matchedPlanKey, + @Param("kcsj") Date kcsj, @Param("jcfs") String jcfs, + @Param("jlid") String jlid, @Param("cc") String cc, @Param("jch") String jch, + @Param("at") Date at); + + int updateTaskReady(@Param("gyid") String gyid, @Param("applyId") String applyId, + @Param("jcsj") Date jcsj, @Param("ruleVersion") Long ruleVersion, + @Param("at") Date at); + + /** 匹配锁定开关(jllx=1 表示本地要素优先,平台不覆盖) */ + int updateMatchLock(@Param("gyid") String gyid, @Param("applyId") String applyId, + @Param("jllx") String jllx); + + AptCheckinOptions.Room selectRoomByNo(@Param("gyid") String gyid, @Param("roomNo") String roomNo); + + int occupyBed(@Param("gyid") String gyid, @Param("roomId") String roomId, + @Param("expectedStateVersion") long expectedStateVersion, + @Param("bedCount") int bedCount); + + int releaseBed(@Param("gyid") String gyid, @Param("roomId") String roomId, + @Param("expectedStateVersion") long expectedStateVersion); + + int recalcRoomStatus(@Param("gyid") String gyid, @Param("roomId") String roomId, + @Param("bedCount") int bedCount); + + int countSipBinding(@Param("gyid") String gyid, @Param("roomId") String roomId); + + int countActiveStay(@Param("gyid") String gyid, @Param("ryid") String ryid); + + int insertJcxx(@Param("gyid") String gyid, @Param("app") AptCheckinApplication app, + @Param("room") AptCheckinOptions.Room room, @Param("callMode") String callMode, + @Param("now") Date now); + + AptCheckinCallTask.Task selectCallTask(@Param("gyid") String gyid, @Param("jcid") String jcid); + + int updateJcxxCallTime(@Param("gyid") String gyid, @Param("jcid") String jcid, + @Param("jcsj") Date jcsj, @Param("jcfs") String jcfs); + + int insertLyEvent(@Param("gyid") String gyid, @Param("eventId") String eventId, + @Param("eventType") String eventType, @Param("eventSource") String eventSource, + @Param("jcid") String jcid, @Param("ryid") String ryid, + @Param("fjh") String fjh, @Param("occurredAt") Date occurredAt); + + int insertOutbox(AptOutboxRow row); + + int insertOperationLog(@Param("gyid") String gyid, @Param("logId") String logId, + @Param("operatorRef") String operatorRef, @Param("module") String module, + @Param("action") String action, @Param("detail") String detail, + @Param("traceId") String traceId, @Param("occurredAt") Date occurredAt); + + List> selectTimelineEvents(@Param("gyid") String gyid, + @Param("applyId") String applyId); + + Map selectPersonForCheckin(@Param("gyid") String gyid, @Param("ryid") String ryid); + + Map selectConfirmedReg(@Param("gyid") String gyid, @Param("ryid") String ryid, + @Param("regId") String regId); + + Map selectRouteForCheckin(@Param("gyid") String gyid, @Param("jlid") String jlid); + + Map selectCallTimeRule(@Param("gyid") String gyid); + + int countHoliday(@Param("gyid") String gyid, @Param("holidayDate") Date holidayDate); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCallTimeCalculator.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCallTimeCalculator.java new file mode 100644 index 0000000..39fdb31 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCallTimeCalculator.java @@ -0,0 +1,125 @@ +package com.ruoyi.apt.stay.checkin.service; + +import java.util.Calendar; +import java.util.Date; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.stay.checkin.domain.AptCallTimeResult; +import org.springframework.stereotype.Component; + +/** + * 叫乘时刻复算器(纯函数,便于单测与回放)。 + * + *

权威口径(CR-02 表 3-57 备注、修订 R-48): + *

    + *
  • {@code jcsj = kcsj - advance}(节假日取 {@code holidayAdvanceMinutes})
  • + *
  • {@code fjsj = jcsj + 10min}、{@code jcbjsj = jcsj + 15min}(与接车方式无关)
  • + *
  • {@code ylysj = jcsj + release}({@code lysjgz},缺省 30 分钟)
  • + *
+ * 不变式 {@code jcsj <= fjsj <= jcbjsj < kcsj} 不成立时返回 40931, + * 不静默钳制——叫乘时刻错位无法事后修复。

+ * + * @author apt-rev01 + */ +@Component +public class AptCallTimeCalculator +{ + /** 规则缺失时的缺省提前量(分钟) */ + public static final int DEFAULT_ADVANCE_MINUTES = 30; + + /** 规则缺失时的缺省离寓时长(分钟) */ + public static final int DEFAULT_RELEASE_MINUTES = 30; + + /** 放叫时刻相对叫乘时刻的固定偏移 */ + public static final int CALL_START_OFFSET_MINUTES = 10; + + /** 补叫时刻相对叫乘时刻的固定偏移 */ + public static final int RE_CALL_OFFSET_MINUTES = 15; + + /** + * 按规则体复算叫乘四时刻。 + * + * @param kcsj 开车时刻(要素,必填) + * @param ruleBody 规则体 JSON 字符串({@code advanceMinutes} / {@code holidayAdvanceMinutes} / + * {@code lysjgz}),可为空 + * @param ruleVersion 规则版本,随结果留痕 + * @param holiday 开车日是否节假日 + */ + public AptCallTimeResult compute(Date kcsj, String ruleBody, long ruleVersion, boolean holiday) + { + if (kcsj == null) + { + throw new AptException(AptErrorCode.ROUTE_INVALID, "KCSJ_MISSING", "开车时刻缺失,无法复算叫乘时刻"); + } + JSONObject rule = parseRule(ruleBody); + int advance = holiday + ? intValue(rule, "holidayAdvanceMinutes", intValue(rule, "advanceMinutes", DEFAULT_ADVANCE_MINUTES)) + : intValue(rule, "advanceMinutes", DEFAULT_ADVANCE_MINUTES); + int release = intValue(rule, "lysjgz", DEFAULT_RELEASE_MINUTES); + + Date jcsj = shift(kcsj, -advance); + Date fjsj = shift(jcsj, CALL_START_OFFSET_MINUTES); + Date jcbjsj = shift(jcsj, RE_CALL_OFFSET_MINUTES); + Date ylysj = shift(jcsj, release); + if (jcsj.after(fjsj) || fjsj.after(jcbjsj) || !jcbjsj.before(kcsj)) + { + throw new AptException(AptErrorCode.ROUTE_INVALID, "CALL_TIME_ILLEGAL", + "叫乘时刻关系非法(需 jcsj <= fjsj <= jcbjsj < kcsj),提前量=" + advance); + } + + AptCallTimeResult result = new AptCallTimeResult(); + result.setKcsj(kcsj); + result.setJcsj(jcsj); + result.setFjsj(fjsj); + result.setJcbjsj(jcbjsj); + result.setYlysj(ylysj); + result.setRuleVersion(ruleVersion); + result.setHoliday(holiday); + result.setAdvanceMinutes(advance); + result.setReleaseMinutes(release); + return result; + } + + private JSONObject parseRule(String ruleBody) + { + if (ruleBody == null || ruleBody.isBlank()) + { + return new JSONObject(); + } + try + { + return JSON.parseObject(ruleBody); + } + catch (RuntimeException ex) + { + throw new AptException(AptErrorCode.DATA_ANOMALY, "CALL_TIME_RULE_INVALID", "叫乘规则体不是合法 JSON", ex); + } + } + + private int intValue(JSONObject rule, String key, int defaultValue) + { + Object value = rule == null ? null : rule.get(key); + if (value == null) + { + return defaultValue; + } + int parsed = value instanceof Number number ? number.intValue() : Integer.parseInt(String.valueOf(value).trim()); + if (parsed < 0) + { + throw new AptException(AptErrorCode.DATA_ANOMALY, "CALL_TIME_RULE_INVALID", "叫乘规则 " + key + " 不能为负数"); + } + return parsed; + } + + private Date shift(Date base, int minutes) + { + Calendar calendar = Calendar.getInstance(); + calendar.setTime(base); + calendar.add(Calendar.MINUTE, minutes); + return calendar.getTime(); + } + +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinMatchServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinMatchServiceImpl.java new file mode 100644 index 0000000..4b1ee86 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinMatchServiceImpl.java @@ -0,0 +1,213 @@ +package com.ruoyi.apt.stay.checkin.service; + +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.Map; + +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.stay.checkin.domain.AptCallTimeResult; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplication; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchResult; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchVerdict; +import com.ruoyi.apt.stay.checkin.domain.AptOutboxRow; +import com.ruoyi.apt.stay.checkin.mapper.AptCheckinMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.DigestUtils; + +/** + * #7 匹配结论落库与 jcsj 复算。 + * + *

落地顺序固定为「结论落库 → 要素补全 → 本地复算 → 写 gy_jcxx → #8 入队」, + * 保证 {@code task_ready_at} 与 {@code gy_jcxx.jcsj} 不出现半写。

+ * + * @author apt-rev01 + */ +@Service +public class AptCheckinMatchServiceImpl implements IAptCheckinMatchService +{ + private static final String STATE_CHECKED_IN = "CHECKED_IN"; + + private static final String VERDICT_MATCHED = "MATCHED"; + + private static final String VERDICT_UNMATCHED = "UNMATCHED"; + + private static final String VERDICT_LOCKED = "LOCKED"; + + private static final String VERDICT_REJECTED = "REJECTED"; + + private final AptCheckinMapper checkinMapper; + + private final AptCallTimeCalculator callTimeCalculator; + + public AptCheckinMatchServiceImpl(AptCheckinMapper checkinMapper, AptCallTimeCalculator callTimeCalculator) + { + this.checkinMapper = checkinMapper; + this.callTimeCalculator = callTimeCalculator; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public AptCheckinMatchResult applyVerdict(AptCheckinMatchVerdict verdict) + { + String gyid = AptContext.getGyId(); + String applyId = verdict.getApplyId(); + if ((applyId == null || applyId.isBlank()) && (verdict.getJcid() == null || verdict.getJcid().isBlank())) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "MATCH_TARGET_MISSING", "applyId 与 jcid 至少提供一个"); + } + AptCheckinApplication app = applyId == null || applyId.isBlank() + ? checkinMapper.selectApplicationByJcid(gyid, verdict.getJcid()) + : checkinMapper.selectApplication(gyid, applyId); + if (app == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "CHECKIN_NOT_FOUND", "入住申请不存在"); + } + if (!STATE_CHECKED_IN.equals(app.getCheckinState())) + { + throw new AptException(AptErrorCode.CHECKIN_STATE_CONFLICT, "NOT_CHECKED_IN", + "#7 匹配结论只作用于已办理入住的申请(当前 " + app.getCheckinState() + ")"); + } + + String matched = verdict.getVerdict() == null ? "" : verdict.getVerdict().trim().toUpperCase(Locale.ROOT); + if (!VERDICT_MATCHED.equals(matched) && !VERDICT_UNMATCHED.equals(matched) + && !VERDICT_LOCKED.equals(matched) && !VERDICT_REJECTED.equals(matched)) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "MATCH_VERDICT_INVALID", + "verdict 只能是 MATCHED / UNMATCHED / LOCKED / REJECTED"); + } + + Date now = new Date(); + AptCheckinMatchResult result = new AptCheckinMatchResult(); + result.setApplyId(app.getApplyId()); + result.setJcid(app.getJcid()); + result.setVerdict(matched); + + boolean alreadyReady = "TASK_READY".equals(app.getSyncState()); + if (matched.equals(app.getMatchState()) && alreadyReady) + { + result.setMatchState(app.getMatchState()); + result.setSyncState(app.getSyncState()); + result.setJcsj(app.getJcsj()); + result.setDerivedRuleVersion(app.getDerivedRuleVersion()); + result.setTaskReady(true); + result.setChanged(false); + result.getWarnings().add("同一匹配结论已落库,按幂等处理:不重复复算、不重复入队 #8"); + return result; + } + + if (VERDICT_UNMATCHED.equals(matched) || VERDICT_REJECTED.equals(matched)) + { + checkinMapper.updateMatched(gyid, app.getApplyId(), matched, null, null, null, null, null, null, now); + checkinMapper.updateSyncState(gyid, app.getApplyId(), "REPORTED", null); + result.setMatchState(matched); + result.setSyncState("REPORTED"); + result.setChanged(true); + result.setTaskReady(false); + result.getWarnings().add("平台未匹配到运安计划:保留本地要素,不生成叫乘任务,也不清空既有任务"); + if (verdict.getReason() != null && !verdict.getReason().isBlank()) + { + result.getWarnings().add("平台说明:" + verdict.getReason()); + } + return result; + } + + Date kcsj = verdict.getKcsj() != null ? verdict.getKcsj() : app.getKcsj(); + String jcfs = verdict.getJcfs() != null ? verdict.getJcfs() : app.getJcfs(); + checkinMapper.updateMatched(gyid, app.getApplyId(), matched, verdict.getMatchedPlanKey(), kcsj, jcfs, + verdict.getJlid(), verdict.getCc(), verdict.getJch(), now); + if (VERDICT_LOCKED.equals(matched)) + { + checkinMapper.updateMatchLock(gyid, app.getApplyId(), "1"); + result.getWarnings().add("本地锁定任务:平台要素不覆盖,按本地要素复算(jllx=1)"); + } + if (kcsj == null) + { + checkinMapper.updateSyncState(gyid, app.getApplyId(), "MATCHED", null); + result.setMatchState(matched); + result.setSyncState("MATCHED"); + result.setChanged(true); + result.setTaskReady(false); + result.getWarnings().add("匹配结论缺少开车时刻(kcsj),任务保持 MATCHED 待要素补齐"); + return result; + } + + Map rule = checkinMapper.selectCallTimeRule(gyid); + String ruleBody = rule == null ? null : stringValue(rule.get("ruleBody")); + long ruleVersion = rule == null ? 0L : longValue(rule.get("ruleVersion")); + boolean holiday = checkinMapper.countHoliday(gyid, kcsj) > 0; + AptCallTimeResult computed = callTimeCalculator.compute(kcsj, ruleBody, ruleVersion, holiday); + + checkinMapper.updateJcxxCallTime(gyid, app.getJcid(), computed.getJcsj(), jcfs); + checkinMapper.updateTaskReady(gyid, app.getApplyId(), computed.getJcsj(), computed.getRuleVersion(), now); + + JSONObject data = new JSONObject(); + data.put("taskId", app.getJcid()); + data.put("applyId", app.getApplyId()); + data.put("matchedPlanKey", verdict.getMatchedPlanKey()); + data.put("computedCallTime", format(computed.getJcsj())); + data.put("fjsj", format(computed.getFjsj())); + data.put("jcbjsj", format(computed.getJcbjsj())); + data.put("ylysj", format(computed.getYlysj())); + data.put("kcsj", format(computed.getKcsj())); + data.put("jcfs", jcfs); + data.put("matchState", matched); + data.put("ruleVersion", computed.getRuleVersion()); + data.put("accepted", Boolean.TRUE); + + JSONObject payload = new JSONObject(); + payload.put("contractVersion", "v2-draft"); + payload.put("gyId", gyid); + payload.put("bizType", "TASK_ACK"); + payload.put("bizId", app.getJcid()); + payload.put("bizVersion", app.getBizVersion() + 1); + payload.put("occurredAt", format(now)); + payload.put("data", data); + + String bizId = app.getJcid(); + long bizVersion = app.getBizVersion() + 1; + AptOutboxRow outbox = new AptOutboxRow(); + outbox.setGyid(gyid); + outbox.setIdempotencyKey(DigestUtils.md5DigestAsHex( + (gyid + "|TASK_ACK|" + bizId + "|" + bizVersion).getBytes(StandardCharsets.UTF_8))); + outbox.setBizType("TASK_ACK"); + outbox.setBizId(bizId); + outbox.setBizVersion(bizVersion); + outbox.setPayload(payload.toJSONString()); + outbox.setOccurredAt(now); + checkinMapper.insertOutbox(outbox); + + result.setMatchState(matched); + result.setSyncState("TASK_READY"); + result.setJcsj(computed.getJcsj()); + result.setDerivedRuleVersion(computed.getRuleVersion()); + result.setTaskReady(true); + result.setChanged(true); + result.setTaskAckOutboxId(outbox.getId()); + return result; + } + + private String stringValue(Object value) + { + return value == null ? null : String.valueOf(value); + } + + private long longValue(Object value) + { + if (value == null) + { + return 0L; + } + return value instanceof Number number ? number.longValue() : Long.parseLong(String.valueOf(value)); + } + + private String format(Date value) + { + return value == null ? null : new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(value); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinServiceImpl.java new file mode 100644 index 0000000..6b82b0f --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/AptCheckinServiceImpl.java @@ -0,0 +1,795 @@ +package com.ruoyi.apt.stay.checkin.service; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplication; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyCommand; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyResult; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinCallTask; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinOptions; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewCommand; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewResult; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinRoomAdjustCommand; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinRoomAdjustResult; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinTimeline; +import com.ruoyi.apt.stay.checkin.domain.AptOutboxRow; +import com.ruoyi.apt.stay.checkin.mapper.AptCheckinMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.DigestUtils; + +/** + * 入住作业核心实现。 + * + *

事务边界(表 11-18):办理入住时「流程态 → gy_jcxx → 住房事件 → 房态 → #9 入队」 + * 在同一事务内完成,任一失败整体回滚;平台回执不参与本地生效判定。

+ * + * @author apt-rev01 + */ +@Service +public class AptCheckinServiceImpl implements IAptCheckinService +{ + private static final Logger log = LoggerFactory.getLogger(AptCheckinServiceImpl.class); + + private static final String STATE_SUBMITTED = "SUBMITTED"; + + private static final String STATE_REVIEWING = "REVIEWING"; + + private static final String STATE_APPROVED = "APPROVED"; + + private static final String STATE_ROOM_ADJUSTED = "ROOM_ADJUSTED"; + + private static final String STATE_REJECTED = "REJECTED"; + + private static final String STATE_CHECKED_IN = "CHECKED_IN"; + + private static final String ORIGIN_ROUTE = "CHECKIN_ROUTE"; + + private static final String ORIGIN_PENDING = "CHECKIN_PENDING"; + + private static final String BIZ_TYPE_CHECKIN = "CHECKIN"; + + private static final String BIZ_TYPE_ROOM_SWAP = "ROOM_SWAP"; + + private static final String MODULE = "CHECKIN"; + + /** N-38 核验会话有效期:5 分钟 */ + private static final long VERIFY_SESSION_MILLIS = 5 * 60 * 1000L; + + private final AptCheckinMapper checkinMapper; + + public AptCheckinServiceImpl(AptCheckinMapper checkinMapper) + { + this.checkinMapper = checkinMapper; + } + + // ------------------------------------------------------------------ N-39 + + @Override + public AptCheckinOptions options(String version) + { + String gyid = gyid(); + String serverVersion = checkinMapper.selectOptionsVersion(gyid); + AptCheckinOptions options = new AptCheckinOptions(); + options.setVersion(serverVersion); + options.setServerTime(now()); + if (version != null && !version.isBlank() && version.trim().equals(serverVersion)) + { + options.setNotModified(true); + return options; + } + options.setRooms(checkinMapper.selectRoomOptions(gyid)); + options.setRoutes(checkinMapper.selectRouteOptions(gyid)); + return options; + } + + // ------------------------------------------------------------------ N-40 + + @Override + @Transactional(rollbackFor = Exception.class) + public AptCheckinApplyResult apply(AptCheckinApplyCommand command, String deviceCode) + { + String gyid = gyid(); + String device = deviceCode == null ? null : deviceCode.trim().toUpperCase(Locale.ROOT); + if (!notBlank(device)) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "DEVICE_CODE_MISSING", "缺少设备编码"); + } + + AptCheckinApplication existing = checkinMapper.selectByDeviceEvent(gyid, device, command.getDeviceEventId()); + if (existing != null) + { + return duplicatedApply(existing, "同一设备事件已受理,返回原申请单"); + } + + Map person = checkinMapper.selectPersonForCheckin(gyid, command.getRyid()); + if (person == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "PERSON_NOT_FOUND", "人员主档不存在:" + command.getRyid()); + } + + Map reg = checkinMapper.selectConfirmedReg(gyid, command.getRyid(), command.getRegId()); + if (reg == null) + { + throw new AptException(AptErrorCode.FACE_REG_PENDING, "FACE_REG_NOT_CONFIRMED", + "该人员人脸注册未获值班员确认,禁止办理入住"); + } + long regFeatureVersion = longValue(reg.get("featureVersion"), 0L); + if (command.getFeatureVersion() != null && command.getFeatureVersion() < regFeatureVersion) + { + throw new AptException(AptErrorCode.FACE_CACHE_STALE, "FEATURE_VERSION_STALE", + "核验特征版本落后于主档(" + command.getFeatureVersion() + " < " + regFeatureVersion + ")"); + } + + Date now = new Date(); + Date verifiedAt = command.getVerifiedAt() == null ? now : command.getVerifiedAt(); + if (now.getTime() - verifiedAt.getTime() > VERIFY_SESSION_MILLIS) + { + throw new AptException(AptErrorCode.VERIFY_SESSION_EXPIRED, "VERIFY_SESSION_EXPIRED", + "N-38 核验已超过 5 分钟,请重新刷脸"); + } + + AptCheckinOptions.Room room = requireRoom(gyid, command.getFjh()); + if (checkinMapper.countActiveStay(gyid, command.getRyid()) > 0) + { + throw new AptException(AptErrorCode.DUPLICATE_STAY, "DUPLICATE_STAY", + "该人员已有在住记录,请先办理退宿"); + } + + AptCheckinApplication app = new AptCheckinApplication(); + String applyId = newId("CHK"); + app.setApplyId(applyId); + app.setTaskId(applyId); + app.setJcid(newId("JC")); + app.setDeviceCode(device); + app.setDeviceEventId(command.getDeviceEventId()); + app.setRyid(command.getRyid()); + app.setName(stringValue(person.get("name"))); + app.setWorkNo(stringValue(person.get("workNo"))); + app.setRegId(stringValue(reg.get("regId"))); + app.setRegStateAtApply("CONFIRMED"); + app.setFjh(room.getRoomNo()); + app.setFjhFinal(room.getRoomNo()); + app.setRoomId(room.getRoomId()); + app.setBedCount(room.getBedCount()); + app.setLiveCount(room.getLiveCount()); + app.setRoomStatus(room.getRoomStatus()); + app.setFaceVerifyState("VERIFIED"); + app.setFaceDeviceCode(device); + app.setFaceVerifiedAt(verifiedAt); + app.setVerifiedRyid(command.getRyid()); + app.setVerifiedAt(verifiedAt); + app.setFeatureVersion(command.getFeatureVersion() == null ? regFeatureVersion : command.getFeatureVersion()); + app.setSource(notBlank(command.getSource()) ? command.getSource().toUpperCase(Locale.ROOT) : "DEVICE"); + app.setJllx("0"); + app.setSubmittedAt(now); + app.setOccurredAt(command.getOccurredAt() == null ? now : command.getOccurredAt()); + app.setRequestPayload(JSON.toJSONString(command)); + + AptCheckinApplyResult result = new AptCheckinApplyResult(); + if (notBlank(command.getJlid())) + { + Map route = checkinMapper.selectRouteForCheckin(gyid, command.getJlid()); + if (route == null) + { + throw new AptException(AptErrorCode.ROUTE_INVALID, "ROUTE_NOT_FOUND", + "交路不存在或已停用:" + command.getJlid()); + } + app.setJlid(stringValue(route.get("jlid"))); + app.setCc(stringValue(route.get("cc"))); + app.setJch(notBlank(command.getJch()) ? command.getJch().trim() : app.getCc()); + app.setKcsj((Date) route.get("kcsj")); + app.setKcsjSnapshot(app.getKcsj()); + app.setOrigin(ORIGIN_ROUTE); + } + else + { + app.setJlid(null); + app.setCc(null); + app.setKcsjSnapshot(null); + app.setOrigin(ORIGIN_PENDING); + result.getWarnings().add("未选择交路:origin=CHECKIN_PENDING,待 #7 匹配补齐要素后再生成叫乘任务"); + } + + try + { + checkinMapper.insertApplication(gyid, app); + } + catch (DuplicateKeyException ex) + { + AptCheckinApplication raced = checkinMapper.selectByDeviceEvent(gyid, device, command.getDeviceEventId()); + if (raced != null) + { + return duplicatedApply(raced, "并发重复提交,返回原申请单"); + } + throw new AptException(AptErrorCode.DUPLICATE_KEY, "APPLY_DUPLICATED", "申请单重复", ex); + } + + recordLog(gyid, null, "APPLY", app.getApplyId(), app.getJcid(), null, STATE_SUBMITTED, + app.getSource(), null, "设备申请:" + app.getFjh()); + result.setApplyId(app.getApplyId()); + result.setJcid(app.getJcid()); + result.setCheckinState(STATE_SUBMITTED); + result.setSyncState("PENDING"); + result.setOrigin(app.getOrigin()); + result.setBizVersion(1L); + result.setServerTime(now()); + return result; + } + + private AptCheckinApplyResult duplicatedApply(AptCheckinApplication existing, String warning) + { + AptCheckinApplyResult result = new AptCheckinApplyResult(); + result.setApplyId(existing.getApplyId()); + result.setJcid(existing.getJcid()); + result.setCheckinState(existing.getCheckinState()); + result.setSyncState(existing.getSyncState()); + result.setOrigin(existing.getOrigin()); + result.setBizVersion(existing.getBizVersion()); + result.setDuplicated(true); + result.setServerTime(now()); + result.getWarnings().add(warning); + return result; + } + + // ------------------------------------------------------------------ N-41 + + @Override + public AptCheckinApplication detail(String applyId) + { + return mustExist(applyId); + } + + // ------------------------------------------------------------------ N-42 + + @Override + public List list(String state, String workNo, String name) + { + return checkinMapper.selectApplicationList(gyid(), state, workNo, name); + } + + // ------------------------------------------------------------------ N-43 + + @Override + @Transactional(rollbackFor = Exception.class) + public AptCheckinApplication dutyDetail(String applyId, String operatorRef) + { + String gyid = gyid(); + AptCheckinApplication app = mustExist(applyId); + if (STATE_SUBMITTED.equals(app.getCheckinState())) + { + int claimed = checkinMapper.claimReviewing(gyid, applyId, operatorRef, new Date()); + if (claimed > 0) + { + app = mustExist(applyId); + recordLog(gyid, operatorRef, "CLAIM", applyId, app.getJcid(), STATE_SUBMITTED, STATE_REVIEWING, + "DUTY", null, "值班员接单"); + } + } + app.setRoomCandidates(checkinMapper.selectRoomOptions(gyid)); + if (!STATE_SUBMITTED.equals(app.getCheckinState()) && !STATE_REVIEWING.equals(app.getCheckinState())) + { + app.getWarnings().add("当前流程态为 " + app.getCheckinState() + ",仅可查看不可审核"); + } + return app; + } + + // ------------------------------------------------------------------ N-44 + + @Override + @Transactional(rollbackFor = Exception.class) + public AptCheckinReviewResult review(String applyId, AptCheckinReviewCommand command, String operatorRef) + { + String gyid = gyid(); + AptCheckinApplication app = mustExist(applyId); + String verdict = command.getVerdict() == null ? "" : command.getVerdict().trim().toUpperCase(Locale.ROOT); + Date now = new Date(); + AptCheckinReviewResult result = new AptCheckinReviewResult(); + result.setApplyId(applyId); + result.setJcid(app.getJcid()); + + if ("REJECT".equals(verdict)) + { + if (!notBlank(command.getReviewNote())) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "REVIEW_NOTE_REQUIRED", "驳回必须填写审核说明"); + } + int rows = transition(gyid, applyId, Arrays.asList(STATE_REVIEWING, STATE_APPROVED, STATE_ROOM_ADJUSTED), + STATE_REJECTED, command.getExpectedBizVersion(), operatorRef, now, + command.getReviewNote(), command.getReviewNote(), null, null, 0, null, null, null); + if (rows == 0) + { + throw stateConflict(app); + } + recordLog(gyid, operatorRef, "REJECT", applyId, app.getJcid(), app.getCheckinState(), STATE_REJECTED, + "DUTY", command.getCommandId(), command.getReviewNote()); + result.setCheckinState(STATE_REJECTED); + result.setSyncState(app.getSyncState()); + result.setFjh(app.getFjh()); + result.setFjhFinal(app.getFjhFinal()); + result.setReviewedAt(now); + result.setRoomAdjustSeq(app.getRoomAdjustSeq()); + result.setBizVersion(app.getBizVersion() + 1); + result.getWarnings().add("驳回为终态:不生成 jcid 之外的任何叫乘事实,也不触发平台 / 设备双路"); + return result; + } + + boolean changeRoom = "APPROVE_WITH_ROOM_CHANGE".equals(verdict); + if (!"APPROVE".equals(verdict) && !changeRoom) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "VERDICT_INVALID", "verdict 只能是 APPROVE / APPROVE_WITH_ROOM_CHANGE / REJECT"); + } + + if (changeRoom) + { + if (!notBlank(command.getTargetFjh())) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "TARGET_ROOM_REQUIRED", "换房通过必须指定目标房号"); + } + AptCheckinOptions.Room target = requireRoom(gyid, command.getTargetFjh()); + if (target.getRoomNo().equals(app.getFjhFinal())) + { + throw new AptException(AptErrorCode.CHECKIN_STATE_CONFLICT, "ROOM_UNCHANGED", "目标房号与当前房号相同"); + } + int rows = transition(gyid, applyId, Arrays.asList(STATE_REVIEWING, STATE_APPROVED, STATE_ROOM_ADJUSTED), + STATE_ROOM_ADJUSTED, command.getExpectedBizVersion(), operatorRef, now, + command.getReviewNote(), null, null, target.getRoomNo(), 1, + operatorRef, now, command.getAdjustReason()); + if (rows == 0) + { + throw stateConflict(app); + } + recordLog(gyid, operatorRef, "ROOM_CHANGE", applyId, app.getJcid(), app.getCheckinState(), + STATE_ROOM_ADJUSTED, "DUTY", command.getCommandId(), + app.getFjhFinal() + " → " + target.getRoomNo()); + result.setCheckinState(STATE_ROOM_ADJUSTED); + result.setSyncState(app.getSyncState()); + result.setFjh(app.getFjh()); + result.setFjhFinal(target.getRoomNo()); + result.setRoomAdjustSeq(app.getRoomAdjustSeq() + 1); + result.setBizVersion(app.getBizVersion() + 1); + result.setReviewedAt(now); + result.setCheckedIn(false); + result.getWarnings().add("换房已登记(ROOM_ADJUSTED):床位占用在办理入住时发生,本次不动 in_person_count"); + result.getWarnings().add("需再次确认办理入住(N-44 APPROVE)才会生成 gy_jcxx 与 #9 上报"); + return result; + } + + // APPROVE:审核通过并同事务办理入住 + if (!Arrays.asList(STATE_REVIEWING, STATE_APPROVED, STATE_ROOM_ADJUSTED).contains(app.getCheckinState())) + { + throw stateConflict(app); + } + AptCheckinOptions.Room room = requireRoom(gyid, app.getFjhFinal()); + int approved = transition(gyid, applyId, Arrays.asList(STATE_REVIEWING, STATE_APPROVED, STATE_ROOM_ADJUSTED), + STATE_APPROVED, command.getExpectedBizVersion(), operatorRef, now, + command.getReviewNote(), null, null, null, 0, null, null, null); + if (approved == 0) + { + throw stateConflict(app); + } + recordLog(gyid, operatorRef, "APPROVE", applyId, app.getJcid(), app.getCheckinState(), STATE_APPROVED, + "DUTY", command.getCommandId(), command.getReviewNote()); + + AptCheckinApplication approvedApp = mustExist(applyId); + approvedApp.setFjhFinal(room.getRoomNo()); + checkIn(gyid, approvedApp, room, operatorRef, now, result); + return result; + } + + /** + * 办理入住(N-44 通过后的核心事务):占床 → gy_jcxx → 住房事件 → 流程态 CHECKED_IN → #9 入队。 + */ + private void checkIn(String gyid, AptCheckinApplication app, AptCheckinOptions.Room room, + String operatorRef, Date now, AptCheckinReviewResult result) + { + AptCheckinOptions.Room fresh = requireRoom(gyid, room.getRoomNo()); + int occupied = checkinMapper.occupyBed(gyid, fresh.getRoomId(), fresh.getStateVersion(), fresh.getBedCount()); + if (occupied == 0) + { + throw new AptException(AptErrorCode.ROOM_NOT_AVAILABLE, "ROOM_FULL", + "房间 " + fresh.getRoomNo() + " 已满或状态已变更,请重新选择"); + } + checkinMapper.recalcRoomStatus(gyid, fresh.getRoomId(), fresh.getBedCount()); + + String callMode = checkinMapper.countSipBinding(gyid, fresh.getRoomId()) > 0 ? "SIP" : "TABLET"; + checkinMapper.insertJcxx(gyid, app, fresh, callMode, now); + checkinMapper.insertLyEvent(gyid, newId("LYE"), "CHECKIN", "MANUAL", + app.getJcid(), app.getRyid(), fresh.getRoomNo(), now); + + int checkedIn = transition(gyid, app.getApplyId(), Collections.singletonList(STATE_APPROVED), + STATE_CHECKED_IN, null, operatorRef, now, app.getReviewNote(), null, now, null, 0, null, null, null); + if (checkedIn == 0) + { + throw new AptException(AptErrorCode.CHECKIN_STATE_CONFLICT, "CHECKIN_TRANSITION_FAILED", + "流程态迁移失败,请刷新后重试"); + } + + AptCheckinApplication done = mustExist(app.getApplyId()); + JSONObject payload = checkinPayload(gyid, done, fresh.getRoomNo(), callMode, now); + long bizVersion = done.getBizVersion(); + String bizId = done.getJcid() + "-" + (done.getRoomAdjustSeq() + 1); + AptOutboxRow outbox = outboxRow(gyid, BIZ_TYPE_CHECKIN, bizId, bizVersion, payload, now); + checkinMapper.insertOutbox(outbox); + Long outboxId = outbox.getId(); + checkinMapper.updateReported(gyid, done.getApplyId(), now); + recordLog(gyid, operatorRef, "CHECKED_IN", done.getApplyId(), done.getJcid(), STATE_APPROVED, STATE_CHECKED_IN, + "DUTY", null, "房号=" + fresh.getRoomNo() + ",叫乘方式=" + callMode); + + result.setCheckinState(STATE_CHECKED_IN); + result.setSyncState("REPORTED"); + result.setFjh(done.getFjh()); + result.setFjhFinal(fresh.getRoomNo()); + result.setRoomAdjustSeq(done.getRoomAdjustSeq()); + result.setBizVersion(done.getBizVersion() + 1); + result.setReviewedAt(now); + result.setCheckedIn(true); + result.setUploadState("PENDING"); + result.setReportOutboxId(outboxId); + result.getWarnings().add("叫乘任务待 #7 匹配结论到达后复算 jcsj 并置 TASK_READY"); + if ("TABLET".equals(callMode)) + { + result.getWarnings().add("该房间无有效 SIP 绑定,叫乘方式回落为 TABLET"); + } + } + + // ------------------------------------------------------------------ N-45 + + @Override + @Transactional(rollbackFor = Exception.class) + public AptCheckinRoomAdjustResult roomAdjust(String applyId, AptCheckinRoomAdjustCommand command, String operatorRef) + { + String gyid = gyid(); + AptCheckinApplication app = mustExist(applyId); + if (STATE_CHECKED_IN.equals(app.getCheckinState())) + { + throw new AptException(AptErrorCode.CHECKIN_STATE_CONFLICT, "ALREADY_CHECKED_IN", + "已办理入住,换房须走退宿换房,不能在入住链路上改写叫乘事实"); + } + if (!Arrays.asList(STATE_APPROVED, STATE_ROOM_ADJUSTED, STATE_REVIEWING).contains(app.getCheckinState())) + { + throw stateConflict(app); + } + AptCheckinOptions.Room target = requireRoom(gyid, command.getTargetFjh()); + if (target.getRoomNo().equals(app.getFjhFinal())) + { + throw new AptException(AptErrorCode.CHECKIN_STATE_CONFLICT, "ROOM_UNCHANGED", "目标房号与当前房号相同"); + } + Date now = new Date(); + int rows = transition(gyid, applyId, Arrays.asList(STATE_REVIEWING, STATE_APPROVED, STATE_ROOM_ADJUSTED), + STATE_ROOM_ADJUSTED, command.getExpectedBizVersion(), operatorRef, now, + null, null, null, target.getRoomNo(), 1, operatorRef, now, command.getReason()); + if (rows == 0) + { + throw stateConflict(app); + } + checkinMapper.insertLyEvent(gyid, newId("LYE"), "ROOM_SWAP", "MANUAL", app.getJcid(), app.getRyid(), + target.getRoomNo(), now); + recordLog(gyid, operatorRef, "ROOM_ADJUST", applyId, app.getJcid(), app.getCheckinState(), STATE_ROOM_ADJUSTED, + "DUTY", command.getCommandId(), app.getFjhFinal() + " → " + target.getRoomNo()); + + AptCheckinRoomAdjustResult result = new AptCheckinRoomAdjustResult(); + result.setApplyId(applyId); + result.setJcid(app.getJcid()); + result.setCheckinState(STATE_ROOM_ADJUSTED); + result.setFjh(app.getFjh()); + result.setFjhFinal(target.getRoomNo()); + result.setRoomAdjustSeq(app.getRoomAdjustSeq() + 1); + result.setBizVersion(app.getBizVersion() + 1); + result.setOriginRoomStatus(app.getRoomStatus()); + result.setTargetRoomStatus(target.getRoomStatus()); + result.setSipBound(checkinMapper.countSipBinding(gyid, target.getRoomId()) > 0); + result.getWarnings().add("审核期换房只变更意向房号与留痕;床位在办理入住时占用,不改 in_person_count"); + if (app.getReportedAt() != null) + { + result.getWarnings().add("该申请已产生 #9 上报,换房差异需在下一次要素上报中体现"); + } + return result; + } + + // ------------------------------------------------------------------ N-46 + + @Override + public AptCheckinTimeline timeline(String applyId) + { + String gyid = gyid(); + AptCheckinApplication app = mustExist(applyId); + AptCheckinTimeline timeline = new AptCheckinTimeline(); + timeline.setApplyId(applyId); + timeline.setJcid(app.getJcid()); + timeline.setCheckinState(app.getCheckinState()); + timeline.setSyncState(app.getSyncState()); + timeline.setMatchState(app.getMatchState()); + timeline.setServerTime(now()); + + List> rows = checkinMapper.selectTimelineEvents(gyid, applyId); + List events = new ArrayList<>(); + for (Map row : rows) + { + AptCheckinTimeline.Event event = new AptCheckinTimeline.Event(); + event.setAt(stringValue(row.get("at"))); + event.setActor(stringValue(row.get("operatorRef"))); + event.setNote(noteOf(row.get("detail"))); + event.setState(stringValue(stateOf(row.get("detail")))); + event.setFromState(stringValue(fromStateOf(row.get("detail")))); + event.setCommandId(stringValue(commandIdOf(row.get("detail")))); + events.add(event); + } + if (events.isEmpty() && app.getSubmittedAt() != null) + { + AptCheckinTimeline.Event submitted = new AptCheckinTimeline.Event(); + submitted.setState(STATE_SUBMITTED); + submitted.setAt(format(app.getSubmittedAt())); + submitted.setActor(app.getDeviceCode()); + submitted.setNote("设备提交入住申请(无审核日志)"); + events.add(submitted); + } + timeline.setEvents(events); + if (STATE_ROOM_ADJUSTED.equals(app.getCheckinState())) + { + timeline.getEvents().add(0, roomAdjustHint(app)); + } + return timeline; + } + + private AptCheckinTimeline.Event roomAdjustHint(AptCheckinApplication app) + { + AptCheckinTimeline.Event hint = new AptCheckinTimeline.Event(); + hint.setState("ROOM_ADJUSTED"); + hint.setAt(format(app.getAdjustedAt() == null ? app.getSubmittedAt() : app.getAdjustedAt())); + hint.setActor(app.getAdjustedBy()); + hint.setNote("房号调整为 " + app.getFjhFinal() + ",待确认办理入住"); + return hint; + } + + // ------------------------------------------------------------------ N-47 + + @Override + public AptCheckinCallTask callTask(String jcid) + { + String gyid = gyid(); + AptCheckinApplication app = checkinMapper.selectApplicationByJcid(gyid, jcid); + if (app == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "CHECKIN_NOT_FOUND", "入住申请不存在:" + jcid); + } + AptCheckinCallTask task = new AptCheckinCallTask(); + task.setApplyId(app.getApplyId()); + task.setJcid(app.getJcid()); + task.setCheckinState(app.getCheckinState()); + task.setSyncState(app.getSyncState()); + task.setMatchState(app.getMatchState()); + task.setRoomAdjustSeq(app.getRoomAdjustSeq()); + task.setFjh(app.getFjh()); + task.setFjhFinal(app.getFjhFinal()); + task.setJcsj(app.getJcsj()); + task.setJcfs(app.getJcfs()); + task.setServerTime(now()); + boolean ready = "TASK_READY".equals(app.getSyncState()); + task.setTaskReady(ready); + if (ready) + { + AptCheckinCallTask.Task detail = checkinMapper.selectCallTask(gyid, jcid); + task.setTask(detail); + if (detail != null) + { + task.setJcsj(detail.getJcsj()); + task.setJcfs(detail.getJcfs()); + } + } + return task; + } + + // ------------------------------------------------------------------ 内部 + + private String gyid() + { + return AptContext.getGyId(); + } + + private AptCheckinApplication mustExist(String applyId) + { + AptCheckinApplication app = checkinMapper.selectApplication(gyid(), applyId); + if (app == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "CHECKIN_NOT_FOUND", "入住申请不存在:" + applyId); + } + return app; + } + + private AptCheckinOptions.Room requireRoom(String gyid, String roomNo) + { + if (!notBlank(roomNo)) + { + throw new AptException(AptErrorCode.ROOM_NOT_AVAILABLE, "ROOM_NOT_FOUND", "房号为空"); + } + AptCheckinOptions.Room room = checkinMapper.selectRoomByNo(gyid, roomNo.trim()); + if (room == null) + { + throw new AptException(AptErrorCode.ROOM_NOT_AVAILABLE, "ROOM_NOT_FOUND", "房间不存在:" + roomNo); + } + if (!room.isAvailable()) + { + throw new AptException(AptErrorCode.ROOM_NOT_AVAILABLE, "ROOM_UNAVAILABLE", + "房间不可住或已满:" + roomNo + "(状态 " + room.getRoomStatus() + ")"); + } + return room; + } + + private AptException stateConflict(AptCheckinApplication app) + { + return new AptException(AptErrorCode.CHECKIN_STATE_CONFLICT, "STATE_VERSION_STALE", + "流程态或版本已变更(当前 " + app.getCheckinState() + " / v" + app.getBizVersion() + "),请刷新后重试"); + } + + private int transition(String gyid, String applyId, List fromStates, String toState, + Long expectedBizVersion, String actor, Date at, String note, String rejectReason, + Date checkedInAt, String fjhFinal, int seqDelta, + String adjustedBy, Date adjustedAt, String adjustReason) + { + return checkinMapper.transition(gyid, applyId, fromStates, toState, expectedBizVersion, actor, at, + note, rejectReason, checkedInAt, fjhFinal, seqDelta, adjustedBy, adjustedAt, adjustReason); + } + + private JSONObject checkinPayload(String gyid, AptCheckinApplication app, String roomNo, + String callMode, Date now) + { + JSONObject data = new JSONObject(); + data.put("jcid", app.getJcid()); + data.put("applyId", app.getApplyId()); + data.put("ryid", app.getRyid()); + data.put("cardId", app.getWorkNo()); + data.put("roomId", app.getRoomId()); + data.put("eventType", "CHECKIN"); + data.put("eventAt", format(now)); + data.put("source", "MANUAL"); + data.put("origin", app.getOrigin()); + data.put("jlid", app.getJlid()); + data.put("cc", app.getCc()); + data.put("jch", app.getJch()); + data.put("kcsj", format(app.getKcsj())); + data.put("callMode", callMode); + data.put("faceVerifyState", app.getFaceVerifyState()); + data.put("bizVersion", app.getBizVersion()); + data.put("matchedPlanKey", null); + + JSONObject payload = new JSONObject(); + payload.put("contractVersion", "v2-draft"); + payload.put("gyId", gyid); + payload.put("bizType", BIZ_TYPE_CHECKIN); + payload.put("bizId", app.getJcid() + "-" + (app.getRoomAdjustSeq() + 1)); + payload.put("bizVersion", app.getBizVersion()); + payload.put("occurredAt", format(now)); + payload.put("data", data); + return payload; + } + + private AptOutboxRow outboxRow(String gyid, String bizType, String bizId, long bizVersion, + JSONObject payload, Date occurredAt) + { + AptOutboxRow row = new AptOutboxRow(); + row.setGyid(gyid); + row.setIdempotencyKey(idempotencyKey(gyid, bizType, bizId, bizVersion)); + row.setBizType(bizType); + row.setBizId(bizId); + row.setBizVersion(bizVersion); + row.setPayload(payload.toJSONString()); + row.setOccurredAt(occurredAt); + return row; + } + + private String idempotencyKey(String gyid, String bizType, String bizId, long bizVersion) + { + String raw = gyid + "|" + bizType + "|" + bizId + "|" + bizVersion; + return DigestUtils.md5DigestAsHex(raw.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + private void recordLog(String gyid, String operatorRef, String action, String applyId, String jcid, + String fromState, String toState, String source, String commandId, String note) + { + JSONObject detail = new JSONObject(); + detail.put("applyId", applyId); + detail.put("jcid", jcid); + detail.put("fromState", fromState); + detail.put("toState", toState); + detail.put("source", source); + detail.put("commandId", commandId); + detail.put("note", note); + checkinMapper.insertOperationLog(gyid, newId("OP"), operatorRef, MODULE, action, + detail.toJSONString(), AptContext.getTraceId(), new Date()); + } + + private String stateOf(Object detail) + { + return jsonField(detail, "toState"); + } + + private String fromStateOf(Object detail) + { + return jsonField(detail, "fromState"); + } + + private String commandIdOf(Object detail) + { + return jsonField(detail, "commandId"); + } + + private String noteOf(Object detail) + { + return jsonField(detail, "note"); + } + + private String jsonField(Object detail, String field) + { + if (detail == null) + { + return null; + } + try + { + return JSON.parseObject(String.valueOf(detail)).getString(field); + } + catch (RuntimeException ex) + { + log.debug("checkin timeline detail 不是合法 JSON,忽略字段 {}", field); + return null; + } + } + + private String newId(String prefix) + { + String stamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()); + int random = ThreadLocalRandom.current().nextInt(100000, 1000000); + return prefix + "-" + stamp + "-" + random; + } + + private String now() + { + return format(new Date()); + } + + private String format(Date value) + { + if (value == null) + { + return null; + } + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(value); + } + + private boolean notBlank(String value) + { + return value != null && !value.isBlank(); + } + + private String stringValue(Object value) + { + return value == null ? null : String.valueOf(value); + } + + private long longValue(Object value, long defaultValue) + { + if (value == null) + { + return defaultValue; + } + return value instanceof Number number ? number.longValue() : Long.parseLong(String.valueOf(value)); + } + +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinMatchService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinMatchService.java new file mode 100644 index 0000000..ea97412 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinMatchService.java @@ -0,0 +1,18 @@ +package com.ruoyi.apt.stay.checkin.service; + +import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchResult; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchVerdict; + +/** + * #7 matchVerdicts[] 落库与复算(供 IT-6 拉取器调用)。 + * + *

本接口只消费**匹配结论**,不做 HTTP 拉取:{@code kcsj} / {@code jcfs} 是要素, + * 平台匹配后由调用方传入;{@code jcsj} 恒由 {@link AptCallTimeCalculator} 本地计算。 + * 幂等:同一结论重复提交只更新时间与版本,不重复入队 #8。

+ * + * @author apt-rev01 + */ +public interface IAptCheckinMatchService +{ + AptCheckinMatchResult applyVerdict(AptCheckinMatchVerdict verdict); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinService.java new file mode 100644 index 0000000..4ed9f3e --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/stay/checkin/service/IAptCheckinService.java @@ -0,0 +1,53 @@ +package com.ruoyi.apt.stay.checkin.service; + +import java.util.List; + +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplication; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyCommand; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyResult; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinCallTask; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinOptions; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewCommand; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewResult; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinRoomAdjustCommand; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinRoomAdjustResult; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinTimeline; + +/** + * 入住作业核心服务(N-39 ~ N-47)。 + * + *

状态机:SUBMITTED →(N-43 接单)REVIEWING →(N-44)APPROVED →(同事务)CHECKED_IN; + * 换房路径 REVIEWING →(N-44 换房通过)ROOM_ADJUSTED →(N-44 通过)CHECKED_IN; + * 驳回为终态 REJECTED。同步态 {@code sync_state} 独立演进,与注册态解耦。

+ * + * @author apt-rev01 + */ +public interface IAptCheckinService +{ + /** N-39 入住选项(版本比较,15 分钟 TTL 由客户端执行) */ + AptCheckinOptions options(String version); + + /** N-40 入住申请(设备侧,按 deviceEventId 幂等) */ + AptCheckinApplyResult apply(AptCheckinApplyCommand command, String deviceCode); + + /** N-41 设备侧查询申请单 */ + AptCheckinApplication detail(String applyId); + + /** N-42 值班台待审列表 */ + List list(String state, String workNo, String name); + + /** N-43 值班台详情(首次查看条件迁移 SUBMITTED → REVIEWING) */ + AptCheckinApplication dutyDetail(String applyId, String operatorRef); + + /** N-44 审核:APPROVE / APPROVE_WITH_ROOM_CHANGE / REJECT */ + AptCheckinReviewResult review(String applyId, AptCheckinReviewCommand command, String operatorRef); + + /** N-45 审核期换房(仅 APPROVED / ROOM_ADJUSTED) */ + AptCheckinRoomAdjustResult roomAdjust(String applyId, AptCheckinRoomAdjustCommand command, String operatorRef); + + /** N-46 审核时间线 */ + AptCheckinTimeline timeline(String applyId); + + /** N-47 按 jcid 查入住与叫乘任务 */ + AptCheckinCallTask callTask(String jcid); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptSipEventCommand.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptSipEventCommand.java new file mode 100644 index 0000000..37a1ba7 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/domain/AptSipEventCommand.java @@ -0,0 +1,70 @@ +package com.ruoyi.apt.voice.domain; + +import java.util.Date; + +/** + * AMI 事件入参(最小 SIP 适配,公寓端/05 表 5-13)。 + * + *

事件只追加到 {@code apt_call_event},并由适配层提交状态机命令; + * AMI 事件既不直接写业务表,也不单独构成对外上报(CR-C21)。

+ * + * @author apt-rev01 + */ +public class AptSipEventCommand +{ + private String jcid; + private String sessionId; + private String eventType; + private Date occurredAt; + private String payload; + + public String getJcid() + { + return jcid; + } + + public void setJcid(String jcid) + { + this.jcid = jcid; + } + + public String getSessionId() + { + return sessionId; + } + + public void setSessionId(String sessionId) + { + this.sessionId = sessionId; + } + + public String getEventType() + { + return eventType; + } + + public void setEventType(String eventType) + { + this.eventType = eventType; + } + + public Date getOccurredAt() + { + return occurredAt; + } + + public void setOccurredAt(Date occurredAt) + { + this.occurredAt = occurredAt; + } + + public String getPayload() + { + return payload; + } + + public void setPayload(String payload) + { + this.payload = payload; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptVoiceEventServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptVoiceEventServiceImpl.java new file mode 100644 index 0000000..55f36a1 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/AptVoiceEventServiceImpl.java @@ -0,0 +1,155 @@ +package com.ruoyi.apt.voice.service; + +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +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.dispatch.domain.AptStateSnapshot; +import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper; +import com.ruoyi.apt.dispatch.service.IAptDutyStateService; +import com.ruoyi.apt.dispatch.tablet.mapper.AptTabletMapper; +import com.ruoyi.apt.framework.config.AptProperties; +import com.ruoyi.apt.voice.domain.AptSipEventCommand; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 最小 SIP 适配实现(IT-5 / M3)。 + * + *

事件映射(公寓端/05 表 5-13):

+ *
+ * ORIGINATE_START / NEWSTATE   -> CALL_START   (ryzt 0 -> 1)
+ * CONFBRIDGEJOIN               -> ANSWERED     (-> 3, 判据是房间侧入会,不是 SIP Answer)
+ * ORIGINATE_FAILURE / NO_ANSWER-> CALL_FAILED  (-> 2)
+ * HANGUP                       -> 不回写(已结束不回写)
+ * AMI_DISCONNECT               -> 不推定终态(5 分钟巡检兜底在 IT-7)
+ * 
+ * + *

本期不含 Asterisk 连接与录音索引;30s 无入会由 + * {@code AptDispatchScanService} 调用状态机命令,不在本类做定时逻辑。

+ * + * @author apt-rev01 + */ +@Service +public class AptVoiceEventServiceImpl implements IAptVoiceEventService +{ + private final AptJcxxMapper jcxxMapper; + private final AptTabletMapper tabletMapper; + private final IAptDutyStateService stateService; + private final AptProperties properties; + + public AptVoiceEventServiceImpl(AptJcxxMapper jcxxMapper, AptTabletMapper tabletMapper, + IAptDutyStateService stateService, AptProperties properties) + { + this.jcxxMapper = jcxxMapper; + this.tabletMapper = tabletMapper; + this.stateService = stateService; + this.properties = properties; + } + + @Override + @Transactional + public Map handle(AptSipEventCommand command) + { + if (command == null || command.getJcid() == null || command.getJcid().isBlank()) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_JCID", "jcid 不能为空"); + } + if (command.getEventType() == null || command.getEventType().isBlank()) + { + throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_EVENT_TYPE", "eventType 不能为空"); + } + AptStateSnapshot snapshot = stateService.snapshot(command.getJcid()); + if (snapshot == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "叫乘记录不存在"); + } + + String eventType = command.getEventType().trim().toUpperCase(Locale.ROOT); + Date occurredAt = command.getOccurredAt() == null ? new Date() : command.getOccurredAt(); + String sessionId = command.getSessionId() == null || command.getSessionId().isBlank() + ? "CS-" + UUID.randomUUID().toString().replace("-", "") + : command.getSessionId(); + + Long nextSeq = jcxxMapper.selectNextEventSeq(gyid(), sessionId); + jcxxMapper.insertCallEvent(gyid(), "EV-" + UUID.randomUUID().toString().replace("-", ""), + sessionId, eventType, command.getPayload(), occurredAt, nextSeq == null ? 1L : nextSeq); + + Map result = new LinkedHashMap<>(); + result.put("jcid", command.getJcid()); + result.put("eventType", eventType); + result.put("sessionId", sessionId); + result.put("submitted", false); + try + { + switch (eventType) + { + case "ORIGINATE_START": + case "NEWSTATE": + stateService.callStart(command.getJcid()); + result.put("submitted", true); + break; + case "CONFBRIDGEJOIN": + stateService.answered(command.getJcid()); + result.put("submitted", true); + break; + case "ORIGINATE_FAILURE": + case "NO_ANSWER": + case "ORIGINATE_NO_ANSWER": + stateService.callFailed(command.getJcid()); + result.put("submitted", true); + break; + case "HANGUP": + case "AMI_DISCONNECT": + // 已结束不回写;AMI 断连不推定终态(表 5-13) + break; + default: + break; + } + } + catch (AptException e) + { + if (e.getErrorCode() != AptErrorCode.STATE_ILLEGAL) + { + throw e; + } + result.put("ignored", true); + result.put("reason", e.getReason()); + } + + AptStateSnapshot after = stateService.snapshot(command.getJcid()); + result.put("stateAfter", after == null ? null : after.getRyzt()); + result.put("traceId", AptContext.getTraceId()); + return result; + } + + @Override + public String resolveCallMode(String jcid) + { + if (!properties.getFeatures().isTabletCall()) + { + return "SIP"; + } + AptStateSnapshot snapshot = stateService.snapshot(jcid); + if (snapshot == null) + { + throw new AptException(AptErrorCode.NOT_FOUND, "JCXX_NOT_FOUND", "叫乘记录不存在"); + } + String mode = snapshot.getCallMode() == null ? "SIP" : snapshot.getCallMode(); + if ("TABLET".equals(mode) && tabletMapper.countActiveTablet(gyid()) == 0) + { + // 平板离线 / 未部署:回落 SIP(CR-C23),一条 jcid 只有一种事实来源 + return "SIP"; + } + return mode; + } + + private String gyid() + { + return AptContext.getGyId(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptVoiceEventService.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptVoiceEventService.java new file mode 100644 index 0000000..e2e105f --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/voice/service/IAptVoiceEventService.java @@ -0,0 +1,21 @@ +package com.ruoyi.apt.voice.service; + +import java.util.Map; +import com.ruoyi.apt.voice.domain.AptSipEventCommand; + +/** + * 最小 SIP 事件适配(IT-5 / M3,公寓端/05 表 5-13、CR-C23 降级回落)。 + * + *

本期不含 Asterisk 接入与录音索引(IT-7):只提供事件 → 状态机命令的映射 + * 与「平板不可用回落 SIP」的模式判定,供联调与单测直接调用。

+ * + * @author apt-rev01 + */ +public interface IAptVoiceEventService +{ + /** 事件落 apt_call_event 后提交状态机命令,返回 submitted / stateAfter */ + Map handle(AptSipEventCommand command); + + /** 生效叫乘方式:平板能力关闭或无 ACTIVE 平板时回落 SIP(CR-C23) */ + String resolveCallMode(String jcid); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptDispatchTask.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptDispatchTask.java new file mode 100644 index 0000000..3cc78db --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/quartz/task/AptDispatchTask.java @@ -0,0 +1,35 @@ +package com.ruoyi.quartz.task; + +import com.ruoyi.apt.dispatch.service.IAptDispatchScanService; +import org.springframework.stereotype.Component; + +/** + * 叫乘扫描 Quartz 任务(IT-5 / M3)。 + * + *

为什么放在 {@code com.ruoyi.quartz.task} 包而不是 apt 包: + * 若依 {@code ScheduleUtils.whiteList} 只允许 {@code com.ruoyi.quartz.task} + * 前缀的 invokeTarget({@code Constants.JOB_WHITELIST_STR}), + * 因此 Job bean 必须落在这个包内,由它委托 {@code ruoyi-apt} 的服务。 + * 这样既不扩白名单,也不把业务逻辑写进 quartz 模块。

+ * + *

建议 cron:{@code 0 * * * * ?}(每分钟),由 sys_job 登记、默认停用, + * 联调与试点时手工启用(见表 13-15 依赖:Quartz 须启用 ScheduleConfig 或显式登记)。

+ * + * @author apt-rev01 + */ +@Component("aptDispatchTask") +public class AptDispatchTask +{ + private final IAptDispatchScanService scanService; + + public AptDispatchTask(IAptDispatchScanService scanService) + { + this.scanService = scanService; + } + + /** 无参入口:sys_job.invoke_target = aptDispatchTask.scan() */ + public void scan() + { + scanService.scan(); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptCheckinMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptCheckinMapper.xml new file mode 100644 index 0000000..32d9069 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptCheckinMapper.xml @@ -0,0 +1,418 @@ + + + + + + + t.apply_id AS applyId, + t.jcid AS jcid, + t.task_id AS taskId, + t.device_code AS deviceCode, + t.work_no AS workNo, + COALESCE(t.name_snapshot, t.xm) AS name, + t.ryid AS ryid, + t.reg_id AS regId, + t.reg_state_at_apply AS regStateAtApply, + t.fjh AS fjh, + t.fjh_final AS fjhFinal, + t.room_adjust_seq AS roomAdjustSeq, + mr.room_id AS roomId, + mr.room_type AS roomType, + COALESCE(mr.bed_count, 0) AS bedCount, + COALESCE(gr.in_person_count, 0) AS liveCount, + gr.room_status AS roomStatus, + t.jlid AS jlid, + t.cc AS cc, + t.jch AS jch, + COALESCE(t.base_kcsj, t.kcsj_snapshot) AS kcsj, + t.kcsj_snapshot AS kcsjSnapshot, + t.biz_version AS bizVersion, + t.checkin_state AS checkinState, + t.sync_state AS syncState, + t.match_state AS matchState, + t.matched_plan_key AS matchedPlanKey, + t.face_verify_state AS faceVerifyState, + t.face_device_code AS faceDeviceCode, + t.face_verified_at AS faceVerifiedAt, + t.verified_ryid AS verifiedRyid, + t.verified_at AS verifiedAt, + t.origin AS origin, + t.source AS source, + t.submitted_at AS submittedAt, + t.occurred_at AS occurredAt, + t.audited_by AS reviewedBy, + t.audited_at AS reviewedAt, + t.review_note AS reviewNote, + t.adjusted_by AS adjustedBy, + t.adjusted_at AS adjustedAt, + t.adjust_reason AS adjustReason, + t.reject_reason AS rejectReason, + t.checked_in_at AS checkedInAt, + t.reported_at AS reportedAt, + t.matched_at AS matchedAt, + t.task_ready_at AS taskReadyAt, + t.jcsj AS jcsj, + j.call_mode AS jcfs, + t.jllx AS jllx, + t.derived_rule_version AS derivedRuleVersion + + + + FROM apt_local_task t + LEFT JOIN apt_master_room mr + ON mr.gyid = t.gyid AND mr.room_no = COALESCE(t.fjh_final, t.fjh) AND mr.deleted = 0 + LEFT JOIN gy_rooms gr + ON gr.gyid = t.gyid AND gr.room_id = mr.room_id AND gr.deleted = 0 + LEFT JOIN gy_jcxx j + ON j.gyid = t.gyid AND j.jcid = t.jcid AND j.deleted = 0 + + + + + + + + + + + + + + + + + + INSERT INTO apt_local_task + (gyid, task_id, apply_id, jcid, origin, local_version, biz_version, + match_state, checkin_state, sync_state, + device_code, device_event_id, work_no, name_snapshot, xm, ryid, + reg_id, reg_state_at_apply, + fjh, fjh_final, room_adjust_seq, + jlid, cc, jch, kcsj_snapshot, + face_verify_state, face_device_code, face_verified_at, + verified_ryid, verified_at, feature_version, + source, submitted_at, occurred_at, request_payload, jllx, deleted) + VALUES + (#{gyid}, #{app.taskId}, #{app.applyId}, #{app.jcid}, #{app.origin}, 1, 1, + 'PENDING', 'SUBMITTED', 'PENDING', + #{app.deviceCode}, #{app.deviceEventId}, #{app.workNo}, #{app.name}, #{app.name}, #{app.ryid}, + #{app.regId}, #{app.regStateAtApply}, + #{app.fjh}, #{app.fjhFinal}, 0, + #{app.jlid}, #{app.cc}, #{app.jch}, #{app.kcsjSnapshot}, + #{app.faceVerifyState}, #{app.faceDeviceCode}, #{app.faceVerifiedAt}, + #{app.verifiedRyid}, #{app.verifiedAt}, #{app.featureVersion}, + #{app.source}, #{app.submittedAt}, #{app.occurredAt}, #{app.requestPayload}, #{app.jllx}, 0) + + + + UPDATE apt_local_task + SET checkin_state = 'REVIEWING', + biz_version = biz_version + 1, + audited_by = #{actor}, + audited_at = #{at} + WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0 + AND checkin_state = 'SUBMITTED' + + + + UPDATE apt_local_task + SET checkin_state = #{toState}, + biz_version = biz_version + 1, + audited_by = COALESCE(#{actor,jdbcType=VARCHAR}, audited_by), + audited_at = COALESCE(#{at,jdbcType=TIMESTAMP}, audited_at), + review_note = COALESCE(#{note,jdbcType=VARCHAR}, review_note), + reject_reason = COALESCE(#{rejectReason,jdbcType=VARCHAR}, reject_reason), + checked_in_at = COALESCE(#{checkedInAt,jdbcType=TIMESTAMP}, checked_in_at), + fjh_final = COALESCE(#{fjhFinal,jdbcType=VARCHAR}, fjh_final), + room_adjust_seq = room_adjust_seq + #{seqDelta}, + adjusted_by = COALESCE(#{adjustedBy,jdbcType=VARCHAR}, adjusted_by), + adjusted_at = COALESCE(#{adjustedAt,jdbcType=TIMESTAMP}, adjusted_at), + adjust_reason = COALESCE(#{adjustReason,jdbcType=VARCHAR}, adjust_reason) + WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0 + AND checkin_state IN + + #{from} + + + AND biz_version = #{expectedBizVersion} + + + + + UPDATE apt_local_task + SET sync_state = 'REPORTED', reported_at = #{at}, biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0 + + + + UPDATE apt_local_task + SET sync_state = #{syncState}, biz_version = biz_version + 1 + + , matched_at = COALESCE(matched_at, #{at}) + + WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0 + + + + UPDATE apt_local_task + SET match_state = #{matchState}, + matched_plan_key = #{matchedPlanKey,jdbcType=VARCHAR}, + base_kcsj = COALESCE(#{kcsj,jdbcType=TIMESTAMP}, base_kcsj), + base_jcfs = COALESCE(#{jcfs,jdbcType=VARCHAR}, base_jcfs), + jlid = COALESCE(#{jlid,jdbcType=VARCHAR}, jlid), + cc = COALESCE(#{cc,jdbcType=VARCHAR}, cc), + jch = COALESCE(#{jch,jdbcType=VARCHAR}, jch), + matched_at = #{at}, + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0 + + + + UPDATE apt_local_task + SET sync_state = 'TASK_READY', jcsj = #{jcsj}, derived_rule_version = #{ruleVersion}, + task_ready_at = #{at}, biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0 + + + + UPDATE apt_local_task + SET jllx = #{jllx}, biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND apply_id = #{applyId} AND deleted = 0 + + + + + + UPDATE gy_rooms + SET in_person_count = in_person_count + 1, + state_version = state_version + 1 + WHERE gyid = #{gyid} AND room_id = #{roomId} AND deleted = 0 + AND state_version = #{expectedStateVersion} + AND in_person_count < #{bedCount} + AND room_status NOT IN ('4', '6') + + + + UPDATE gy_rooms + SET in_person_count = GREATEST(in_person_count - 1, 0), + state_version = state_version + 1 + WHERE gyid = #{gyid} AND room_id = #{roomId} AND deleted = 0 + AND state_version = #{expectedStateVersion} + AND in_person_count > 0 + + + + UPDATE gy_rooms + SET room_status = CASE + WHEN in_person_count <= 0 THEN '1' + WHEN in_person_count >= #{bedCount} THEN '2' + ELSE '5' END, + state_version = state_version + 1 + WHERE gyid = #{gyid} AND room_id = #{roomId} AND deleted = 0 + AND room_status NOT IN ('4', '6') + + + + + + + + INSERT INTO gy_jcxx + (gyid, jcid, jlid, cc, ryid, xm, fjh, kcsj, jcsj, + ryzt, finish_jc, finish_ly, stoped, call_mode, dispatch_seq, + biz_version, occurred_at, received_at, deleted) + VALUES + (#{gyid}, #{app.jcid}, #{app.jlid}, #{app.cc}, #{app.ryid}, #{app.name}, #{app.fjhFinal}, + #{app.kcsj}, NULL, + '0', '0', '0', '0', #{callMode}, NULL, + 1, #{now}, #{now}, 0) + + + + + + UPDATE gy_jcxx + SET jcsj = #{jcsj}, + call_mode = COALESCE(#{jcfs}, call_mode), + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 + + + + INSERT INTO gygl_ly_event + (gyid, event_id, event_type, event_source, jcid, ryid, fjh, occurred_at, received_at, report_state, deleted) + VALUES + (#{gyid}, #{eventId}, #{eventType}, #{eventSource}, #{jcid}, #{ryid}, #{fjh}, + #{occurredAt}, NOW(3), 'PENDING', 0) + + + + INSERT INTO apt_report_outbox + (gyid, idempotency_key, biz_type, biz_id, biz_version, payload, state, occurred_at) + VALUES + (#{gyid}, #{idempotencyKey}, #{bizType}, #{bizId}, #{bizVersion}, #{payload}, 'PENDING', #{occurredAt}) + + + + INSERT INTO duty_operation_log + (gyid, log_id, operator_ref, module, action, detail, trace_id, occurred_at) + VALUES + (#{gyid}, #{logId}, #{operatorRef}, #{module}, #{action}, #{detail}, #{traceId}, #{occurredAt}) + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..3239cbd --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptDutyStateMapper.xml @@ -0,0 +1,121 @@ + + + + + + + + + + UPDATE gy_jcxx + SET ryzt = '1', + jcsj_s = NOW(3), + is_temp_call = CASE WHEN #{tempCall} THEN '1' ELSE is_temp_call END, + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 + AND ryzt = '0' AND finish_jc = '0' AND finish_ly = '0' AND stoped = '0' + + + + UPDATE gy_jcxx + SET ryzt = '3', + finish_jc = '1', + hjxysj = COALESCE(hjxysj, NOW(3)), + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 + AND finish_jc = '0' AND finish_ly = '0' + AND ryzt IN + + #{state} + + + + + UPDATE gy_jcxx + SET ryzt = '2', + jcbjsj_s = NOW(3), + is_jcbj = '1', + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 + AND ryzt = '1' AND finish_jc = '0' AND finish_ly = '0' + + + + UPDATE gy_jcxx + SET stoped = '1', + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 + AND ryzt IN ('1','2') AND finish_ly = '0' + + + + UPDATE gy_jcxx + SET ryzt = '1', + stoped = '0', + fjsj_s = NOW(3), + jcsj_s = COALESCE(jcsj_s, NOW(3)), + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 + AND (stoped = '1' OR ryzt = '2') + AND finish_jc = '0' AND finish_ly = '0' + + + + UPDATE gy_jcxx + SET finish_jc = '1', + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 + AND finish_ly = '0' AND finish_jc = '0' + + + + UPDATE gy_jcxx + SET finish_ly = '1', + sjlysj = NOW(3), + leave_type = #{leaveType}, + leave_event_id = #{eventId}, + leave_remark = #{remark}, + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 + AND finish_ly = '0' + + + + UPDATE gy_jcxx + SET stoped = '1', + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 + AND ryzt = '0' AND finish_ly = '0' + + + + INSERT INTO apt_call_session + (gyid, session_id, jcid, call_mode, call_state, started_at, biz_version, deleted) + VALUES + (#{gyid}, #{sessionId}, #{jcid}, #{callMode}, 'INIT', NOW(3), 1, 0) + + + + INSERT INTO gygl_ly_event + (gyid, event_id, event_type, event_source, jcid, ryid, fjh, + occurred_at, received_at, report_state, deleted) + SELECT gyid, #{eventId}, 'CHECKOUT', 'DUTY', jcid, ryid, fjh, + NOW(3), NOW(3), 'PENDING', 0 + FROM gy_jcxx + WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 + + + diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceRegMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceRegMapper.xml new file mode 100644 index 0000000..6db4411 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceRegMapper.xml @@ -0,0 +1,193 @@ + + + + + + r.reg_id AS regId, + r.work_no AS workNo, + r.name AS name, + r.unit_code AS unitCode, + r.person_category AS personCategory, + r.reg_state AS regState, + r.local_only AS localOnly, + r.verdict AS verdict, + r.verdict_version AS verdictVersion, + r.platform_version AS platformVersion, + r.upload_state AS uploadState, + r.dispatch_state AS dispatchState, + r.face_sync_state AS faceSyncState, + r.apply_device_code AS applyDeviceCode, + r.confirmed_ryid AS confirmedRyid, + r.confirmed_by AS confirmedBy, + r.confirmed_at AS confirmedAt, + r.reject_reason AS rejectReason, + r.feature_algo AS featureAlgo, + r.feature_version AS featureVersion, + r.photo_ref AS photoRef, + r.photo_checksum AS photoChecksum, + r.face_feature_ref AS featureRef, + r.feature_checksum AS featureChecksum, + r.occurred_at AS occurredAt + + + + + + + + + + + + + INSERT INTO apt_person_face_reg + (gyid, reg_id, work_no, name, unit_code, face_feature_ref, photo_ref, photo_checksum, + feature_checksum, feature_algo, feature_version, person_category, reg_state, local_only, + verdict, verdict_version, platform_version, upload_state, dispatch_state, face_sync_state, + apply_device_code, occurred_at, report_state) + VALUES + (#{gyid}, #{reg.regId}, #{reg.workNo}, #{reg.name}, #{reg.unitCode}, #{reg.featureRef}, + #{reg.photoRef}, #{reg.photoChecksum}, #{reg.featureChecksum}, #{reg.featureAlgo}, + #{reg.featureVersion}, #{reg.personCategory}, 'APPLIED', 1, + 'PENDING', 0, 0, 'PENDING', 'PENDING', #{reg.faceSyncState}, + #{reg.applyDeviceCode}, #{reg.occurredAt}, 'PENDING') + + + + + UPDATE apt_person_face_reg + SET reg_state = 'CONFIRMED', + confirmed_ryid = #{confirmedRyid}, + confirmed_by = #{confirmedBy}, + confirmed_at = NOW(3), + local_only = 0, + unit_code = #{unitCode}, + person_category = #{personCategory}, + feature_algo = #{featureAlgo}, + upload_state = 'PENDING', + dispatch_state = 'PENDING' + WHERE gyid = #{gyid} AND reg_id = #{regId} AND reg_state = 'APPLIED' + + + + UPDATE apt_person_face_reg + SET reg_state = 'REJECTED', + reject_reason = #{rejectReason} + WHERE gyid = #{gyid} AND reg_id = #{regId} AND reg_state = 'APPLIED' + + + + + UPDATE apt_person_face_reg + SET verdict = #{verdict}, + platform_version = #{platformVersion}, + verdict_version = #{platformVersion} + WHERE gyid = #{gyid} AND reg_id = #{regId} + + + + + + + + INSERT INTO apt_master_person + (gyid, ryid, xm, work_no, dept_code, person_category, photo_ref, photo_checksum, + feature_ref, feature_checksum, feature_algo, feature_version, face_sync_state, + face_platform_version, source_version, deleted) + VALUES + (#{gyid}, #{person.ryid}, #{person.xm}, #{person.workNo}, #{person.deptCode}, + #{person.personCategory}, #{person.photoRef}, #{person.photoChecksum}, + #{person.featureRef}, #{person.featureChecksum}, #{person.featureAlgo}, + #{person.featureVersion}, #{person.faceSyncState}, 0, 0, 0) + + + + + INSERT INTO apt_report_outbox + (gyid, idempotency_key, biz_type, biz_id, biz_version, payload, state, occurred_at) + VALUES + (#{gyid}, #{idempotencyKey}, 'PERSON_REG', #{bizId}, 1, #{payload}, 'PENDING', #{occurredAt}) + + + + + INSERT INTO apt_device_command + (gyid, command_id, device_code, command_type, payload_json, state, protocol_confirmed, face_scope_ref) + VALUES + (#{gyid}, #{commandId}, #{deviceCode}, 'FACE_ISSUE', #{payload}, 'PENDING', 0, #{faceScopeRef}) + + + + + + + diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceVerifyMapper.xml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceVerifyMapper.xml new file mode 100644 index 0000000..8da3c1f --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptFaceVerifyMapper.xml @@ -0,0 +1,27 @@ + + + + + + + + INSERT INTO duty_operation_log + (gyid, log_id, operator_ref, module, action, detail, trace_id, occurred_at) + VALUES + (#{gyid}, #{logId}, #{operatorRef}, 'FACE', 'VERIFY_SESSION', #{detail}, #{traceId}, #{occurredAt}) + + + + INSERT INTO duty_operation_log + (gyid, log_id, operator_ref, module, action, detail, trace_id, occurred_at) + VALUES + (#{gyid}, #{logId}, NULL, 'FACE', 'SCOPE_DENIED', #{detail}, #{traceId}, #{occurredAt}) + + + 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 new file mode 100644 index 0000000..0a62c7a --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptJcxxMapper.xml @@ -0,0 +1,300 @@ + + + + + + + + j.id, j.jcid, j.jlid, j.cc, j.jch, j.ryid, j.xm AS rymc, j.fjh, + j.kcsj, j.jcsj, j.jcsj_s AS jcsjS, j.hjxysj, j.jcbjsj_s AS jcbjsjS, + j.fjsj_s AS fjsjS, j.lybjsj, j.sjlysj, + j.ryzt, + CASE j.ryzt + WHEN '0' THEN '待叫乘' + WHEN '1' THEN '叫乘中' + WHEN '2' THEN '叫乘报警' + WHEN '3' THEN '已应答待离寓' + WHEN '4' THEN '离寓报警' + WHEN '5' THEN '通话中' + ELSE '未知' END AS ryztLabel, + j.finish_jc AS finishJc, j.finish_ly AS finishLy, j.stoped, + j.is_jcbj AS isJcbj, j.is_lybj AS isLybj, j.is_temp_call AS isTempCall, + j.leave_type AS leaveType, j.leave_remark AS leaveRemark, + j.call_mode AS callMode, j.dispatch_seq AS dispatchSeq, + j.biz_version AS bizVersion, j.received_at AS receivedAt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO duty_manual_action + (gyid, action_id, jcid, action_type, state, duplicated, reason, result_code, + requested_by, occurred_at) + VALUES + (#{gyid}, #{actionId}, #{jcid}, #{actionType}, #{state}, 0, #{reason}, NULL, + #{requestedBy}, #{occurredAt}) + + + + UPDATE duty_manual_action + SET state = #{state}, result_code = #{resultCode} + WHERE gyid = #{gyid} AND action_id = #{actionId} + + + + UPDATE duty_manual_action + SET duplicated = 1, state = 'DUPLICATED' + WHERE gyid = #{gyid} AND action_id = #{actionId} + + + + UPDATE gy_jcxx + + fjh = #{command.fjh}, + jlid = #{command.jlid}, + cc = #{command.cc}, + jch = #{command.jch}, + leave_remark = #{command.leaveRemark}, + is_temp_call = '1', + biz_version = biz_version + 1 + + WHERE gyid = #{gyid} AND id = #{id} AND deleted = 0 + + + + + + + + INSERT INTO apt_call_event + (gyid, event_seq, event_id, session_id, event_type, event_payload, occurred_at) + VALUES + (#{gyid}, #{eventSeq}, #{eventId}, #{sessionId}, #{eventType}, #{payload}, #{occurredAt}) + + + + + 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 new file mode 100644 index 0000000..f61422c --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/mapper/apt/AptTabletMapper.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + UPDATE apt_tablet_task + SET pull_state = 'PULLED', + pulled_at = NOW(3), + pulled_by = #{deviceCode}, + device_code = #{deviceCode}, + updated_at = NOW(3) + WHERE gyid = #{gyid} AND task_id = #{taskId} AND pull_state = 'WAITING' + + + + UPDATE apt_tablet_task + SET pull_state = 'DONE', + device_code = COALESCE(device_code, ''), + updated_at = NOW(3) + WHERE gyid = #{gyid} AND task_id = #{taskId} AND pull_state IN ('WAITING','PULLED') + + + + UPDATE apt_tablet_task + SET pull_state = 'EXPIRED', + updated_at = NOW(3) + WHERE gyid = #{gyid} AND pull_state IN ('WAITING','PULLED') + AND expire_at IS NOT NULL AND expire_at < #{now} + + + + + + INSERT INTO apt_tablet_task + (gyid, task_id, jcid, dispatch_seq, call_mode, jcsj, device_code, + pull_state, expire_at) + VALUES + (#{gyid}, #{taskId}, #{jcid}, #{dispatchSeq}, 'TABLET', #{jcsj}, NULL, + 'WAITING', #{expireAt}) + + + + UPDATE gy_jcxx + SET dispatch_seq = #{dispatchSeq}, + biz_version = biz_version + 1 + WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0 + + + + + + + + + + + + + + INSERT INTO apt_tablet_call_result + (gyid, result_id, task_id, jcid, face_verify_state, verified_ryid, + result_type, person_count, device_code, occurred_at, received_at, applied_state) + VALUES + (#{gyid}, #{resultId}, #{taskId}, #{jcid}, #{faceVerifyState}, #{verifiedRyid}, + #{resultType}, #{personCount}, #{deviceCode}, #{occurredAt}, #{receivedAt}, 'PENDING') + + + + UPDATE apt_tablet_call_result + SET applied_state = #{appliedState} + WHERE gyid = #{gyid} AND result_id = #{resultId} + + + + INSERT INTO apt_report_outbox + (gyid, idempotency_key, biz_type, biz_id, biz_version, payload, state, occurred_at) + VALUES + (#{gyid}, #{idempotencyKey}, #{bizType}, #{bizId}, #{bizVersion}, #{payload}, + 'PENDING', #{occurredAt}) + + + diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java new file mode 100644 index 0000000..75d13a1 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java @@ -0,0 +1,171 @@ +package com.ruoyi.apt.dispatch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.LinkedHashMap; +import java.util.Map; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.dispatch.domain.AptDutyCommand; +import com.ruoyi.apt.dispatch.domain.AptDutyCommandResult; +import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper; +import com.ruoyi.apt.dispatch.service.AptDutyCommandServiceImpl; +import com.ruoyi.apt.dispatch.service.IAptDutyStateService; +import com.ruoyi.apt.framework.audit.AptAuditWriter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * IT-5 / M3 命令层回归(公寓端/04 表 4-9、07 表 7-3 M3)。 + */ +class AptDutyCommandServiceTest +{ + private AptJcxxMapper mapper; + private IAptDutyStateService stateService; + private AptAuditWriter auditWriter; + private AptDutyCommandServiceImpl service; + + @BeforeEach + void setUp() + { + mapper = mock(AptJcxxMapper.class); + stateService = mock(IAptDutyStateService.class); + auditWriter = mock(AptAuditWriter.class); + service = new AptDutyCommandServiceImpl(mapper, stateService, auditWriter); + AptContext.clear(); + AptContext.setGyId("APT-A"); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + } + + @Test + void stopSubmitsStateCommandAndWritesAudit() + { + when(mapper.selectManualAction("APT-A", "CMD-1")).thenReturn(null); + when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "1", "0", "0", "0"), + row("JC-1", "1", "0", "0", "1")); + when(mapper.insertManualAction(eq("APT-A"), eq("CMD-1"), eq("JC-1"), eq("STOP"), + anyString(), anyString(), anyString(), any())).thenReturn(1); + when(stateService.stop("JC-1")).thenReturn(1); + + AptDutyCommandResult result = service.execute(1L, command("CMD-1", "STOP")); + + assertTrue(result.isAccepted()); + assertFalse(result.isDuplicated()); + assertEquals("CALLING|finishJc=0|finishLy=0|stoped=1", result.getStateAfter()); + verify(stateService).stop("JC-1"); + verify(mapper).updateManualActionResult("APT-A", "CMD-1", "DONE", result.getStateAfter()); + verify(auditWriter).write(any()); + } + + @Test + void sameCommandIdIsIdempotentAndDoesNotTransitionAgain() + { + Map existing = new LinkedHashMap<>(); + existing.put("actionId", "CMD-2"); + existing.put("actionType", "STOP"); + existing.put("state", "DONE"); + when(mapper.selectManualAction("APT-A", "CMD-2")).thenReturn(existing); + when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "2", "0", "0", "1")); + + AptDutyCommandResult result = service.execute(1L, command("CMD-2", "STOP")); + + assertTrue(result.isDuplicated()); + verify(mapper).markManualActionDuplicated("APT-A", "CMD-2"); + verify(stateService, never()).stop(anyString()); + verify(mapper, never()).insertManualAction(anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString(), any()); + } + + @Test + void stopOnLeftRecordReturns40902() + { + when(mapper.selectManualAction("APT-A", "CMD-3")).thenReturn(null); + when(mapper.selectJcxxById("APT-A", 2L)).thenReturn(row("JC-2", "1", "0", "1", "0")); + + AptDutyCommand command = command("CMD-3", "STOP"); + AptException e = assertThrows(AptException.class, () -> service.execute(2L, command)); + + assertEquals(40902, e.getErrorCode().getCode()); + verify(mapper, never()).insertManualAction(anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString(), any()); + } + + @Test + void cancelOnlyAllowedBeforeCalling() + { + when(mapper.selectManualAction("APT-A", "CMD-4")).thenReturn(null); + when(mapper.selectJcxxById("APT-A", 3L)).thenReturn(row("JC-3", "1", "0", "0", "0")); + + AptException e = assertThrows(AptException.class, + () -> service.execute(3L, command("CMD-4", "CANCEL"))); + assertEquals(40902, e.getErrorCode().getCode()); + } + + @Test + void unknownCommandReturns40001() + { + AptException e = assertThrows(AptException.class, + () -> service.execute(1L, command("CMD-5", "DELETE"))); + assertEquals(40001, e.getErrorCode().getCode()); + } + + @Test + void missingReasonReturns40001() + { + AptDutyCommand command = command("CMD-6", "STOP"); + command.setReason(" "); + + AptException e = assertThrows(AptException.class, () -> service.execute(1L, command)); + assertEquals(40001, e.getErrorCode().getCode()); + } + + @Test + void stateIllegalFromStateMachineMarksActionFailed() + { + when(mapper.selectManualAction("APT-A", "CMD-7")).thenReturn(null); + when(mapper.selectJcxxById("APT-A", 4L)).thenReturn(row("JC-4", "1", "0", "0", "0")); + when(mapper.insertManualAction(anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString(), any())).thenReturn(1); + when(stateService.stop("JC-4")).thenThrow(new AptException( + com.ruoyi.apt.common.AptErrorCode.STATE_ILLEGAL, "STATE_TRANSITION_ILLEGAL", "并发已终止")); + + assertThrows(AptException.class, () -> service.execute(4L, command("CMD-7", "STOP"))); + verify(mapper).updateManualActionResult("APT-A", "CMD-7", "FAILED", "STATE_TRANSITION_ILLEGAL"); + } + + private AptDutyCommand command(String commandId, String command) + { + AptDutyCommand cmd = new AptDutyCommand(); + cmd.setCommandId(commandId); + cmd.setCommand(command); + cmd.setReason("值班员操作"); + return cmd; + } + + private Map row(String jcid, String ryzt, String finishJc, String finishLy, String stoped) + { + Map row = new LinkedHashMap<>(); + row.put("id", 1L); + row.put("jcid", jcid); + row.put("ryzt", ryzt); + row.put("finishJc", finishJc); + row.put("finishLy", finishLy); + row.put("stoped", stoped); + return row; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java new file mode 100644 index 0000000..e1c501c --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java @@ -0,0 +1,105 @@ +package com.ruoyi.apt.dispatch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.List; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.dispatch.domain.AptStateSnapshot; +import com.ruoyi.apt.dispatch.mapper.AptDutyStateMapper; +import com.ruoyi.apt.dispatch.service.AptDutyStateServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * IT-5 / M3 状态机回归:条件更新未命中必须 40902,且只走唯一写入口。 + */ +class AptDutyStateServiceTest +{ + private AptDutyStateMapper mapper; + private AptDutyStateServiceImpl service; + + @BeforeEach + void setUp() + { + mapper = mock(AptDutyStateMapper.class); + service = new AptDutyStateServiceImpl(mapper); + AptContext.clear(); + AptContext.setGyId("APT-A"); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + } + + @Test + void answeredAlwaysCarriesGyidAndExpectedStates() + { + when(mapper.updateAnswered(eq("APT-A"), eq("JC-1"), anyList())).thenReturn(1); + + assertEquals(1, service.answered("JC-1")); + verify(mapper).updateAnswered("APT-A", "JC-1", List.of("0", "1", "2")); + } + + @Test + void missedConditionalUpdateMapsTo40902() + { + when(mapper.updateStop("APT-A", "JC-2")).thenReturn(0); + + AptException e = assertThrows(AptException.class, () -> service.stop("JC-2")); + assertEquals(40902, e.getErrorCode().getCode()); + assertEquals("STATE_TRANSITION_ILLEGAL", e.getReason()); + } + + @Test + void recallWritesNewSessionWithCurrentCallMode() + { + AptStateSnapshot snapshot = new AptStateSnapshot(); + snapshot.setJcid("JC-3"); + snapshot.setCallMode("TABLET"); + when(mapper.updateRecall("APT-A", "JC-3")).thenReturn(1); + when(mapper.selectSnapshot("APT-A", "JC-3")).thenReturn(snapshot); + + assertEquals(1, service.recall("JC-3")); + verify(mapper).insertCallSession(eq("APT-A"), anyString(), eq("JC-3"), eq("TABLET")); + } + + @Test + void markLeaveWritesLeaveEventBeforeReturning() + { + when(mapper.updateMarkLeave(eq("APT-A"), eq("JC-4"), eq("MANUAL"), anyString(), any())) + .thenReturn(1); + + service.markLeave("JC-4", "MANUAL", "值班员登记离寓"); + verify(mapper).insertLeaveEvent(eq("APT-A"), anyString(), eq("JC-4"), eq("MANUAL")); + } + + @Test + void recallWithoutTransitionDoesNotCreateSession() + { + when(mapper.updateRecall("APT-A", "JC-5")).thenReturn(0); + + assertThrows(AptException.class, () -> service.recall("JC-5")); + verify(mapper, never()).insertCallSession(anyString(), anyString(), anyString(), anyString()); + } + + @Test + void callFailedOnlyFromCallingState() + { + when(mapper.updateCallFailed("APT-A", "JC-6")).thenReturn(0); + + AptException e = assertThrows(AptException.class, () -> service.callFailed("JC-6")); + assertEquals(40902, e.getErrorCode().getCode()); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptJcxxServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptJcxxServiceTest.java new file mode 100644 index 0000000..ffa9188 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptJcxxServiceTest.java @@ -0,0 +1,206 @@ +package com.ruoyi.apt.dispatch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.dispatch.domain.AptCorrectCommand; +import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper; +import com.ruoyi.apt.dispatch.service.AptJcxxServiceImpl; +import com.ruoyi.apt.framework.audit.AptAuditWriter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * IT-5 / M3 叫乘台账回归:白名单修正、时间线排序、只读状态字段。 + */ +class AptJcxxServiceTest +{ + private AptJcxxMapper mapper; + private AptAuditWriter auditWriter; + private AptJcxxServiceImpl service; + + @BeforeEach + void setUp() + { + mapper = mock(AptJcxxMapper.class); + auditWriter = mock(AptAuditWriter.class); + service = new AptJcxxServiceImpl(mapper, auditWriter); + AptContext.clear(); + AptContext.setGyId("APT-A"); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + } + + @Test + void correctRejectsStateFieldsWith40001() + { + AptCorrectCommand command = new AptCorrectCommand(); + command.setReason("改状态"); + command.setRyzt("3"); + + AptException e = assertThrows(AptException.class, () -> service.correct(1L, command)); + assertEquals(40001, e.getErrorCode().getCode()); + assertEquals("STATE_FIELD_READONLY", e.getReason()); + verify(mapper, never()).updateJcxxCorrect(anyString(), any(), any()); + } + + @Test + void correctRequiresWhitelistedField() + { + AptCorrectCommand command = new AptCorrectCommand(); + command.setReason("空修正"); + + AptException e = assertThrows(AptException.class, () -> service.correct(1L, command)); + assertEquals("NO_FIELD_TO_CORRECT", e.getReason()); + } + + @Test + void correctWritesWhitelistAndAudit() + { + when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "0")); + when(mapper.selectManualAction("APT-A", "COR-1")).thenReturn(null); + when(mapper.countRoomByNo("APT-A", "302")).thenReturn(1); + when(mapper.insertManualAction(eq("APT-A"), eq("COR-1"), eq("JC-1"), eq("CORRECT"), + anyString(), anyString(), anyString(), any())).thenReturn(1); + when(mapper.updateJcxxCorrect(eq("APT-A"), eq(1L), any())).thenReturn(1); + + AptCorrectCommand command = new AptCorrectCommand(); + command.setCommandId("COR-1"); + command.setReason("房间修正"); + command.setFjh("302"); + + Map result = service.correct(1L, command); + + assertEquals(false, result.get("duplicated")); + assertEquals(List.of("fjh"), result.get("changedFields")); + verify(mapper).updateManualActionResult("APT-A", "COR-1", "DONE", "OK"); + verify(auditWriter).write(any()); + } + + @Test + void correctUnknownRoomReturns40001() + { + when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "0")); + when(mapper.countRoomByNo("APT-A", "999")).thenReturn(0); + + AptCorrectCommand command = new AptCorrectCommand(); + command.setCommandId("COR-2"); + command.setReason("房间修正"); + command.setFjh("999"); + + AptException e = assertThrows(AptException.class, () -> service.correct(1L, command)); + assertEquals("ROOM_NOT_FOUND", e.getReason()); + } + + @Test + void correctOnLeftRecordCannotMoveRoom() + { + when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "1")); + + AptCorrectCommand command = new AptCorrectCommand(); + command.setCommandId("COR-3"); + command.setReason("房间修正"); + command.setFjh("302"); + + AptException e = assertThrows(AptException.class, () -> service.correct(1L, command)); + assertEquals(40902, e.getErrorCode().getCode()); + } + + @Test + @SuppressWarnings("unchecked") + void timelineMergesFourSourcesInAscendingOrder() + { + when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "0")); + when(mapper.selectTimelineStates("APT-A", "JC-1")).thenReturn(List.of( + event(3000L, "ANSWERED"), event(1000L, "PLANNED"))); + when(mapper.selectTimelineEvents("APT-A", "JC-1")).thenReturn(List.of( + event(2500L, "CONFBRIDGEJOIN"))); + when(mapper.selectTimelineActions("APT-A", "JC-1")).thenReturn(List.of( + event(4000L, "STOP"))); + when(mapper.selectTimelineAudits("APT-A", "JC-1")).thenReturn(List.of( + event(4500L, "JCXX_STOP"))); + when(mapper.selectTimelineTabletResults("APT-A", "JC-1")).thenReturn(List.of( + event(2000L, "TABLET_ANSWERED"))); + + List> timeline = service.timeline(1L); + + assertEquals(6, timeline.size()); + assertEquals(1000L, timeline.get(0).get("atMillis")); + assertEquals(4500L, timeline.get(5).get("atMillis")); + } + + @Test + void detailIncludesSessionAndManualActions() + { + when(mapper.selectJcxxById("APT-A", 1L)).thenReturn(row("JC-1", "0")); + when(mapper.selectLatestSession("APT-A", "JC-1")).thenReturn(Map.of("sessionId", "CS-1")); + when(mapper.selectRecordings("APT-A", "JC-1")).thenReturn(List.of(Map.of("recordingId", "R-1"))); + when(mapper.selectManualActions("APT-A", "JC-1")).thenReturn(List.of(Map.of("actionId", "CMD-1"))); + + Map detail = service.detail(1L); + + assertEquals("JC-1", detail.get("jcid")); + assertTrue(detail.containsKey("session")); + assertEquals(1, ((List) detail.get("recordings")).size()); + assertEquals(1, ((List) detail.get("manualActions")).size()); + } + + @Test + void detailOfMissingRecordReturns40401() + { + when(mapper.selectJcxxById("APT-A", 9L)).thenReturn(null); + + AptException e = assertThrows(AptException.class, () -> service.detail(9L)); + assertEquals(40401, e.getErrorCode().getCode()); + } + + @Test + void statisticsDefaultsToZeroWhenNoRows() + { + when(mapper.selectStatistics("APT-A", null)).thenReturn(null); + + Map statistics = service.statistics(null); + + assertEquals(0L, statistics.get("waiting")); + assertEquals(0L, statistics.get("finished")); + } + + private Map event(long atMillis, String type) + { + Map event = new LinkedHashMap<>(); + event.put("atMillis", atMillis); + event.put("at", String.valueOf(atMillis)); + event.put("type", type); + event.put("source", "STATE"); + return event; + } + + private Map row(String jcid, String finishLy) + { + Map row = new LinkedHashMap<>(); + row.put("id", 1L); + row.put("jcid", jcid); + row.put("ryzt", "0"); + row.put("finishJc", "0"); + row.put("finishLy", finishLy); + row.put("stoped", "0"); + return row; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletResultServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletResultServiceTest.java new file mode 100644 index 0000000..25e779d --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletResultServiceTest.java @@ -0,0 +1,238 @@ +package com.ruoyi.apt.dispatch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.LinkedHashMap; +import java.util.Map; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.dispatch.domain.AptStateSnapshot; +import com.ruoyi.apt.dispatch.service.IAptDutyStateService; +import com.ruoyi.apt.dispatch.tablet.domain.AptTabletResultCommand; +import com.ruoyi.apt.dispatch.tablet.mapper.AptTabletMapper; +import com.ruoyi.apt.dispatch.tablet.service.AptTabletResultServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * IT-5 / M3 平板结果受理回归(公寓端/05 表 5-20)。 + */ +class AptTabletResultServiceTest +{ + private AptTabletMapper mapper; + private IAptDutyStateService stateService; + private AptTabletResultServiceImpl service; + + @BeforeEach + void setUp() + { + mapper = mock(AptTabletMapper.class); + stateService = mock(IAptDutyStateService.class); + service = new AptTabletResultServiceImpl(mapper, stateService); + AptContext.clear(); + AptContext.setGyId("APT-A"); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + } + + @Test + void verifiedResultSubmitsAnsweredAndOnlyEnqueuesTabletResult() + { + stubInsert("RS-1", "TASK-1", "JC-1"); + when(stateService.snapshot("JC-1")).thenReturn(snapshot("1", "0", "0"), snapshot("3", "1", "0")); + when(stateService.answered("JC-1")).thenReturn(1); + + Map result = service.accept("TAB-01", + command("RS-1", "ANSWERED", "VERIFIED")); + + assertEquals("APPLIED", result.get("appliedState")); + verify(stateService).answered("JC-1"); + verify(mapper).updateResultApplied("APT-A", "RS-1", "APPLIED"); + verify(mapper).markTaskDone("APT-A", "TASK-1"); + + ArgumentCaptor bizType = ArgumentCaptor.forClass(String.class); + verify(mapper, times(1)).insertOutbox(eq("APT-A"), anyString(), bizType.capture(), + anyString(), anyLong(), anyString(), any()); + assertEquals("TABLET_RESULT", bizType.getValue()); + } + + @Test + void unverifiedKeepsStateAndRaisesAlarmWithoutVoiceSummary() + { + stubInsert("RS-2", "TASK-2", "JC-2"); + when(stateService.snapshot("JC-2")).thenReturn(snapshot("1", "0", "0")); + + Map result = service.accept("TAB-01", + command("RS-2", "ANSWERED", "UNVERIFIED")); + + assertEquals("APPLIED", result.get("appliedState")); + assertTrue(((java.util.List) result.get("warnings")).size() > 0); + verify(stateService, never()).answered(anyString()); + verify(stateService, never()).callFailed(anyString()); + + ArgumentCaptor bizType = ArgumentCaptor.forClass(String.class); + verify(mapper, times(2)).insertOutbox(eq("APT-A"), anyString(), bizType.capture(), + anyString(), anyLong(), anyString(), any()); + assertTrue(bizType.getAllValues().contains("TABLET_RESULT")); + assertTrue(bizType.getAllValues().contains("DEVICE_ALARM")); + assertFalse(bizType.getAllValues().contains("VOICE_SUMMARY"), + "平板链路绝不上报 #24 语音摘要"); + } + + @Test + void noAnswerSubmitsCallFailed() + { + stubInsert("RS-3", "TASK-3", "JC-3"); + when(stateService.snapshot("JC-3")).thenReturn(snapshot("1", "0", "0"), snapshot("2", "0", "0")); + when(stateService.callFailed("JC-3")).thenReturn(1); + + Map result = service.accept("TAB-01", + command("RS-3", "NO_ANSWER", "UNVERIFIED")); + + assertEquals("APPLIED", result.get("appliedState")); + verify(stateService).callFailed("JC-3"); + } + + @Test + void cancelledTaskStopsWhenCalling() + { + stubInsert("RS-4", "TASK-4", "JC-4"); + when(stateService.snapshot("JC-4")).thenReturn(snapshot("1", "0", "0"), snapshot("1", "0", "1")); + when(stateService.stop("JC-4")).thenReturn(1); + + service.accept("TAB-01", command("RS-4", "CANCELLED", "SKIPPED")); + + verify(stateService).stop("JC-4"); + verify(stateService, never()).cancel(anyString()); + } + + @Test + void cancelledTaskCancelsWhenWaiting() + { + stubInsert("RS-5", "TASK-5", "JC-5"); + when(stateService.snapshot("JC-5")).thenReturn(snapshot("0", "0", "0"), snapshot("0", "0", "1")); + when(stateService.cancel("JC-5")).thenReturn(1); + + service.accept("TAB-01", command("RS-5", "CANCELLED", "SKIPPED")); + + verify(stateService).cancel("JC-5"); + verify(stateService, never()).stop(anyString()); + } + + @Test + void duplicateResultIdReturnsFirstResultWithoutTransition() + { + Map existing = new LinkedHashMap<>(); + existing.put("resultId", "RS-6"); + existing.put("taskId", "TASK-6"); + existing.put("jcid", "JC-6"); + existing.put("faceVerifyState", "VERIFIED"); + existing.put("resultType", "ANSWERED"); + existing.put("appliedState", "APPLIED"); + when(mapper.selectResult("APT-A", "RS-6")).thenReturn(existing); + + Map result = service.accept("TAB-01", + command("RS-6", "ANSWERED", "VERIFIED")); + + assertTrue((Boolean) result.get("duplicated")); + assertEquals("APPLIED", result.get("appliedState")); + verify(mapper, never()).insertResult(anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString(), anyInt(), anyString(), any(), any()); + verify(stateService, never()).answered(anyString()); + } + + @Test + void lateResultOnFinishedCallIsIgnored() + { + stubInsert("RS-7", "TASK-7", "JC-7"); + when(stateService.snapshot("JC-7")).thenReturn(snapshot("3", "1", "0")); + + Map result = service.accept("TAB-01", + command("RS-7", "ANSWERED", "VERIFIED")); + + assertEquals("IGNORED", result.get("appliedState")); + verify(stateService, never()).answered(anyString()); + verify(mapper, never()).insertOutbox(anyString(), anyString(), anyString(), anyString(), + anyLong(), anyString(), any()); + } + + @Test + void stateConflictDuringTransitionIsRecordedAsIgnored() + { + stubInsert("RS-8", "TASK-8", "JC-8"); + when(stateService.snapshot("JC-8")).thenReturn(snapshot("1", "0", "0")); + when(stateService.callFailed("JC-8")).thenThrow(new AptException( + com.ruoyi.apt.common.AptErrorCode.STATE_ILLEGAL, "STATE_TRANSITION_ILLEGAL", "并发已终止")); + + Map result = service.accept("TAB-01", + command("RS-8", "NO_ANSWER", "UNVERIFIED")); + + assertEquals("IGNORED", result.get("appliedState")); + verify(mapper).updateResultApplied("APT-A", "RS-8", "IGNORED"); + } + + @Test + void invalidFaceStateReturns40001() + { + AptTabletResultCommand command = command("RS-9", "ANSWERED", "NOT_FOUND"); + + AptException e = assertThrows(AptException.class, () -> service.accept("TAB-01", command)); + assertEquals(40001, e.getErrorCode().getCode()); + assertEquals("TABLET_FACE_STATE_INVALID", e.getReason()); + } + + private void stubInsert(String resultId, String taskId, String jcid) + { + when(mapper.selectResult("APT-A", resultId)).thenReturn(null); + Map task = new LinkedHashMap<>(); + task.put("taskId", taskId); + task.put("jcid", jcid); + task.put("deviceCode", "TAB-01"); + task.put("pullState", "PULLED"); + when(mapper.selectTask("APT-A", taskId)).thenReturn(task); + when(mapper.insertResult(eq("APT-A"), eq(resultId), eq(taskId), eq(jcid), + anyString(), any(), anyString(), anyInt(), anyString(), any(), any())).thenReturn(1); + } + + private AptTabletResultCommand command(String resultId, String resultType, String faceState) + { + AptTabletResultCommand command = new AptTabletResultCommand(); + command.setResultId(resultId); + command.setTaskId("TASK-" + resultId.substring(resultId.indexOf('-') + 1)); + command.setJcid("JC-" + resultId.substring(resultId.indexOf('-') + 1)); + command.setResultType(resultType); + command.setFaceVerifyState(faceState); + command.setPersonCount(1); + return command; + } + + private AptStateSnapshot snapshot(String ryzt, String finishJc, String finishLy) + { + AptStateSnapshot snapshot = new AptStateSnapshot(); + snapshot.setJcid("JC"); + snapshot.setRyzt(ryzt); + snapshot.setFinishJc(finishJc); + snapshot.setFinishLy(finishLy); + snapshot.setStoped("0"); + snapshot.setBizVersion(2L); + return snapshot; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptDeviceSignerTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptDeviceSignerTest.java new file mode 100644 index 0000000..3aa5242 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptDeviceSignerTest.java @@ -0,0 +1,142 @@ +package com.ruoyi.apt.face; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.framework.config.AptProperties; +import com.ruoyi.apt.framework.device.AptDeviceSigner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * 设备四要素鉴权单测(表 10-24 #1:编码 + HMAC + 时间窗 + nonce)。 + */ +class AptDeviceSignerTest +{ + private static final String SECRET = "unit-test-secret"; + private static final String URI = "/open/v1/face/reg-requests"; + + private AptDeviceSigner signer; + private MockHttpServletRequest request; + + @BeforeEach + void setUp() + { + AptProperties properties = new AptProperties(); + AptProperties.Device device = new AptProperties.Device(); + device.setHmacEnabled(true); + device.setHmacSecret(SECRET); + device.setTimeWindowSeconds(300); + device.setNonceTtlSeconds(600); + properties.setDevice(device); + signer = new AptDeviceSigner(properties); + + request = new MockHttpServletRequest("POST", URI); + request.addHeader("X-Device-Code", "dev-1"); + } + + @Test + void validSignatureReturnsUpperCasedDeviceCode() + { + String timestamp = String.valueOf(System.currentTimeMillis()); + String nonce = "nonce-ok-1"; + String canonical = signer.canonicalString(request, "DEV-1", timestamp, nonce); + + request.addHeader("X-Device-Timestamp", timestamp); + request.addHeader("X-Device-Nonce", nonce); + request.addHeader("X-Device-Signature", hmacHex(canonical)); + + assertEquals("DEV-1", signer.verify(request)); + } + + @Test + void missingSignatureHeadersReturns40101() + { + AptException error = assertThrows(AptException.class, () -> signer.verify(request)); + assertEquals(40101, error.getErrorCode().getCode()); + assertEquals("DEVICE_SIGNATURE_MISSING", error.getReason()); + } + + @Test + void missingDeviceCodeReturns40101() + { + request.removeHeader("X-Device-Code"); + + AptException error = assertThrows(AptException.class, () -> signer.verify(request)); + assertEquals(40101, error.getErrorCode().getCode()); + assertEquals("DEVICE_CREDENTIAL_MISSING", error.getReason()); + } + + @Test + void timestampOutsideWindowReturns40101() + { + String timestamp = String.valueOf(System.currentTimeMillis() - 3_600_000L); + String nonce = "nonce-old-1"; + String canonical = signer.canonicalString(request, "DEV-1", timestamp, nonce); + + request.addHeader("X-Device-Timestamp", timestamp); + request.addHeader("X-Device-Nonce", nonce); + request.addHeader("X-Device-Signature", hmacHex(canonical)); + + AptException error = assertThrows(AptException.class, () -> signer.verify(request)); + assertEquals(40101, error.getErrorCode().getCode()); + assertEquals("DEVICE_TIMESTAMP_OUT_OF_WINDOW", error.getReason()); + } + + @Test + void replayedNonceReturns40901() + { + String timestamp = String.valueOf(System.currentTimeMillis()); + String nonce = "nonce-replay-1"; + String canonical = signer.canonicalString(request, "DEV-1", timestamp, nonce); + + request.addHeader("X-Device-Timestamp", timestamp); + request.addHeader("X-Device-Nonce", nonce); + request.addHeader("X-Device-Signature", hmacHex(canonical)); + + assertEquals("DEV-1", signer.verify(request)); + + AptException error = assertThrows(AptException.class, () -> signer.verify(request)); + assertEquals(40901, error.getErrorCode().getCode()); + assertEquals("DEVICE_NONCE_REPLAY", error.getReason()); + } + + @Test + void wrongSignatureReturns40101() + { + request.addHeader("X-Device-Timestamp", String.valueOf(System.currentTimeMillis())); + request.addHeader("X-Device-Nonce", "nonce-bad-1"); + request.addHeader("X-Device-Signature", "deadbeef"); + + AptException error = assertThrows(AptException.class, () -> signer.verify(request)); + assertEquals(40101, error.getErrorCode().getCode()); + assertEquals("DEVICE_SIGNATURE_INVALID", error.getReason()); + } + + private static String hmacHex(String payload) + { + try + { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(digest.length * 2); + for (byte b : digest) + { + builder.append(Character.forDigit((b >> 4) & 0xF, 16)); + builder.append(Character.forDigit(b & 0xF, 16)); + } + return builder.toString(); + } + catch (Exception e) + { + throw new IllegalStateException(e); + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceRegServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceRegServiceTest.java new file mode 100644 index 0000000..9b3cda0 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceRegServiceTest.java @@ -0,0 +1,314 @@ +package com.ruoyi.apt.face; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; + +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.face.domain.AptFaceReg; +import com.ruoyi.apt.face.domain.AptFaceRegAck; +import com.ruoyi.apt.face.domain.AptFaceRegCommand; +import com.ruoyi.apt.face.domain.AptFaceRegConfirmCommand; +import com.ruoyi.apt.face.domain.AptFaceRegRejectCommand; +import com.ruoyi.apt.face.mapper.AptFaceRegMapper; +import com.ruoyi.apt.face.service.AptFaceRegServiceImpl; +import com.ruoyi.apt.face.service.AptFaceSyncDispatcher; +import com.ruoyi.apt.framework.config.AptProperties; +import com.ruoyi.apt.framework.device.AptDeviceSigner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * 人脸注册链路单测(DV-3 验收①~④)。 + */ +class AptFaceRegServiceTest +{ + private AptFaceRegMapper mapper; + private AptFaceSyncDispatcher dispatcher; + private AptFaceRegServiceImpl service; + + @BeforeEach + void setUp() + { + mapper = mock(AptFaceRegMapper.class); + dispatcher = mock(AptFaceSyncDispatcher.class); + AptProperties properties = new AptProperties(); + properties.setDevice(new AptProperties.Device()); + service = new AptFaceRegServiceImpl(mapper, dispatcher, properties); + AptContext.clear(); + AptContext.setGyId("APT-A"); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + } + + private AptFaceRegCommand command() + { + AptFaceRegCommand command = new AptFaceRegCommand(); + command.setWorkNo("W001"); + command.setName("张三"); + command.setUnitCode("U01"); + command.setPhoto("cGhvdG8="); + command.setPhotoChecksum(AptDeviceSigner.sha256Hex("cGhvdG8=".getBytes(StandardCharsets.UTF_8))); + command.setFeatureAlgo("FEAT_V1"); + command.setFeature("ZmVhdHVyZQ=="); + command.setFeatureChecksum(AptDeviceSigner.sha256Hex("ZmVhdHVyZQ==".getBytes(StandardCharsets.UTF_8))); + return command; + } + + private AptFaceReg appliedReg() + { + AptFaceReg reg = new AptFaceReg(); + reg.setRegId("FR1"); + reg.setWorkNo("W001"); + reg.setName("张三"); + reg.setUnitCode("U01"); + reg.setPersonCategory("TRANSIENT"); + reg.setRegState("APPLIED"); + reg.setFeatureAlgo("FEAT_V1"); + reg.setFeatureVersion(1L); + reg.setPhotoRef("apt://face-reg/FR1/photo"); + reg.setFeatureRef("apt://face-reg/FR1/feature"); + reg.setApplyDeviceCode("DEV-1"); + return reg; + } + + @Test + void applyOnlyWritesAppliedStateAndNeverTouchesPersonOrSync() + { + when(mapper.countDeviceByCode("APT-A", "DEV-1")).thenReturn(1); + when(mapper.selectPendingByWorkNo("APT-A", "W001")).thenReturn(null); + when(mapper.countConfirmedByWorkNo("APT-A", "W001")).thenReturn(0); + when(mapper.insertFaceReg(eq("APT-A"), any(AptFaceReg.class))).thenReturn(1); + + AptFaceRegAck ack = service.apply(command(), "DEV-1"); + + assertEquals("APPLIED", ack.getRegState()); + assertEquals("PENDING_REVIEW", ack.getNextAction()); + assertEquals(Boolean.TRUE, ack.getAlgoSupported()); + verify(mapper, never()).insertPerson(anyString(), any()); + verify(mapper, never()).insertReportOutbox(anyString(), anyString(), anyString(), anyString(), any()); + verify(mapper, never()).insertDeviceCommand(anyString(), anyString(), anyString(), anyString(), any()); + verify(dispatcher, never()).enqueuePlatformReport(anyString(), any(), anyString()); + } + + @Test + void applyWithoutAnyMediaReturns40919() + { + AptFaceRegCommand command = command(); + command.setPhoto(null); + command.setPhotoChecksum(null); + command.setFeature(null); + command.setFeatureChecksum(null); + + AptException error = assertThrows(AptException.class, () -> service.apply(command, "DEV-1")); + assertEquals(40919, error.getErrorCode().getCode()); + } + + @Test + void applyWithChecksumMismatchReturns40920() + { + AptFaceRegCommand command = command(); + command.setPhotoChecksum("bad-checksum"); + + AptException error = assertThrows(AptException.class, () -> service.apply(command, "DEV-1")); + assertEquals(40920, error.getErrorCode().getCode()); + } + + @Test + void applyOversizedPhotoReturns40922() + { + AptFaceRegCommand command = command(); + String huge = "A".repeat(800000); + command.setPhoto(huge); + command.setPhotoChecksum(AptDeviceSigner.sha256Hex(huge.getBytes(StandardCharsets.UTF_8))); + + AptException error = assertThrows(AptException.class, () -> service.apply(command, "DEV-1")); + assertEquals(40922, error.getErrorCode().getCode()); + } + + @Test + void applyDuplicatePendingReturnsOriginalRegId() + { + when(mapper.countDeviceByCode("APT-A", "DEV-1")).thenReturn(1); + when(mapper.selectPendingByWorkNo("APT-A", "W001")).thenReturn(appliedReg()); + + AptFaceRegAck ack = service.apply(command(), "DEV-1"); + + assertEquals("FR1", ack.getRegId()); + assertEquals(Boolean.TRUE, ack.getDuplicated()); + verify(mapper, never()).insertFaceReg(anyString(), any()); + } + + @Test + void applyAlreadyConfirmedWorkNoReturns40918() + { + when(mapper.countDeviceByCode("APT-A", "DEV-1")).thenReturn(1); + when(mapper.selectPendingByWorkNo("APT-A", "W001")).thenReturn(null); + when(mapper.countConfirmedByWorkNo("APT-A", "W001")).thenReturn(1); + + AptException error = assertThrows(AptException.class, () -> service.apply(command(), "DEV-1")); + assertEquals(40918, error.getErrorCode().getCode()); + } + + @Test + void applyUnknownDeviceReturns40303() + { + when(mapper.countDeviceByCode("APT-A", "DEV-X")).thenReturn(0); + + AptException error = assertThrows(AptException.class, () -> service.apply(command(), "DEV-X")); + assertEquals(40303, error.getErrorCode().getCode()); + } + + @Test + void confirmIsIdempotentAndReturnsOriginalRyid() + { + AptFaceReg reg = appliedReg(); + reg.setRegState("CONFIRMED"); + reg.setConfirmedRyid("RY-OLD"); + when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(reg); + + AptFaceRegAck ack = service.confirm("FR1", new AptFaceRegConfirmCommand(), "admin"); + + assertEquals("RY-OLD", ack.getConfirmedRyid()); + assertEquals(Boolean.TRUE, ack.getDuplicated()); + verify(mapper, never()).insertPerson(anyString(), any()); + verify(mapper, never()).confirmFaceReg(anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString()); + verify(dispatcher, never()).enqueuePlatformReport(anyString(), any(), anyString()); + } + + @Test + void confirmWritesPersonAndTriggersBothPathsIndependently() + { + when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(appliedReg()); + when(mapper.selectPersonByWorkNo("APT-A", "W001")).thenReturn(null); + when(mapper.insertPerson(eq("APT-A"), any())).thenReturn(1); + when(mapper.confirmFaceReg(eq("APT-A"), eq("FR1"), anyString(), anyString(), + anyString(), anyString(), anyString())).thenReturn(1); + when(dispatcher.enqueuePlatformReport(eq("APT-A"), any(), anyString())).thenReturn("IDEM-1"); + when(dispatcher.enqueueDeviceIssue(eq("APT-A"), any(), anyString())).thenReturn("CMD-1"); + + AptFaceRegAck ack = service.confirm("FR1", new AptFaceRegConfirmCommand(), "admin"); + + assertEquals("CONFIRMED", ack.getRegState()); + assertNotNull(ack.getConfirmedRyid()); + assertEquals("IDEM-1", ack.getReportOutboxId()); + assertEquals("CMD-1", ack.getDispatchCommandId()); + assertEquals("PENDING", ack.getUploadState()); + assertEquals("PENDING", ack.getDispatchState()); + verify(mapper).insertPerson(eq("APT-A"), any()); + verify(dispatcher).enqueuePlatformReport(eq("APT-A"), any(), anyString()); + verify(dispatcher).enqueueDeviceIssue(eq("APT-A"), any(), anyString()); + } + + @Test + void confirmKeepsUploadWhenDevicePathFails() + { + when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(appliedReg()); + when(mapper.selectPersonByWorkNo("APT-A", "W001")).thenReturn(null); + when(mapper.insertPerson(eq("APT-A"), any())).thenReturn(1); + when(mapper.confirmFaceReg(eq("APT-A"), eq("FR1"), anyString(), anyString(), + anyString(), anyString(), anyString())).thenReturn(1); + when(dispatcher.enqueuePlatformReport(eq("APT-A"), any(), anyString())).thenReturn("IDEM-1"); + when(dispatcher.enqueueDeviceIssue(eq("APT-A"), any(), anyString())) + .thenThrow(new RuntimeException("device queue down")); + + AptFaceRegAck ack = service.confirm("FR1", new AptFaceRegConfirmCommand(), "admin"); + + assertEquals("PENDING", ack.getUploadState()); + assertEquals("FAILED", ack.getDispatchState()); + assertTrue(ack.getWarnings().stream().anyMatch(w -> w.contains("不影响平台上报"))); + } + + @Test + void confirmRejectedRegReturns40924() + { + AptFaceReg reg = appliedReg(); + reg.setRegState("REJECTED"); + when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(reg); + + AptException error = assertThrows(AptException.class, + () -> service.confirm("FR1", new AptFaceRegConfirmCommand(), "admin")); + assertEquals(40924, error.getErrorCode().getCode()); + } + + @Test + void rejectConfirmedRegReturns40924() + { + AptFaceReg reg = appliedReg(); + reg.setRegState("CONFIRMED"); + reg.setConfirmedRyid("RY-1"); + when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(reg); + + AptFaceRegRejectCommand command = new AptFaceRegRejectCommand(); + command.setRejectReason("重复注册"); + + AptException error = assertThrows(AptException.class, () -> service.reject("FR1", command, "admin")); + assertEquals(40924, error.getErrorCode().getCode()); + verify(mapper, never()).rejectFaceReg(anyString(), anyString(), anyString()); + } + + @Test + void rejectAppliedRegNeverCreatesPersonNorSync() + { + when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(appliedReg()); + when(mapper.rejectFaceReg("APT-A", "FR1", "重复注册")).thenReturn(1); + + AptFaceRegRejectCommand command = new AptFaceRegRejectCommand(); + command.setRejectReason("重复注册"); + + AptFaceRegAck ack = service.reject("FR1", command, "admin"); + + assertEquals("REJECTED", ack.getRegState()); + verify(mapper, never()).insertPerson(anyString(), any()); + verify(dispatcher, never()).enqueuePlatformReport(anyString(), any(), anyString()); + verify(dispatcher, never()).enqueueDeviceIssue(anyString(), any(), anyString()); + } + + @Test + void unsupportedAlgoIsStoredButNotDispatched() + { + when(mapper.countDeviceByCode("APT-A", "DEV-1")).thenReturn(1); + when(mapper.selectPendingByWorkNo("APT-A", "W001")).thenReturn(null); + when(mapper.countConfirmedByWorkNo("APT-A", "W001")).thenReturn(0); + when(mapper.insertFaceReg(eq("APT-A"), any(AptFaceReg.class))).thenReturn(1); + + AptFaceRegCommand command = command(); + command.setFeatureAlgo("FEAT_X"); + + AptFaceRegAck ack = service.apply(command, "DEV-1"); + + assertEquals(Boolean.FALSE, ack.getAlgoSupported()); + assertTrue(ack.getWarnings().stream().anyMatch(w -> w.contains("40921"))); + } + + @Test + void invalidPersonCategoryReturns40001() + { + when(mapper.countDeviceByCode("APT-A", "DEV-1")).thenReturn(1); + when(mapper.selectPendingByWorkNo("APT-A", "W001")).thenReturn(null); + when(mapper.countConfirmedByWorkNo("APT-A", "W001")).thenReturn(0); + + AptFaceRegCommand command = command(); + command.setPersonCategory("UNKNOWN"); + + AptException error = assertThrows(AptException.class, () -> service.apply(command, "DEV-1")); + assertEquals(40001, error.getErrorCode().getCode()); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerdictServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerdictServiceTest.java new file mode 100644 index 0000000..27996cc --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerdictServiceTest.java @@ -0,0 +1,99 @@ +package com.ruoyi.apt.face; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.face.domain.AptFaceReg; +import com.ruoyi.apt.face.mapper.AptFaceRegMapper; +import com.ruoyi.apt.face.service.AptFaceVerdictService; +import com.ruoyi.apt.framework.audit.AptAuditWriter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * 平台回执单测:REJECTED 不回滚本地注册(Q-35 / 验收④)。 + */ +class AptFaceVerdictServiceTest +{ + private AptFaceRegMapper mapper; + private AptAuditWriter auditWriter; + private AptFaceVerdictService service; + + @BeforeEach + void setUp() + { + mapper = mock(AptFaceRegMapper.class); + auditWriter = mock(AptAuditWriter.class); + service = new AptFaceVerdictService(mapper, auditWriter); + AptContext.clear(); + AptContext.setGyId("APT-A"); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + } + + private AptFaceReg confirmedReg() + { + AptFaceReg reg = new AptFaceReg(); + reg.setRegId("FR1"); + reg.setRegState("CONFIRMED"); + reg.setConfirmedRyid("RY1"); + return reg; + } + + @Test + void rejectedVerdictKeepsLocalRegStateAndNeverRollsBack() + { + when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(confirmedReg()); + + service.applyVerdict("FR1", "REJECTED", 7L, "DUPLICATE_WORK_NO"); + + verify(mapper).updateVerdict("APT-A", "FR1", "REJECTED", 7L); + verify(mapper, never()).rejectFaceReg(anyString(), anyString(), anyString()); + verify(mapper, never()).confirmFaceReg(anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString()); + verify(mapper, never()).insertPerson(anyString(), any()); + verify(auditWriter).write(any()); + } + + @Test + void acceptedVerdictDoesNotWriteAlertAudit() + { + when(mapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(confirmedReg()); + + service.applyVerdict("FR1", "ACCEPTED", 8L, null); + + verify(mapper).updateVerdict("APT-A", "FR1", "ACCEPTED", 8L); + verify(auditWriter, never()).write(any()); + } + + @Test + void invalidVerdictReturns40001() + { + AptException error = assertThrows(AptException.class, + () -> service.applyVerdict("FR1", "PENDING", 1L, null)); + assertEquals(40001, error.getErrorCode().getCode()); + } + + @Test + void unknownRegReturns40401() + { + when(mapper.selectFaceRegByRegId("APT-A", "FR-NONE")).thenReturn(null); + + AptException error = assertThrows(AptException.class, + () -> service.applyVerdict("FR-NONE", "REJECTED", 1L, null)); + assertEquals(40401, error.getErrorCode().getCode()); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerifyServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerifyServiceTest.java new file mode 100644 index 0000000..5c25538 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/face/AptFaceVerifyServiceTest.java @@ -0,0 +1,164 @@ +package com.ruoyi.apt.face; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.alibaba.fastjson2.JSON; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.face.domain.AptFacePerson; +import com.ruoyi.apt.face.domain.AptFaceReg; +import com.ruoyi.apt.face.domain.AptFaceVerifyCommand; +import com.ruoyi.apt.face.domain.AptFaceVerifyResult; +import com.ruoyi.apt.face.mapper.AptFaceRegMapper; +import com.ruoyi.apt.face.mapper.AptFaceVerifyMapper; +import com.ruoyi.apt.face.service.AptFaceVerifyServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * 核验会话单测(验收⑤ faceVerifyState 三值与 regState 判定)。 + */ +class AptFaceVerifyServiceTest +{ + private AptFaceVerifyMapper verifyMapper; + private AptFaceRegMapper regMapper; + private AptFaceVerifyServiceImpl service; + + @BeforeEach + void setUp() + { + verifyMapper = mock(AptFaceVerifyMapper.class); + regMapper = mock(AptFaceRegMapper.class); + service = new AptFaceVerifyServiceImpl(verifyMapper, regMapper); + AptContext.clear(); + AptContext.setGyId("APT-A"); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + } + + private AptFaceVerifyCommand command(String state, String ryid, String regId) + { + AptFaceVerifyCommand command = new AptFaceVerifyCommand(); + command.setDeviceEventId("E1"); + command.setFaceVerifyState(state); + command.setVerifiedRyid(ryid); + command.setRegId(regId); + return command; + } + + @Test + void confirmedPersonReturnsProceed() + { + when(verifyMapper.selectVerifySessionDetail("APT-A", "VS-DEV-1-E1")).thenReturn(null); + AptFacePerson person = new AptFacePerson(); + person.setRyid("RY1"); + person.setXm("张三"); + person.setWorkNo("W001"); + person.setDeptCode("U01"); + person.setPersonCategory("TRANSIENT"); + when(regMapper.selectPersonByRyid("APT-A", "RY1")).thenReturn(person); + + AptFaceVerifyResult result = service.verify(command("VERIFIED", "RY1", null), "DEV-1"); + + assertTrue(result.isPersonFound()); + assertEquals("PROCEED", result.getNextAction()); + assertEquals("CONFIRMED", result.getRegState()); + assertEquals("张三", result.getName()); + verify(verifyMapper).insertVerifySession(eq("APT-A"), eq("VS-DEV-1-E1"), eq("DEV-1"), + anyString(), any(), any()); + } + + @Test + void appliedRegReturnsRegPending() + { + when(verifyMapper.selectVerifySessionDetail("APT-A", "VS-DEV-1-E1")).thenReturn(null); + when(regMapper.selectPersonByRyid("APT-A", "RY-UNKNOWN")).thenReturn(null); + AptFaceReg reg = new AptFaceReg(); + reg.setRegId("FR1"); + reg.setWorkNo("W001"); + reg.setName("张三"); + reg.setRegState("APPLIED"); + when(regMapper.selectFaceRegByRegId("APT-A", "FR1")).thenReturn(reg); + + AptFaceVerifyResult result = service.verify(command("VERIFIED", "RY-UNKNOWN", "FR1"), "DEV-1"); + + assertFalse(result.isPersonFound()); + assertEquals("REG_PENDING", result.getNextAction()); + assertEquals("APPLIED", result.getRegState()); + assertEquals("FR1", result.getRegId()); + assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("40914"))); + } + + @Test + void unknownPersonReturnsRegisterRequired() + { + when(verifyMapper.selectVerifySessionDetail("APT-A", "VS-DEV-1-E1")).thenReturn(null); + when(regMapper.selectPersonByRyid("APT-A", "RY-UNKNOWN")).thenReturn(null); + + AptFaceVerifyResult result = service.verify(command("VERIFIED", "RY-UNKNOWN", null), "DEV-1"); + + assertEquals("REGISTER_REQUIRED", result.getNextAction()); + assertEquals("NOT_FOUND", result.getRegState()); + } + + @Test + void duplicateDeviceEventReturnsDuplicatedWithoutSecondInsert() + { + AptFaceVerifyResult prior = new AptFaceVerifyResult(); + prior.setFaceVerifyState("VERIFIED"); + prior.setNextAction("PROCEED"); + prior.setPersonFound(true); + when(verifyMapper.selectVerifySessionDetail("APT-A", "VS-DEV-1-E1")) + .thenReturn(JSON.toJSONString(prior)); + + AptFaceVerifyResult result = service.verify(command("VERIFIED", "RY1", null), "DEV-1"); + + assertEquals(Boolean.TRUE, result.getDuplicated()); + assertEquals("PROCEED", result.getNextAction()); + verify(verifyMapper, never()).insertVerifySession(anyString(), anyString(), anyString(), + anyString(), any(), any()); + } + + @Test + void skippedStateReturnsManualConfirm() + { + when(verifyMapper.selectVerifySessionDetail("APT-A", "VS-DEV-1-E1")).thenReturn(null); + + AptFaceVerifyResult result = service.verify(command("SKIPPED", null, null), "DEV-1"); + + assertEquals("MANUAL_CONFIRM", result.getNextAction()); + } + + @Test + void invalidStateReturns40001() + { + AptException error = assertThrows(AptException.class, + () -> service.verify(command("NOT_FOUND", null, null), "DEV-1")); + assertEquals(40001, error.getErrorCode().getCode()); + } + + @Test + void verifiedWithoutRyidReturns40001() + { + when(verifyMapper.selectVerifySessionDetail("APT-A", "VS-DEV-1-E1")).thenReturn(null); + + AptException error = assertThrows(AptException.class, + () -> service.verify(command("VERIFIED", null, null), "DEV-1")); + assertEquals(40001, error.getErrorCode().getCode()); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptDutyStateSingleWriterArchTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptDutyStateSingleWriterArchTest.java new file mode 100644 index 0000000..69f76c8 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptDutyStateSingleWriterArchTest.java @@ -0,0 +1,133 @@ +package com.ruoyi.apt.framework; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; + +/** + * 架构测试(IT-5 / DV-5):gy_jcxx 的 ryzt / finish_jc / finish_ly / stoped + * 只允许在 {@code AptDutyStateMapper.xml} 里被 UPDATE。 + * + *

与 {@link AptMapperGyidArchTest} 互补:那个断言每条 SQL 带 gyid, + * 这个断言状态字段只有一个写文件(表 7-3 M3 / 表 7-5 第 7 项)。

+ * + * @author apt-rev01 + */ +class AptDutyStateSingleWriterArchTest +{ + /** 只截取 UPDATE gy_jcxx 的 SET 子句(到 WHERE 为止),避免误判 WHERE 里的期望态条件 */ + private static final Pattern UPDATE_JCXX = Pattern.compile( + "update\\s+gy_jcxx\\s+set\\s+([\\s\\S]{0,900}?)\\bwhere\\b", + Pattern.CASE_INSENSITIVE); + + private static final Pattern STATE_COLUMN = Pattern.compile( + "\\b(ryzt|finish_jc|finish_ly|stoped)\\s*=", Pattern.CASE_INSENSITIVE); + + private static final String STATE_MAPPER = "AptDutyStateMapper.xml"; + + @Test + void onlyDutyStateMapperUpdatesStateColumns() throws IOException + { + Path root = locateMainDir(); + assertNotNull(root, "未找到 ruoyi-apt/src/main"); + List offenders = new ArrayList<>(); + int stateWrites = 0; + for (Path file : files(root)) + { + String content = Files.readString(file, StandardCharsets.UTF_8); + Matcher matcher = UPDATE_JCXX.matcher(content); + while (matcher.find()) + { + Matcher column = STATE_COLUMN.matcher(matcher.group(1)); + if (!column.find()) + { + continue; + } + if (STATE_MAPPER.equals(file.getFileName().toString())) + { + stateWrites++; + } + else + { + offenders.add(file.getFileName() + " -> " + column.group(1)); + } + } + } + assertEquals(List.of(), offenders, "状态字段只允许 apt.duty.state 写:" + offenders); + assertTrue(stateWrites >= 6, + "AptDutyStateMapper.xml 应覆盖 CALL_START/ANSWERED/CALL_FAILED/STOP/RE_CALL/" + + "FORCE_FINISH/MARK_LEAVE/CANCEL,实际 " + stateWrites); + } + + @Test + void stateMapperKeepsExpectedStateConditions() throws IOException + { + String xml = Files.readString(locateMainDir() + .resolve("resources/mapper/apt/" + STATE_MAPPER), StandardCharsets.UTF_8); + assertTrue(xml.contains("ryzt = '0'"), "CALL_START 必须带 ryzt='0' 期望态"); + assertTrue(xml.contains("ryzt IN"), "ANSWERED 必须带来源态集合"); + assertTrue(xml.contains("stoped = '1'"), "STOP 语义缺失"); + assertTrue(xml.contains("finish_ly = '0'"), "MARK_LEAVE / FORCE_FINISH 必须带在寓条件"); + assertTrue(xml.contains("biz_version = biz_version + 1"), "状态迁移必须递增 biz_version"); + } + + @Test + void tabletMapperDoesNotTouchStateColumns() throws IOException + { + String xml = Files.readString(locateMainDir() + .resolve("resources/mapper/apt/AptTabletMapper.xml"), StandardCharsets.UTF_8); + assertTrue(xml.contains("dispatch_seq = #{dispatchSeq}"), "平板只允许写派发序号"); + Matcher matcher = UPDATE_JCXX.matcher(xml); + while (matcher.find()) + { + assertTrue(!STATE_COLUMN.matcher(matcher.group(1)).find(), "平板 Mapper 不得写状态字段"); + } + } + + private List files(Path root) throws IOException + { + try (Stream stream = Files.walk(root)) + { + return stream.filter(Files::isRegularFile) + .filter(path -> { + String name = path.getFileName().toString(); + return name.endsWith(".java") || name.endsWith("Mapper.xml"); + }) + .collect(Collectors.toList()); + } + } + + private Path locateMainDir() + { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + int depth = 0; + while (current != null && depth < 6) + { + Path inModule = current.resolve("src/main"); + if (Files.isDirectory(inModule)) + { + return inModule; + } + Path inReactor = current.resolve("ruoyi-apt/src/main"); + if (Files.isDirectory(inReactor)) + { + return inReactor; + } + current = current.getParent(); + depth++; + } + return null; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt3SqlContractTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt3SqlContractTest.java new file mode 100644 index 0000000..45751c7 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt3SqlContractTest.java @@ -0,0 +1,133 @@ +package com.ruoyi.apt.framework; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.junit.jupiter.api.Test; + +/** + * IT-3 SQL 契约静态校验(对齐 AptIt2SeedMarkersTest 做法)。 + */ +class AptIt3SqlContractTest +{ + @Test + void it3ColumnScriptAddsReceiptFieldsIdempotently() throws IOException + { + String sql = read("14_it3_columns.sql"); + assertTrue(sql.contains("platform_version"), "回执版本列缺失"); + assertTrue(sql.contains("face_platform_version"), "主档人脸回执版本列缺失"); + assertTrue(sql.contains("unit_code"), "N-34 单位编码列缺失"); + assertTrue(sql.contains("information_schema.COLUMNS"), "补列必须幂等(先查 information_schema)"); + assertTrue(sql.contains("apt_person_face_reg"), "缺少注册申请表"); + assertTrue(sql.contains("apt_master_person"), "缺少人员主档表"); + } + + @Test + void it3ColumnScriptAddsDeviceScopeColumnsFromTable10_2() throws IOException + { + String sql = read("14_it3_columns.sql"); + assertTrue(sql.contains("device_class"), "表 10-2 设备类别列缺失"); + assertTrue(sql.contains("face_scope_value"), "表 10-2 可见范围取值列缺失"); + assertTrue(sql.contains("face_cache_enabled"), "表 10-2 缓存开关列缺失"); + assertTrue(sql.contains("ALTER TABLE gy_sb"), "设备范围补列应落在 gy_sb"); + } + + @Test + void it3SeedRegistersCheckinDeviceAndKeepsTabletCacheless() throws IOException + { + String seed = read("15_it3_seed.sql"); + assertTrue(seed.contains("INSERT INTO gy_sb"), "缺少设备档案灌数"); + assertTrue(seed.contains("DEV-CHECKIN-01"), "缺少签到终端样例设备"); + assertTrue(seed.contains("'CHECKIN'"), "签到终端须标 device_class=CHECKIN"); + assertTrue(seed.contains("'ALL_ACTIVE'"), "签到终端范围须为 ALL_ACTIVE(表 10-16)"); + assertTrue(seed.contains("DEV-TABLET-01"), "缺少平板样例设备"); + assertTrue(seed.contains("DEV-CHECKIN-99"), "缺少停用设备样例(验证 40303 口径)"); + assertTrue(seed.contains("state <> 'DISABLED'"), "须校验停用设备不计入可申请设备"); + assertFalse(seed.contains("INSERT INTO gy_sb_xt"), "不得伪造设备心跳 / 缓存台账"); + } + + @Test + void it3PermissionScriptRegistersFacePointsAndGreyIt7() throws IOException + { + String sql = read("16_it3_permissions.sql"); + assertTrue(sql.contains("apt:face:list"), "缺少 N-25 查看权限点"); + assertTrue(sql.contains("duty:person:reg"), "缺少 N-35/36/37 值班台权限点"); + assertTrue(sql.contains("(3201,"), "缺少人脸注册审核页面菜单"); + assertTrue(sql.contains("apt/face/reg/index"), "菜单组件路径与前端不一致"); + assertTrue(sql.contains("apt:face:dispatch"), "缺少 IT-7 预留权限点"); + assertTrue(sql.contains("apt:face:scope"), "缺少 IT-7 预留权限点"); + assertTrue(sql.contains("apt:face:reconcile"), "缺少 IT-7 预留权限点"); + assertTrue(sql.contains("apt:face:category"), "缺少表 10-24 #7 类别标注权限点"); + } + + @Test + void it3PermissionScriptKeepsIt7PointsGrey() throws IOException + { + String sql = read("16_it3_permissions.sql"); + int greyCount = countOccurrences(sql, "'F', '1', '1'"); + assertTrue(greyCount >= 4, "IT-7 预留权限点应置灰(visible=1/status=1),实际 " + greyCount); + assertTrue(sql.contains("SELECT COUNT(*) AS admin_bound"), "缺少超管关联校验"); + assertTrue(sql.contains("grey_bound"), "缺少置灰项未授权校验"); + assertTrue(sql.contains("3213, 3214, 3215, 3216"), "置灰项校验须覆盖全部 IT-7 预留点"); + } + + @Test + void it3VerifyScriptCoversRegistrationStateChain() throws IOException + { + String sql = read("99_verify.sql"); + assertTrue(sql.contains("IT-3 增量列"), "验收脚本缺少 IT-3 增量列校验"); + assertTrue(sql.contains("it3_apply_device_count"), "验收脚本缺少可申请设备校验"); + assertTrue(sql.contains("it3_grey_bound"), "验收脚本缺少 IT-7 置灰未授权校验"); + assertTrue(sql.contains("it3_applied_leak"), "验收脚本缺少 APPLIED 不得带 ryid 的校验"); + assertTrue(sql.contains("it3_confirmed_without_ryid"), "验收脚本缺少确认必带 ryid 的校验"); + assertTrue(sql.contains("it3_confirmed_orphan"), "验收脚本缺少确认行孤儿检查"); + } + + private static int countOccurrences(String text, String target) + { + int count = 0; + int index = text.indexOf(target); + while (index >= 0) + { + count++; + index = text.indexOf(target, index + target.length()); + } + return count; + } + + private String read(String name) throws IOException + { + Path dir = locateSqlDir(); + assertNotNull(dir, "未找到 dev/sql/apt_rev01"); + return Files.readString(dir.resolve(name), StandardCharsets.UTF_8); + } + + private Path locateSqlDir() + { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + int depth = 0; + while (current != null && depth < 8) + { + Path direct = current.resolve("dev/sql/apt_rev01"); + if (Files.isDirectory(direct)) + { + return direct; + } + Path sibling = current.resolve("sql/apt_rev01"); + if (Files.isDirectory(sibling)) + { + return sibling; + } + current = current.getParent(); + depth++; + } + return null; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt4SqlContractTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt4SqlContractTest.java new file mode 100644 index 0000000..05ef52a --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt4SqlContractTest.java @@ -0,0 +1,115 @@ +package com.ruoyi.apt.framework; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * IT-4 SQL 契约静态校验(对齐 AptIt2SeedMarkersTest / AptIt3SqlContractTest 做法)。 + * + * @author apt-rev01 + */ +class AptIt4SqlContractTest +{ + @Test + void it4ColumnScriptAddsCheckinColumnsIdempotently() throws IOException + { + String sql = read("17_it4_columns.sql"); + assertTrue(sql.contains("apt_add_column_if_missing"), "补列必须走幂等辅助"); + assertTrue(sql.contains("device_event_id"), "设备幂等键缺失"); + assertTrue(sql.contains("verified_at"), "N-38 核验时刻缺失"); + assertTrue(sql.contains("submitted_at"), "申请提交时刻缺失"); + assertTrue(sql.contains("base_kcsj"), "平台补齐开车时刻缺失"); + assertTrue(sql.contains("derived_rule_version"), "复算规则版本缺失"); + assertTrue(sql.contains("uk_lt_gyid_device_event"), "设备事件唯一键缺失"); + assertTrue(sql.contains("uk_lt_gyid_apply"), "申请单唯一键缺失"); + assertTrue(sql.contains("ROOM_SWAP"), "住房事件字典未补 ROOM_SWAP"); + } + + @Test + void it4ColumnScriptExtendsFlowStateDictionaries() throws IOException + { + String sql = read("17_it4_columns.sql"); + assertTrue(sql.contains("ck_lt_checkin"), "缺少入住流程态 CHECK"); + assertTrue(sql.contains("ck_lt_sync"), "缺少同步态 CHECK"); + for (String state : new String[] { "SUBMITTED", "REVIEWING", "APPROVED", "ROOM_ADJUSTED", + "REJECTED", "CHECKED_IN", "CANCELLED", "CLOSED" }) + { + assertTrue(sql.contains("''" + state + "''"), "checkin_state 字典缺少 " + state); + } + for (String state : new String[] { "PENDING", "REPORTED", "MATCHED", "UNMATCHED", "TASK_READY" }) + { + assertTrue(sql.contains("''" + state + "''"), "sync_state 字典缺少 " + state); + } + assertTrue(sql.contains("information_schema.TABLE_CONSTRAINTS"), "CHECK 变更必须幂等"); + } + + @Test + void it4PermissionScriptEnablesCheckinPoints() throws IOException + { + String sql = read("18_it4_permissions.sql"); + assertTrue(sql.contains("UPDATE sys_menu"), "3005 应由置灰改为启用,而不是重复插入"); + assertTrue(sql.contains("menu_id = 3005"), "缺少 3005 启用语句"); + assertTrue(sql.contains("duty:checkin:list"), "缺少 N-42 列表权限点"); + assertTrue(sql.contains("duty:checkin:review"), "缺少 N-44 审核权限点"); + assertTrue(sql.contains("duty:checkin:adjust"), "缺少 N-45 换房权限点"); + assertTrue(sql.contains("apt:checkin:query"), "缺少 N-47 查询权限点"); + assertTrue(sql.contains("(3202,"), "缺少入住审核页面菜单"); + assertTrue(sql.contains("apt/checkin/index"), "菜单组件路径与前端不一致"); + } + + @Test + void it4PermissionScriptBindsOnlyUsablePoints() throws IOException + { + String sql = read("18_it4_permissions.sql"); + assertTrue(sql.contains("admin_bound"), "缺少超管关联校验"); + assertTrue(sql.contains("(3202, 3005, 3217, 3218, 3219)"), "超管关联应覆盖本期全部可用点"); + assertTrue(sql.contains("still_grey"), "缺少置灰残留校验"); + } + + @Test + void it4VerifyScriptCoversCheckinChain() throws IOException + { + String sql = read("99_verify.sql"); + assertTrue(sql.contains("it4_local_task_columns"), "验收脚本缺少 IT-4 增量列校验"); + assertTrue(sql.contains("it4_task_ready_without_jcsj"), "验收脚本缺少 TASK_READY 必须有 jcsj 的校验"); + assertTrue(sql.contains("it4_checked_in_orphan"), "验收脚本缺少 CHECKED_IN 孤儿叫乘行校验"); + assertTrue(sql.contains("it4_checkin_leak"), "验收脚本缺少未入住不得生成叫乘行校验"); + } + + private String read(String name) throws IOException + { + Path dir = locateSqlDir(); + assertNotNull(dir, "未找到 dev/sql/apt_rev01"); + return Files.readString(dir.resolve(name), StandardCharsets.UTF_8); + } + + private Path locateSqlDir() + { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + int depth = 0; + while (current != null && depth < 8) + { + Path direct = current.resolve("dev/sql/apt_rev01"); + if (Files.isDirectory(direct)) + { + return direct; + } + Path sibling = current.resolve("sql/apt_rev01"); + if (Files.isDirectory(sibling)) + { + return sibling; + } + current = current.getParent(); + depth++; + } + return null; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt5SqlContractTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt5SqlContractTest.java new file mode 100644 index 0000000..afe9174 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptIt5SqlContractTest.java @@ -0,0 +1,110 @@ +package com.ruoyi.apt.framework; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; + +/** + * IT-5 SQL 契约静态校验(对齐 AptIt3/It4 做法),覆盖 19/20/99 三个脚本。 + * + * @author apt-rev01 + */ +class AptIt5SqlContractTest +{ + /** 11 个状态时间戳/标志位 + 表 4-9 correct 白名单缺列 jch,共 12 */ + private static final String[] JCXX_COLUMNS = { "jcsj_s", "hjxysj", "jcbjsj_s", "fjsj_s", + "lybjsj", "is_jcbj", "is_lybj", "is_temp_call", "leave_type", "leave_event_id", + "leave_remark", "jch" }; + + @Test + void it5ColumnScriptAddsStateTimestampsIdempotently() throws IOException + { + String sql = read("19_it5_columns.sql"); + assertTrue(sql.contains("apt_add_column_if_missing"), "补列必须走幂等辅助"); + for (String column : JCXX_COLUMNS) + { + assertTrue(sql.contains(column), "gy_jcxx 缺少表 3-10 所需列:" + column); + } + assertTrue(sql.contains("duty_manual_action"), "缺少人工命令表补列"); + assertTrue(sql.contains("reason"), "duty_manual_action 缺少 reason 列"); + } + + @Test + void it5ColumnScriptExtendsCommandDictionary() throws IOException + { + String sql = read("19_it5_columns.sql"); + for (String action : new String[] { "STOP", "RE_CALL", "FORCE_FINISH", "MARK_LEAVE", "CANCEL" }) + { + assertTrue(sql.contains(action), "命令枚举注释缺少 " + action); + } + assertTrue(sql.contains("idx_jcxx_gyid_mode_state"), "缺少按模式扫描的索引"); + assertTrue(sql.contains("information_schema.STATISTICS"), "索引创建必须幂等"); + } + + @Test + void it5PermissionScriptEnablesJcxxPoints() throws IOException + { + String sql = read("20_it5_permissions.sql"); + assertTrue(sql.contains("UPDATE sys_menu"), "3004 应由置灰改为启用,而不是重复插入"); + assertTrue(sql.contains("menu_id = 3004"), "缺少 3004 启用语句"); + assertTrue(sql.contains("apt:jcxx:query"), "缺少详情/时间线权限点"); + assertTrue(sql.contains("apt:jcxx:correct"), "缺少修正权限点"); + assertTrue(sql.contains("apt:jcxx:changeStatus"), "缺少状态机命令权限点"); + assertTrue(sql.contains("(3203,"), "缺少叫乘台账页面菜单"); + assertTrue(sql.contains("apt/jcxx/index"), "菜单组件路径与前端不一致"); + assertTrue(sql.contains("(3203, 3004, 3220, 3221, 3222)"), "超管关联应覆盖本期全部可用点"); + } + + @Test + void it5VerifyScriptCoversDispatchChain() throws IOException + { + String sql = read("99_verify.sql"); + assertTrue(sql.contains("it5_jcxx_columns"), "验收脚本缺少 IT-5 增量列校验"); + assertTrue(sql.contains("it5_answered_without_finish"), "缺少 ryzt=3 必须 finish_jc=1 校验"); + assertTrue(sql.contains("it5_finished_regressed"), "缺少已结束叫乘不得回退校验"); + assertTrue(sql.contains("it5_task_done_without_result"), "缺少 DONE 任务必须有结果行校验"); + assertTrue(sql.contains("it5_tablet_voice_summary"), "缺少平板链路不得上报 #24 校验"); + } + + @Test + void it5JobSeedUsesWhitelistedPackage() throws IOException + { + String sql = read("20_it5_permissions.sql"); + assertTrue(sql.contains("sys_menu"), "权限脚本应只写框架表"); + assertTrue(!sql.contains("INSERT INTO gy_jcxx"), "权限脚本不得写业务表"); + } + + private String read(String name) throws IOException + { + Path dir = locateSqlDir(); + assertNotNull(dir, "未找到 dev/sql/apt_rev01"); + return Files.readString(dir.resolve(name), StandardCharsets.UTF_8); + } + + private Path locateSqlDir() + { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + int depth = 0; + while (current != null && depth < 8) + { + Path direct = current.resolve("dev/sql/apt_rev01"); + if (Files.isDirectory(direct)) + { + return direct; + } + Path sibling = current.resolve("sql/apt_rev01"); + if (Files.isDirectory(sibling)) + { + return sibling; + } + current = current.getParent(); + depth++; + } + return null; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCallTimeCalculatorTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCallTimeCalculatorTest.java new file mode 100644 index 0000000..5f17c68 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCallTimeCalculatorTest.java @@ -0,0 +1,105 @@ +package com.ruoyi.apt.stay.checkin; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.stay.checkin.domain.AptCallTimeResult; +import com.ruoyi.apt.stay.checkin.service.AptCallTimeCalculator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 叫乘时刻复算器单测:规则驱动、节假日分支、不变式校验。 + * + * @author apt-rev01 + */ +class AptCallTimeCalculatorTest +{ + private final AptCallTimeCalculator calculator = new AptCallTimeCalculator(); + + @Test + @DisplayName("缺省规则:jcsj = kcsj - 30,fjsj/jcbjsj 固定 +10/+15,ylysj = jcsj + 30") + void computeWithDefaultRule() + { + Date kcsj = at(2026, 11, 12, 8, 0); + AptCallTimeResult result = calculator.compute(kcsj, null, 0L, false); + + assertEquals(at(2026, 11, 12, 7, 30), result.getJcsj()); + assertEquals(at(2026, 11, 12, 7, 40), result.getFjsj()); + assertEquals(at(2026, 11, 12, 7, 45), result.getJcbjsj()); + assertEquals(at(2026, 11, 12, 8, 0), result.getYlysj()); + assertEquals(AptCallTimeCalculator.DEFAULT_ADVANCE_MINUTES, result.getAdvanceMinutes()); + assertEquals(0L, result.getRuleVersion()); + } + + @Test + @DisplayName("规则体生效:advanceMinutes / lysjgz 均按 JSON 取值") + void computeWithCustomRule() + { + Date kcsj = at(2026, 11, 12, 9, 0); + String ruleBody = "{\"advanceMinutes\":45,\"holidayAdvanceMinutes\":60,\"lysjgz\":20}"; + + AptCallTimeResult result = calculator.compute(kcsj, ruleBody, 7L, false); + assertEquals(at(2026, 11, 12, 8, 15), result.getJcsj()); + assertEquals(at(2026, 11, 12, 8, 35), result.getYlysj()); + assertEquals(7L, result.getRuleVersion()); + assertEquals(45, result.getAdvanceMinutes()); + } + + @Test + @DisplayName("节假日分支取 holidayAdvanceMinutes") + void computeOnHoliday() + { + Date kcsj = at(2026, 11, 14, 9, 0); + String ruleBody = "{\"advanceMinutes\":30,\"holidayAdvanceMinutes\":60}"; + + AptCallTimeResult result = calculator.compute(kcsj, ruleBody, 3L, true); + assertTrue(result.isHoliday()); + assertEquals(at(2026, 11, 14, 8, 0), result.getJcsj()); + assertEquals(60, result.getAdvanceMinutes()); + } + + @Test + @DisplayName("提前量 <=15 分钟导致 jcbjsj >= kcsj:返回 40931,不静默钳制") + void computeRejectsIllegalRelation() + { + Date kcsj = at(2026, 11, 12, 9, 0); + String ruleBody = "{\"advanceMinutes\":10}"; + + AptException ex = assertThrows(AptException.class, () -> calculator.compute(kcsj, ruleBody, 1L, false)); + assertEquals(AptErrorCode.ROUTE_INVALID, ex.getErrorCode()); + assertEquals("CALL_TIME_ILLEGAL", ex.getReason()); + } + + @Test + @DisplayName("开车时刻缺失:40931 / KCSJ_MISSING") + void computeRequiresKcsj() + { + AptException ex = assertThrows(AptException.class, () -> calculator.compute(null, null, 1L, false)); + assertEquals(AptErrorCode.ROUTE_INVALID, ex.getErrorCode()); + assertEquals("KCSJ_MISSING", ex.getReason()); + } + + @Test + @DisplayName("规则体非法 JSON:50304,不静默回落缺省值") + void computeRejectsBrokenRuleBody() + { + Date kcsj = at(2026, 11, 12, 9, 0); + AptException ex = assertThrows(AptException.class, () -> calculator.compute(kcsj, "{oops", 1L, false)); + assertEquals(AptErrorCode.DATA_ANOMALY, ex.getErrorCode()); + assertEquals("CALL_TIME_RULE_INVALID", ex.getReason()); + } + + private Date at(int year, int month, int day, int hour, int minute) + { + return Date.from(LocalDateTime.of(year, month, day, hour, minute) + .atZone(ZoneId.systemDefault()).toInstant()); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinMatchServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinMatchServiceTest.java new file mode 100644 index 0000000..4cd2a91 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinMatchServiceTest.java @@ -0,0 +1,226 @@ +package com.ruoyi.apt.stay.checkin; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplication; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchResult; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchVerdict; +import com.ruoyi.apt.stay.checkin.domain.AptOutboxRow; +import com.ruoyi.apt.stay.checkin.mapper.AptCheckinMapper; +import com.ruoyi.apt.stay.checkin.service.AptCallTimeCalculator; +import com.ruoyi.apt.stay.checkin.service.AptCheckinMatchServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * #7 匹配结论消费单测:复算、置 TASK_READY、#8 入队、幂等与异常分支。 + * + * @author apt-rev01 + */ +class AptCheckinMatchServiceTest +{ + private static final String GYID = "APT-REV01-001"; + + private static final String APPLY_ID = "CHK-1"; + + private AptCheckinMapper mapper; + + private AptCheckinMatchServiceImpl service; + + @BeforeEach + void setUp() + { + AptContext.setGyId(GYID); + mapper = mock(AptCheckinMapper.class); + service = new AptCheckinMatchServiceImpl(mapper, new AptCallTimeCalculator()); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + } + + @Test + @DisplayName("MATCHED:按要素复算 jcsj 并置 TASK_READY,同时写 gy_jcxx 与 #8 outbox") + void matchedComputesCallTimeAndQueuesAck() + { + AptCheckinApplication app = checkedInApplication(); + when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(app); + when(mapper.selectCallTimeRule(GYID)).thenReturn(rule("{\"advanceMinutes\":45}", 6L)); + when(mapper.countHoliday(eq(GYID), any())).thenReturn(0); + when(mapper.insertOutbox(any())).thenAnswer(invocation -> { + AptOutboxRow row = invocation.getArgument(0); + row.setId(77L); + return 1; + }); + + AptCheckinMatchResult result = service.applyVerdict(verdict("MATCHED")); + + assertTrue(result.isTaskReady()); + assertTrue(result.isChanged()); + assertEquals("TASK_READY", result.getSyncState()); + assertEquals("MATCHED", result.getMatchState()); + assertEquals(at(2026, 11, 12, 7, 15), result.getJcsj()); + assertEquals(6L, result.getDerivedRuleVersion()); + assertEquals(77L, result.getTaskAckOutboxId()); + verify(mapper).updateJcxxCallTime(GYID, "JC-1", at(2026, 11, 12, 7, 15), "SIP"); + verify(mapper).updateTaskReady(eq(GYID), eq(APPLY_ID), eq(at(2026, 11, 12, 7, 15)), eq(6L), any()); + } + + @Test + @DisplayName("同结论重复提交:幂等,不重复复算、不重复入队 #8") + void matchedIsIdempotent() + { + AptCheckinApplication app = checkedInApplication(); + app.setMatchState("MATCHED"); + app.setSyncState("TASK_READY"); + app.setJcsj(at(2026, 11, 12, 7, 15)); + when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(app); + + AptCheckinMatchResult result = service.applyVerdict(verdict("MATCHED")); + + assertFalse(result.isChanged()); + assertTrue(result.isTaskReady()); + verify(mapper, never()).insertOutbox(any()); + verify(mapper, never()).updateTaskReady(anyString(), anyString(), any(), anyLong(), any()); + } + + @Test + @DisplayName("UNMATCHED:保留本地要素、不生成任务,sync_state 回 REPORTED") + void unmatchedKeepsLocalElements() + { + when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(checkedInApplication()); + + AptCheckinMatchResult result = service.applyVerdict(verdict("UNMATCHED")); + + assertFalse(result.isTaskReady()); + assertEquals("UNMATCHED", result.getMatchState()); + assertEquals("REPORTED", result.getSyncState()); + verify(mapper).updateSyncState(GYID, APPLY_ID, "REPORTED", null); + verify(mapper, never()).updateTaskReady(anyString(), anyString(), any(), anyLong(), any()); + } + + @Test + @DisplayName("LOCKED:置 jllx=1 并按本地要素复算(平台不覆盖)") + void lockedKeepsLocalElementsAndLocks() + { + when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(checkedInApplication()); + when(mapper.selectCallTimeRule(GYID)).thenReturn(null); + when(mapper.countHoliday(eq(GYID), any())).thenReturn(1); + when(mapper.insertOutbox(any())).thenReturn(1); + + AptCheckinMatchResult result = service.applyVerdict(verdict("LOCKED")); + + assertEquals("LOCKED", result.getMatchState()); + assertTrue(result.isTaskReady()); + verify(mapper).updateMatchLock(GYID, APPLY_ID, "1"); + assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("jllx=1"))); + } + + @Test + @DisplayName("未办理入住:40926,不允许匹配结论提前落地") + void rejectsWhenNotCheckedIn() + { + AptCheckinApplication app = checkedInApplication(); + app.setCheckinState("APPROVED"); + when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(app); + + AptException ex = assertThrows(AptException.class, () -> service.applyVerdict(verdict("MATCHED"))); + assertEquals(AptErrorCode.CHECKIN_STATE_CONFLICT, ex.getErrorCode()); + assertEquals("NOT_CHECKED_IN", ex.getReason()); + } + + @Test + @DisplayName("MATCHED 但缺开车时刻:只置 MATCHED,不伪造 jcsj") + void matchedWithoutKcsjStaysMatched() + { + AptCheckinApplication app = checkedInApplication(); + app.setKcsj(null); + when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(app); + AptCheckinMatchVerdict verdict = verdict("MATCHED"); + verdict.setKcsj(null); + + AptCheckinMatchResult result = service.applyVerdict(verdict); + + assertFalse(result.isTaskReady()); + assertEquals("MATCHED", result.getSyncState()); + verify(mapper, never()).updateTaskReady(anyString(), anyString(), any(), anyLong(), any()); + } + + @Test + @DisplayName("非法 verdict:40001") + void rejectsUnknownVerdict() + { + when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(checkedInApplication()); + AptCheckinMatchVerdict verdict = verdict("MAYBE"); + + AptException ex = assertThrows(AptException.class, () -> service.applyVerdict(verdict)); + assertEquals(AptErrorCode.BAD_REQUEST, ex.getErrorCode()); + assertEquals("MATCH_VERDICT_INVALID", ex.getReason()); + } + + private AptCheckinApplication checkedInApplication() + { + AptCheckinApplication app = new AptCheckinApplication(); + app.setApplyId(APPLY_ID); + app.setJcid("JC-1"); + app.setCheckinState("CHECKED_IN"); + app.setSyncState("REPORTED"); + app.setMatchState("PENDING"); + app.setBizVersion(8L); + app.setKcsj(at(2026, 11, 12, 8, 0)); + app.setJcfs("SIP"); + app.setFjhFinal("A101"); + return app; + } + + private AptCheckinMatchVerdict verdict(String value) + { + AptCheckinMatchVerdict verdict = new AptCheckinMatchVerdict(); + verdict.setApplyId(APPLY_ID); + verdict.setJcid("JC-1"); + verdict.setVerdict(value); + verdict.setMatchedPlanKey("PLAN-1"); + verdict.setKcsj(at(2026, 11, 12, 8, 0)); + verdict.setJcfs("SIP"); + verdict.setReason("平台说明"); + return verdict; + } + + private Map rule(String body, long version) + { + Map rule = new LinkedHashMap<>(); + rule.put("ruleBody", body); + rule.put("ruleVersion", version); + return rule; + } + + private Date at(int year, int month, int day, int hour, int minute) + { + return Date.from(LocalDateTime.of(year, month, day, hour, minute) + .atZone(ZoneId.systemDefault()).toInstant()); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinServiceTest.java new file mode 100644 index 0000000..167208c --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/stay/checkin/AptCheckinServiceTest.java @@ -0,0 +1,485 @@ +package com.ruoyi.apt.stay.checkin; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.common.AptErrorCode; +import com.ruoyi.apt.common.AptException; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplication; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyCommand; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinApplyResult; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinCallTask; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinOptions; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewCommand; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinReviewResult; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinRoomAdjustCommand; +import com.ruoyi.apt.stay.checkin.domain.AptCheckinRoomAdjustResult; +import com.ruoyi.apt.stay.checkin.domain.AptOutboxRow; +import com.ruoyi.apt.stay.checkin.mapper.AptCheckinMapper; +import com.ruoyi.apt.stay.checkin.service.AptCheckinServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 入住作业服务单测:申请校验、审核事务、换房边界。 + * + * @author apt-rev01 + */ +class AptCheckinServiceTest +{ + private static final String GYID = "APT-REV01-001"; + + private static final String APPLY_ID = "CHK-20261112120000-100001"; + + private static final String DEVICE = "DEV-CHECKIN-01"; + + private AptCheckinMapper mapper; + + private AptCheckinServiceImpl service; + + @BeforeEach + void setUp() + { + AptContext.setGyId(GYID); + mapper = mock(AptCheckinMapper.class); + service = new AptCheckinServiceImpl(mapper); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + } + + // ---------------------------------------------------------------- N-40 + + @Test + @DisplayName("申请:固定交路 → origin=CHECKIN_ROUTE,只落 SUBMITTED,不写人员表/叫乘表") + void applyWithRouteCreatesSubmittedApplication() + { + when(mapper.selectPersonForCheckin(GYID, "RY001")).thenReturn(person()); + when(mapper.selectConfirmedReg(GYID, "RY001", null)).thenReturn(confirmedReg()); + when(mapper.selectRoomByNo(GYID, "A101")).thenReturn(availableRoom("A101")); + when(mapper.countActiveStay(GYID, "RY001")).thenReturn(0); + when(mapper.selectRouteForCheckin(GYID, "JL-01")).thenReturn(route()); + when(mapper.insertApplication(eq(GYID), any())).thenReturn(1); + + AptCheckinApplyResult result = service.apply(command("EV-1"), DEVICE); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AptCheckinApplication.class); + verify(mapper).insertApplication(eq(GYID), captor.capture()); + AptCheckinApplication saved = captor.getValue(); + assertEquals("CHECKIN_ROUTE", saved.getOrigin()); + assertEquals("A101", saved.getFjh()); + assertEquals("A101", saved.getFjhFinal()); + assertEquals("RY001", saved.getVerifiedRyid()); + assertEquals("0", saved.getJllx()); + assertEquals("CONFIRMED", saved.getRegStateAtApply()); + assertEquals(at(2026, 11, 12, 8, 0), saved.getKcsjSnapshot()); + assertEquals("SUBMITTED", result.getCheckinState()); + assertEquals("PENDING", result.getSyncState()); + assertFalse(result.isDuplicated()); + verify(mapper, never()).insertJcxx(anyString(), any(), any(), anyString(), any()); + } + + @Test + @DisplayName("申请:同一设备事件重复提交 → 返回原 applyId,不重复插入") + void applyIsIdempotentByDeviceEvent() + { + AptCheckinApplication existing = new AptCheckinApplication(); + existing.setApplyId(APPLY_ID); + existing.setJcid("JC-1"); + existing.setCheckinState("CHECKED_IN"); + existing.setSyncState("TASK_READY"); + existing.setBizVersion(9L); + when(mapper.selectByDeviceEvent(GYID, DEVICE, "EV-1")).thenReturn(existing); + + AptCheckinApplyResult result = service.apply(command("EV-1"), DEVICE); + + assertTrue(result.isDuplicated()); + assertEquals(APPLY_ID, result.getApplyId()); + assertEquals(9L, result.getBizVersion()); + verify(mapper, never()).insertApplication(anyString(), any()); + } + + @Test + @DisplayName("申请:人脸注册未确认 → 40914,不落申请单") + void applyRejectsUnconfirmedFaceRegistration() + { + when(mapper.selectPersonForCheckin(GYID, "RY001")).thenReturn(person()); + when(mapper.selectConfirmedReg(GYID, "RY001", null)).thenReturn(null); + + AptException ex = assertThrows(AptException.class, () -> service.apply(command("EV-1"), DEVICE)); + assertEquals(AptErrorCode.FACE_REG_PENDING, ex.getErrorCode()); + assertEquals("FACE_REG_NOT_CONFIRMED", ex.getReason()); + verify(mapper, never()).insertApplication(anyString(), any()); + } + + @Test + @DisplayName("申请:核验会话超过 5 分钟 → 40929") + void applyRejectsExpiredVerifySession() + { + when(mapper.selectPersonForCheckin(GYID, "RY001")).thenReturn(person()); + when(mapper.selectConfirmedReg(GYID, "RY001", null)).thenReturn(confirmedReg()); + + AptCheckinApplyCommand command = command("EV-1"); + command.setVerifiedAt(new Date(System.currentTimeMillis() - 6 * 60 * 1000L)); + + AptException ex = assertThrows(AptException.class, () -> service.apply(command, DEVICE)); + assertEquals(AptErrorCode.VERIFY_SESSION_EXPIRED, ex.getErrorCode()); + } + + @Test + @DisplayName("申请:已有在住记录 → 40932") + void applyRejectsDuplicateStay() + { + when(mapper.selectPersonForCheckin(GYID, "RY001")).thenReturn(person()); + when(mapper.selectConfirmedReg(GYID, "RY001", null)).thenReturn(confirmedReg()); + when(mapper.selectRoomByNo(GYID, "A101")).thenReturn(availableRoom("A101")); + when(mapper.countActiveStay(GYID, "RY001")).thenReturn(1); + + AptException ex = assertThrows(AptException.class, () -> service.apply(command("EV-1"), DEVICE)); + assertEquals(AptErrorCode.DUPLICATE_STAY, ex.getErrorCode()); + } + + @Test + @DisplayName("申请:房间住满 → 40928") + void applyRejectsFullRoom() + { + when(mapper.selectPersonForCheckin(GYID, "RY001")).thenReturn(person()); + when(mapper.selectConfirmedReg(GYID, "RY001", null)).thenReturn(confirmedReg()); + AptCheckinOptions.Room full = availableRoom("A101"); + full.setAvailable(false); + when(mapper.selectRoomByNo(GYID, "A101")).thenReturn(full); + + AptException ex = assertThrows(AptException.class, () -> service.apply(command("EV-1"), DEVICE)); + assertEquals(AptErrorCode.ROOM_NOT_AVAILABLE, ex.getErrorCode()); + } + + // ---------------------------------------------------------------- N-44 + + @Test + @DisplayName("审核通过:同事务占床 + 建 gy_jcxx + 住房事件 + #9 入队 + 置 CHECKED_IN") + void approveChecksInWithinSameTransaction() + { + AptCheckinApplication reviewing = application("REVIEWING", 5L); + AptCheckinApplication approved = application("APPROVED", 6L); + AptCheckinApplication checkedIn = application("CHECKED_IN", 8L); + checkedIn.setSyncState("REPORTED"); + when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(reviewing, approved, checkedIn); + when(mapper.transition(anyString(), anyString(), anyList(), anyString(), any(), any(), any(), + any(), any(), any(), any(), anyInt(), any(), any(), any())).thenReturn(1); + when(mapper.selectRoomByNo(GYID, "A101")).thenReturn(availableRoom("A101")); + when(mapper.occupyBed(eq(GYID), anyString(), anyLong(), anyInt())).thenReturn(1); + when(mapper.countSipBinding(GYID, "ROOM-A101")).thenReturn(0); + when(mapper.insertJcxx(eq(GYID), any(), any(), anyString(), any())).thenReturn(1); + when(mapper.insertOutbox(any())).thenAnswer(invocation -> { + AptOutboxRow row = invocation.getArgument(0); + row.setId(88L); + return 1; + }); + + AptCheckinReviewResult result = service.review(APPLY_ID, approveCommand(5L), "duty01"); + + assertTrue(result.isCheckedIn()); + assertEquals("CHECKED_IN", result.getCheckinState()); + assertEquals("PENDING", result.getUploadState()); + assertEquals(88L, result.getReportOutboxId()); + verify(mapper).occupyBed(eq(GYID), eq("ROOM-A101"), anyLong(), eq(2)); + verify(mapper).recalcRoomStatus(GYID, "ROOM-A101", 2); + verify(mapper).insertJcxx(eq(GYID), any(), any(), eq("TABLET"), any()); + verify(mapper).insertLyEvent(eq(GYID), anyString(), eq("CHECKIN"), eq("MANUAL"), anyString(), anyString(), + eq("A101"), any()); + verify(mapper).updateReported(eq(GYID), eq(APPLY_ID), any()); + assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("TABLET"))); + } + + @Test + @DisplayName("审核通过:bizVersion 过期 → 40926,不占床不建单") + void approveRejectsStaleVersion() + { + when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(application("REVIEWING", 7L)); + when(mapper.selectRoomByNo(GYID, "A101")).thenReturn(availableRoom("A101")); + when(mapper.transition(anyString(), anyString(), anyList(), anyString(), any(), any(), any(), + any(), any(), any(), any(), anyInt(), any(), any(), any())).thenReturn(0); + + AptException ex = assertThrows(AptException.class, + () -> service.review(APPLY_ID, approveCommand(5L), "duty01")); + assertEquals(AptErrorCode.CHECKIN_STATE_CONFLICT, ex.getErrorCode()); + verify(mapper, never()).occupyBed(anyString(), anyString(), anyLong(), anyInt()); + verify(mapper, never()).insertJcxx(anyString(), any(), any(), anyString(), any()); + } + + @Test + @DisplayName("换房通过:仅迁移 ROOM_ADJUSTED 与 seq+1,不占床、不建叫乘行") + void approveWithRoomChangeOnlyAdjustsIntent() + { + when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(application("REVIEWING", 5L)); + when(mapper.selectRoomByNo(GYID, "B202")).thenReturn(availableRoom("B202")); + when(mapper.transition(anyString(), anyString(), anyList(), anyString(), any(), any(), any(), + any(), any(), any(), any(), anyInt(), any(), any(), any())).thenReturn(1); + + AptCheckinReviewCommand command = approveCommand(5L); + command.setVerdict("APPROVE_WITH_ROOM_CHANGE"); + command.setTargetFjh("B202"); + AptCheckinReviewResult result = service.review(APPLY_ID, command, "duty01"); + + assertFalse(result.isCheckedIn()); + assertEquals("ROOM_ADJUSTED", result.getCheckinState()); + assertEquals("B202", result.getFjhFinal()); + assertEquals(1, result.getRoomAdjustSeq()); + verify(mapper, never()).occupyBed(anyString(), anyString(), anyLong(), anyInt()); + verify(mapper, never()).insertJcxx(anyString(), any(), any(), anyString(), any()); + } + + @Test + @DisplayName("驳回:缺审核说明 → 40001;有说明则置 REJECTED 且不建叫乘行") + void rejectRequiresNote() + { + when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(application("REVIEWING", 5L)); + AptCheckinReviewCommand command = new AptCheckinReviewCommand(); + command.setVerdict("REJECT"); + command.setExpectedBizVersion(5L); + + AptException ex = assertThrows(AptException.class, + () -> service.review(APPLY_ID, command, "duty01")); + assertEquals(AptErrorCode.BAD_REQUEST, ex.getErrorCode()); + assertEquals("REVIEW_NOTE_REQUIRED", ex.getReason()); + + command.setReviewNote("要素不符"); + when(mapper.transition(anyString(), anyString(), anyList(), anyString(), any(), any(), any(), + any(), any(), any(), any(), anyInt(), any(), any(), any())).thenReturn(1); + AptCheckinReviewResult result = service.review(APPLY_ID, command, "duty01"); + assertEquals("REJECTED", result.getCheckinState()); + assertFalse(result.isCheckedIn()); + verify(mapper, never()).insertJcxx(anyString(), any(), any(), anyString(), any()); + } + + // ---------------------------------------------------------------- N-45 + + @Test + @DisplayName("换房:已入住 → 40926(走退宿换房)") + void roomAdjustAfterCheckedInIsRejected() + { + when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(application("CHECKED_IN", 9L)); + AptCheckinRoomAdjustCommand command = new AptCheckinRoomAdjustCommand(); + command.setTargetFjh("B202"); + command.setExpectedBizVersion(9L); + + AptException ex = assertThrows(AptException.class, + () -> service.roomAdjust(APPLY_ID, command, "duty01")); + assertEquals(AptErrorCode.CHECKIN_STATE_CONFLICT, ex.getErrorCode()); + assertEquals("ALREADY_CHECKED_IN", ex.getReason()); + } + + @Test + @DisplayName("换房:审核期迁移 ROOM_ADJUSTED,写 ROOM_SWAP 事件,不动房态计数") + void roomAdjustDuringReviewWritesSwapEvent() + { + when(mapper.selectApplication(GYID, APPLY_ID)).thenReturn(application("APPROVED", 6L)); + when(mapper.selectRoomByNo(GYID, "B202")).thenReturn(availableRoom("B202")); + when(mapper.transition(anyString(), anyString(), anyList(), anyString(), any(), any(), any(), + any(), any(), any(), any(), anyInt(), any(), any(), any())).thenReturn(1); + when(mapper.countSipBinding(GYID, "ROOM-B202")).thenReturn(1); + + AptCheckinRoomAdjustCommand command = new AptCheckinRoomAdjustCommand(); + command.setTargetFjh("B202"); + command.setReason("原房空调故障"); + command.setExpectedBizVersion(6L); + AptCheckinRoomAdjustResult result = service.roomAdjust(APPLY_ID, command, "duty01"); + + assertEquals("ROOM_ADJUSTED", result.getCheckinState()); + assertEquals("B202", result.getFjhFinal()); + assertEquals(1, result.getRoomAdjustSeq()); + assertTrue(result.isSipBound()); + verify(mapper).insertLyEvent(eq(GYID), anyString(), eq("ROOM_SWAP"), eq("MANUAL"), anyString(), anyString(), + eq("B202"), any()); + verify(mapper, never()).occupyBed(anyString(), anyString(), anyLong(), anyInt()); + verify(mapper, never()).releaseBed(anyString(), anyString(), anyLong()); + } + + // ---------------------------------------------------------------- N-47 + + @Test + @DisplayName("N-47:未就绪时 task=null 且 taskReady=false,与 40401 区分") + void callTaskNotReadyReturnsNullTask() + { + AptCheckinApplication app = application("CHECKED_IN", 8L); + app.setSyncState("REPORTED"); + when(mapper.selectApplicationByJcid(GYID, "JC-1")).thenReturn(app); + + AptCheckinCallTask task = service.callTask("JC-1"); + + assertFalse(task.isTaskReady()); + assertNull(task.getTask()); + assertEquals("REPORTED", task.getSyncState()); + } + + @Test + @DisplayName("N-47:TASK_READY 时返回 gy_jcxx 任务与复算后的 jcsj") + void callTaskReadyReturnsJcxxRow() + { + AptCheckinApplication app = application("CHECKED_IN", 8L); + app.setSyncState("TASK_READY"); + app.setJcsj(at(2026, 11, 12, 7, 30)); + AptCheckinCallTask.Task row = new AptCheckinCallTask.Task(); + row.setTaskId("JC-1"); + row.setState("PENDING"); + row.setJcsj(at(2026, 11, 12, 7, 30)); + row.setJcfs("SIP"); + when(mapper.selectApplicationByJcid(GYID, "JC-1")).thenReturn(app); + when(mapper.selectCallTask(GYID, "JC-1")).thenReturn(row); + + AptCheckinCallTask task = service.callTask("JC-1"); + + assertTrue(task.isTaskReady()); + assertNotNull(task.getTask()); + assertEquals(at(2026, 11, 12, 7, 30), task.getJcsj()); + assertEquals("SIP", task.getJcfs()); + } + + @Test + @DisplayName("N-39:version 一致时 notModified=true 且不下发选项") + void optionsHonoursVersion() + { + when(mapper.selectOptionsVersion(GYID)).thenReturn("20261112080000"); + + AptCheckinOptions fresh = service.options("20261112080000"); + assertTrue(fresh.isNotModified()); + assertTrue(fresh.getRooms().isEmpty()); + + List rooms = List.of(availableRoom("A101")); + when(mapper.selectRoomOptions(GYID)).thenReturn(rooms); + AptCheckinOptions changed = service.options("20261111120000"); + assertFalse(changed.isNotModified()); + assertEquals(1, changed.getRooms().size()); + assertEquals("20261112080000", changed.getVersion()); + } + + // ---------------------------------------------------------------- 工具 + + private AptCheckinApplyCommand command(String deviceEventId) + { + AptCheckinApplyCommand command = new AptCheckinApplyCommand(); + command.setDeviceEventId(deviceEventId); + command.setRyid("RY001"); + command.setFjh("A101"); + command.setJlid("JL-01"); + command.setJch("JCH-1"); + command.setOccurredAt(new Date()); + command.setVerifiedAt(new Date()); + command.setFeatureVersion(3L); + return command; + } + + private AptCheckinReviewCommand approveCommand(long expectedVersion) + { + AptCheckinReviewCommand command = new AptCheckinReviewCommand(); + command.setVerdict("APPROVE"); + command.setExpectedBizVersion(expectedVersion); + command.setReviewNote("要素齐全"); + command.setCommandId("CMD-1"); + return command; + } + + private AptCheckinApplication application(String state, long bizVersion) + { + AptCheckinApplication app = new AptCheckinApplication(); + app.setApplyId(APPLY_ID); + app.setJcid("JC-1"); + app.setTaskId(APPLY_ID); + app.setRyid("RY001"); + app.setName("张三"); + app.setWorkNo("W001"); + app.setFjh("A101"); + app.setFjhFinal("A101"); + app.setRoomId("ROOM-A101"); + app.setBedCount(2); + app.setLiveCount(0); + app.setRoomStatus("1"); + app.setCheckinState(state); + app.setSyncState("PENDING"); + app.setMatchState("PENDING"); + app.setBizVersion(bizVersion); + app.setOrigin("CHECKIN_ROUTE"); + app.setJlid("JL-01"); + app.setCc("G101"); + app.setKcsj(at(2026, 11, 12, 8, 0)); + app.setRoomAdjustSeq(0); + return app; + } + + private AptCheckinOptions.Room availableRoom(String roomNo) + { + AptCheckinOptions.Room room = new AptCheckinOptions.Room(); + room.setRoomId("ROOM-" + roomNo); + room.setRoomNo(roomNo); + room.setBedCount(2); + room.setLiveCount(0); + room.setRoomStatus("1"); + room.setRoomStatusLabel("空闲"); + room.setStateVersion(4L); + room.setAvailable(true); + return room; + } + + private Map person() + { + Map person = new LinkedHashMap<>(); + person.put("ryid", "RY001"); + person.put("name", "张三"); + person.put("workNo", "W001"); + person.put("personCategory", "TRANSIENT"); + person.put("featureVersion", 3L); + return person; + } + + private Map confirmedReg() + { + Map reg = new LinkedHashMap<>(); + reg.put("regId", "REG-1"); + reg.put("regState", "CONFIRMED"); + reg.put("featureVersion", 3L); + return reg; + } + + private Map route() + { + Map route = new LinkedHashMap<>(); + route.put("jlid", "JL-01"); + route.put("cc", "G101"); + route.put("kcsj", at(2026, 11, 12, 8, 0)); + return route; + } + + private Date at(int year, int month, int day, int hour, int minute) + { + return Date.from(LocalDateTime.of(year, month, day, hour, minute) + .atZone(ZoneId.systemDefault()).toInstant()); + } +} diff --git a/dev/scripts/apply-it3-sql.ps1 b/dev/scripts/apply-it3-sql.ps1 new file mode 100644 index 0000000..42c84fa --- /dev/null +++ b/dev/scripts/apply-it3-sql.ps1 @@ -0,0 +1,110 @@ +<# + Apply IT-3 SQL scripts to apt_rev01 (idempotent, replayable). + Credentials come from dev/.env first, then APT_DB_* environment variables. + Passwords are never printed. + SQL files are streamed to mysql stdin as raw UTF-8 bytes: PowerShell 5.1's + text pipeline can replace Chinese literals with '?'. + Usage: + powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it3-sql.ps1 + powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it3-sql.ps1 -DryRun + powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it3-sql.ps1 -EnvFile C:\secrets\apt.env +#> +param( + [string]$EnvFile = '', + [string]$MysqlPath = 'C:\Program Files\MySQL\MySQL Server 9.5\bin\mysql.exe', + [switch]$DryRun +) + +$ErrorActionPreference = 'Stop' +$devDir = Split-Path $PSScriptRoot -Parent +$sqlDir = Join-Path $devDir 'sql/apt_rev01' +$scripts = @('14_it3_columns.sql', '15_it3_seed.sql', '16_it3_permissions.sql') + +foreach ($name in $scripts) { + $file = Join-Path $sqlDir $name + if (-not [System.IO.File]::Exists($file)) { throw "Missing SQL file: $file" } +} +if ($EnvFile -eq '') { $EnvFile = Join-Path $devDir '.env' } + +$values = @{} +if ([System.IO.File]::Exists($EnvFile)) { + foreach ($line in [System.IO.File]::ReadAllLines($EnvFile)) { + $trimmed = $line.Trim() + if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue } + if ($trimmed -match '^([A-Za-z0-9_]+)\s*=\s*(.*)$') { $values[$Matches[1]] = $Matches[2] } + } + Write-Host "Credentials source: $EnvFile" +} else { + Write-Host "No credentials file at $EnvFile; using APT_DB_* environment variables." + foreach ($key in @('APT_DB_HOST','APT_DB_PORT','APT_DB_NAME','APT_DB_USER','APT_DB_PASSWORD')) { + $values[$key] = [Environment]::GetEnvironmentVariable($key) + } +} + +$dbHost = $values['APT_DB_HOST']; if ([string]::IsNullOrEmpty($dbHost)) { $dbHost = '127.0.0.1' } +$dbPort = $values['APT_DB_PORT']; if ([string]::IsNullOrEmpty($dbPort)) { $dbPort = '3306' } +$dbName = $values['APT_DB_NAME']; if ([string]::IsNullOrEmpty($dbName)) { $dbName = 'apt_rev01' } +$dbUser = $values['APT_DB_USER']; if ([string]::IsNullOrEmpty($dbUser)) { $dbUser = 'iotlocks' } + +if ($DryRun) { + Write-Host '--- DRY RUN: no database connection, no credentials required ---' + Write-Host ("Target database: {0}:{1}/{2} (user {3})" -f $dbHost, $dbPort, $dbName, $dbUser) + foreach ($name in $scripts) { + $file = Join-Path $sqlDir $name + $lineCount = ([System.IO.File]::ReadAllLines($file, [System.Text.Encoding]::UTF8)).Count + Write-Host (" would apply {0} ({1} lines)" -f $name, $lineCount) + } + Write-Host 'Validation SELECTs in 14/15/16 print column, device-seed, permission and menu checks.' + Write-Host 'Run 99_verify.sql afterwards for the IT-3 acceptance checks (sections 7~13).' + return +} + +if ([string]::IsNullOrEmpty($values['APT_DB_PASSWORD'])) { + $hint = @' +Missing APT_DB_PASSWORD. Provide one of: + 1) copy dev/.env.example to dev/.env, fill APT_DB_PASSWORD, then re-run this script + 2) set APT_DB_PASSWORD (plus optional APT_DB_HOST/PORT/NAME/USER) in the environment +Use -DryRun to preview SQL files without connecting to MySQL. +'@ + throw $hint +} +if (-not [System.IO.File]::Exists($MysqlPath)) { throw "Missing mysql client: $MysqlPath" } + +[Environment]::SetEnvironmentVariable('MYSQL_PWD', $values['APT_DB_PASSWORD'], 'Process') +try { + foreach ($name in $scripts) { + $file = Join-Path $sqlDir $name + Write-Host "--- applying $name ---" + + $arguments = "--host=$dbHost --port=$dbPort --user=$dbUser --database=$dbName" + + ' --default-character-set=utf8mb4 --batch' + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $MysqlPath + $psi.Arguments = $arguments + $psi.UseShellExecute = $false + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8 + $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8 + + $process = [System.Diagnostics.Process]::Start($psi) + $bytes = [System.IO.File]::ReadAllBytes($file) + $offset = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { + $offset = 3 + } + $process.StandardInput.BaseStream.Write($bytes, $offset, $bytes.Length - $offset) + $process.StandardInput.BaseStream.Flush() + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($stdout) { Write-Host $stdout } + if ($stderr) { Write-Host $stderr } + if ($process.ExitCode -ne 0) { throw "SQL failed: $name (exit $($process.ExitCode))" } + } + Write-Host 'IT-3 SQL scripts applied successfully.' +} finally { + [Environment]::SetEnvironmentVariable('MYSQL_PWD', $null, 'Process') +} diff --git a/dev/scripts/apply-it4-sql.ps1 b/dev/scripts/apply-it4-sql.ps1 new file mode 100644 index 0000000..6ae52fa --- /dev/null +++ b/dev/scripts/apply-it4-sql.ps1 @@ -0,0 +1,110 @@ +<# + Apply IT-4 SQL scripts to apt_rev01 (idempotent, replayable). + Credentials come from dev/.env first, then APT_DB_* environment variables. + Passwords are never printed. + SQL files are streamed to mysql stdin as raw UTF-8 bytes: PowerShell 5.1's + text pipeline can replace Chinese literals with '?'. + Usage: + powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it4-sql.ps1 + powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it4-sql.ps1 -DryRun + powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it4-sql.ps1 -EnvFile C:\secrets\apt.env +#> +param( + [string]$EnvFile = '', + [string]$MysqlPath = 'C:\Program Files\MySQL\MySQL Server 9.5\bin\mysql.exe', + [switch]$DryRun +) + +$ErrorActionPreference = 'Stop' +$devDir = Split-Path $PSScriptRoot -Parent +$sqlDir = Join-Path $devDir 'sql/apt_rev01' +$scripts = @('17_it4_columns.sql', '18_it4_permissions.sql') + +foreach ($name in $scripts) { + $file = Join-Path $sqlDir $name + if (-not [System.IO.File]::Exists($file)) { throw "Missing SQL file: $file" } +} +if ($EnvFile -eq '') { $EnvFile = Join-Path $devDir '.env' } + +$values = @{} +if ([System.IO.File]::Exists($EnvFile)) { + foreach ($line in [System.IO.File]::ReadAllLines($EnvFile)) { + $trimmed = $line.Trim() + if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue } + if ($trimmed -match '^([A-Za-z0-9_]+)\s*=\s*(.*)$') { $values[$Matches[1]] = $Matches[2] } + } + Write-Host "Credentials source: $EnvFile" +} else { + Write-Host "No credentials file at $EnvFile; using APT_DB_* environment variables." + foreach ($key in @('APT_DB_HOST','APT_DB_PORT','APT_DB_NAME','APT_DB_USER','APT_DB_PASSWORD')) { + $values[$key] = [Environment]::GetEnvironmentVariable($key) + } +} + +$dbHost = $values['APT_DB_HOST']; if ([string]::IsNullOrEmpty($dbHost)) { $dbHost = '127.0.0.1' } +$dbPort = $values['APT_DB_PORT']; if ([string]::IsNullOrEmpty($dbPort)) { $dbPort = '3306' } +$dbName = $values['APT_DB_NAME']; if ([string]::IsNullOrEmpty($dbName)) { $dbName = 'apt_rev01' } +$dbUser = $values['APT_DB_USER']; if ([string]::IsNullOrEmpty($dbUser)) { $dbUser = 'iotlocks' } + +if ($DryRun) { + Write-Host '--- DRY RUN: no database connection, no credentials required ---' + Write-Host ("Target database: {0}:{1}/{2} (user {3})" -f $dbHost, $dbPort, $dbName, $dbUser) + foreach ($name in $scripts) { + $file = Join-Path $sqlDir $name + $lineCount = ([System.IO.File]::ReadAllLines($file, [System.Text.Encoding]::UTF8)).Count + Write-Host (" would apply {0} ({1} lines)" -f $name, $lineCount) + } + Write-Host 'Validation SELECTs in 17/18 print column, state-dictionary, permission and menu checks.' + Write-Host 'Run 99_verify.sql afterwards for the IT-4 acceptance checks (sections 14~18).' + return +} + +if ([string]::IsNullOrEmpty($values['APT_DB_PASSWORD'])) { + $hint = @' +Missing APT_DB_PASSWORD. Provide one of: + 1) copy dev/.env.example to dev/.env, fill APT_DB_PASSWORD, then re-run this script + 2) set APT_DB_PASSWORD (plus optional APT_DB_HOST/PORT/NAME/USER) in the environment +Use -DryRun to preview SQL files without connecting to MySQL. +'@ + throw $hint +} +if (-not [System.IO.File]::Exists($MysqlPath)) { throw "Missing mysql client: $MysqlPath" } + +[Environment]::SetEnvironmentVariable('MYSQL_PWD', $values['APT_DB_PASSWORD'], 'Process') +try { + foreach ($name in $scripts) { + $file = Join-Path $sqlDir $name + Write-Host "--- applying $name ---" + + $arguments = "--host=$dbHost --port=$dbPort --user=$dbUser --database=$dbName" + + ' --default-character-set=utf8mb4 --batch' + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $MysqlPath + $psi.Arguments = $arguments + $psi.UseShellExecute = $false + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8 + $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8 + + $process = [System.Diagnostics.Process]::Start($psi) + $bytes = [System.IO.File]::ReadAllBytes($file) + $offset = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { + $offset = 3 + } + $process.StandardInput.BaseStream.Write($bytes, $offset, $bytes.Length - $offset) + $process.StandardInput.BaseStream.Flush() + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($stdout) { Write-Host $stdout } + if ($stderr) { Write-Host $stderr } + if ($process.ExitCode -ne 0) { throw "SQL failed: $name (exit $($process.ExitCode))" } + } + Write-Host 'IT-4 SQL scripts applied successfully.' +} finally { + [Environment]::SetEnvironmentVariable('MYSQL_PWD', $null, 'Process') +} diff --git a/dev/scripts/apply-it5-sql.ps1 b/dev/scripts/apply-it5-sql.ps1 new file mode 100644 index 0000000..9234296 --- /dev/null +++ b/dev/scripts/apply-it5-sql.ps1 @@ -0,0 +1,110 @@ +<# + Apply IT-5 SQL scripts to apt_rev01 (idempotent, replayable). + Credentials come from dev/.env first, then APT_DB_* environment variables. + Passwords are never printed. + SQL files are streamed to mysql stdin as raw UTF-8 bytes: PowerShell 5.1's + text pipeline can replace Chinese literals with '?'. + Usage: + powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it5-sql.ps1 + powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it5-sql.ps1 -DryRun + powershell -ExecutionPolicy Bypass -File dev/scripts/apply-it5-sql.ps1 -EnvFile C:\secrets\apt.env +#> +param( + [string]$EnvFile = '', + [string]$MysqlPath = 'C:\Program Files\MySQL\MySQL Server 9.5\bin\mysql.exe', + [switch]$DryRun +) + +$ErrorActionPreference = 'Stop' +$devDir = Split-Path $PSScriptRoot -Parent +$sqlDir = Join-Path $devDir 'sql/apt_rev01' +$scripts = @('19_it5_columns.sql', '20_it5_permissions.sql') + +foreach ($name in $scripts) { + $file = Join-Path $sqlDir $name + if (-not [System.IO.File]::Exists($file)) { throw "Missing SQL file: $file" } +} +if ($EnvFile -eq '') { $EnvFile = Join-Path $devDir '.env' } + +$values = @{} +if ([System.IO.File]::Exists($EnvFile)) { + foreach ($line in [System.IO.File]::ReadAllLines($EnvFile)) { + $trimmed = $line.Trim() + if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue } + if ($trimmed -match '^([A-Za-z0-9_]+)\s*=\s*(.*)$') { $values[$Matches[1]] = $Matches[2] } + } + Write-Host "Credentials source: $EnvFile" +} else { + Write-Host "No credentials file at $EnvFile; using APT_DB_* environment variables." + foreach ($key in @('APT_DB_HOST','APT_DB_PORT','APT_DB_NAME','APT_DB_USER','APT_DB_PASSWORD')) { + $values[$key] = [Environment]::GetEnvironmentVariable($key) + } +} + +$dbHost = $values['APT_DB_HOST']; if ([string]::IsNullOrEmpty($dbHost)) { $dbHost = '127.0.0.1' } +$dbPort = $values['APT_DB_PORT']; if ([string]::IsNullOrEmpty($dbPort)) { $dbPort = '3306' } +$dbName = $values['APT_DB_NAME']; if ([string]::IsNullOrEmpty($dbName)) { $dbName = 'apt_rev01' } +$dbUser = $values['APT_DB_USER']; if ([string]::IsNullOrEmpty($dbUser)) { $dbUser = 'iotlocks' } + +if ($DryRun) { + Write-Host '--- DRY RUN: no database connection, no credentials required ---' + Write-Host ("Target database: {0}:{1}/{2} (user {3})" -f $dbHost, $dbPort, $dbName, $dbUser) + foreach ($name in $scripts) { + $file = Join-Path $sqlDir $name + $lineCount = ([System.IO.File]::ReadAllLines($file, [System.Text.Encoding]::UTF8)).Count + Write-Host (" would apply {0} ({1} lines)" -f $name, $lineCount) + } + Write-Host 'Validation SELECTs in 19/20 print column, index, permission and menu checks.' + Write-Host 'Run 99_verify.sql afterwards for the IT-5 acceptance checks (sections 20~26).' + return +} + +if ([string]::IsNullOrEmpty($values['APT_DB_PASSWORD'])) { + $hint = @' +Missing APT_DB_PASSWORD. Provide one of: + 1) copy dev/.env.example to dev/.env, fill APT_DB_PASSWORD, then re-run this script + 2) set APT_DB_PASSWORD (plus optional APT_DB_HOST/PORT/NAME/USER) in the environment +Use -DryRun to preview SQL files without connecting to MySQL. +'@ + throw $hint +} +if (-not [System.IO.File]::Exists($MysqlPath)) { throw "Missing mysql client: $MysqlPath" } + +[Environment]::SetEnvironmentVariable('MYSQL_PWD', $values['APT_DB_PASSWORD'], 'Process') +try { + foreach ($name in $scripts) { + $file = Join-Path $sqlDir $name + Write-Host "--- applying $name ---" + + $arguments = "--host=$dbHost --port=$dbPort --user=$dbUser --database=$dbName" + + ' --default-character-set=utf8mb4 --batch' + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $MysqlPath + $psi.Arguments = $arguments + $psi.UseShellExecute = $false + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8 + $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8 + + $process = [System.Diagnostics.Process]::Start($psi) + $bytes = [System.IO.File]::ReadAllBytes($file) + $offset = 0 + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { + $offset = 3 + } + $process.StandardInput.BaseStream.Write($bytes, $offset, $bytes.Length - $offset) + $process.StandardInput.BaseStream.Flush() + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($stdout) { Write-Host $stdout } + if ($stderr) { Write-Host $stderr } + if ($process.ExitCode -ne 0) { throw "SQL failed: $name (exit $($process.ExitCode))" } + } + Write-Host 'IT-5 SQL scripts applied successfully.' +} finally { + [Environment]::SetEnvironmentVariable('MYSQL_PWD', $null, 'Process') +} diff --git a/dev/scripts/verify-it2-m1m2.ps1 b/dev/scripts/verify-it2-m1m2.ps1 new file mode 100644 index 0000000..5e97ab4 --- /dev/null +++ b/dev/scripts/verify-it2-m1m2.ps1 @@ -0,0 +1,373 @@ +<# + IT-2 / DV-2 end-to-end verification: M1 route, M2 room, master version markers. + Maps to docs/apartment/07 table 7-3 M1/M2 and table 7-7 steps 4/5. + + Prerequisites: + 1) dev/.env with APT_DB_PASSWORD; MySQL apt_rev01 and Redis database=1 reachable. + 2) dev/scripts/apply-it2-sql.ps1 applied (DDL + Mock seed + permissions). + 3) mvn -pl ruoyi-admin -am package built ruoyi-admin/target/ruoyi-admin.jar. + + Usage: + powershell -ExecutionPolicy Bypass -File dev/scripts/verify-it2-m1m2.ps1 + powershell -ExecutionPolicy Bypass -File dev/scripts/verify-it2-m1m2.ps1 -SkipStartup + + Notes: captcha stays enabled and sys_config is unchanged; the captcha answer is + read (read-only) from Redis database=1. A short APT_TOKEN_SECRET is replaced + by an ephemeral Base64 HS512 key for this run only. The script starts its own + jar on -ServerPort (default 8099), leaving any 8080 instance untouched. +#> +param( + [switch]$SkipStartup, + [string]$BaseUrl = 'http://127.0.0.1:8099', + [int]$ServerPort = 8099, + [string]$JavaPath = '', + [string]$AdminPassword = 'admin123', + [string]$GuestPassword = 'admin123', + [int]$ReadyAttempts = 60 +) + +$ErrorActionPreference = 'Stop' +$devDir = Split-Path $PSScriptRoot -Parent +$backendDir = Join-Path $devDir 'RuoYi-Vue-master' +$jar = Join-Path $backendDir 'ruoyi-admin/target/ruoyi-admin.jar' +$results = @() + +# ---------- 1. Credentials ---------- +$envFile = Join-Path $PSScriptRoot '.env' +if (-not [System.IO.File]::Exists($envFile)) { $envFile = Join-Path $devDir '.env' } +if (-not [System.IO.File]::Exists($envFile)) { throw "Missing credentials file: $envFile" } +Write-Host "Using credentials file: $envFile" +foreach ($line in [System.IO.File]::ReadAllLines($envFile)) { + $trimmed = $line.Trim() + if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue } + if ($trimmed -match '^([A-Za-z0-9_]+)\s*=\s*(.*)$') { + [Environment]::SetEnvironmentVariable($Matches[1], $Matches[2], 'Process') + } +} +foreach ($name in @('APT_DB_PASSWORD', 'APT_TOKEN_SECRET')) { + if ([string]::IsNullOrEmpty([Environment]::GetEnvironmentVariable($name, 'Process'))) { + throw "Missing required environment variable $name" + } +} +$secretValue = [Environment]::GetEnvironmentVariable('APT_TOKEN_SECRET', 'Process') +$secretValid = $false +try { + $decoded = [System.Convert]::FromBase64String($secretValue) + if ($decoded.Length -ge 64) { $secretValid = $true } +} +catch { $secretValid = $false } +if (-not $secretValid) { + $rng = New-Object System.Security.Cryptography.RNGCryptoServiceProvider + $keyBytes = New-Object byte[] 64 + $rng.GetBytes($keyBytes) + $rng.Dispose() + [Environment]::SetEnvironmentVariable('APT_TOKEN_SECRET', [System.Convert]::ToBase64String($keyBytes), 'Process') + Write-Host 'APT_TOKEN_SECRET was not a valid Base64 HS512 key; generated an ephemeral one for this run.' +} + +# ---------- 2. Redis RESP read-only helper (captcha answer) ---------- +function Send-RedisCommand { + param([System.Net.Sockets.NetworkStream]$Stream, [string[]]$Parts) + $sb = [System.Text.StringBuilder]::new() + [void]$sb.Append('*').Append($Parts.Count).Append("`r`n") + foreach ($p in $Parts) { + $n = [System.Text.Encoding]::UTF8.GetByteCount($p) + [void]$sb.Append('$').Append($n).Append("`r`n").Append($p).Append("`r`n") + } + $bytes = [System.Text.Encoding]::UTF8.GetBytes($sb.ToString()) + $Stream.Write($bytes, 0, $bytes.Length) + $Stream.Flush() +} +function Read-RedisLine { + param([System.Net.Sockets.NetworkStream]$Stream) + $sb = [System.Text.StringBuilder]::new() + while ($true) { + $b = $Stream.ReadByte() + if ($b -lt 0) { throw 'Redis connection closed unexpectedly' } + if ($b -eq 13) { [void]$Stream.ReadByte(); break } + [void]$sb.Append([char]$b) + } + return $sb.ToString() +} +function Read-RedisBulk { + param([System.Net.Sockets.NetworkStream]$Stream) + $line = Read-RedisLine -Stream $Stream + if (-not $line.StartsWith('$')) { throw "Redis reply is not a bulk string: $line" } + $len = [int]$line.Substring(1) + if ($len -lt 0) { return $null } + $buf = [byte[]]::new($len) + $read = 0 + while ($read -lt $len) { + $n = $Stream.Read($buf, $read, $len - $read) + if ($n -le 0) { throw 'Incomplete Redis reply' } + $read += $n + } + [void]$Stream.ReadByte() + [void]$Stream.ReadByte() + return [System.Text.Encoding]::UTF8.GetString($buf) +} +function Get-CaptchaCode { + param([string]$Uuid) + $tcp = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 6379) + try { + $stream = $tcp.GetStream() + $stream.ReadTimeout = 3000 + Send-RedisCommand -Stream $stream -Parts @('SELECT', '1') + [void](Read-RedisLine -Stream $stream) + Send-RedisCommand -Stream $stream -Parts @('GET', ('captcha_codes:' + $Uuid)) + $value = Read-RedisBulk -Stream $stream + if ($null -eq $value) { throw 'Captcha answer missing in Redis database=1' } + return $value.Trim('"') + } + finally { $tcp.Dispose() } +} + +# ---------- 3. HTTP helpers ---------- +function Invoke-Api { + param([string]$Path, [string]$Token = '', [string]$Method = 'Get', [hashtable]$Body = $null) + $headers = @{} + if ($Token -ne '') { $headers['Authorization'] = 'Bearer ' + $Token } + $params = @{ + Uri = ($BaseUrl + $Path); Method = $Method; Headers = $headers + UseBasicParsing = $true; TimeoutSec = 20 + } + if ($null -ne $Body) { + $params['ContentType'] = 'application/json;charset=UTF-8' + $params['Body'] = [System.Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Compress)) + } + try { + $res = Invoke-WebRequest @params + return @{ status = [int]$res.StatusCode; raw = $res.Content; body = ($res.Content | ConvertFrom-Json) } + } + catch { + $response = $_.Exception.Response + if ($null -eq $response) { throw } + $content = $_.ErrorDetails.Message + if ([string]::IsNullOrEmpty($content)) { + $reader = [System.IO.StreamReader]::new($response.GetResponseStream()) + $content = $reader.ReadToEnd() + } + if ([string]::IsNullOrEmpty($content)) { $content = '{}' } + return @{ status = [int]$response.StatusCode; raw = $content; body = ($content | ConvertFrom-Json) } + } +} +function Get-Token { + param([string]$User, [string]$Password) + $captcha = Invoke-RestMethod -Uri ($BaseUrl + '/captchaImage') -Method Get -TimeoutSec 10 + $body = @{ username = $User; password = $Password } + if ($captcha.captchaEnabled) { + $body.code = Get-CaptchaCode -Uuid $captcha.uuid + $body.uuid = $captcha.uuid + } + $resp = Invoke-RestMethod -Uri ($BaseUrl + '/login') -Method Post ` + -ContentType 'application/json;charset=UTF-8' -Body ($body | ConvertTo-Json -Compress) -TimeoutSec 15 + if ($resp.code -ne 200) { throw "Login failed [$User]: $($resp.msg)" } + return $resp.token +} +function Add-Result { + param([string]$Case, [string]$Expected, [string]$Actual, [bool]$Pass) + $script:results += [pscustomobject]@{ + Case = $Case; Expected = $Expected; Actual = $Actual; Pass = $Pass + } +} + +# ---------- 4. Startup and readiness ---------- +$proc = $null +if (-not $SkipStartup) { + if (-not [System.IO.File]::Exists($jar)) { throw "Missing $jar - run mvn package first" } + if ($JavaPath -eq '') { + $javaHome = [Environment]::GetEnvironmentVariable('JAVA_HOME', 'Process') + if ([string]::IsNullOrEmpty($javaHome)) { $JavaPath = 'java' } + else { $JavaPath = Join-Path $javaHome 'bin/java.exe' } + } + $outLog = Join-Path $env:TEMP 'apt-it2-stdout.log' + $errLog = Join-Path $env:TEMP 'apt-it2-stderr.log' + $proc = Start-Process -FilePath $JavaPath -ArgumentList @('-jar', ('"' + $jar + '"'), ('--server.port=' + $ServerPort)) ` + -PassThru -WindowStyle Hidden -RedirectStandardOutput $outLog -RedirectStandardError $errLog + Write-Host "Started application PID=$($proc.Id) on port $ServerPort, stderr log: $errLog" +} +$ready = $false +for ($i = 0; $i -lt $ReadyAttempts; $i++) { + try { + $probe = Invoke-WebRequest -Uri ($BaseUrl + '/apt/ops/connectivity') -UseBasicParsing -TimeoutSec 3 + if ([int]$probe.StatusCode -eq 200) { $ready = $true; break } + } + catch { Start-Sleep -Seconds 2 } +} +if (-not $ready) { throw 'Application not ready - check stderr log' } + +# ---------- 5. M0 regression + M1 + M2 + version checks ---------- +try { + $admin = Get-Token -User 'admin' -Password $AdminPassword + $guest = Get-Token -User 'apt_guest' -Password $GuestPassword + + $r = Invoke-Api -Path '/apt/route/list' + Add-Result 'M0-1 no token' 'HTTP 401 / 40101' "HTTP $($r.status) / $($r.body.code)" ` + ($r.status -eq 401 -and $r.body.code -eq 40101) + + $r = Invoke-Api -Path '/apt/route/list' -Token $guest + Add-Result 'M0-2 no permission' 'HTTP 403 / 40301' "HTTP $($r.status) / $($r.body.code)" ` + ($r.status -eq 403 -and $r.body.code -eq 40301) + + $r = Invoke-Api -Path '/apt/room/list?gyid=APT-B' -Token $admin + Add-Result 'M0-3 cross gyid' 'HTTP 403 / 40302' "HTTP $($r.status) / $($r.body.code)" ` + ($r.status -eq 403 -and $r.body.code -eq 40302 -and $r.raw -notmatch '"rows"') + + $r = Invoke-Api -Path '/apt/room/list?pageSize=1000' -Token $admin + Add-Result 'M0-4 page cap' 'HTTP 200 / pageSize=100' "HTTP $($r.status) / pageSize=$($r.body.pageSize)" ` + ($r.status -eq 200 -and $r.body.pageSize -eq 100) + + # apt.* / springdoc group-configs now live in ruoyi-apt/apt-defaults.yml via spring.config.import + $doc = Invoke-Api -Path '/v3/api-docs/apt' + $aptPaths = @() + if ($doc.status -eq 200 -and $null -ne $doc.body.paths) { + $aptPaths = @($doc.body.paths.PSObject.Properties.Name | Where-Object { $_ -like '/apt/*' }) + } + Add-Result 'M0-5 apt config + springdoc group' 'HTTP 200 with /apt/* paths' ` + "HTTP $($doc.status) / aptPaths=$($aptPaths.Count)" ` + ($doc.status -eq 200 -and $aptPaths.Count -gt 0) + + $routeList = Invoke-Api -Path '/apt/route/list' -Token $admin + $routeRows = @($routeList.body.rows) + $routeRow = $routeRows | Where-Object { $_.jlid -eq 'JL-001' } | Select-Object -First 1 + if ($null -eq $routeRow) { throw 'Mock route JL-001 missing - run apply-it2-sql.ps1' } + $routeId = $routeRow.id + Add-Result 'M1-1 list + mapping' 'HTTP 200, >=3 rows, jlid/jch mapped' ` + "HTTP $($routeList.status) / rows=$($routeRows.Count) / jch=$($routeRow.jch)" ` + ($routeList.status -eq 200 -and $routeRows.Count -ge 3 -and $routeRow.jch) + + $r = Invoke-Api -Path "/apt/route/$routeId" -Token $admin + Add-Result 'M1-2 detail' 'HTTP 200 / jlid=JL-001' "HTTP $($r.status) / $($r.body.data.jlid)" ` + ($r.status -eq 200 -and $r.body.data.jlid -eq 'JL-001') + + $now = Get-Date + $dupBody = @{ + jlid = 'JL-001'; cc = 'G101' + kcsj = $now.AddHours(8).ToString('yyyy-MM-dd HH:mm:ss') + jcsj = $now.AddHours(6).ToString('yyyy-MM-dd HH:mm:ss') + lysj = $now.AddHours(5).ToString('yyyy-MM-dd HH:mm:ss') + jch = 'e2e'; qd = 'e2e'; zd = 'e2e' + } + $r = Invoke-Api -Path '/apt/route' -Token $admin -Method Post -Body $dupBody + Add-Result 'M1-3 duplicate jlid' 'HTTP 409 / 40901' "HTTP $($r.status) / $($r.body.code)" ` + ($r.status -eq 409 -and $r.body.code -eq 40901) + + $r = Invoke-Api -Path "/apt/route/$routeId/status" -Token $admin -Method Put -Body @{ enabled = $false } + Add-Result 'M1-4 missing reason' 'HTTP 400 / 40001' "HTTP $($r.status) / $($r.body.code)" ` + ($r.status -eq 400 -and $r.body.code -eq 40001) + + $r = Invoke-Api -Path "/apt/route/$routeId/recalc-check" -Token $admin -Method Post + Add-Result 'M1-5 recalc-check' 'HTTP 200 / computedJcsj present' ` + "HTTP $($r.status) / computedJcsj=$($r.body.data.computedJcsj)" ` + ($r.status -eq 200 -and $r.body.data.computedJcsj) + + $off = Invoke-Api -Path "/apt/route/$routeId/status" -Token $admin -Method Put ` + -Body @{ enabled = $false; reason = 'IT-2 e2e disable' } + $on = Invoke-Api -Path "/apt/route/$routeId/status" -Token $admin -Method Put ` + -Body @{ enabled = $true; reason = 'IT-2 e2e restore' } + Add-Result 'M1-6 status with reason' 'HTTP 200 / 200' "HTTP $($off.status) / $($on.status)" ` + ($off.status -eq 200 -and $on.status -eq 200) + + $roomList = Invoke-Api -Path '/apt/room/list' -Token $admin + $roomRows = @($roomList.body.rows) + $roomRow = $roomRows | Where-Object { $_.roomNo -eq '201' } | Select-Object -First 1 + if ($null -eq $roomRow) { throw 'Mock room 201 missing - run apply-it2-sql.ps1' } + $roomId = $roomRow.id + Add-Result 'M2-1 room list' 'HTTP 200, >=5 rows' "HTTP $($roomList.status) / rows=$($roomRows.Count)" ` + ($roomList.status -eq 200 -and $roomRows.Count -ge 5) + + $r = Invoke-Api -Path '/apt/room/list?roomStatus=4' -Token $admin + $filtered = @($r.body.rows) + $allFour = $true + foreach ($row in $filtered) { if ($row.roomStatus -ne '4') { $allFour = $false } } + Add-Result 'M2-2 status filter' 'only roomStatus=4' ` + "HTTP $($r.status) / rows=$($filtered.Count) / allFour=$allFour" ` + ($r.status -eq 200 -and $filtered.Count -ge 1 -and $allFour) + + $r = Invoke-Api -Path '/apt/room/status-summary' -Token $admin + Add-Result 'M2-3 summary' 'HTTP 200 / total>=5' "HTTP $($r.status) / total=$($r.body.data.total)" ` + ($r.status -eq 200 -and $r.body.data.total -ge 5) + + $r = Invoke-Api -Path "/apt/room/$roomId" -Token $admin + $hasLists = $r.body.data.PSObject.Properties.Name -contains 'bindings' + Add-Result 'M2-4 detail' 'HTTP 200 / roomNo=201 / bindings present' ` + "HTTP $($r.status) / roomNo=$($r.body.data.roomNo) / lists=$hasLists" ` + ($r.status -eq 200 -and $r.body.data.roomNo -eq '201' -and $hasLists) + + $r = Invoke-Api -Path "/apt/room/$roomId" -Token $admin -Method Put ` + -Body @{ roomType = 'DOUBLE'; roomFloor = '3' } + Add-Result 'M2-5 snapshot readonly' 'HTTP 409 / 40904' "HTTP $($r.status) / $($r.body.code)" ` + ($r.status -eq 409 -and $r.body.code -eq 40904) + + $r = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put -Body @{ action = 'DISABLE' } + Add-Result 'M2-6 missing reason' 'HTTP 400 / 40001' "HTTP $($r.status) / $($r.body.code)" ` + ($r.status -eq 400 -and $r.body.code -eq 40001) + + # Make the disable/restore sequence replayable even after an interrupted run. + $before = Invoke-Api -Path "/apt/room/$roomId" -Token $admin + if ($before.body.data.roomStatus -eq '4') { + $reset = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put ` + -Body @{ action = 'RESTORE'; reason = 'IT-2 e2e precondition reset'; + commandId = ('IT2R' + [guid]::NewGuid().ToString('N')) } + if ($reset.status -ne 200) { throw "Precondition reset failed: $($reset.raw)" } + $before = Invoke-Api -Path "/apt/room/$roomId" -Token $admin + } + $beforeVersion = [long]$before.body.data.stateVersion + $cmdId = 'IT2D' + [guid]::NewGuid().ToString('N') + $disableBody = @{ action = 'DISABLE'; reason = 'IT-2 e2e disable'; commandId = $cmdId } + + $disable = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put -Body $disableBody + $v1 = $disable.body.data.stateVersion + Add-Result 'M2-7 disable' 'status=4 / version+1' ` + "HTTP $($disable.status) / status=$($disable.body.data.roomStatus) / v=$beforeVersion->$v1" ` + ($disable.status -eq 200 -and $disable.body.data.roomStatus -eq '4' -and [long]$v1 -eq ($beforeVersion + 1)) + + $dup = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put -Body $disableBody + Add-Result 'M2-8 commandId idempotent' 'duplicated=true / version unchanged' ` + "HTTP $($dup.status) / duplicated=$($dup.body.data.duplicated) / v=$($dup.body.data.stateVersion)" ` + ($dup.status -eq 200 -and $dup.body.data.duplicated -eq $true -and [long]$dup.body.data.stateVersion -eq [long]$v1) + + $after = Invoke-Api -Path "/apt/room/$roomId" -Token $admin + $livePersonsPresent = $after.body.data.PSObject.Properties.Name -contains 'livePersons' + Add-Result 'M2-9 disabled still queryable' 'status=4 / livePersons present' ` + "HTTP $($after.status) / status=$($after.body.data.roomStatus) / livePersons=$livePersonsPresent" ` + ($after.status -eq 200 -and $after.body.data.roomStatus -eq '4' -and $livePersonsPresent) + + $restore = Invoke-Api -Path "/apt/room/$roomId/status" -Token $admin -Method Put ` + -Body @{ action = 'RESTORE'; reason = 'IT-2 e2e restore'; + commandId = ('IT2R' + [guid]::NewGuid().ToString('N')) } + Add-Result 'M2-10 restore recalc' 'status<>4 / version+1 again' ` + "HTTP $($restore.status) / status=$($restore.body.data.roomStatus) / v=$($restore.body.data.stateVersion)" ` + ($restore.status -eq 200 -and $restore.body.data.roomStatus -ne '4' ` + -and [long]$restore.body.data.stateVersion -eq ($beforeVersion + 2)) + + $r = Invoke-Api -Path '/apt/room' -Token $admin -Method Post -Body @{} + Add-Result 'M2-11 add disabled' 'HTTP 409 / 40940' "HTTP $($r.status) / $($r.body.code)" ` + ($r.status -eq 409 -and $r.body.code -eq 40940) + + $versions = Invoke-Api -Path '/apt/master/versions' -Token $admin + $vdata = @($versions.body.data) + $allLocal = $true + foreach ($v in $vdata) { + if ($v.source -ne 'LOCAL' -or $v.sourceVersion -ne 0 -or $v.localMock -ne $true) { $allLocal = $false } + } + Add-Result 'M2-12 version markers' '6 domains LOCAL/0/mock=true' ` + "HTTP $($versions.status) / count=$($vdata.Count) / allLocal=$allLocal / notice=$($versions.body.mockNotice)" ` + ($versions.status -eq 200 -and $vdata.Count -eq 6 -and $allLocal -and $versions.body.mockNotice) + + $r = Invoke-Api -Path '/apt/master/ROUTE/resync' -Token $admin -Method Post -Body @{} + Add-Result 'M2-13 resync disabled' 'HTTP 409 / 40940' "HTTP $($r.status) / $($r.body.code)" ` + ($r.status -eq 409 -and $r.body.code -eq 40940) +} +finally { + if ($null -ne $proc -and -not $proc.HasExited) { + Stop-Process -Id $proc.Id -Force + Write-Host "Stopped application PID=$($proc.Id)" + } +} + +$results | Format-Table -AutoSize +$failed = @($results | Where-Object { -not $_.Pass }) +Write-Host "IT-2 checks passed: $($results.Count - $failed.Count)/$($results.Count)" +if ($failed.Count -gt 0) { exit 1 } +exit 0