diff --git a/db/20260909_add_hard_after_sale_order_index.sql b/db/20260909_add_hard_after_sale_order_index.sql new file mode 100644 index 000000000..6a09734ef --- /dev/null +++ b/db/20260909_add_hard_after_sale_order_index.sql @@ -0,0 +1,18 @@ +-- 订单分页(POST hard/order/page-join)性能优化配套索引(OceanBase MySQL 模式) +-- 背景:HardOrderMapper.xml 已参考 biz-api-cloud-zax 将"每单最新售后"的全表 GROUP BY +-- 派生表改写为相关标量子查询: +-- select max(id) from hard_after_sale where order_id = ? and del_flag = '0' +-- 该写法需要 (order_id, del_flag, id) 前导索引支撑;此前 slowlog_optimization_20260826 +-- 推荐的 (tenant_id, order_id, id) 因租户列在前导位,无法服务新子查询。 +-- 执行前请确认同名索引不存在;建议低峰期执行并观察建索引耗时。本文件只包含索引 DDL。 + +CREATE INDEX idx_hard_after_sale_order_del_id + ON pjcloudx_hardware.hard_after_sale (order_id, del_flag, id); + +-- 说明: +-- 1) 若 db/slowlog_optimization_20260826/01_add_indexes.sql 尚未执行,请一并执行其中 +-- idx_hard_order_tenant_del_ct_uid (tenant_id, del_flag, create_time, user_id): +-- 订单列表 ORDER BY create_time desc, user_id desc, id desc 依赖该索引做反向范围扫描 +-- (InnoDB/OceanBase 二级索引隐含主键 id 后缀,id desc 排序可直接复用,无需额外索引)。 +-- 2) 20260826 中的 idx_hard_after_sale_tenant_order_id 服务于旧派生表写法;本次新索引 +-- 上线后如确认无其他统计 SQL 依赖,可自行评估删除,本文件不强制处理。 diff --git a/db/20260909_add_sys_user_face_quality.sql b/db/20260909_add_sys_user_face_quality.sql new file mode 100644 index 000000000..e96186239 --- /dev/null +++ b/db/20260909_add_sys_user_face_quality.sql @@ -0,0 +1,8 @@ +-- 终端虹软静态图片质量结果列(迁移自 hq-platform/biz-api-cloud-zax db/20260901_add_sys_user_face_quality.sql)。 +-- 保存终端虹软静态图片质量结果;旧终端和历史人脸保持 NULL,不影响现有人脸业务。 +ALTER TABLE sys_user_face + ADD COLUMN quality_score decimal(5, 4) DEFAULT NULL COMMENT '虹软静态图片质量分,范围0-1' AFTER face_img, + ADD COLUMN quality_status varchar(16) DEFAULT NULL COMMENT '终端检测状态:detected/failed' AFTER quality_score, + ADD COLUMN quality_checked_at datetime DEFAULT NULL COMMENT '终端完成质量检测的时间' AFTER quality_status, + ADD COLUMN quality_error_code int DEFAULT NULL COMMENT '虹软或终端质量检测错误码' AFTER quality_checked_at, + ADD COLUMN quality_device_id bigint DEFAULT NULL COMMENT '最后一次上报质量的终端ID' AFTER quality_error_code; diff --git a/local.env b/local.env index a5024b3c0..e8d08751e 100644 --- a/local.env +++ b/local.env @@ -1,4 +1,4 @@ -MYSQL_HOST=123.157.250.162 +MYSQL_HOST=127.0.0.1 MYSQL_PORT=33905 MYSQL_USER=root@sys MYSQL_PWD=bsd.1799 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceDeleteDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceDeleteDto.java new file mode 100644 index 000000000..c3fb365ca --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceDeleteDto.java @@ -0,0 +1,21 @@ +package com.bsd.pjcloud.hard.api.dto.hard; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +/** + * Android 终端批量删除服务端人脸请求。 + */ +@Data +public class HardTerminalFaceDeleteDto { + + /** + * 待删除的虹软样本编号,设备部门由请求头解析,客户端不得指定。 + */ + @NotNull(message = "人脸样本编号不能为空") + @Size(min = 1, max = 500, message = "单次最多删除500条人脸") + private List sampleIds; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceQualityReportDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceQualityReportDto.java new file mode 100644 index 000000000..aa89a38c2 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceQualityReportDto.java @@ -0,0 +1,23 @@ +package com.bsd.pjcloud.hard.api.dto.hard; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +/** + * Android 终端批量上报在库人脸质量请求。 + */ +@Data +public class HardTerminalFaceQualityReportDto { + + /** + * 本批人脸质量结果;设备和部门身份仅从 DEVICE-ID 解析,单批最多 500 条。 + */ + @Valid + @NotEmpty(message = "人脸质量结果不能为空") + @Size(max = 500, message = "单次最多上报500条人脸质量结果") + private List items; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceQualityReportItemDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceQualityReportItemDto.java new file mode 100644 index 000000000..5497cf827 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceQualityReportItemDto.java @@ -0,0 +1,49 @@ +package com.bsd.pjcloud.hard.api.dto.hard; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Android 终端提交的一条人脸静态图片质量结果。 + */ +@Data +public class HardTerminalFaceQualityReportItemDto { + + /** + * 虹软 32 位搜索样本编号;终端不得用用户编号替代。 + */ + @NotBlank(message = "人脸样本编号不能为空") + private String sampleId; + + /** + * 检测状态,跨端协议值仅允许 detected 或 failed。 + */ + @NotBlank(message = "人脸质量检测状态不能为空") + private String status; + + /** + * 虹软静态图片质量分,detected 时必填且范围为 0 到 1。 + */ + private BigDecimal score; + + /** + * 终端检测所依据的人脸图片版本,来源于服务端人脸快照的 sourceUpdatedAt。 + */ + @NotNull(message = "人脸图片版本不能为空") + private LocalDateTime sourceUpdatedAt; + + /** + * 终端完成本次检测的本地时间,用于服务端拒绝旧结果覆盖新结果。 + */ + @NotNull(message = "人脸质量检测时间不能为空") + private LocalDateTime checkedAt; + + /** + * 虹软 SDK 或终端插件错误码,检测成功时为空。 + */ + private Integer errorCode; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java index 30ed3eb64..ba0af61a5 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java @@ -1,6 +1,6 @@ package com.bsd.pjcloud.hard.api.dto.order; -import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeDeptMchTreeInterface; import com.bsd.pjcloud.common.core.constant.enums.DateRangeTypeEnum; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import io.swagger.v3.oas.annotations.media.Schema; @@ -11,7 +11,7 @@ import java.time.LocalDateTime; import java.util.List; @Data -public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeInterface { +public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeDeptMchTreeInterface { private DateRangeTypeEnum dateRangeType = DateRangeTypeEnum.YESTERDAY; @@ -25,6 +25,8 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn private List merchantDeptIds; + private Boolean isIncludeMerchantDept = false; + private List orderIds; private List hardIds; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalFaceQualityLeaseVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalFaceQualityLeaseVo.java new file mode 100644 index 000000000..b0bdd20b6 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalFaceQualityLeaseVo.java @@ -0,0 +1,26 @@ +package com.bsd.pjcloud.hard.api.vo.manager; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 终端申请商户部门人脸质量上报租约的结果。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HardTerminalFaceQualityLeaseVo { + + /** + * 当前设备是否取得租约;未取得时必须跳过本轮质量批量上报。 + */ + private boolean granted; + + /** + * 当前租约剩余秒数;Redis 未返回有效 TTL 时为 {@code 0}。 + */ + private long remainingSeconds; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java index 23d29eac2..d51639ad0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java @@ -1,6 +1,8 @@ package com.bsd.pjcloud.hard.controller.hard; import cn.hutool.core.util.ObjectUtil; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.idempotent.annotation.Idempotent; import com.bsd.pjcloud.common.security.service.PjcloudUser; @@ -18,6 +20,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardChartLatitudeVo; import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto; import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo; import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalFaceQualityLeaseVo; import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo; @@ -57,6 +60,11 @@ public class HardApiController { */ private final HardTerminalStatusService hardTerminalStatusService; + /** + * 终端在库人脸维护服务。 + */ + private final HardTerminalFaceService hardTerminalFaceService; + @PostMapping("/bind") @Operation(summary = "绑定服务端设备") public R bind(@RequestBody HardBindDto dto) { @@ -77,6 +85,49 @@ public class HardApiController { return R.ok(); } + /** + * 删除当前设备商户部门及子部门范围内的人脸。 + * + * @param hardId 当前绑定设备 ID + * @param dto 最多 500 条样本编号 + * @return 实际删除及已不存在的样本编号 + */ + @PostMapping("/face/delete") + @Operation(summary = "终端批量删除人脸") + public R deleteTerminalFaces( + @RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardTerminalFaceDeleteDto dto) { + return R.ok(hardTerminalFaceService.delete(hardId, dto)); + } + + /** + * 竞争当前商户及绑定部门的人脸质量上报租约。 + * + * @param hardId 当前绑定设备 ID + * @return 是否允许当前设备执行本轮质量上报 + */ + @PostMapping("/face/quality/lease") + @Operation(summary = "终端申请人脸质量上报租约") + public R acquireTerminalFaceQualityLease( + @RequestHeader("DEVICE-ID") Long hardId) { + return R.ok(hardTerminalFaceService.acquireQualityLease(hardId)); + } + + /** + * 保存当前设备检测得到的人脸静态图片质量结果。 + * + * @param hardId 当前绑定设备 ID + * @param dto 最多 500 条质量结果,客户端不得指定部门或来源设备 + * @return 已更新、按版本或状态优先级忽略和已不存在的样本编号 + */ + @PostMapping("/face/quality/report") + @Operation(summary = "终端批量上报人脸质量") + public R reportTerminalFaceQuality( + @RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardTerminalFaceQualityReportDto dto) { + return R.ok(hardTerminalFaceService.reportQuality(hardId, dto)); + } + @GetMapping("/get") @Operation(summary = "获取硬件配置信息") public R get(@RequestParam Long id) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalFaceService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalFaceService.java new file mode 100644 index 000000000..ce71c5f9a --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalFaceService.java @@ -0,0 +1,39 @@ +package com.bsd.pjcloud.hard.service; + +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo; +import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceDeleteDto; +import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceQualityReportDto; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalFaceQualityLeaseVo; + +/** + * 终端在库人脸维护服务。 + */ +public interface HardTerminalFaceService { + + /** + * 以 DEVICE-ID 解析真实商户部门后请求用户服务删除人脸。 + * + * @param hardId 当前设备主键 + * @param dto 样本编号集合 + * @return 实际删除及已不存在的样本编号 + */ + SysUserFaceTerminalDeleteVo delete(Long hardId, HardTerminalFaceDeleteDto dto); + + /** + * 以 DEVICE-ID 竞争当前商户部门的人脸质量上报租约。 + * + * @param hardId 当前设备主键 + * @return 是否取得租约及当前租约剩余时间 + */ + HardTerminalFaceQualityLeaseVo acquireQualityLease(Long hardId); + + /** + * 以 DEVICE-ID 解析真实设备和商户部门后请求用户服务保存人脸质量结果。 + * + * @param hardId 当前设备主键 + * @param dto 最多 500 条质量结果 + * @return 已更新、按版本或状态优先级忽略和已不存在的样本编号 + */ + SysUserFaceTerminalQualityReportVo reportQuality(Long hardId, HardTerminalFaceQualityReportDto dto); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalFaceServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalFaceServiceImpl.java new file mode 100644 index 000000000..b584aa194 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalFaceServiceImpl.java @@ -0,0 +1,202 @@ +package com.bsd.pjcloud.hard.service.impl.manager; + +import cn.hutool.core.util.StrUtil; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceQualityReportItemDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalQualityReportDto; +import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceDeleteDto; +import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceQualityReportDto; +import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceQualityReportItemDto; +import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; +import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalFaceQualityLeaseVo; +import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; +import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardTerminalFaceService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +/** + * 终端在库人脸维护服务实现。 + */ +@Service +@RequiredArgsConstructor +public class HardTerminalFaceServiceImpl implements HardTerminalFaceService { + + /** + * 商户部门人脸质量上报租约 Redis 键前缀,租户隔离由 Redis 基础设施统一处理。 + */ + private static final String FACE_QUALITY_LEASE_KEY_PREFIX = "hard:face-quality:reporter:"; + + /** + * 单台终端持有人脸质量上报资格的时长,覆盖常规批量上报且允许异常设备自动让位。 + */ + private static final Duration FACE_QUALITY_LEASE_TTL = Duration.ofMinutes(10); + + /** + * 设备 Mapper,用于在当前租户内验证 DEVICE-ID。 + */ + private final HardManagerMapper hardManagerMapper; + + /** + * 设备服务,用于解析与终端人脸同步一致的商户部门。 + */ + private final HardManagerService hardManagerService; + + /** + * UPMS 人脸远程服务,负责最终部门树范围校验和事务删除。 + */ + private final RemoteUserFaceService remoteUserFaceService; + + /** + * Redis 字符串客户端,用于在同一商户部门的多台终端之间选出本轮唯一上报者。 + */ + private final StringRedisTemplate stringRedisTemplate; + + /** + * 根据当前设备真实绑定关系删除部门范围内人脸。 + * + * @param hardId 当前设备主键 + * @param dto 样本编号集合 + * @return 实际删除及已不存在的样本编号 + */ + @Override + public SysUserFaceTerminalDeleteVo delete(Long hardId, HardTerminalFaceDeleteDto dto) { + HardManagerVo trustedDevice = resolveTrustedDevice(hardId); + + SysUserFaceTerminalDeleteDto request = new SysUserFaceTerminalDeleteDto(); + request.setMerchantDeptId(trustedDevice.getMerchantDeptId()); + request.setSampleIds(dto.getSampleIds()); + R response = remoteUserFaceService.terminalDelete(request); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null || StrUtil.isBlank(response.getMsg()) + ? "用户服务删除人脸失败" + : response.getMsg(); + throw new RuntimeException(message); + } + return response.getData(); + } + + /** + * 竞争当前商户部门的人脸质量上报租约;同一设备重复请求时仍视为租约持有者。 + * + * @param hardId 当前设备主键 + * @return 是否取得租约及租约剩余秒数 + */ + @Override + public HardTerminalFaceQualityLeaseVo acquireQualityLease(Long hardId) { + HardManagerVo trustedDevice = resolveTrustedDevice(hardId); + String leaseKey = qualityLeaseKey(trustedDevice); + boolean granted = tryAcquireQualityLease(leaseKey, hardId); + Long remainingSeconds = stringRedisTemplate.getExpire(leaseKey, TimeUnit.SECONDS); + return HardTerminalFaceQualityLeaseVo.builder() + .granted(granted) + .remainingSeconds(remainingSeconds == null || remainingSeconds < 0 ? 0 : remainingSeconds) + .build(); + } + + /** + * 根据当前设备真实绑定关系上报部门范围内的人脸质量结果。 + * + * @param hardId 当前设备主键 + * @param dto 最多 500 条质量结果 + * @return 已更新、按版本或状态优先级忽略和已不存在的样本编号 + */ + @Override + public SysUserFaceTerminalQualityReportVo reportQuality(Long hardId, HardTerminalFaceQualityReportDto dto) { + if (dto == null || dto.getItems() == null || dto.getItems().isEmpty()) { + throw new RuntimeException("人脸质量结果不能为空"); + } + HardManagerVo trustedDevice = resolveTrustedDevice(hardId); + if (!tryAcquireQualityLease(qualityLeaseKey(trustedDevice), hardId)) { + throw new RuntimeException("当前设备未取得人脸质量上报租约"); + } + SysUserFaceTerminalQualityReportDto request = new SysUserFaceTerminalQualityReportDto(); + request.setMerchantDeptId(trustedDevice.getMerchantDeptId()); + request.setQualityDeviceId(hardId); + request.setItems(dto.getItems().stream().map(this::toUserFaceQualityItem).toList()); + R response = remoteUserFaceService.terminalReportQuality(request); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null || StrUtil.isBlank(response.getMsg()) + ? "用户服务保存人脸质量失败" + : response.getMsg(); + throw new RuntimeException(message); + } + return response.getData(); + } + + /** + * 将终端公开请求转换为 UPMS 内部质量条目,不携带任何客户端部门信息。 + * + * @param item 终端质量结果 + * @return UPMS 内部质量结果 + */ + private SysUserFaceQualityReportItemDto toUserFaceQualityItem(HardTerminalFaceQualityReportItemDto item) { + SysUserFaceQualityReportItemDto converted = new SysUserFaceQualityReportItemDto(); + converted.setSampleId(item.getSampleId()); + converted.setStatus(item.getStatus()); + converted.setScore(item.getScore()); + converted.setSourceUpdatedAt(item.getSourceUpdatedAt()); + converted.setCheckedAt(item.getCheckedAt()); + converted.setErrorCode(item.getErrorCode()); + return converted; + } + + /** + * 校验 DEVICE-ID 对应当前租户真实设备,并解析设备绑定商户与部门。 + * + * @param hardId 请求头中的设备主键 + * @return 包含服务端可信商户及部门的设备详情 + */ + private HardManagerVo resolveTrustedDevice(Long hardId) { + if (hardId == null) { + throw new RuntimeException("DEVICE-ID不能为空"); + } + HardManagerEntity hard = hardManagerMapper.selectById(hardId); + if (hard == null) { + throw new RuntimeException("当前租户下设备不存在"); + } + if (hard.getMerchantId() == null) { + throw new RuntimeException("设备尚未绑定商户"); + } + HardManagerVo hardDetail = hardManagerService.getByHardId(hardId); + if (hardDetail == null || hardDetail.getMerchantDeptId() == null) { + throw new RuntimeException("设备绑定商户缺少部门"); + } + hardDetail.setMerchantId(hard.getMerchantId()); + return hardDetail; + } + + /** + * 构造商户与绑定部门共同隔离的质量上报租约键。 + * + * @param trustedDevice 已通过 DEVICE-ID 校验的设备详情 + * @return 不包含人员、人脸或图片信息的 Redis 业务键 + */ + private String qualityLeaseKey(HardManagerVo trustedDevice) { + return FACE_QUALITY_LEASE_KEY_PREFIX + + trustedDevice.getMerchantId() + ":" + trustedDevice.getMerchantDeptId(); + } + + /** + * 原子创建租约;租约已存在时仅允许同一 DEVICE-ID 继续完成后续批次。 + * + * @param leaseKey 当前商户部门租约键 + * @param hardId 当前设备主键 + * @return 当前设备是否为租约持有者 + */ + private boolean tryAcquireQualityLease(String leaseKey, Long hardId) { + String owner = hardId.toString(); + Boolean acquired = stringRedisTemplate.opsForValue() + .setIfAbsent(leaseKey, owner, FACE_QUALITY_LEASE_TTL); + return Boolean.TRUE.equals(acquired) || owner.equals(stringRedisTemplate.opsForValue().get(leaseKey)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index 103158b6f..153dcfaec 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -164,7 +164,14 @@ - and a.merchant_dept_id in + + + and a.merchant_dept_id NOT IN + + + and a.merchant_dept_id IN + + #{v} @@ -275,13 +282,13 @@ on w_m.id = a.wallet_id and w_m.tenant_id = a.tenant_id and w_m.del_flag = '0' left join pjcloudx_wallet.wallet_type w_t on w_t.id = w_m.wallet_type_id and w_t.tenant_id = a.tenant_id and w_t.del_flag = '0' - left join (select tenant_id, order_id, max(id) as id - from hard_after_sale - where del_flag = '0' - group by tenant_id, order_id) sx - on sx.order_id = a.id and sx.tenant_id = a.tenant_id - left join hard_after_sale s - on s.id = sx.id and s.tenant_id = a.tenant_id and s.del_flag = '0' + + left join hard_after_sale s on s.id = ( + select max(latest_sale.id) + from hard_after_sale latest_sale + where latest_sale.order_id = a.id + and latest_sale.del_flag = '0' + ) @@ -306,7 +313,8 @@ - order by a.create_time desc, a.user_id desc + + order by a.create_time desc, a.user_id desc, a.id desc @@ -393,7 +401,13 @@ select count(0) from hard_order a left join hard_manager m on m.id = a.hard_id - left join hard_after_sale s on s.order_id = a.id + + left join hard_after_sale s on s.id = ( + select max(latest_sale.id) + from hard_after_sale latest_sale + where latest_sale.order_id = a.id + and latest_sale.del_flag = '0' + ) diff --git a/pjcloud-integrated/pjcloud-integrated-biz/pom.xml b/pjcloud-integrated/pjcloud-integrated-biz/pom.xml index 35a41449b..f10b639c8 100644 --- a/pjcloud-integrated/pjcloud-integrated-biz/pom.xml +++ b/pjcloud-integrated/pjcloud-integrated-biz/pom.xml @@ -112,6 +112,12 @@ com.bsd pjcloud-common-log + + + com.jcraft + jsch + 0.1.55 + com.baomidou diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/appmodule/AppVersionController.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/appmodule/AppVersionController.java index 341d86c82..6358300ab 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/appmodule/AppVersionController.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/appmodule/AppVersionController.java @@ -8,7 +8,9 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.integrated.dto.appmodule.AppVersionDto; import com.bsd.pjcloud.integrated.entity.app.AppVersionEntity; +import com.bsd.pjcloud.integrated.service.AppVersionPackageUploadService; import com.bsd.pjcloud.integrated.service.AppVersionService; +import com.bsd.pjcloud.integrated.vo.appmodule.AppPackageUploadVo; import com.bsd.pjcloud.integrated.vo.appmodule.AppVersionVo; import org.springframework.security.access.prepost.PreAuthorize; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -18,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; /** * app版本控制表 @@ -30,12 +33,13 @@ import org.springframework.web.bind.annotation.*; public class AppVersionController { private final AppVersionService appVersionService; + private final AppVersionPackageUploadService appVersionPackageUploadService; /* ************************************* start 设备端 start ************************************* */ @Operation(summary = "分页查询", description = "分页查询") @GetMapping("/ad/page") public R> hardPage(@ParameterObject Page page, @ParameterObject AppVersionDto dto) { - return R.ok(appVersionService.pageList(page, dto)); + return R.ok(appVersionService.terminalPageList(page, dto)); } /* ************************************* end 设备端 end ************************************* */ @@ -45,6 +49,16 @@ public class AppVersionController { public R lastVersion(@ParameterObject AppVersionDto dto) { return R.ok(appVersionService.lastVersion(dto)); } + + /** + * 部门邀请等场景:取安卓最新已发布 APK 整包地址(排除 wgt 热更) + */ + @Operation(summary = "最新安卓APK整包", description = "平台含安卓且 downloadUrl 以 .apk 结尾的最新已发布版本") + @GetMapping("/latest-android-apk") + @PreAuthorize("@pms.hasPermission('integrated_appVersion_view') || @pms.hasPermission('sys_dept_edit') || @pms.hasPermission('sys_dept_view')") + public R latestAndroidApk() { + return R.ok(appVersionService.latestAndroidApk()); + } /* ************************************* end app end ************************************* */ /** @@ -89,6 +103,29 @@ public class AppVersionController { return R.ok(appVersionService.updateById(appVersion)); } + /** + * 上传安装包到指定服务器目录(SFTP),返回对外下载地址 + */ + @Operation(summary = "上传安装包到远程服务器", description = "SFTP 上传 apk/wgt 等到宝塔等服务器指定目录") + @SysLog("上传App安装包到远程服务器") + @PostMapping("/upload-package") + @PreAuthorize("@pms.hasPermission('integrated_appVersion_add')") + public R uploadPackage(@RequestPart("file") MultipartFile file, + @RequestParam("host") String host, + @RequestParam(value = "port", required = false) Integer port, + @RequestParam("username") String username, + @RequestParam("password") String password, + @RequestParam("remoteDir") String remoteDir, + @RequestParam("publicBaseUrl") String publicBaseUrl) { + try { + return R.ok(appVersionPackageUploadService.uploadBySftp(file, host, port, username, password, remoteDir, + publicBaseUrl)); + } + catch (IllegalArgumentException ex) { + return R.failed(ex.getMessage()); + } + } + /** * 通过id删除app版本控制表 */ diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/mapper/appmodule/AppVersionMapper.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/mapper/appmodule/AppVersionMapper.java index d1fc5e67a..f9245a67a 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/mapper/appmodule/AppVersionMapper.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/mapper/appmodule/AppVersionMapper.java @@ -13,4 +13,13 @@ public interface AppVersionMapper extends PjcloudBaseMapper { Page pagList(Page page, @Param("dto") AppVersionDto dto); + /** + * 精确查询指定终端和平台的已发布版本,排序由 SQL 固定为版本码倒序。 + * + * @param page 分页参数 + * @param dto 终端 App 编码与平台参数 + * @return 终端可见的已发布版本分页 + */ + Page terminalPageList(Page page, @Param("dto") AppVersionDto dto); + } \ No newline at end of file diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionPackageUploadService.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionPackageUploadService.java new file mode 100644 index 000000000..a023be6ab --- /dev/null +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionPackageUploadService.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.integrated.service; + +import com.bsd.pjcloud.integrated.vo.appmodule.AppPackageUploadVo; +import org.springframework.web.multipart.MultipartFile; + +/** + * App 安装包上传到远程服务器(宝塔等) + */ +public interface AppVersionPackageUploadService { + + /** + * 通过 SFTP 上传安装包,并返回对外可访问的下载地址。 + * + * @param file 安装包文件 + * @param host 服务器 IP/域名 + * @param port SFTP 端口,默认 22 + * @param username 登录用户名 + * @param password 登录密码 + * @param remoteDir 远程目录,如 /www/wwwroot/h5.xxx.com/download + * @param publicBaseUrl 对外访问前缀,如 https://h5.xxx.com/download + */ + AppPackageUploadVo uploadBySftp(MultipartFile file, String host, Integer port, String username, String password, + String remoteDir, String publicBaseUrl); +} diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionService.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionService.java index d2d248c15..bd61e309a 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionService.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionService.java @@ -17,4 +17,20 @@ public interface AppVersionService extends IService { * 获取最新版本 */ AppVersionEntity lastVersion(AppVersionDto dto); + + /** + * 按终端 App 编码和平台精确查询已发布版本。 + * + * @param page 分页参数 + * @param dto 终端 App 编码与平台参数 + * @return 版本码倒序的终端版本分页 + */ + Page terminalPageList(Page page, AppVersionDto dto); + + /** + * 取安卓平台最新已发布的整包安装地址(downloadUrl 以 .apk 结尾)。 + * + * @return 安卓最新已发布整包版本;没有记录时为 {@code null} + */ + AppVersionEntity latestAndroidApk(); } \ No newline at end of file diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/AppVersionPackageUploadServiceImpl.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/AppVersionPackageUploadServiceImpl.java new file mode 100644 index 000000000..061c351e3 --- /dev/null +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/AppVersionPackageUploadServiceImpl.java @@ -0,0 +1,128 @@ +package com.bsd.pjcloud.integrated.service.impl; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.ssh.Sftp; +import com.bsd.pjcloud.integrated.service.AppVersionPackageUploadService; +import com.bsd.pjcloud.integrated.vo.appmodule.AppPackageUploadVo; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.Locale; +import java.util.Set; + +/** + * 将 APK/WGT 等安装包经 SFTP 上传到宝塔等目标机指定目录。 + */ +@Slf4j +@Service +public class AppVersionPackageUploadServiceImpl implements AppVersionPackageUploadService { + + private static final String TAG = "AppPackageUpload"; + + private static final Set ALLOWED_EXT = Set.of("apk", "wgt", "ipa", "zip"); + + @Override + public AppPackageUploadVo uploadBySftp(MultipartFile file, String host, Integer port, String username, + String password, String remoteDir, String publicBaseUrl) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("请选择要上传的安装包文件"); + } + if (StrUtil.hasBlank(host, username, password, remoteDir, publicBaseUrl)) { + throw new IllegalArgumentException("服务器地址、账号、密码、远程目录、对外访问前缀不能为空"); + } + String safeDir = normalizeRemoteDir(remoteDir.trim()); + String originalName = file.getOriginalFilename(); + if (StrUtil.isBlank(originalName)) { + throw new IllegalArgumentException("无法识别文件名"); + } + String fileName = FileUtil.getName(originalName); + String ext = FileUtil.extName(fileName); + if (StrUtil.isBlank(ext) || !ALLOWED_EXT.contains(ext.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("仅支持上传 apk/wgt/ipa/zip 文件"); + } + int sftpPort = (port == null || port <= 0) ? 22 : port; + String remoteFilePath = safeDir + "/" + fileName; + String publicUrl = joinUrl(publicBaseUrl.trim(), fileName); + + Session session = null; + Sftp sftp = null; + try { + JSch jsch = new JSch(); + session = jsch.getSession(username.trim(), host.trim(), sftpPort); + session.setPassword(password); + session.setConfig("StrictHostKeyChecking", "no"); + session.connect(15000); + sftp = new Sftp(session); + try { + if (!sftp.exist(safeDir)) { + sftp.mkDirs(safeDir); + } + } + catch (Exception ex) { + // 部分环境 exist 不稳定,直接尝试建目录 + try { + sftp.mkDirs(safeDir); + } + catch (Exception mkdirEx) { + log.warn("|{}|创建目录失败(可能已存在):|{}|", TAG, mkdirEx.getMessage()); + } + } + try (InputStream in = file.getInputStream()) { + boolean ok = sftp.upload(safeDir, fileName, in); + if (!ok) { + throw new IllegalArgumentException("SFTP 上传返回失败"); + } + } + log.info("|{}|上传成功 host:|{}| remote:|{}| size:|{}|", TAG, host, remoteFilePath, file.getSize()); + return AppPackageUploadVo.builder() + .url(publicUrl) + .fileName(fileName) + .remotePath(remoteFilePath) + .build(); + } + catch (IllegalArgumentException ex) { + throw ex; + } + catch (Exception ex) { + log.error("|{}|SFTP上传失败 host:|{}| dir:|{}| error:|{}|", TAG, host, safeDir, ex.getMessage(), ex); + throw new IllegalArgumentException("上传到服务器失败:" + StrUtil.blankToDefault(ex.getMessage(), "请检查账号、目录与网络")); + } + finally { + if (sftp != null) { + try { + sftp.close(); + } + catch (Exception ignore) { + // ignore + } + } + if (session != null && session.isConnected()) { + session.disconnect(); + } + } + } + + private static String normalizeRemoteDir(String remoteDir) { + String dir = remoteDir.replace('\\', '/'); + if (dir.contains("..")) { + throw new IllegalArgumentException("远程目录非法"); + } + if (!dir.startsWith("/")) { + dir = "/" + dir; + } + while (dir.endsWith("/") && dir.length() > 1) { + dir = dir.substring(0, dir.length() - 1); + } + return dir; + } + + private static String joinUrl(String base, String fileName) { + String b = StrUtil.removeSuffix(base, "/"); + return b + "/" + fileName; + } +} diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/appmodule/AppVersionServiceImpl.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/appmodule/AppVersionServiceImpl.java index c6c5c2426..c45b7c2b6 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/appmodule/AppVersionServiceImpl.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/appmodule/AppVersionServiceImpl.java @@ -4,6 +4,7 @@ import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.integrated.constant.appmodule.AppPlatformEnum; import com.bsd.pjcloud.integrated.constant.appmodule.AppVersionStatusEnum; import com.bsd.pjcloud.integrated.dto.appmodule.AppVersionDto; import com.bsd.pjcloud.integrated.entity.app.AppVersionEntity; @@ -39,4 +40,30 @@ public class AppVersionServiceImpl extends ServiceImpl terminalPageList(Page page, AppVersionDto dto) { + return baseMapper.terminalPageList(page, dto); + } + + /** + * 取安卓平台最新已发布的整包安装地址(downloadUrl 以 .apk 结尾)。 + *

排除 .wgt 热更补丁,供部门邀请下载二维码使用。

+ */ + @Override + public AppVersionEntity latestAndroidApk() { + return baseMapper.selectOne(new LambdaQueryWrapper() + .eq(AppVersionEntity::getStatus, AppVersionStatusEnum.PUBLISHED.getCode()) + .apply("FIND_IN_SET({0}, platform) > 0", AppPlatformEnum.ANDROID.getCode()) + .apply("LOWER(download_url) LIKE '%.apk'") + .orderByDesc(AppVersionEntity::getVersionCode) + .last("LIMIT 1")); + } } \ No newline at end of file diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/vo/appmodule/AppPackageUploadVo.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/vo/appmodule/AppPackageUploadVo.java new file mode 100644 index 000000000..38194112b --- /dev/null +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/vo/appmodule/AppPackageUploadVo.java @@ -0,0 +1,23 @@ +package com.bsd.pjcloud.integrated.vo.appmodule; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * App 安装包远程上传结果 + */ +@Data +@Builder +@Schema(description = "App安装包上传结果") +public class AppPackageUploadVo { + + @Schema(description = "对外下载地址") + private String url; + + @Schema(description = "服务器上的文件名") + private String fileName; + + @Schema(description = "服务器完整路径") + private String remotePath; +} diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/resources/mapper/appmodule/AppVersionMapper.xml b/pjcloud-integrated/pjcloud-integrated-biz/src/main/resources/mapper/appmodule/AppVersionMapper.xml index 1444d1f11..3c701c58a 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/resources/mapper/appmodule/AppVersionMapper.xml +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/resources/mapper/appmodule/AppVersionMapper.xml @@ -42,4 +42,20 @@
+ + \ No newline at end of file diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceSyncSnapshotDto.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceSyncSnapshotDto.java new file mode 100644 index 000000000..9e7a891e0 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceSyncSnapshotDto.java @@ -0,0 +1,53 @@ +package com.bsd.pjcloud.admin.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 终端部门人脸固定上界游标快照查询参数。 + * + *

首次请求不传 {@code snapshotMaxId},后续分页必须原样携带首次响应中的上界, + * 避免同步期间新增记录改变当前对账集合。

+ */ +@Data +@Schema(description = "终端部门人脸同步快照查询") +public class SysUserFaceSyncSnapshotDto { + + /** + * 终端绑定的商户部门主键;查询范围包含该部门及所有未删除子部门。 + */ + @NotNull(message = "商户部门不能为空") + @Schema(description = "商户部门主键", requiredMode = Schema.RequiredMode.REQUIRED) + private Long merchantDeptId; + + /** + * 可选用户主键;MQ 增量通知回查时传入,部门完整快照时为空。 + */ + @Schema(description = "可选用户主键") + private Long userId; + + /** + * 排他起始游标;首次请求为空或 0,后续使用上一页的 nextAfterId。 + */ + @Min(value = 0, message = "人脸同步游标不能小于0") + @Schema(description = "排他起始游标") + private Long afterId; + + /** + * 单页记录数;空值使用服务端默认值,允许范围为 1 至 500。 + */ + @Min(value = 1, message = "人脸同步分页大小不能小于1") + @Max(value = 500, message = "人脸同步分页大小不能超过500") + @Schema(description = "单页记录数") + private Integer limit; + + /** + * 首次响应固定的人脸数据库主键上界;首次请求为空,后续分页必须原样携带。 + */ + @Min(value = 0, message = "人脸同步快照上界不能小于0") + @Schema(description = "固定快照主键上界") + private Long snapshotMaxId; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysUserFaceEntity.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysUserFaceEntity.java index b7a6efc3b..86a7f3b71 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysUserFaceEntity.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysUserFaceEntity.java @@ -12,6 +12,7 @@ import lombok.NoArgsConstructor; import com.bsd.pjcloud.common.core.util.TenantTable; +import java.math.BigDecimal; import java.time.LocalDateTime; /** @@ -73,6 +74,36 @@ public class SysUserFaceEntity extends Model { @Schema(description = "人脸图片") private String faceImg; + /** + * 虹软静态图片质量分,范围为 0 到 1;未检测或检测失败时为空。 + */ + @Schema(description = "虹软静态图片质量分,范围0-1") + private BigDecimal qualityScore; + + /** + * 终端质量检测状态,持久化协议值为 detected 或 failed;历史未检测记录为空。 + */ + @Schema(description = "终端质量检测状态:detected/failed") + private String qualityStatus; + + /** + * 终端完成本次静态图片质量检测的本地时间,用于阻止旧结果覆盖新结果。 + */ + @Schema(description = "终端完成质量检测的时间") + private LocalDateTime qualityCheckedAt; + + /** + * 虹软 SDK 或终端插件质量检测错误码;检测成功时为空。 + */ + @Schema(description = "虹软或终端质量检测错误码") + private Integer qualityErrorCode; + + /** + * 最后一次成功写入质量结果的终端设备主键,来源于 Hardware 对 DEVICE-ID 的可信解析。 + */ + @Schema(description = "最后一次上报质量的终端ID") + private Long qualityDeviceId; + /** * 创建人 */ diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncRecordVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncRecordVo.java new file mode 100644 index 000000000..8bb574972 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncRecordVo.java @@ -0,0 +1,65 @@ +package com.bsd.pjcloud.admin.api.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 终端人脸同步快照中的单条权威记录。 + * + *

记录只包含审核通过且位于目标部门树内的人脸;终端使用 {@code sampleId} 作为虹软搜索标识, + * 使用 {@code sourceUpdatedAt} 与图片地址共同判断是否需要重新提取特征。

+ */ +@Data +@Schema(description = "终端人脸同步快照记录") +public class SysUserFaceSyncRecordVo { + + /** + * 人脸记录数据库主键,用于游标排序和快照范围约束。 + */ + @Schema(description = "人脸记录数据库主键") + private Long id; + + /** + * 虹软人脸库搜索标识,来源于人脸记录 {@code auto_code},必须是 32 位正整数且在当前租户内唯一。 + */ + @Schema(description = "虹软人脸库搜索标识") + private String sampleId; + + /** + * 人脸所属用户主键,MQ 增量通知按该字段回查同一用户的最多三张人脸。 + */ + @Schema(description = "用户主键") + private Long userId; + + /** + * 用户姓名,仅用于终端本地展示和识别结果元数据。 + */ + @Schema(description = "用户姓名") + private String username; + + /** + * 用户当前所属部门主键,数据来源为 {@code sys_user.dept_id}。 + */ + @Schema(description = "当前所属部门主键") + private Long deptId; + + /** + * 用户当前所属部门名称,仅用于终端本地展示。 + */ + @Schema(description = "当前所属部门名称") + private String deptName; + + /** + * 服务端人脸图片地址;地址变化会触发终端重新下载和提取特征。 + */ + @Schema(description = "人脸图片地址") + private String faceImg; + + /** + * 服务端人脸图片版本时间;未修改的历史记录回退为创建时间,终端将其持久化作为上报版本依据。 + */ + @Schema(description = "服务端人脸图片版本时间") + private LocalDateTime sourceUpdatedAt; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncSnapshotVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncSnapshotVo.java new file mode 100644 index 000000000..e3d966a03 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncSnapshotVo.java @@ -0,0 +1,41 @@ +package com.bsd.pjcloud.admin.api.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 终端部门人脸同步的游标快照响应。 + * + *

首次请求生成固定的 {@code snapshotMaxId},后续分页必须原样携带;终端只有在全部页面成功返回并校验后, + * 才能据此删除不在目标集合中的本地孤儿记录。

+ */ +@Data +@Schema(description = "终端部门人脸同步游标快照") +public class SysUserFaceSyncSnapshotVo { + + /** + * 当前页面的人脸记录,按数据库主键升序排列。 + */ + @Schema(description = "当前页面记录") + private List records; + + /** + * 下一页起始游标;当前页为空时沿用请求游标,末页无需继续请求。 + */ + @Schema(description = "下一页起始游标") + private Long nextAfterId; + + /** + * 是否仍有位于固定快照上界内的后续记录。 + */ + @Schema(description = "是否还有下一页") + private Boolean hasMore; + + /** + * 首次请求时固定的人脸数据库主键上界,后续分页必须原样携带。 + */ + @Schema(description = "固定快照主键上界") + private Long snapshotMaxId; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseIsIncludeDeptMchTreeInterface.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseIsIncludeDeptMchTreeInterface.java new file mode 100644 index 000000000..35f50e503 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseIsIncludeDeptMchTreeInterface.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.admin.api.vo.base; + +import java.util.List; + +/** + * 用户部门+商户部门树查询条件接口 + * 在 {@link BaseIsIncludeTreeInterface} 用户部门包含/不包含的基础上,增加商户部门的包含/不包含 + */ +public interface BaseIsIncludeDeptMchTreeInterface extends BaseIsIncludeTreeInterface { + + /** + * 商户部门ids是否包含 + */ + Boolean getIsIncludeMerchantDept(); + + void setIsIncludeMerchantDept(Boolean isIncludeMerchantDept); + + /** + * 商户部门ids + */ + List getMerchantDeptIds(); + + void setMerchantDeptIds(List merchantDeptIds); +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/config/SchedulingConfig.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/config/SchedulingConfig.java new file mode 100644 index 000000000..a825860a4 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/config/SchedulingConfig.java @@ -0,0 +1,13 @@ +package com.bsd.pjcloud.admin.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 定时任务配置 + * 启用定时任务功能,支持调岗钱包部门对账等周期性兜底任务 + */ +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java index 45679bd95..aaa7f8a21 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java @@ -7,9 +7,16 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceSyncSnapshotDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalQualityReportDto; import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo; import com.bsd.pjcloud.admin.service.SysUserFaceService; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; @@ -29,6 +36,7 @@ import org.springframework.http.HttpHeaders; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; @@ -69,6 +77,42 @@ public class SysUserFaceController { public R evidenceSource(@RequestParam Long userId, @RequestParam String sampleId) { return R.ok(sysUserFaceService.evidenceSource(userId, sampleId)); } + + /** + * 批量查询指定用户的人脸状态数量,调用方必须先完成自身业务授权校验。 + */ + @Inner + @Operation(summary = "批量查询用户人脸状态数量", description = "返回审核通过和审核中的人脸数量") + @PostMapping("/inner/status-count") + public R> listStatusCounts(@RequestBody List userIds) { + return R.ok(sysUserFaceService.listStatusCounts(userIds)); + } + + /** + * 按 Hardware 提供的可信部门范围执行终端人脸删除。 + * + * @param dto 商户部门和样本编号 + * @return 实际删除及已不存在的样本编号 + */ + @Inner + @Operation(summary = "终端按部门范围删除人脸") + @PostMapping("/inner/terminal-delete") + public R terminalDelete(@Valid @RequestBody SysUserFaceTerminalDeleteDto dto) { + return R.ok(sysUserFaceService.terminalDelete(dto)); + } + + /** + * 按 Hardware 提供的可信设备和部门范围保存终端人脸质量结果。 + * + * @param dto 商户部门、来源设备和质量结果 + * @return 已更新、按版本或状态优先级忽略和已不存在的样本编号 + */ + @Inner + @Operation(summary = "终端按部门范围上报人脸质量") + @PostMapping("/inner/terminal-quality-report") + public R terminalReportQuality(@Valid @RequestBody SysUserFaceTerminalQualityReportDto dto) { + return R.ok(sysUserFaceService.terminalReportQuality(dto)); + } /* ************************************* end 远程调用 end ************************************* */ /* ************************************* start app start ************************************* */ @@ -134,6 +178,18 @@ public class SysUserFaceController { dto.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode()); return R.ok(sysUserFaceService.allPageJoin(page, dto).getRecords()); } + + /** + * 按固定数据库主键上界游标返回本部门及子部门的人脸权威快照。 + * + * @param dto 商户部门、可选用户、排他游标、页面大小及固定快照上界 + * @return 当前页快照数据 + */ + @Operation(summary = "android人脸部门快照", description = "按数据库主键游标同步本部门及子部门审核通过的人脸") + @PostMapping("/ad/v3/sync-snapshot") + public R adV3SyncSnapshot(@Valid @RequestBody SysUserFaceSyncSnapshotDto dto) { + return R.ok(sysUserFaceService.syncSnapshot(dto)); + } /* ************************************* start android start ************************************* */ @Operation(summary = "设置为全局人脸", description = "设置为全局人脸") diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEvent.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEvent.java new file mode 100644 index 000000000..4aada4c9a --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEvent.java @@ -0,0 +1,48 @@ +package com.bsd.pjcloud.admin.handler; + +/** + * 用户默认钱包创建事件。 + * + *

事件在用户保存事务中发布,只携带异步调用所需的稳定标识,避免跨线程持有已结束的 HTTP 请求对象。

+ */ +public final class UserWalletCreateEvent { + + /** + * 已保存的用户主键;钱包服务使用该主键回查用户及其自动创建钱包角色。 + */ + private final Long userId; + + /** + * 发布事件时的租户主键;为空时沿用下游服务的默认租户处理规则。 + */ + private final Long tenantId; + + /** + * 创建用户默认钱包事件。 + * + * @param userId 已保存的用户主键 + * @param tenantId 用户所属租户主键,非 Web 调用场景下允许为空 + */ + public UserWalletCreateEvent(Long userId, Long tenantId) { + this.userId = userId; + this.tenantId = tenantId; + } + + /** + * 获取需要创建默认钱包的用户主键。 + * + * @return 已保存的用户主键 + */ + public Long getUserId() { + return userId; + } + + /** + * 获取事件发布时的租户主键。 + * + * @return 租户主键,非 Web 调用场景下可能为空 + */ + public Long getTenantId() { + return tenantId; + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEventListener.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEventListener.java new file mode 100644 index 000000000..ae1010a38 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEventListener.java @@ -0,0 +1,111 @@ +package com.bsd.pjcloud.admin.handler; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 用户默认钱包创建事件监听器。 + * + *

监听器在用户事务提交成功后才进入 Spring 管理的异步线程池,避免钱包服务回查到未提交的用户数据。 + * 调用钱包服务失败时进行有限次重试(createWallet、userChange 均为幂等操作),重试仍失败则记录日志, + * 由调岗钱包对账定时任务兜底修复,保证钱包部门与用户部门最终一致。

+ */ +@Slf4j +@Component +public class UserWalletCreateEventListener { + + /** + * 最大尝试次数:首次调用 + 2 次重试。 + */ + private static final int MAX_ATTEMPTS = 3; + + /** + * 重试间隔(毫秒)。 + */ + private static final long RETRY_DELAY_MILLIS = 5_000L; + + /** + * 钱包服务远程接口,用于创建默认钱包并同步用户部门等关联信息。 + */ + private final RemoteWalletService remoteWalletService; + + /** + * 创建用户默认钱包事件监听器。 + * + * @param remoteWalletService 钱包服务远程接口 + */ + public UserWalletCreateEventListener(RemoteWalletService remoteWalletService) { + this.remoteWalletService = remoteWalletService; + } + + /** + * 在用户事务提交成功后异步创建默认钱包并通知钱包服务刷新用户信息。 + * + *

无事务调用使用 fallbackExecution 直接进入异步线程;此类调用中的用户修改已按单条语句提交, + * 不需要等待事务同步回调。异步线程通过 TenantBroker 恢复事件发布时的租户,确保 Feign 请求携带正确租户头。

+ * + * @param event 用户主键及事件发布时租户信息 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void createDefaultWallet(UserWalletCreateEvent event) { + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + Boolean success = TenantBroker.applyAs(event.getTenantId(), tenantId -> syncUserWallet(event.getUserId())); + if (Boolean.TRUE.equals(success)) { + if (attempt > 1) { + log.warn("用户钱包同步第{}次尝试成功,userId:{},tenantId:{}", attempt, event.getUserId(), event.getTenantId()); + } + return; + } + log.error("用户钱包同步第{}/{}次未成功,userId:{},tenantId:{}", attempt, MAX_ATTEMPTS, event.getUserId(), event.getTenantId()); + } catch (Exception exception) { + log.error("用户钱包同步第{}/{}次异常,userId:{},tenantId:{}", attempt, MAX_ATTEMPTS, event.getUserId(), event.getTenantId(), exception); + } + if (attempt < MAX_ATTEMPTS) { + try { + Thread.sleep(RETRY_DELAY_MILLIS); + } catch (InterruptedException interrupt) { + Thread.currentThread().interrupt(); + log.error("用户钱包同步重试等待被中断,userId:{},tenantId:{}", event.getUserId(), event.getTenantId()); + return; + } + } + } + log.error("用户钱包同步重试耗尽,userId:{},tenantId:{},等待对账任务兜底修复", event.getUserId(), event.getTenantId()); + } + + /** + * 创建缺失的默认钱包并同步用户部门等关联信息。 + * + * @return 全部调用成功返回 true;任一调用无返回或业务失败返回 false,由外层决定是否重试 + */ + private boolean syncUserWallet(Long userId) { + R createResult = remoteWalletService.createWallet(userId); + if (createResult == null) { + log.error("创建默认钱包失败,userId:{},钱包服务未返回结果", userId); + return false; + } + if (!createResult.isOk()) { + log.error("创建默认钱包失败,userId:{},code:{},msg:{}", userId, createResult.getCode(), createResult.getMsg()); + return false; + } + + R changeResult = remoteWalletService.userChange(userId, Boolean.FALSE); + if (changeResult == null) { + log.error("同步用户钱包信息失败,userId:{},钱包服务未返回结果", userId); + return false; + } + if (!changeResult.isOk()) { + log.error("同步用户钱包信息失败,userId:{},code:{},msg:{}", userId, changeResult.getCode(), changeResult.getMsg()); + return false; + } + return true; + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java index 5fe2d9366..2ec9b9368 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java @@ -9,7 +9,9 @@ import com.bsd.pjcloud.admin.api.entity.SysDept; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; @@ -51,6 +53,83 @@ public interface SysUserFaceMapper extends PjcloudBaseMapper return source; } + /** + * 查询仍存在的目标样本及其用户当前部门,供终端删除执行整批范围校验。 + * + * @param sampleIds 虹软样本编号 + * @return 人脸记录及用户部门;用户异常缺失时部门为空并由服务层拒绝 + */ + default List terminalDeleteCandidates(List sampleIds) { + MPJLambdaWrapper wrapper = new MPJLambdaWrapper() + .selectAll(SysUserFaceEntity.class) + .selectAs(SysUser::getDeptId, SysUserFaceVo::getDeptId) + .leftJoin(SysUser.class, SysUser::getUserId, SysUserFaceEntity::getUserId) + .in(SysUserFaceEntity::getAutoCode, sampleIds); + return selectJoinList(SysUserFaceVo.class, wrapper); + } + + /** + * 查询仍存在的目标样本、当前质量时间和用户部门,供终端质量上报执行整批范围校验。 + * + * @param sampleIds 虹软样本编号 + * @return 人脸记录、当前质量字段及用户部门;用户异常缺失时由服务层整批拒绝 + */ + default List terminalQualityCandidates(List sampleIds) { + MPJLambdaWrapper wrapper = new MPJLambdaWrapper() + .selectAll(SysUserFaceEntity.class) + .selectAs(SysUser::getDeptId, SysUserFaceVo::getDeptId) + .leftJoin(SysUser.class, SysUser::getUserId, SysUserFaceEntity::getUserId) + .in(SysUserFaceEntity::getAutoCode, sampleIds); + return selectJoinList(SysUserFaceVo.class, wrapper); + } + + /** + * 审计目标部门树内审核通过且未删除的人脸编号是否存在数值冲突。 + * + * @param deptIds 终端绑定部门及其有效子部门主键 + * @param processStatus 审核通过状态编码 + * @return 当前同步范围的重复数字编号分组数量;大于零时禁止下发快照 + */ + Long countSyncDuplicateAutoCodes(@Param("deptIds") List deptIds, @Param("processStatus") String processStatus); + + /** + * 查询目标部门树和固定主键上界内的最大人脸主键,用于建立稳定快照边界。 + * + * @param deptIds 商户部门及其子部门主键 + * @param userId 可选用户主键,MQ 增量同步时使用 + * @param processStatus 审核通过状态编码 + * @return 快照最大主键;没有记录时返回 {@code 0} + */ + Long selectSyncSnapshotMaxId(@Param("deptIds") List deptIds, @Param("userId") Long userId, @Param("processStatus") String processStatus); + + /** + * 按数据库主键升序读取终端人脸快照的一页,多取一条用于判断是否存在下一页。 + * + * @param deptIds 商户部门及其子部门主键 + * @param userId 可选用户主键 + * @param afterId 排他起始游标 + * @param snapshotMaxId 固定快照主键上界 + * @param processStatus 审核通过状态编码 + * @param fetchSize 实际读取数量,通常为页面大小加一 + * @return 按人脸数据库主键严格升序排列的记录 + */ + List listSyncSnapshotRecords(@Param("deptIds") List deptIds, + @Param("userId") Long userId, + @Param("afterId") Long afterId, + @Param("snapshotMaxId") Long snapshotMaxId, + @Param("processStatus") String processStatus, + @Param("fetchSize") int fetchSize); + + /** + * 按用户批量统计可用与审核中的人脸数量。 + * + * @param userIds 用户ID列表 + * @param availableStatus 审核通过状态编码 + * @param pendingStatus 审核中状态编码 + * @return 各用户的人脸状态数量 + */ + List listStatusCounts(@Param("userIds") List userIds, @Param("availableStatus") String availableStatus, @Param("pendingStatus") String pendingStatus); + default SysUserFaceVo join(Long id) { MPJLambdaWrapper wrapper = new MPJLambdaWrapper() .selectAll(SysUserFaceEntity.class) diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java index bf16dea3f..7b96e0ca1 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java @@ -3,19 +3,56 @@ package com.bsd.pjcloud.admin.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceSyncSnapshotDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalQualityReportDto; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto; import java.util.ArrayList; +import java.util.List; public interface SysUserFaceService extends IService { Long create(SysUserFaceEntity sysUserFace); + /** + * 按固定主键上界游标读取本部门及子部门的审核通过人脸。 + * + * @param dto 商户部门、可选用户、排他游标、页面大小及固定快照上界 + * @return 当前页快照和下一页信息 + */ + SysUserFaceSyncSnapshotVo syncSnapshot(SysUserFaceSyncSnapshotDto dto); + + /** + * 校验全部样本属于目标部门树后执行逻辑删除,任何越权样本都会整批拒绝。 + * + * @param dto 可信商户部门和样本编号 + * @return 实际删除及已不存在的样本编号 + */ + SysUserFaceTerminalDeleteVo terminalDelete(SysUserFaceTerminalDeleteDto dto); + + /** + * 校验全部质量样本属于目标部门树后幂等保存结果,图片版本和成功状态优先级均不得倒退。 + * + * @param dto 可信商户部门、来源设备和质量结果 + * @return 已更新、按版本或状态优先级忽略和已不存在的样本编号 + */ + SysUserFaceTerminalQualityReportVo terminalReportQuality(SysUserFaceTerminalQualityReportDto dto); + + /** + * 批量统计指定用户的人脸可用与审核中数量。 + */ + List listStatusCounts(List userIds); + void edit(SysUserFaceEntity sysUserFace); Boolean editGlobalFace(SysUserFaceDto dto); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java index b6e8a200d..4bdda30d1 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java @@ -5,15 +5,25 @@ import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSON; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceSyncSnapshotDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceQualityReportItemDto; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalQualityReportDto; import com.bsd.pjcloud.admin.api.entity.SysDept; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; import com.bsd.pjcloud.admin.api.utils.DataUtil; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo; import com.bsd.pjcloud.admin.api.vo.UserVO; import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper; import com.bsd.pjcloud.admin.service.SysDeptService; @@ -45,10 +55,17 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; /** * 人脸样本表 @@ -64,6 +81,31 @@ public class SysUserFaceServiceImpl extends ServiceImpl listStatusCounts(List userIds) { + List distinctUserIds = userIds == null ? List.of() : userIds.stream().filter(Objects::nonNull).distinct().toList(); + if (distinctUserIds.isEmpty()) { + return List.of(); + } + return baseMapper.listStatusCounts(distinctUserIds, TaskCheckStatusEnum.RESULT.getCode(), TaskCheckStatusEnum.PADDING.getCode()); + } + + /** + * 按固定数据库主键上界读取本部门及子部门的审核通过人脸快照。 + * + *

该方法显式排除外部门全局人脸。调用方必须完整拉取所有页面后再做本地孤儿清理; + * 服务端仅审计当前部门树内实际生效的人脸编号;外部门、已删除和未审核历史记录不会阻断本机同步。

+ * + * @param dto 商户部门、可选用户、排他游标、页面大小及固定快照上界 + * @return 当前页面、下一游标和固定快照上界 + */ + @Override + public SysUserFaceSyncSnapshotVo syncSnapshot(SysUserFaceSyncSnapshotDto dto) { + Long merchantDeptId = dto.getMerchantDeptId(); + Long userId = dto.getUserId(); + Long afterId = dto.getAfterId(); + Integer limit = dto.getLimit(); + Long snapshotMaxId = dto.getSnapshotMaxId(); + if (merchantDeptId == null) { + throw new RuntimeException("商户部门不能为空"); + } + List deptIds = sysDeptService.listDescendant(merchantDeptId).stream() + .map(SysDept::getDeptId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (deptIds.isEmpty()) { + throw new RuntimeException("商户部门不存在或已删除"); + } + assertSyncFaceAutoCodeUnique(deptIds, TaskCheckStatusEnum.RESULT.getCode()); + + long normalizedAfterId = afterId == null ? 0L : afterId; + if (normalizedAfterId < 0) { + throw new RuntimeException("人脸同步游标不能小于0"); + } + int normalizedLimit = limit == null ? FACE_SYNC_DEFAULT_LIMIT : limit; + if (normalizedLimit <= 0 || normalizedLimit > FACE_SYNC_MAX_LIMIT) { + throw new RuntimeException("人脸同步分页大小必须在1到" + FACE_SYNC_MAX_LIMIT + "之间"); + } + Long normalizedSnapshotMaxId = snapshotMaxId; + if (normalizedSnapshotMaxId == null) { + normalizedSnapshotMaxId = baseMapper.selectSyncSnapshotMaxId(deptIds, userId, TaskCheckStatusEnum.RESULT.getCode()); + } + if (normalizedSnapshotMaxId == null || normalizedSnapshotMaxId < 0) { + throw new RuntimeException("人脸同步快照上界无效"); + } + if (normalizedAfterId > normalizedSnapshotMaxId) { + throw new RuntimeException("人脸同步游标超过快照上界"); + } + + List fetchedRecords = baseMapper.listSyncSnapshotRecords( + deptIds, userId, normalizedAfterId, normalizedSnapshotMaxId, + TaskCheckStatusEnum.RESULT.getCode(), normalizedLimit + 1); + boolean hasMore = fetchedRecords.size() > normalizedLimit; + List records = hasMore + ? new ArrayList<>(fetchedRecords.subList(0, normalizedLimit)) + : new ArrayList<>(fetchedRecords); + records.forEach(this::validateSyncRecord); + + SysUserFaceSyncSnapshotVo snapshot = new SysUserFaceSyncSnapshotVo(); + snapshot.setRecords(records); + snapshot.setNextAfterId(records.isEmpty() ? normalizedAfterId : records.get(records.size() - 1).getId()); + snapshot.setHasMore(hasMore); + snapshot.setSnapshotMaxId(normalizedSnapshotMaxId); + return snapshot; + } + + /** + * 按 Hardware 提供的可信商户部门树批量删除人脸。 + * + *

先一次性查询所有仍存在的样本并校验用户当前部门;任意记录不在部门树内时 + * 立即抛出异常,删除逻辑尚未执行,因此不会产生部分删除。重复样本按首次顺序 + * 去重;服务端已经不存在的样本作为幂等成功返回给终端。

+ * + * @param dto 商户部门和待删除样本编号 + * @return 实际删除及请求前已不存在的样本编号 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public SysUserFaceTerminalDeleteVo terminalDelete(SysUserFaceTerminalDeleteDto dto) { + if (dto == null || dto.getMerchantDeptId() == null || ObjectUtil.isEmpty(dto.getSampleIds())) { + throw new RuntimeException("终端人脸删除参数不能为空"); + } + if (dto.getSampleIds().size() > FACE_SYNC_MAX_LIMIT) { + throw new RuntimeException("单次最多删除" + FACE_SYNC_MAX_LIMIT + "条人脸"); + } + + LinkedHashSet normalizedSet = new LinkedHashSet<>(); + for (String sampleId : dto.getSampleIds()) { + normalizedSet.add(normalizeTerminalSampleId(sampleId)); + } + List sampleIds = new ArrayList<>(normalizedSet); + List deptIds = sysDeptService.listDescendant(dto.getMerchantDeptId()).stream() + .map(SysDept::getDeptId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (deptIds.isEmpty()) { + throw new RuntimeException("终端绑定部门不存在或已删除"); + } + + List candidates = baseMapper.terminalDeleteCandidates(sampleIds); + Set allowedDeptIds = new HashSet<>(deptIds); + boolean outOfScope = candidates.stream() + .anyMatch(face -> face.getDeptId() == null || !allowedDeptIds.contains(face.getDeptId())); + if (outOfScope) { + throw new RuntimeException("请求包含不属于当前设备部门范围的人脸"); + } + + Set existingSampleIds = candidates.stream() + .map(SysUserFaceVo::getAutoCode) + .filter(StrUtil::isNotBlank) + .collect(Collectors.toSet()); + List deletedSampleIds = sampleIds.stream() + .filter(existingSampleIds::contains) + .toList(); + List alreadyAbsentSampleIds = sampleIds.stream() + .filter(sampleId -> !existingSampleIds.contains(sampleId)) + .toList(); + ArrayList databaseIds = candidates.stream() + .map(SysUserFaceVo::getId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toCollection(ArrayList::new)); + delByIds(databaseIds); + return SysUserFaceTerminalDeleteVo.builder() + .deletedSampleIds(deletedSampleIds) + .alreadyAbsentSampleIds(alreadyAbsentSampleIds) + .build(); + } + + /** + * 按 Hardware 提供的可信设备和商户部门树幂等保存人脸质量结果。 + * + *

先校验完整请求、审核通过状态和部门范围,再逐条按图片版本与状态优先级更新。 + * 质量回写故意不修改 {@code update_time} 且不发布人脸变更事件,避免终端将 + * 质量变化误判为图片版本变化并形成重复下载、检测和上报循环。

+ * + * @param dto 商户部门、来源设备和质量结果 + * @return 已更新、按版本或状态优先级忽略和已不存在的样本编号 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public SysUserFaceTerminalQualityReportVo terminalReportQuality(SysUserFaceTerminalQualityReportDto dto) { + if (dto == null || dto.getMerchantDeptId() == null || dto.getQualityDeviceId() == null + || dto.getQualityDeviceId() <= 0 || ObjectUtil.isEmpty(dto.getItems())) { + throw new RuntimeException("终端人脸质量上报参数不能为空"); + } + if (dto.getItems().size() > FACE_SYNC_MAX_LIMIT) { + throw new RuntimeException("单次最多上报" + FACE_SYNC_MAX_LIMIT + "条人脸质量结果"); + } + + LinkedHashMap itemBySampleId = new LinkedHashMap<>(); + for (SysUserFaceQualityReportItemDto item : dto.getItems()) { + validateQualityReportItem(item); + String sampleId = normalizeTerminalSampleId(item.getSampleId()); + item.setSampleId(sampleId); + if (itemBySampleId.putIfAbsent(sampleId, item) != null) { + throw new RuntimeException("人脸质量上报包含重复样本编号"); + } + } + + List deptIds = sysDeptService.listDescendant(dto.getMerchantDeptId()).stream() + .map(SysDept::getDeptId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (deptIds.isEmpty()) { + throw new RuntimeException("终端绑定部门不存在或已删除"); + } + + List candidates = baseMapper.terminalQualityCandidates( + new ArrayList<>(itemBySampleId.keySet())); + Set allowedDeptIds = new HashSet<>(deptIds); + boolean outOfScope = candidates.stream() + .anyMatch(face -> face.getDeptId() == null || !allowedDeptIds.contains(face.getDeptId())); + if (outOfScope) { + throw new RuntimeException("请求包含不属于当前设备部门范围的人脸"); + } + boolean notApproved = candidates.stream() + .anyMatch(face -> !TaskCheckStatusEnum.RESULT.getCode().equals(face.getProcessStatus())); + if (notApproved) { + throw new RuntimeException("请求包含未审核通过的人脸"); + } + + LinkedHashMap candidateBySampleId = new LinkedHashMap<>(); + for (SysUserFaceVo candidate : candidates) { + String sampleId = StrUtil.trim(candidate.getAutoCode()); + if (candidate.getId() == null || StrUtil.isBlank(sampleId) + || candidateBySampleId.putIfAbsent(sampleId, candidate) != null) { + throw new RuntimeException("服务端存在重复或无效人脸样本编号"); + } + } + + List updatedSampleIds = new ArrayList<>(); + List ignoredStaleSampleIds = new ArrayList<>(); + List alreadyAbsentSampleIds = new ArrayList<>(); + for (Map.Entry entry : itemBySampleId.entrySet()) { + String sampleId = entry.getKey(); + SysUserFaceQualityReportItemDto item = entry.getValue(); + SysUserFaceVo candidate = candidateBySampleId.get(sampleId); + if (candidate == null) { + alreadyAbsentSampleIds.add(sampleId); + continue; + } + LocalDateTime sourceUpdatedAt = candidate.getUpdateTime() == null + ? candidate.getCreateTime() + : candidate.getUpdateTime(); + if (sourceUpdatedAt == null || !sourceUpdatedAt.equals(item.getSourceUpdatedAt())) { + ignoredStaleSampleIds.add(sampleId); + continue; + } + // 同一图片版本的首个有效 detected 是服务端权威结果,失败设备和重复设备均不得覆盖。 + if (FACE_QUALITY_STATUS_DETECTED.equals(candidate.getQualityStatus())) { + ignoredStaleSampleIds.add(sampleId); + continue; + } + if (FACE_QUALITY_STATUS_FAILED.equals(item.getStatus()) + && candidate.getQualityCheckedAt() != null + && !item.getCheckedAt().isAfter(candidate.getQualityCheckedAt())) { + ignoredStaleSampleIds.add(sampleId); + continue; + } + + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper() + .set(SysUserFaceEntity::getQualityScore, item.getScore()) + .set(SysUserFaceEntity::getQualityStatus, item.getStatus()) + .set(SysUserFaceEntity::getQualityCheckedAt, item.getCheckedAt()) + .set(SysUserFaceEntity::getQualityErrorCode, item.getErrorCode()) + .set(SysUserFaceEntity::getQualityDeviceId, dto.getQualityDeviceId()) + .eq(SysUserFaceEntity::getId, candidate.getId()) + .and(version -> version.eq(SysUserFaceEntity::getUpdateTime, item.getSourceUpdatedAt()) + .or(fallback -> fallback.isNull(SysUserFaceEntity::getUpdateTime) + .eq(SysUserFaceEntity::getCreateTime, item.getSourceUpdatedAt()))); + if (FACE_QUALITY_STATUS_DETECTED.equals(item.getStatus())) { + updateWrapper.and(status -> status.isNull(SysUserFaceEntity::getQualityStatus) + .or() + .ne(SysUserFaceEntity::getQualityStatus, FACE_QUALITY_STATUS_DETECTED)); + } else { + updateWrapper.and(status -> status.isNull(SysUserFaceEntity::getQualityStatus) + .or(failed -> failed.eq(SysUserFaceEntity::getQualityStatus, FACE_QUALITY_STATUS_FAILED) + .and(time -> time.isNull(SysUserFaceEntity::getQualityCheckedAt) + .or() + .lt(SysUserFaceEntity::getQualityCheckedAt, item.getCheckedAt())))); + } + int updatedRows = baseMapper.update(null, updateWrapper); + if (updatedRows > 0) { + updatedSampleIds.add(sampleId); + } else { + ignoredStaleSampleIds.add(sampleId); + } + } + return SysUserFaceTerminalQualityReportVo.builder() + .updatedSampleIds(updatedSampleIds) + .ignoredStaleSampleIds(ignoredStaleSampleIds) + .alreadyAbsentSampleIds(alreadyAbsentSampleIds) + .build(); + } + + /** + * 校验一条质量结果的状态、分数、错误码和检测时间组合。 + * + * @param item 待校验的终端质量结果 + */ + private void validateQualityReportItem(SysUserFaceQualityReportItemDto item) { + if (item == null || item.getSourceUpdatedAt() == null || item.getCheckedAt() == null) { + throw new RuntimeException("人脸质量结果、图片版本或检测时间不能为空"); + } + String status = StrUtil.trim(item.getStatus()); + item.setStatus(status); + if (FACE_QUALITY_STATUS_DETECTED.equals(status)) { + if (item.getScore() == null || item.getScore().compareTo(BigDecimal.ZERO) < 0 + || item.getScore().compareTo(BigDecimal.ONE) > 0) { + throw new RuntimeException("检测成功的人脸质量分必须在0到1之间"); + } + if (item.getErrorCode() != null) { + throw new RuntimeException("检测成功的人脸质量结果不能包含错误码"); + } + return; + } + if (FACE_QUALITY_STATUS_FAILED.equals(status)) { + if (item.getScore() != null) { + throw new RuntimeException("检测失败的人脸质量结果不能包含分数"); + } + return; + } + throw new RuntimeException("人脸质量检测状态仅支持detected或failed"); + } + + /** + * 将终端样本编号规范化为虹软可接受的 32 位正整数文本。 + * + * @param sampleId 原始样本编号 + * @return 去除首尾空白后的样本编号 + */ + private String normalizeTerminalSampleId(String sampleId) { + String normalized = StrUtil.trim(sampleId); + try { + long numeric = Long.parseLong(normalized); + if (numeric <= 0 || numeric > FACE_AUTO_CODE_MAX_VALUE) { + throw new NumberFormatException("out of range"); + } + } catch (Exception exception) { + throw new RuntimeException("存在无效人脸样本编号"); + } + return normalized; + } + + /** + * 校验快照记录的虹软搜索标识可被 Android 端安全解析为 32 位正整数。 + * + * @param record 待下发的快照记录 + */ + private void validateSyncRecord(SysUserFaceSyncRecordVo record) { + try { + long sampleId = Long.parseLong(record.getSampleId()); + if (sampleId <= 0 || sampleId > FACE_AUTO_CODE_MAX_VALUE) { + throw new NumberFormatException("out of range"); + } + } catch (NumberFormatException exception) { + throw new RuntimeException("存在无效的人脸编号,faceId=" + record.getId() + ",请先完成数据修复", exception); + } + } + + /** + * 审计当前终端部门树内实际生效的人脸编号是否冲突。 + * + *

同步只维护本部门及子部门,因此外部门、已删除和未审核记录即使存在历史重复编号, + * 也不应阻断当前终端;当前范围内的冲突仍必须停止下发,避免虹软相同搜索标识互相覆盖。

+ * + * @param deptIds 终端绑定部门及其有效子部门主键 + * @param processStatus 审核通过状态编码 + */ + private void assertSyncFaceAutoCodeUnique(List deptIds, String processStatus) { + Long duplicateCount = baseMapper.countSyncDuplicateAutoCodes(deptIds, processStatus); + if (duplicateCount != null && duplicateCount > 0) { + throw new RuntimeException("当前部门范围存在重复的人脸编号,已停止同步,请先修复冲突数据"); + } + } + /** * 开启审批 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index 0c84fef92..b6da12e7c 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -45,6 +45,7 @@ import com.bsd.pjcloud.admin.api.utils.DataUtil; import com.bsd.pjcloud.admin.api.vo.*; import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper; import com.bsd.pjcloud.admin.mapper.SysUserMapper; +import com.bsd.pjcloud.admin.handler.UserWalletCreateEvent; import com.bsd.pjcloud.admin.mapper.SysUserPostMapper; import com.bsd.pjcloud.admin.mapper.SysUserRoleMapper; import com.bsd.pjcloud.admin.service.*; @@ -80,6 +81,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CacheEvict; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Lazy; import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.RedisCallback; @@ -136,6 +138,7 @@ public class SysUserServiceImpl extends ServiceImpl impl private final CacheManager cacheManager; private final RemoteWalletService remoteWalletService; + private final ApplicationEventPublisher applicationEventPublisher; private final SysFileService sysFileService; @@ -208,15 +211,8 @@ public class SysUserServiceImpl extends ServiceImpl impl if (isDeptAdmin) { getSelf().applyFlow(sysUser); } else { - // 创建用户默认钱包、保证线程header - RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); - CompletableFuture.runAsync(() -> { - RequestContextHolder.setRequestAttributes(requestAttributes); - // 创建钱包 - remoteWalletService.createWallet(sysUser.getUserId()); - // 通知钱包 - remoteWalletService.userChange(sysUser.getUserId(), Boolean.FALSE); - }); + // 事务提交前其他连接无法读取新用户,因此仅发布事件,由监听器在提交成功后异步创建钱包。 + applicationEventPublisher.publishEvent(new UserWalletCreateEvent(sysUser.getUserId(), TenantContextHolder.getTenantId())); } return sysUser.getUserId(); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserTransactionServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserTransactionServiceImpl.java index e5a23ec85..aa8e33d8e 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserTransactionServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserTransactionServiceImpl.java @@ -18,6 +18,7 @@ import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.entity.SysUserTransactionEntity; import com.bsd.pjcloud.admin.api.vo.SysUserTransactionVo; import com.bsd.pjcloud.admin.mapper.SysUserTransactionMapper; +import com.bsd.pjcloud.admin.handler.UserWalletCreateEvent; import com.bsd.pjcloud.admin.service.SysDeptService; import com.bsd.pjcloud.admin.service.SysUserService; import com.bsd.pjcloud.admin.service.SysUserTransactionService; @@ -34,22 +35,19 @@ import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum; import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto; import com.bsd.pjcloud.flow.task.dto.TaskParamDto; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRuleTransactionService; -import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletThirdZyService; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.CacheManager; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.context.request.RequestAttributes; -import org.springframework.web.context.request.RequestContextHolder; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CompletableFuture; /** * 人员调动表 @@ -69,7 +67,7 @@ public class SysUserTransactionServiceImpl extends ServiceImpl { - RequestContextHolder.setRequestAttributes(requestAttributes); - remoteWalletService.userChange(detail.getUserId(), Boolean.FALSE); - }); + // 通知钱包进行相关修改:发布事件由监听器异步执行,携带租户上下文并记录失败日志 + applicationEventPublisher.publishEvent(new UserWalletCreateEvent(detail.getUserId(), TenantContextHolder.getTenantId())); sysUserService.deleteDiscountsCacheByUserId(detail.getUserId()); } } catch (Exception e) { diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/task/SysUserTransactionWalletSyncTask.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/task/SysUserTransactionWalletSyncTask.java new file mode 100644 index 000000000..8cee9d6e9 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/task/SysUserTransactionWalletSyncTask.java @@ -0,0 +1,111 @@ +package com.bsd.pjcloud.admin.task; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.admin.api.entity.SysUserTransactionEntity; +import com.bsd.pjcloud.admin.handler.UserWalletCreateEvent; +import com.bsd.pjcloud.admin.service.SysUserTransactionService; +import com.bsd.pjcloud.admin.service.SysUserService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum; +import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; +import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 调岗审批后钱包部门对账任务。 + * + *

审批回调通过事件异步通知钱包服务并带重试,但钱包服务长时间不可用等情况下重试仍会耗尽, + * 导致钱包部门与用户部门不一致。本任务周期性比对近期审批通过的调岗记录,发现不一致即重新发布同步事件, + * 使钱包部门与用户部门最终一致。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SysUserTransactionWalletSyncTask { + + private static final String TAG = "调岗钱包对账"; + + /** + * 对账窗口:比对最近 N 天内审批通过的调岗记录。 + */ + private static final int CHECK_DAYS = 7; + + private final SysUserTransactionService sysUserTransactionService; + private final SysUserService sysUserService; + private final RemoteWalletService remoteWalletService; + private final ApplicationEventPublisher applicationEventPublisher; + + /** + * 每小时对账一次,兜底审批回调时丢失的钱包部门同步。 + * + *

定时线程无租户上下文,租户拦截器对空租户不过滤,因此初始查询能覆盖全部租户的记录; + * 单条记录的比对在记录自身租户下执行,保证用户和钱包查询的租户条件正确。

+ */ + @Scheduled(cron = "0 25 * * * ?") + public void reconcileWalletDept() { + try { + List list = sysUserTransactionService.list(Wrappers.lambdaQuery() + .eq(SysUserTransactionEntity::getProcessStatus, TaskCheckStatusEnum.RESULT.getCode()) + .ge(SysUserTransactionEntity::getProcessTime, LocalDateTime.now().minusDays(CHECK_DAYS))); + if (CollUtil.isEmpty(list)) { + return; + } + // 同一用户多条调岗记录只对账一次,以最新记录的租户为准 + Map userTenantMap = list.stream().collect(Collectors.toMap( + SysUserTransactionEntity::getUserId, SysUserTransactionEntity::getTenantId, (first, second) -> second)); + int mismatchCount = 0; + for (Map.Entry entry : userTenantMap.entrySet()) { + Long userId = entry.getKey(); + try { + if (isWalletDeptMismatched(userId, entry.getValue())) { + mismatchCount++; + log.warn("|{}|用户<{}>钱包部门与用户部门不一致,重新触发钱包同步,tenantId:{}", TAG, userId, entry.getValue()); + applicationEventPublisher.publishEvent(new UserWalletCreateEvent(userId, entry.getValue())); + } + } catch (Exception e) { + log.error("|{}|用户<{}>对账异常", TAG, userId, e); + } + } + if (mismatchCount > 0) { + log.warn("|{}|比对{}个用户,{}个钱包部门不一致已重新触发同步", TAG, userTenantMap.size(), mismatchCount); + } else { + log.info("|{}|比对{}个用户,钱包部门全部一致", TAG, userTenantMap.size()); + } + } catch (Exception e) { + log.error("|{}|执行异常", TAG, e); + } + } + + /** + * 判断用户饭卡钱包部门是否与用户当前部门不一致。 + * + * @return 不一致返回 true;用户已删除、部门为空、没有饭卡钱包等无法比对的情况返回 false + */ + private boolean isWalletDeptMismatched(Long userId, Long tenantId) { + return Boolean.TRUE.equals(TenantBroker.applyAs(tenantId, tenant -> { + SysUser user = sysUserService.getById(userId); + if (user == null || user.getDeptId() == null) { + return false; + } + R walletResult = remoteWalletService.getWallet(userId, WalletTypeEnum.EAT); + if (walletResult == null || !walletResult.isOk() || walletResult.getData() == null) { + return false; + } + return !ObjectUtil.equals(walletResult.getData().getDeptId(), user.getDeptId()); + })); + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml index 6f13c9201..ad805e185 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml @@ -126,4 +126,79 @@ order by a.create_time desc, a.id desc + + + + + + + + \ No newline at end of file diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java index c8062d4ef..37424437a 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java @@ -481,8 +481,11 @@ public class WalletManageServiceImpl extends ServiceImpl