From 64fbd4e16a1d77e5e75c9c8b41f3a149520643a5 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Tue, 1 Sep 2026 12:43:14 +0800
Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E7=BB=9F=E8=AE=A1=E5=A2=9E?=
=?UTF-8?q?=E5=8A=A0=E8=A1=A8=E5=A4=B4=E6=8F=90=E7=A4=BA=20feat=EF=BC=9A?=
=?UTF-8?q?=E5=95=86=E6=88=B7=E4=BA=A4=E6=98=93=E8=AE=B0=E5=BD=95=E3=80=81?=
=?UTF-8?q?=E9=83=A8=E9=97=A8=E4=BA=A4=E6=98=93=E8=AE=B0=E5=BD=95=E5=A2=9E?=
=?UTF-8?q?=E5=8A=A0=E5=88=86=E7=89=87=E3=80=81=E8=BF=9B=E5=BA=A6=E6=9D=A1?=
=?UTF-8?q?=E5=AF=BC=E5=87=BA=20feat=EF=BC=9A=E5=A2=9E=E5=8A=A0=E4=BA=BA?=
=?UTF-8?q?=E8=84=B8=E8=B4=A8=E9=87=8F=E4=B8=8A=E6=8A=A5=20feat=EF=BC=9Aap?=
=?UTF-8?q?p"=E9=80=9A=E7=9F=A5"=E6=9D=83=E9=99=90=E4=BF=AE=E6=94=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
db/20260901_add_sys_user_face_quality.sql | 7 +
db/800pjcloudx_wallet.sql | 5 +
.../HardTerminalFaceQualityReportDto.java | 23 +
.../HardTerminalFaceQualityReportItemDto.java | 49 ++
.../HardTerminalFaceQualityLeaseVo.java | 26 +
.../controller/hard/HardApiController.java | 30 +
.../order/HardOrderTotalController.java | 92 ++-
.../service/HardOrderTotalExportService.java | 29 +
.../hard/service/HardTerminalFaceService.java | 20 +
.../manager/HardTerminalFaceServiceImpl.java | 149 +++-
.../HardOrderTotalExportServiceImpl.java | 717 ++++++++++++++++++
.../HardTerminalFaceServiceImplTest.java | 116 ++-
.../dto/SysUserFaceQualityReportItemDto.java | 49 ++
.../SysUserFaceTerminalQualityReportDto.java | 36 +
.../admin/api/entity/SysUserFaceEntity.java | 31 +
.../api/feign/RemoteUserFaceService.java | 12 +
.../admin/api/feign/RemoteUserService.java | 18 +
.../admin/api/vo/SysUserFaceSyncRecordVo.java | 4 +-
.../SysUserFaceTerminalQualityReportVo.java | 33 +
.../controller/SysUserFaceController.java | 15 +
.../admin/mapper/SysUserFaceMapper.java | 15 +
.../admin/service/SysUserFaceService.java | 10 +
.../service/impl/SysUserFaceServiceImpl.java | 218 +++++-
.../resources/mapper/SysUserFaceMapper.xml | 12 +-
...eTerminalQualityReportServiceImplTest.java | 252 ++++++
.../recharge/WalletRechargeApplyMapper.java | 27 +-
.../service/WalletRechargeApplyService.java | 16 +
.../WalletRechargeApplyServiceImpl.java | 66 +-
.../support/WalletUserIdentitySupport.java | 34 +-
.../recharge/WalletRechargeApplyMapper.xml | 39 +-
...letRechargeApplyMapperSqlContractTest.java | 91 +++
.../WalletRechargeApplyServiceImplTest.java | 161 ++++
.../WalletUserIdentitySupportTest.java | 96 +++
33 files changed, 2420 insertions(+), 78 deletions(-)
create mode 100644 db/20260901_add_sys_user_face_quality.sql
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceQualityReportDto.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceQualityReportItemDto.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardTerminalFaceQualityLeaseVo.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderTotalExportService.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalExportServiceImpl.java
create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceQualityReportItemDto.java
create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceTerminalQualityReportDto.java
create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceTerminalQualityReportVo.java
create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceTerminalQualityReportServiceImplTest.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeApplyMapperSqlContractTest.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImplTest.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/WalletUserIdentitySupportTest.java
diff --git a/db/20260901_add_sys_user_face_quality.sql b/db/20260901_add_sys_user_face_quality.sql
new file mode 100644
index 00000000..9fb551a5
--- /dev/null
+++ b/db/20260901_add_sys_user_face_quality.sql
@@ -0,0 +1,7 @@
+-- 保存终端虹软静态图片质量结果;旧终端和历史人脸保持 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/db/800pjcloudx_wallet.sql b/db/800pjcloudx_wallet.sql
index deffa17b..4beb4888 100644
--- a/db/800pjcloudx_wallet.sql
+++ b/db/800pjcloudx_wallet.sql
@@ -45,6 +45,11 @@ CREATE TABLE `sys_user_face`
process_time datetime COMMENT '审核时间',
reject_reason varchar(500) DEFAULT NULL COMMENT '审核拒绝原因',
face_img varchar(256) NOT NULL COMMENT '人脸图片',
+ quality_score decimal(5, 4) DEFAULT NULL COMMENT '虹软静态图片质量分,范围0-1',
+ quality_status varchar(16) DEFAULT NULL COMMENT '终端检测状态:detected/failed',
+ quality_checked_at datetime DEFAULT NULL COMMENT '终端完成质量检测的时间',
+ quality_error_code int DEFAULT NULL COMMENT '虹软或终端质量检测错误码',
+ quality_device_id bigint DEFAULT NULL COMMENT '最后一次上报质量的终端ID',
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
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 00000000..aa89a38c
--- /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 00000000..5497cf82
--- /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/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 00000000..b0bdd20b
--- /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 295bfe3c..7556bdaa 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
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.hard.controller.hard;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
+import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
@@ -18,6 +19,7 @@ import com.bsd.pjcloud.hard.api.dto.hard.*;
import com.bsd.pjcloud.hard.api.dto.order.HardChartLatitudeVo;
import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalFaceQualityLeaseVo;
import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo;
import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo;
@@ -109,6 +111,34 @@ public class HardApiController {
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/controller/order/HardOrderTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java
index 6451c2a5..68e9a299 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java
@@ -12,6 +12,8 @@ import com.alibaba.excel.write.metadata.fill.FillConfig;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
+import com.bsd.pjcloud.common.core.constant.CommonRoleConstant;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.R;
@@ -29,6 +31,7 @@ import com.bsd.pjcloud.hard.api.vo.order.excel.HardMchOrderExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOrderService;
+import com.bsd.pjcloud.hard.service.HardOrderTotalExportService;
import com.bsd.pjcloud.hard.service.HardOrderTotalService;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -46,6 +49,7 @@ import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -61,6 +65,8 @@ public class HardOrderTotalController {
private final HardOrderService hardOrderService;
private final HardOrderTotalService hardOrderTotalService;
+ /** 部门与商户交易汇总异步导出服务,负责分片查询、进度上报和文件中心上传。 */
+ private final HardOrderTotalExportService hardOrderTotalExportService;
private final HardManagerService hardManagerService;
private final HardOrderMapper hardOrderMapper;
@@ -154,10 +160,10 @@ public class HardOrderTotalController {
}
/**
- * 查询当前用户所属食堂商户范围内的部门交易统计。
+ * 查询当前用户有权查看的部门交易统计。
*
- * @param dto 时间、钱包类型及部门树筛选条件;部门条件会与当前用户可管理的食堂部门取交集
- * @return 当前用户有权查看的部门交易统计,无可见食堂或部门时返回空列表
+ * @param dto 时间、钱包类型及部门树筛选条件;平台管理员保留全局范围,其他账号与可管理食堂部门取交集
+ * @return 当前用户有权查看的部门交易统计,无可见部门时返回空列表
*/
@PostMapping("/page-list-dept")
@Operation(summary = "部门交易记录")
@@ -168,12 +174,59 @@ public class HardOrderTotalController {
return R.ok(hardOrderTotalService.pageListDept(dto));
}
+ /**
+ * 提交商户交易汇总异步分片导出任务。
+ *
+ * @param dto 页面当前的商户、部门和支付时间筛选条件
+ * @return 可供前端轮询的初始任务进度
+ * @throws IllegalStateException 商户层级远程查询失败时抛出,避免创建筛选范围不完整的导出任务
+ */
+ @Operation(summary = "商户交易记录异步导出", description = "按自然日分片统计并生成 Excel")
+ @PostMapping("/export-cs-task")
+ @SysLog("商户交易记录异步导出")
+ @PreAuthorize("@pms.hasPermission('hard_hardTotal_export_cs')")
+ public R submitMerchantExport(@RequestBody HardOrderDto dto) {
+ dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes());
+ if (ObjectUtil.isNotEmpty(dto.getMerchantId())) {
+ R> merchantResponse = mchService.getByParentId(dto.getMerchantId());
+ if (merchantResponse == null || !merchantResponse.isOk()) {
+ throw new IllegalStateException("查询商户交易导出的下级商户失败");
+ }
+ List merchantList = merchantResponse.getData();
+ if (ObjectUtil.isNotEmpty(merchantList)) {
+ List merchantIds = new ArrayList<>();
+ merchantIds.add(dto.getMerchantId());
+ merchantIds.addAll(merchantList.stream().map(MchManageVo::getId).toList());
+ dto.setMerchantId(null);
+ dto.setMchIds(merchantIds);
+ }
+ }
+ return R.ok(hardOrderTotalExportService.submitMerchantExport(dto), "商户交易导出任务已提交");
+ }
+
+ /**
+ * 提交当前用户有权查看的部门交易汇总异步分片导出任务。
+ *
+ * @param dto 页面当前的部门、钱包类型和创建时间筛选条件
+ * @return 可供前端轮询的初始任务进度;当前账号无可见部门时返回失败响应
+ */
+ @Operation(summary = "部门交易记录异步导出", description = "按部门分片统计并生成 Excel")
+ @PostMapping("/export-dept-task")
+ @SysLog("部门交易记录异步导出")
+ @PreAuthorize("@pms.hasPermission('hard_hardTotal_export_dept')")
+ public R submitDepartmentExport(@RequestBody HardDeptTotalDto dto) {
+ if (!applyCanteenDepartmentScope(dto)) {
+ return R.failed("当前账号没有可导出的部门交易数据");
+ }
+ return R.ok(hardOrderTotalExportService.submitDepartmentExport(dto), "部门交易导出任务已提交");
+ }
+
/**
* 将部门统计条件限制到当前用户可管理的食堂商户所属部门。
*
- * 钱包服务根据当前用户主键和食堂商户类型一次性返回其所属食堂覆盖的用户部门。页面传入的
- * 包含或排除条件只在该权限部门集合内生效,最终统一改写为明确的包含集合,避免空集合或排除
- * 条件导致底层统计退化为全量查询。
+ * 平台管理员保留全局部门查询能力,避免将整租户部门主键拼成大集合后传递给多个统计查询。
+ * 其他账号由钱包服务根据当前用户主键和食堂商户类型返回所属食堂覆盖的用户部门,页面传入的
+ * 包含或排除条件只在该权限部门集合内生效,最终统一改写为明确的包含集合。
*
* @param dto 待收敛的部门统计条件,方法会写入最终允许查询的部门 ID 集合
* @return 是否仍存在可查询部门;false 表示调用方必须直接返回空数据
@@ -183,7 +236,9 @@ public class HardOrderTotalController {
if (user == null || user.getId() == null) {
return false;
}
-
+ if (canViewAllDepartmentStatistics(user)) {
+ return true;
+ }
R> departmentResponse = mchService.getSelfMchUserDeptIdListByUserId(
user.getId(), List.of(MerchantTypeEnum.CANTEEN));
if (departmentResponse == null || !departmentResponse.isOk() || ObjectUtil.isEmpty(departmentResponse.getData())) {
@@ -217,6 +272,25 @@ public class HardOrderTotalController {
return true;
}
+ /**
+ * 判断当前用户是否以平台管理员身份查看全部部门统计。
+ *
+ * 仅 {@code ROLE_ADMIN} 绕过食堂商户部门范围,其他业务管理员仍需应用既有商户隔离,
+ * 避免为修复平台管理员查询而扩大普通管理账号的数据权限。
+ *
+ * @param user 当前认证用户,调用前已保证用户主键有效
+ * @return 当前用户包含平台管理员角色时返回 true
+ * @throws IllegalStateException 当前用户信息远程查询失败时抛出,避免将权限异常伪装为空数据
+ */
+ private boolean canViewAllDepartmentStatistics(PjcloudUser user) {
+ R userResponse = userService.getInnerByUserId(user.getId());
+ if (userResponse == null || !userResponse.isOk() || userResponse.getData() == null) {
+ throw new IllegalStateException("查询部门交易统计的当前用户信息失败");
+ }
+ String[] roleCodes = userResponse.getData().getRoleCodes();
+ return ObjectUtil.isNotEmpty(roleCodes) && Arrays.asList(roleCodes).contains(CommonRoleConstant.ROLE_ADMIN);
+ }
+
@Operation(summary = "设备交易记录导出", description = "设备交易记录导出")
@PostMapping("/export-sb")
@SysLog("设备交易记录导出")
@@ -526,10 +600,10 @@ public class HardOrderTotalController {
}
/**
- * 导出当前用户所属食堂商户范围内的部门交易统计。
+ * 导出当前用户有权查看的部门交易统计。
*
* @param response Excel 文件响应
- * @param dto 时间、钱包类型及部门树筛选条件;部门条件会与当前用户可管理的食堂部门取交集
+ * @param dto 时间、钱包类型及部门树筛选条件;平台管理员保留全局范围,其他账号与可管理食堂部门取交集
* @throws IOException 写入 Excel 响应失败时抛出
*/
@Operation(summary = "部门交易记录导出", description = "部门交易记录导出")
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderTotalExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderTotalExportService.java
new file mode 100644
index 00000000..44530e12
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderTotalExportService.java
@@ -0,0 +1,29 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
+import com.bsd.pjcloud.hard.api.dto.order.HardDeptTotalDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
+
+/**
+ * 部门与商户交易汇总异步导出服务。
+ *
+ * 该服务负责冻结查询条件、拆分统计查询、生成模板工作簿、上报任务进度并上传文件中心。
+ */
+public interface HardOrderTotalExportService {
+
+ /**
+ * 提交部门交易汇总异步导出任务。
+ *
+ * @param dto 已由控制器收敛到当前用户权限范围的部门统计条件
+ * @return 可供前端轮询的初始任务进度
+ */
+ AsyncTaskProgressVO submitDepartmentExport(HardDeptTotalDto dto);
+
+ /**
+ * 提交商户交易汇总异步导出任务。
+ *
+ * @param dto 已展开商户层级的商户统计条件
+ * @return 可供前端轮询的初始任务进度
+ */
+ AsyncTaskProgressVO submitMerchantExport(HardOrderDto dto);
+}
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
index 9c4e7274..ce71c5f9 100644
--- 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
@@ -1,7 +1,10 @@
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;
/**
* 终端在库人脸维护服务。
@@ -16,4 +19,21 @@ public interface HardTerminalFaceService {
* @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
index 652e2e9b..b584aa19 100644
--- 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
@@ -1,19 +1,29 @@
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;
+
/**
* 终端在库人脸维护服务实现。
*/
@@ -21,6 +31,16 @@ import org.springframework.stereotype.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。
*/
@@ -36,6 +56,11 @@ public class HardTerminalFaceServiceImpl implements HardTerminalFaceService {
*/
private final RemoteUserFaceService remoteUserFaceService;
+ /**
+ * Redis 字符串客户端,用于在同一商户部门的多台终端之间选出本轮唯一上报者。
+ */
+ private final StringRedisTemplate stringRedisTemplate;
+
/**
* 根据当前设备真实绑定关系删除部门范围内人脸。
*
@@ -45,6 +70,93 @@ public class HardTerminalFaceServiceImpl implements HardTerminalFaceService {
*/
@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不能为空");
}
@@ -59,17 +171,32 @@ public class HardTerminalFaceServiceImpl implements HardTerminalFaceService {
if (hardDetail == null || hardDetail.getMerchantDeptId() == null) {
throw new RuntimeException("设备绑定商户缺少部门");
}
+ hardDetail.setMerchantId(hard.getMerchantId());
+ return hardDetail;
+ }
- SysUserFaceTerminalDeleteDto request = new SysUserFaceTerminalDeleteDto();
- request.setMerchantDeptId(hardDetail.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 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/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalExportServiceImpl.java
new file mode 100644
index 00000000..8aab7980
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalExportServiceImpl.java
@@ -0,0 +1,717 @@
+package com.bsd.pjcloud.hard.service.impl.order;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.date.DatePattern;
+import cn.hutool.core.date.LocalDateTimeUtil;
+import cn.hutool.core.io.resource.ResourceUtil;
+import cn.hutool.core.util.StrUtil;
+import com.alibaba.excel.EasyExcel;
+import com.alibaba.excel.ExcelWriter;
+import com.alibaba.excel.write.metadata.WriteSheet;
+import com.alibaba.excel.write.metadata.fill.FillConfig;
+import com.bsd.pjcloud.admin.api.dto.DeptAllListDto;
+import com.bsd.pjcloud.admin.api.entity.SysDept;
+import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
+import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.hard.api.dto.order.HardDeptTotalDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
+import com.bsd.pjcloud.hard.api.vo.order.HardDeptTotalVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo;
+import com.bsd.pjcloud.hard.service.HardOrderTotalExportService;
+import com.bsd.pjcloud.hard.service.HardOrderTotalService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 部门与商户交易汇总异步导出服务实现。
+ *
+ * 部门统计按部门主键批次查询,商户统计按自然日批次查询并按商户合并。两个任务继续使用原有
+ * XLSX 模板,完整文件生成后上传文件中心,由前端根据任务进度下载结果。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class HardOrderTotalExportServiceImpl implements HardOrderTotalExportService {
+
+ /** 统计导出日志标签,用于定位分片查询、模板填充和文件上传异常。 */
+ private static final String LOG_TAG = "交易汇总导出";
+
+ /** 部门交易导出任务使用的稳定 Redis 业务命名空间。 */
+ private static final String DEPARTMENT_EXPORT_REDIS_KEY = "hard:order-total:export:department";
+
+ /** 商户交易导出任务使用的稳定 Redis 业务命名空间。 */
+ private static final String MERCHANT_EXPORT_REDIS_KEY = "hard:order-total:export:merchant";
+
+ /** 部门交易导出任务在进度弹窗中显示的名称。 */
+ private static final String DEPARTMENT_EXPORT_TASK_NAME = "部门交易记录导出";
+
+ /** 商户交易导出任务在进度弹窗中显示的名称。 */
+ private static final String MERCHANT_EXPORT_TASK_NAME = "商户交易记录导出";
+
+ /** 后台尚未计算实际批次数时使用的初始任务总进度。 */
+ private static final int EXPORT_INITIAL_TOTAL = 1;
+
+ /** 单次部门统计查询包含的最大部门数量,控制远程钱包与订单聚合 SQL 的 IN 条件长度。 */
+ private static final int DEPARTMENT_EXPORT_BATCH_SIZE = 100;
+
+ /** 所有查询批次完成后,工作簿生成和文件上传各占一个进度步骤。 */
+ private static final int EXPORT_EXTRA_PROGRESS_STEPS = 2;
+
+ /** 部门交易汇总使用的现有 XLSX 模板资源路径。 */
+ private static final String DEPARTMENT_EXPORT_TEMPLATE = "file/部门交易金额汇总.xlsx";
+
+ /** 商户交易汇总使用的现有 XLSX 模板资源路径。 */
+ private static final String MERCHANT_EXPORT_TEMPLATE = "file/商户交易金额汇总.xlsx";
+
+ /** 文件中心保存交易汇总导出文件的业务目录。 */
+ private static final String EXPORT_FILE_DIR = "hard-export";
+
+ /** 文件中心区分部门交易汇总文件的业务类型编码。 */
+ private static final String DEPARTMENT_EXPORT_FILE_TYPE = "HARD_DEPARTMENT_TOTAL_EXPORT";
+
+ /** 文件中心区分商户交易汇总文件的业务类型编码。 */
+ private static final String MERCHANT_EXPORT_FILE_TYPE = "HARD_MERCHANT_TOTAL_EXPORT";
+
+ /** XLSX 文件上传时使用的标准 MIME 类型。 */
+ private static final String EXCEL_CONTENT_TYPE =
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+
+ /** 异步导出文件名使用的时间格式。 */
+ private static final DateTimeFormatter EXPORT_FILE_TIME_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+ /** 交易统计服务,负责复用页面当前的商户和部门统计口径。 */
+ private final HardOrderTotalService hardOrderTotalService;
+
+ /** 部门远程服务,用于先解析完整部门范围再切分成明确的包含批次。 */
+ private final RemoteDeptService remoteDeptService;
+
+ /** 文件中心远程服务,用于持久化后台生成的完整工作簿。 */
+ private final RemoteFileService remoteFileService;
+
+ /** 通用异步任务执行器,负责恢复请求上下文、保存任务进度和记录终态。 */
+ private final AsyncTaskExecutorService asyncTaskExecutorService;
+
+ /**
+ * 提交部门交易汇总异步导出任务。
+ *
+ * @param dto 已由控制器收敛到当前用户权限范围的部门统计条件
+ * @return 可供前端轮询的初始任务进度
+ */
+ @Override
+ public AsyncTaskProgressVO submitDepartmentExport(HardDeptTotalDto dto) {
+ HardDeptTotalDto querySnapshot = copyDepartmentQuery(dto);
+ return asyncTaskExecutorService.submitWithResult(DEPARTMENT_EXPORT_REDIS_KEY,
+ DEPARTMENT_EXPORT_TASK_NAME, EXPORT_INITIAL_TOTAL,
+ reporter -> exportDepartmentToFile(querySnapshot, reporter));
+ }
+
+ /**
+ * 提交商户交易汇总异步导出任务。
+ *
+ * @param dto 已展开商户层级的商户统计条件
+ * @return 可供前端轮询的初始任务进度
+ */
+ @Override
+ public AsyncTaskProgressVO submitMerchantExport(HardOrderDto dto) {
+ HardOrderDto querySnapshot = copyMerchantQuery(dto);
+ return asyncTaskExecutorService.submitWithResult(MERCHANT_EXPORT_REDIS_KEY,
+ MERCHANT_EXPORT_TASK_NAME, EXPORT_INITIAL_TOTAL,
+ reporter -> exportMerchantToFile(querySnapshot, reporter));
+ }
+
+ /**
+ * 按部门主键分批统计并生成部门交易汇总文件。
+ *
+ * @param query 已冻结权限范围和页面筛选条件的部门查询
+ * @param reporter 当前异步任务进度报告器
+ * @return 文件中心下载地址和最终文件名
+ */
+ private AsyncTaskResult exportDepartmentToFile(HardDeptTotalDto query, AsyncTaskProgressReporter reporter) {
+ List departmentIds = listDepartmentIds(query);
+ if (departmentIds.isEmpty()) {
+ throw new IllegalStateException("当前筛选条件没有可导出的部门交易数据");
+ }
+ int batchCount = calculateBatchCount(departmentIds.size(), DEPARTMENT_EXPORT_BATCH_SIZE);
+ reporter.updateTotal(batchCount + EXPORT_EXTRA_PROGRESS_STEPS,
+ "已拆分为 " + batchCount + " 个部门批次,正在生成统计文件");
+
+ List
+ *
+ * @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 位正整数。
*
@@ -657,11 +840,16 @@ public class SysUserFaceServiceImpl extends ServiceImpl()
+ .set(SysUserFaceEntity::getFaceImg, sysUserFace.getFaceImg())
+ .set(SysUserFaceEntity::getUpdateTime, LocalDateTime.now())
+ .set(SysUserFaceEntity::getQualityScore, null)
+ .set(SysUserFaceEntity::getQualityStatus, null)
+ .set(SysUserFaceEntity::getQualityCheckedAt, null)
+ .set(SysUserFaceEntity::getQualityErrorCode, null)
+ .set(SysUserFaceEntity::getQualityDeviceId, null)
+ .eq(SysUserFaceEntity::getId, userFace.getId()));
+ if (updatedRows <= 0) {
throw new RuntimeException("人脸样本修改失败,请刷新后重试");
}
publishFaceChange(userFace.getUserId(), null);
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 5d7d0047..5e0366d1 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
@@ -7,6 +7,11 @@
+
+
+
+
+
@@ -23,6 +28,11 @@
a.process_status,
a.reject_reason,
a.face_img,
+ a.quality_score,
+ a.quality_status,
+ a.quality_checked_at,
+ a.quality_error_code,
+ a.quality_device_id,
a.create_by,
a.create_time,
a.update_by,
@@ -136,7 +146,7 @@
d.dept_id as deptId,
d.name as deptName,
a.face_img as faceImg,
- a.update_time as sourceUpdatedAt
+ coalesce(a.update_time, a.create_time) as sourceUpdatedAt
from sys_user_face a
inner join sys_user u on u.user_id = a.user_id and u.del_flag = '0'
inner join sys_dept d on d.dept_id = u.dept_id and d.del_flag = '0'
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceTerminalQualityReportServiceImplTest.java b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceTerminalQualityReportServiceImplTest.java
new file mode 100644
index 00000000..b2ffd8f7
--- /dev/null
+++ b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceTerminalQualityReportServiceImplTest.java
@@ -0,0 +1,252 @@
+package com.bsd.pjcloud.admin.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+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.vo.SysUserFaceTerminalQualityReportVo;
+import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo;
+import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper;
+import com.bsd.pjcloud.admin.service.SysDeptService;
+import com.bsd.pjcloud.admin.service.SysRoleService;
+import com.bsd.pjcloud.admin.service.SysUserRelationService;
+import com.bsd.pjcloud.admin.service.SysUserService;
+import com.bsd.pjcloud.admin.utils.flow.FlowUtil;
+import com.bsd.pjcloud.flow.task.api.feign.RemoteFlowEngineService;
+import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.redisson.Redisson;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+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.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 用户服务终端人脸质量上报测试,验证协议组合、部门范围、图片版本和状态优先级规则。
+ */
+class SysUserFaceTerminalQualityReportServiceImplTest {
+
+ /** 测试人脸当前图片版本,模拟数据库 update_time。 */
+ private static final LocalDateTime SOURCE_UPDATED_AT = LocalDateTime.of(2026, 9, 1, 9, 0);
+
+ /** 被测服务使用的人脸 Mapper。 */
+ private final SysUserFaceMapper faceMapper = mock(SysUserFaceMapper.class);
+
+ /** 部门树服务。 */
+ private final SysDeptService sysDeptService = mock(SysDeptService.class);
+
+ /** 被测用户人脸服务。 */
+ private SysUserFaceServiceImpl service;
+
+ /** 创建只保留质量上报路径依赖的服务实例,并注入 MyBatis-Plus 基础 Mapper。 */
+ @BeforeEach
+ void setUp() {
+ service = new SysUserFaceServiceImpl(
+ mock(SysUserService.class),
+ sysDeptService,
+ mock(SysRoleService.class),
+ mock(SysUserRelationService.class),
+ mock(StringRedisTemplate.class),
+ mock(Redisson.class),
+ mock(ApplicationEventPublisher.class),
+ mock(RemoteFlowEngineService.class),
+ mock(FlowUtil.class));
+ ReflectionTestUtils.setField(service, "baseMapper", faceMapper);
+ }
+
+ /** detected 必须带合法分数且不能带错误码,failed 不能带分数。 */
+ @Test
+ void reportRejectsInvalidStatusScoreCombinations() {
+ LocalDateTime checkedAt = LocalDateTime.of(2026, 9, 1, 10, 30);
+ SysUserFaceQualityReportItemDto missingSourceVersion =
+ item("1001", "detected", "0.80", checkedAt, null);
+ missingSourceVersion.setSourceUpdatedAt(null);
+
+ assertThrows(RuntimeException.class,
+ () -> service.terminalReportQuality(request(item("1001", "detected", null, checkedAt, null))));
+ assertThrows(RuntimeException.class,
+ () -> service.terminalReportQuality(request(item("1001", "detected", "1.01", checkedAt, null))));
+ assertThrows(RuntimeException.class,
+ () -> service.terminalReportQuality(request(item("1001", "failed", "0.50", checkedAt, 90114))));
+ assertThrows(RuntimeException.class,
+ () -> service.terminalReportQuality(request(missingSourceVersion)));
+
+ verify(faceMapper, never()).terminalQualityCandidates(any());
+ }
+
+ /** 同一批出现重复样本编号时必须整批拒绝,避免相互冲突的结果依赖请求顺序。 */
+ @Test
+ void reportRejectsDuplicateSampleIds() {
+ LocalDateTime checkedAt = LocalDateTime.of(2026, 9, 1, 10, 30);
+ SysUserFaceTerminalQualityReportDto request = request(
+ item("1001", "detected", "0.80", checkedAt, null),
+ item(" 1001 ", "failed", null, checkedAt.plusSeconds(1), 90114));
+
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> service.terminalReportQuality(request));
+
+ assertEquals("人脸质量上报包含重复样本编号", exception.getMessage());
+ verify(faceMapper, never()).terminalQualityCandidates(any());
+ }
+
+ /** 请求中任一仍存在的人脸不属于设备部门树时,整批不能产生数据库更新。 */
+ @Test
+ void reportRejectsWholeBatchWhenAnyFaceIsOutOfScope() {
+ LocalDateTime checkedAt = LocalDateTime.of(2026, 9, 1, 10, 30);
+ when(sysDeptService.listDescendant(30L)).thenReturn(List.of(dept(30L), dept(31L)));
+ when(faceMapper.terminalQualityCandidates(any())).thenReturn(List.of(
+ face(1L, "1001", 30L, null),
+ face(2L, "1002", 99L, null)));
+
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> service.terminalReportQuality(request(
+ item("1001", "detected", "0.82", checkedAt, null),
+ item("1002", "failed", null, checkedAt, 90114))));
+
+ assertEquals("请求包含不属于当前设备部门范围的人脸", exception.getMessage());
+ verify(faceMapper, never()).update(isNull(), any(LambdaUpdateWrapper.class));
+ }
+
+ /** 仍存在但尚未审核通过的人脸不能接收终端质量,且不能伪装成已删除样本。 */
+ @Test
+ void reportRejectsExistingFaceThatIsNotApproved() {
+ LocalDateTime checkedAt = LocalDateTime.of(2026, 9, 1, 10, 30);
+ SysUserFaceVo pendingFace = face(1L, "1001", 30L, null);
+ pendingFace.setProcessStatus(TaskCheckStatusEnum.PADDING.getCode());
+ when(sysDeptService.listDescendant(30L)).thenReturn(List.of(dept(30L)));
+ when(faceMapper.terminalQualityCandidates(any())).thenReturn(List.of(pendingFace));
+
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> service.terminalReportQuality(request(
+ item("1001", "detected", "0.82", checkedAt, null))));
+
+ assertEquals("请求包含未审核通过的人脸", exception.getMessage());
+ verify(faceMapper, never()).update(isNull(), any(LambdaUpdateWrapper.class));
+ }
+
+ /** 新结果写入、相同或旧时间忽略、已删除样本幂等确认应在同一响应中准确区分。 */
+ @Test
+ void reportUpdatesNewerResultsAndClassifiesStaleAndAbsentSamples() {
+ LocalDateTime checkedAt = LocalDateTime.of(2026, 9, 1, 10, 30);
+ when(sysDeptService.listDescendant(30L)).thenReturn(List.of(dept(30L), dept(31L)));
+ when(faceMapper.terminalQualityCandidates(any())).thenReturn(List.of(
+ face(1L, "1001", 31L, null),
+ face(2L, "1002", 30L, checkedAt.plusMinutes(1))));
+ when(faceMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1);
+
+ SysUserFaceTerminalQualityReportVo result = service.terminalReportQuality(request(
+ item("1001", "detected", "0.82", checkedAt, null),
+ item("1002", "failed", null, checkedAt, 90114),
+ item("1003", "detected", "0.58", checkedAt, null)));
+
+ assertEquals(List.of("1001"), result.getUpdatedSampleIds());
+ assertEquals(List.of("1002"), result.getIgnoredStaleSampleIds());
+ assertEquals(List.of("1003"), result.getAlreadyAbsentSampleIds());
+ verify(faceMapper).update(isNull(), any(LambdaUpdateWrapper.class));
+ }
+
+ /** 终端上报的图片版本与服务端当前版本不一致时必须忽略,防止旧图片质量污染新图片。 */
+ @Test
+ void reportIgnoresQualityFromDifferentImageVersion() {
+ LocalDateTime checkedAt = LocalDateTime.of(2026, 9, 1, 10, 30);
+ SysUserFaceQualityReportItemDto item = item("1001", "detected", "0.82", checkedAt, null);
+ item.setSourceUpdatedAt(SOURCE_UPDATED_AT.minusMinutes(1));
+ when(sysDeptService.listDescendant(30L)).thenReturn(List.of(dept(30L)));
+ when(faceMapper.terminalQualityCandidates(any())).thenReturn(List.of(
+ face(1L, "1001", 30L, null)));
+
+ SysUserFaceTerminalQualityReportVo result = service.terminalReportQuality(request(item));
+
+ assertEquals(List.of(), result.getUpdatedSampleIds());
+ assertEquals(List.of("1001"), result.getIgnoredStaleSampleIds());
+ verify(faceMapper, never()).update(isNull(), any(LambdaUpdateWrapper.class));
+ }
+
+ /** 同一图片已有 detected 时,时间更晚的 failed 也不得覆盖有效分数。 */
+ @Test
+ void reportDoesNotReplaceDetectedWithFailed() {
+ LocalDateTime checkedAt = LocalDateTime.of(2026, 9, 1, 10, 30);
+ SysUserFaceVo detectedFace = face(1L, "1001", 30L, checkedAt.minusMinutes(1));
+ detectedFace.setQualityStatus("detected");
+ detectedFace.setQualityScore(new BigDecimal("0.82"));
+ when(sysDeptService.listDescendant(30L)).thenReturn(List.of(dept(30L)));
+ when(faceMapper.terminalQualityCandidates(any())).thenReturn(List.of(detectedFace));
+
+ SysUserFaceTerminalQualityReportVo result = service.terminalReportQuality(request(
+ item("1001", "failed", null, checkedAt.plusHours(2), 90114)));
+
+ assertEquals(List.of("1001"), result.getIgnoredStaleSampleIds());
+ verify(faceMapper, never()).update(isNull(), any(LambdaUpdateWrapper.class));
+ }
+
+ /** detected 必须能够覆盖同一图片较新的 failed,避免未授权设备长期占用失败状态。 */
+ @Test
+ void reportReplacesFailedWithDetectedRegardlessOfDeviceClock() {
+ LocalDateTime checkedAt = LocalDateTime.of(2026, 9, 1, 10, 30);
+ SysUserFaceVo failedFace = face(1L, "1001", 30L, checkedAt.plusHours(2));
+ failedFace.setQualityStatus("failed");
+ when(sysDeptService.listDescendant(30L)).thenReturn(List.of(dept(30L)));
+ when(faceMapper.terminalQualityCandidates(any())).thenReturn(List.of(failedFace));
+ when(faceMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1);
+
+ SysUserFaceTerminalQualityReportVo result = service.terminalReportQuality(request(
+ item("1001", "detected", "0.82", checkedAt, null)));
+
+ assertEquals(List.of("1001"), result.getUpdatedSampleIds());
+ verify(faceMapper).update(isNull(), any(LambdaUpdateWrapper.class));
+ }
+
+ /** 构造带可信部门和设备的内部质量请求。 */
+ private static SysUserFaceTerminalQualityReportDto request(SysUserFaceQualityReportItemDto... items) {
+ SysUserFaceTerminalQualityReportDto dto = new SysUserFaceTerminalQualityReportDto();
+ dto.setMerchantDeptId(30L);
+ dto.setQualityDeviceId(10L);
+ dto.setItems(List.of(items));
+ return dto;
+ }
+
+ /** 构造一条跨端质量结果。 */
+ private static SysUserFaceQualityReportItemDto item(
+ String sampleId, String status, String score, LocalDateTime checkedAt, Integer errorCode) {
+ SysUserFaceQualityReportItemDto item = new SysUserFaceQualityReportItemDto();
+ item.setSampleId(sampleId);
+ item.setStatus(status);
+ item.setScore(score == null ? null : new BigDecimal(score));
+ item.setSourceUpdatedAt(SOURCE_UPDATED_AT);
+ item.setCheckedAt(checkedAt);
+ item.setErrorCode(errorCode);
+ return item;
+ }
+
+ /** 构造部门树节点。 */
+ private static SysDept dept(Long deptId) {
+ SysDept dept = new SysDept();
+ dept.setDeptId(deptId);
+ return dept;
+ }
+
+ /** 构造仍存在的人脸、用户当前部门和服务端已有检测时间。 */
+ private static SysUserFaceVo face(Long id, String sampleId, Long deptId, LocalDateTime checkedAt) {
+ SysUserFaceVo face = new SysUserFaceVo();
+ face.setId(id);
+ face.setAutoCode(sampleId);
+ face.setDeptId(deptId);
+ face.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode());
+ face.setUpdateTime(SOURCE_UPDATED_AT);
+ face.setQualityCheckedAt(checkedAt);
+ return face;
+ }
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeApplyMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeApplyMapper.java
index 555fe42d..9df90248 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeApplyMapper.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeApplyMapper.java
@@ -23,11 +23,34 @@ import java.util.List;
@Mapper
public interface WalletRechargeApplyMapper extends PjcloudBaseMapper {
-
+ /**
+ * 按当前操作者数据权限分页查询充值申请。
+ *
+ * @param page 分页参数,第一页默认同时查询符合条件的申请总数
+ * @param dto 申请人、审批、钱包类型、发票及时间筛选条件
+ * @param dataScope 当前操作者的数据权限范围
+ * @return 保留分页顺序的充值申请;批量金额由 Service 在当前页查询后装配
+ */
Page scopePageList(Page page, @Param("dto") WalletRechargeApplyDto dto, DataScope dataScope);
+ /**
+ * 按调用方显式传入的数据权限范围分页查询充值申请。
+ *
+ * @param page 分页参数
+ * @param dto 申请人、审批、钱包类型、发票及时间筛选条件
+ * @param dataScope 可选的数据权限对象;为空时不附加数据范围限制
+ * @return 保留分页顺序的充值申请;批量金额由 Service 在当前页查询后装配
+ */
Page pageJoin(Page page, @Param("dto") WalletRechargeApplyDto dto, DataScope dataScope);
+ /**
+ * 按当前页申请主键批量汇总未删除充值明细的操作金额。
+ *
+ * @param applyIds 当前分页返回的申请主键,调用方保证非空并完成去重
+ * @return 仅包含申请主键和总金额的部分充值申请对象;没有有效明细的申请不返回记录
+ */
+ List listTotalPriceByApplyIds(@Param("applyIds") List applyIds);
+
default Page scopePageList(Page page, WalletRechargeApplyDto dto) {
LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
.eq(ObjectUtil.isNotEmpty(dto.getBizType()), WalletRechargeApplyEntity::getBizType, dto.getBizType())
@@ -60,4 +83,4 @@ public interface WalletRechargeApplyMapper extends PjcloudBaseMapper list);
+ /**
+ * 分页查询充值申请,并批量补齐当前页金额和用户展示资料。
+ *
+ * @param page 分页参数
+ * @param dto 充值申请筛选条件
+ * @param dataScope 可选的数据权限范围
+ * @return 已完成当前页展示资料装配的充值申请分页
+ */
Page pageJoin(Page page, WalletRechargeApplyDto dto, DataScope dataScope);
+ /**
+ * 按当前操作者数据权限分页查询充值申请,并批量补齐当前页展示资料和待办状态。
+ *
+ * @param page 分页参数
+ * @param dto 充值申请筛选条件
+ * @param dataScope 当前操作者的数据权限范围
+ * @return 已完成当前页展示资料装配的充值申请分页
+ */
Page scopePageList(Page page, WalletRechargeApplyDto dto, DataScope dataScope);
/**
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java
index c6a2efa7..92986ffe 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java
@@ -35,7 +35,6 @@ import com.bsd.pjcloud.wallet.api.constant.WalletBizRechargeEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletRechargeInvoiceStatusEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.dto.WalletManageDto;
-import com.bsd.pjcloud.wallet.api.dto.WalletTypeDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeApplyDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeImportDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeSaveTopupDto;
@@ -44,14 +43,12 @@ import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeImportEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeInvoiceEntity;
-import com.bsd.pjcloud.wallet.api.utils.WalletDataUtil;
import com.bsd.pjcloud.wallet.api.vo.WalletManageVo;
import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeApplyVo;
import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeImportVo;
import com.bsd.pjcloud.wallet.api.vo.recharge.excel.WalletRechargeByApplyExcelVo;
import com.bsd.pjcloud.wallet.mapper.WalletManageMapper;
import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
-import com.bsd.pjcloud.wallet.mapper.WalletTypeMapper;
import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeApplyMapper;
import com.bsd.pjcloud.wallet.service.*;
import com.bsd.pjcloud.wallet.support.WalletUserIdentitySupport;
@@ -92,7 +89,6 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl pageJoin(Page page, WalletRechargeApplyDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page list = baseMapper.pageJoin(page, dto, dataScope);
if (ObjectUtil.isNotEmpty(list.getRecords())) {
+ fillTotalPrice(list.getRecords());
userIdentitySupport.fillUserInfo(list.getRecords(), true);
// 部门
List deptIds = list.getRecords().stream().map(WalletRechargeApplyVo::getDeptId).distinct().toList();
@@ -225,10 +230,19 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl scopePageList(Page page, WalletRechargeApplyDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page list = baseMapper.scopePageList(page, dto, dataScope);
+ fillTotalPrice(list.getRecords());
disposeList(list.getRecords(), dto);
return list;
}
@@ -470,6 +484,12 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl list, WalletRechargeApplyDto dto) {
if (ObjectUtil.isEmpty(list)) return;
userIdentitySupport.fillUserInfo(list, true);
@@ -481,19 +501,14 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl walletTypeIds = list.stream().map(WalletRechargeApplyVo::getWalletTypeId).distinct().toList();
- if (ObjectUtil.isNotEmpty(walletTypeIds)) {
- WalletTypeDto walletTypeDto = new WalletTypeDto();
- walletTypeDto.setWalletTypeIds(walletTypeIds);
- List walletTypeList = walletTypeMapper.list(walletTypeDto);
- if (ObjectUtil.isNotEmpty(walletTypeList)) {
- WalletDataUtil.convertWalletTypeList(list, CollectionUtils.convertMap(walletTypeList, WalletTypeEntity::getId));
- }
- }
// 审批流程
- if (dto.getIsFindCheckUser()) {
- List processInstanceIds = list.stream().map(WalletRechargeApplyVo::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList();
+ if (Boolean.TRUE.equals(dto.getIsFindCheckUser())) {
+ List processInstanceIds = list.stream()
+ .filter(v -> ObjectUtil.equals(v.getProcessStatus(), TaskCheckStatusEnum.PADDING.getCode()))
+ .map(WalletRechargeApplyVo::getProcessId)
+ .filter(ObjectUtil::isNotEmpty)
+ .distinct()
+ .toList();
if (ObjectUtil.isNotEmpty(processInstanceIds)) {
List taskList = flowUtil.queryProcessInstanceList(SecurityUtils.getUser().getId().toString(), processInstanceIds);
if (ObjectUtil.isNotEmpty(taskList)) {
@@ -510,6 +525,25 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl没有有效充值明细的申请继续保留 {@code null},与原 {@code SUM} 子查询的返回语义一致。
+ *
+ * @param list 当前分页的充值申请;为空时不访问数据库
+ */
+ private void fillTotalPrice(List list) {
+ if (ObjectUtil.isEmpty(list)) return;
+ List applyIds = list.stream().map(WalletRechargeApplyVo::getId).filter(Objects::nonNull).distinct().toList();
+ if (ObjectUtil.isEmpty(applyIds)) return;
+ List totalPriceList = baseMapper.listTotalPriceByApplyIds(applyIds);
+ Map totalPriceMap = CollectionUtils.convertMap(totalPriceList, WalletRechargeApplyVo::getId);
+ for (WalletRechargeApplyVo item : list) {
+ WalletRechargeApplyVo totalPrice = totalPriceMap.get(item.getId());
+ item.setTotalPrice(totalPrice == null ? null : totalPrice.getTotalPrice());
+ }
+ }
+
/**
* 将充值申请身份条件预筛选为申请人用户主键,保证分页和权限查询使用同一用户范围。
*
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/WalletUserIdentitySupport.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/WalletUserIdentitySupport.java
index ac959177..cca17a2f 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/WalletUserIdentitySupport.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/WalletUserIdentitySupport.java
@@ -95,9 +95,7 @@ public class WalletUserIdentitySupport {
if (userIds.isEmpty()) {
return;
}
- UserDTO userQuery = new UserDTO();
- userQuery.setUserIds(userIds);
- Map userMap = CollectionUtils.convertMap(fetchAllUsers(userQuery), UserVO::getUserId);
+ Map userMap = CollectionUtils.convertMap(fetchUsersByIds(userIds), UserVO::getUserId);
for (T row : rows) {
UserVO user = userMap.get(row.getUserId());
if (user == null) {
@@ -187,6 +185,36 @@ public class WalletUserIdentitySupport {
return users;
}
+ /**
+ * 分批查询已知主键的用户展示资料,并关闭无业务用途的分页总数统计。
+ *
+ * 调用方传入的用户主键已经来自完成数据权限过滤的业务记录;UPMS 仍按当前登录人执行数据权限,且每批最多
+ * {@value USER_BATCH_SIZE} 条,保证响应体和 SQL IN 条件受控。
+ *
+ * @param userIds 当前业务记录中的去重用户主键
+ * @return UPMS 数据权限范围内存在的用户及身份资料
+ */
+ private List fetchUsersByIds(List userIds) {
+ List users = new ArrayList<>();
+ int batchSize = Math.toIntExact(USER_BATCH_SIZE);
+ for (int fromIndex = 0; fromIndex < userIds.size(); fromIndex += batchSize) {
+ int toIndex = Math.min(fromIndex + batchSize, userIds.size());
+ UserDTO userQuery = new UserDTO();
+ userQuery.setUserIds(new ArrayList<>(userIds.subList(fromIndex, toIndex)));
+ R> response = remoteUserService.getScopeBasicUserPageWithCountOption(
+ 1L, (long) (toIndex - fromIndex), false, userQuery);
+ if (response == null || !response.isOk() || response.getData() == null) {
+ String message = response == null ? null : response.getMsg();
+ throw new IllegalStateException(StrUtil.blankToDefault(message, "查询权限用户及身份信息失败"));
+ }
+ List records = response.getData().getRecords();
+ if (records != null) {
+ users.addAll(records);
+ }
+ }
+ return users;
+ }
+
/**
* 判断学生扩展对象是否包含有效筛选字段。
*
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml
index 3a71db16..240485a9 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml
@@ -36,7 +36,7 @@
and a.user_id = #{dto.userId}
-
+
and a.biz_type = #{dto.bizType}
@@ -55,7 +55,7 @@
and a.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]}
- and a.create_time between #{dto.processTimeList[0]} and #{dto.processTimeList[1]}
+ and a.process_time between #{dto.processTimeList[0]} and #{dto.processTimeList[1]}
@@ -65,32 +65,24 @@
t.unique_code as walletTypeUniqueCode,
t.icon as walletTypeIcon,
t.is_rule as isRule,
- i.code as invoiceCode,
- (select sum(i.price)
- from wallet_recharge_import i
- where i.del_flag = '0'
- and i.apply_id = a.id) as totalPrice
+ i.code as invoiceCode
from wallet_recharge_apply a
left join wallet_type t on a.wallet_type_id = t.id and t.del_flag = '0'
left join wallet_recharge_invoice i on i.id = a.invoice_id and i.del_flag = '0'
-
+
+ and a.wallet_type_id = #{dto.walletTypeId}
+
+
and i.code = #{dto.invoiceCode}
- group by a.id
order by a.process_time desc , a.id desc
- ORDER BY
- CASE
- WHEN a.apply_time IS NULL THEN 1
- ELSE 0
- END ASC,
- a.apply_time DESC,
- a.create_time DESC
+ order by a.apply_time desc, a.create_time desc, a.id desc
@@ -102,4 +94,17 @@
-
\ No newline at end of file
+
+
+
+
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeApplyMapperSqlContractTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeApplyMapperSqlContractTest.java
new file mode 100644
index 00000000..56fea81d
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeApplyMapperSqlContractTest.java
@@ -0,0 +1,91 @@
+package com.bsd.pjcloud.wallet.mapper.recharge;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 充值申请分页 SQL 契约测试,防止维护时重新引入全量分组、表达式排序或逐行金额汇总。
+ */
+class WalletRechargeApplyMapperSqlContractTest {
+
+ /** 充值申请 MyBatis 映射资源路径。 */
+ private static final String MAPPER_RESOURCE = "mapper/recharge/WalletRechargeApplyMapper.xml";
+
+ /** 测试启动时读取的充值申请 Mapper XML。 */
+ private static String mapperXml;
+
+ /**
+ * 从测试类路径读取实际参与编译的 Mapper XML。
+ *
+ * @throws IOException 资源读取失败时抛出
+ */
+ @BeforeAll
+ static void loadMapperXml() throws IOException {
+ try (InputStream input = WalletRechargeApplyMapperSqlContractTest.class.getClassLoader()
+ .getResourceAsStream(MAPPER_RESOURCE)) {
+ Assertions.assertNotNull(input, "充值申请 Mapper XML 必须进入运行时类路径");
+ mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ /**
+ * 主分页必须只关联一对一展示表,不能对全部候选申请执行明细汇总或无意义分组。
+ */
+ @Test
+ void pageQueryShouldRemainFreeOfDetailAggregateAndGrouping() {
+ String sql = fragment("sql", "selectPageA");
+
+ Assertions.assertAll(
+ () -> Assertions.assertFalse(sql.contains("wallet_recharge_import")),
+ () -> Assertions.assertFalse(sql.toLowerCase().contains("group by")),
+ () -> Assertions.assertFalse(sql.toLowerCase().contains("case")),
+ () -> Assertions.assertTrue(sql.contains("order by a.apply_time desc, a.create_time desc, a.id desc")));
+ }
+
+ /**
+ * 操作金额必须只针对当前页申请主键执行一次集合聚合,并保留软删除约束。
+ */
+ @Test
+ void totalPriceQueryShouldAggregateCurrentPageIdsOnly() {
+ String sql = fragment("select", "listTotalPriceByApplyIds");
+
+ Assertions.assertAll(
+ () -> Assertions.assertTrue(sql.contains("where r.del_flag = '0'")),
+ () -> Assertions.assertTrue(sql.contains("r.apply_id in")),
+ () -> Assertions.assertTrue(sql.contains("collection=\"applyIds\"")),
+ () -> Assertions.assertTrue(sql.contains("group by r.apply_id")));
+ }
+
+ /**
+ * 审批时间和单饭卡类型筛选必须落在各自业务字段,空发票号不能形成无效等值条件。
+ */
+ @Test
+ void filtersShouldUseTheirBusinessColumns() {
+ Assertions.assertAll(
+ () -> Assertions.assertTrue(mapperXml.contains(
+ "a.process_time between #{dto.processTimeList[0]} and #{dto.processTimeList[1]}")),
+ () -> Assertions.assertTrue(mapperXml.contains("dto.bizType != null and dto.bizType != ''")),
+ () -> Assertions.assertTrue(mapperXml.contains("a.wallet_type_id = #{dto.walletTypeId}")),
+ () -> Assertions.assertTrue(mapperXml.contains("dto.invoiceCode != null and dto.invoiceCode != ''")));
+ }
+
+ /**
+ * 提取指定 MyBatis XML 片段。
+ *
+ * @param element XML 元素名,例如 sql 或 select
+ * @param id MyBatis 片段或语句 ID
+ * @return 对应 XML 片段
+ */
+ private static String fragment(String element, String id) {
+ int idIndex = mapperXml.indexOf("id=\"" + id + "\"");
+ int start = mapperXml.lastIndexOf("<" + element, idIndex);
+ int end = mapperXml.indexOf("" + element + ">", idIndex);
+ Assertions.assertTrue(idIndex >= 0 && start >= 0 && end > idIndex, "缺少 MyBatis 片段:" + id);
+ return mapperXml.substring(start, end);
+ }
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImplTest.java
new file mode 100644
index 00000000..34ce175c
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImplTest.java
@@ -0,0 +1,161 @@
+package com.bsd.pjcloud.wallet.service.impl.recharge;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
+import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.data.datascope.DataScope;
+import com.bsd.pjcloud.flow.task.api.feign.RemoteFlowEngineService;
+import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeApplyDto;
+import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeApplyVo;
+import com.bsd.pjcloud.wallet.mapper.WalletManageMapper;
+import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
+import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeApplyMapper;
+import com.bsd.pjcloud.wallet.service.WalletManageService;
+import com.bsd.pjcloud.wallet.service.WalletRechargeInvoiceService;
+import com.bsd.pjcloud.wallet.service.WalletThirdZyService;
+import com.bsd.pjcloud.wallet.service.WalletTransactionService;
+import com.bsd.pjcloud.wallet.service.WalletTypeService;
+import com.bsd.pjcloud.wallet.support.WalletUserIdentitySupport;
+import com.bsd.pjcloud.wallet.utils.flow.FlowUtil;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 充值申请分页当前页金额装配与空页短路测试。
+ */
+@ExtendWith(MockitoExtension.class)
+class WalletRechargeApplyServiceImplTest {
+
+ /** 充值导入服务 Mock。 */
+ @Mock
+ private WalletRechargeImportServiceImpl rechargeImportService;
+ /** 钱包管理服务 Mock。 */
+ @Mock
+ private WalletManageService walletManageService;
+ /** 钱包交易服务 Mock。 */
+ @Mock
+ private WalletTransactionService walletTransactionService;
+ /** 钱包类型服务 Mock。 */
+ @Mock
+ private WalletTypeService walletTypeService;
+ /** 第三方钱包服务 Mock。 */
+ @Mock
+ private WalletThirdZyService walletThirdZyService;
+ /** 钱包交易 Mapper Mock。 */
+ @Mock
+ private WalletTransactionMapper walletTransactionMapper;
+ /** 钱包管理 Mapper Mock。 */
+ @Mock
+ private WalletManageMapper walletManageMapper;
+ /** 充值发票服务 Mock。 */
+ @Mock
+ private WalletRechargeInvoiceService walletRechargeInvoiceService;
+ /** 用户远程服务 Mock。 */
+ @Mock
+ private RemoteUserService userService;
+ /** 部门远程服务 Mock。 */
+ @Mock
+ private RemoteDeptService deptService;
+ /** 用户身份资料装配器 Mock。 */
+ @Mock
+ private WalletUserIdentitySupport userIdentitySupport;
+ /** 流程引擎远程服务 Mock。 */
+ @Mock
+ private RemoteFlowEngineService flowEngineService;
+ /** 流程工具 Mock。 */
+ @Mock
+ private FlowUtil flowUtil;
+ /** 充值申请 Mapper Mock。 */
+ @Mock
+ private WalletRechargeApplyMapper rechargeApplyMapper;
+
+ /** 被测充值申请服务。 */
+ @InjectMocks
+ private WalletRechargeApplyServiceImpl service;
+
+ /**
+ * 把 MyBatis-Plus 基类 Mapper 指向当前 Mock,避免启动 Spring 和数据库。
+ */
+ @BeforeEach
+ void setUp() {
+ ReflectionTestUtils.setField(service, "baseMapper", rechargeApplyMapper);
+ }
+
+ /**
+ * 当前页金额应使用一次去重主键批量查询装配,无有效明细的申请继续返回空金额。
+ */
+ @Test
+ void scopePageListShouldFillTotalPriceWithOneBatchQuery() {
+ WalletRechargeApplyVo first = apply(10L, 100L);
+ WalletRechargeApplyVo second = apply(20L, 100L);
+ WalletRechargeApplyVo firstTotal = new WalletRechargeApplyVo();
+ firstTotal.setId(10L);
+ firstTotal.setTotalPrice(3600);
+ Page page = new Page<>(1, 10);
+ page.setRecords(List.of(first, second));
+ WalletRechargeApplyDto dto = new WalletRechargeApplyDto();
+ DataScope dataScope = DataScope.of();
+ when(rechargeApplyMapper.scopePageList(page, dto, dataScope)).thenReturn(page);
+ when(rechargeApplyMapper.listTotalPriceByApplyIds(anyList())).thenReturn(List.of(firstTotal));
+ when(deptService.getDeptByDeptIds(List.of(100L))).thenReturn(R.ok(Map.of()));
+
+ Page result = service.scopePageList(page, dto, dataScope);
+
+ assertEquals(3600, result.getRecords().get(0).getTotalPrice());
+ assertNull(result.getRecords().get(1).getTotalPrice());
+ ArgumentCaptor> idsCaptor = ArgumentCaptor.forClass(List.class);
+ verify(rechargeApplyMapper).listTotalPriceByApplyIds(idsCaptor.capture());
+ assertEquals(List.of(10L, 20L), idsCaptor.getValue());
+ verify(userIdentitySupport).fillUserInfo(page.getRecords(), true);
+ }
+
+ /**
+ * 空页不得执行金额、用户和部门补查。
+ */
+ @Test
+ void scopePageListShouldSkipEnrichmentForEmptyPage() {
+ Page page = new Page<>(1, 10);
+ page.setRecords(List.of());
+ WalletRechargeApplyDto dto = new WalletRechargeApplyDto();
+ DataScope dataScope = DataScope.of();
+ when(rechargeApplyMapper.scopePageList(page, dto, dataScope)).thenReturn(page);
+
+ service.scopePageList(page, dto, dataScope);
+
+ verify(rechargeApplyMapper, never()).listTotalPriceByApplyIds(anyList());
+ verify(userIdentitySupport, never()).fillUserInfo(eq(page.getRecords()), eq(true));
+ verify(deptService, never()).getDeptByDeptIds(anyList());
+ }
+
+ /**
+ * 创建最小充值申请分页记录。
+ *
+ * @param id 申请主键
+ * @param deptId 申请部门主键
+ * @return 可参与金额和部门装配的分页记录
+ */
+ private WalletRechargeApplyVo apply(Long id, Long deptId) {
+ WalletRechargeApplyVo apply = new WalletRechargeApplyVo();
+ apply.setId(id);
+ apply.setDeptId(deptId);
+ return apply;
+ }
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/WalletUserIdentitySupportTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/WalletUserIdentitySupportTest.java
new file mode 100644
index 00000000..a05e570c
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/WalletUserIdentitySupportTest.java
@@ -0,0 +1,96 @@
+package com.bsd.pjcloud.wallet.support;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.admin.api.dto.UserDTO;
+import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
+import com.bsd.pjcloud.admin.api.vo.UserVO;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeApplyDto;
+import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeApplyVo;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 钱包用户身份筛选和已知用户展示补数的分页策略测试。
+ */
+@ExtendWith(MockitoExtension.class)
+class WalletUserIdentitySupportTest {
+
+ /** UPMS 用户远程服务 Mock。 */
+ @Mock
+ private RemoteUserService remoteUserService;
+
+ /** 被测钱包用户身份支持组件。 */
+ private WalletUserIdentitySupport support;
+
+ /**
+ * 初始化用户身份支持组件。
+ */
+ @BeforeEach
+ void setUp() {
+ support = new WalletUserIdentitySupport(remoteUserService);
+ }
+
+ /**
+ * 当前页已知用户主键补数必须关闭无业务用途的总数查询并回填标准展示字段。
+ */
+ @Test
+ void fillUserInfoShouldDisableCountForKnownUserIds() {
+ WalletRechargeApplyVo row = new WalletRechargeApplyVo();
+ row.setUserId(10L);
+ UserVO user = new UserVO();
+ user.setUserId(10L);
+ user.setName("测试用户");
+ user.setPhone("13800000000");
+ user.setDeptId(20L);
+ user.setDeptName("测试部门");
+ Page userPage = new Page<>(1, 1, false);
+ userPage.setRecords(List.of(user));
+ when(remoteUserService.getScopeBasicUserPageWithCountOption(eq(1L), eq(1L), eq(false), any(UserDTO.class)))
+ .thenReturn(R.ok(userPage));
+
+ support.fillUserInfo(List.of(row), true);
+
+ assertEquals("测试用户", row.getUsername());
+ assertEquals("13800000000", row.getUserMobile());
+ assertEquals(20L, row.getUserDeptId());
+ ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(UserDTO.class);
+ verify(remoteUserService).getScopeBasicUserPageWithCountOption(1L, 1L, false, queryCaptor.capture());
+ assertEquals(List.of(10L), queryCaptor.getValue().getUserIds());
+ verify(remoteUserService, never()).getScopeBasicUserPage(any(), any(), any(UserDTO.class));
+ }
+
+ /**
+ * 未知用户范围的身份筛选必须保留准确总数,以便跨多页收集全部匹配用户主键。
+ */
+ @Test
+ void identityFilterShouldKeepCountUntilAllMatchesAreRead() {
+ WalletRechargeApplyDto query = new WalletRechargeApplyDto();
+ query.setIdentityTypes(List.of("student"));
+ UserVO user = new UserVO();
+ user.setUserId(30L);
+ Page userPage = new Page<>(1, 500);
+ userPage.setTotal(1);
+ userPage.setRecords(List.of(user));
+ when(remoteUserService.getScopeBasicUserPage(eq(1L), eq(500L), any(UserDTO.class))).thenReturn(R.ok(userPage));
+
+ List userIds = support.findIdentityUserIds(query, null, null);
+
+ assertEquals(List.of(30L), userIds);
+ verify(remoteUserService).getScopeBasicUserPage(eq(1L), eq(500L), any(UserDTO.class));
+ verify(remoteUserService, never()).getScopeBasicUserPageWithCountOption(any(), any(), any(), any(UserDTO.class));
+ }
+}