feat:统计增加表头提示

feat:商户交易记录、部门交易记录增加分片、进度条导出
feat:增加人脸质量上报
feat:app"通知"权限修改
This commit is contained in:
dzc committed 2026-09-01 12:43:14 +08:00
1 parent 87c5a6ee39
commit 64fbd4e16a
33 files changed
+2420 -78

No files matched your search

@@ -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;
+5
View File
@@ -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 '修改人',
@@ -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<HardTerminalFaceQualityReportItemDto> items;
}
@@ -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;
}
@@ -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;
}
@@ -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<HardTerminalFaceQualityLeaseVo> 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<SysUserFaceTerminalQualityReportVo> reportTerminalFaceQuality(
@RequestHeader("DEVICE-ID") Long hardId,
@Valid @RequestBody HardTerminalFaceQualityReportDto dto) {
return R.ok(hardTerminalFaceService.reportQuality(hardId, dto));
}
@GetMapping("/get")
@Operation(summary = "获取硬件配置信息")
public R<HardManagerVo> get(@RequestParam Long id) {
@@ -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<AsyncTaskProgressVO> submitMerchantExport(@RequestBody HardOrderDto dto) {
dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes());
if (ObjectUtil.isNotEmpty(dto.getMerchantId())) {
R<List<MchManageVo>> merchantResponse = mchService.getByParentId(dto.getMerchantId());
if (merchantResponse == null || !merchantResponse.isOk()) {
throw new IllegalStateException("查询商户交易导出的下级商户失败");
}
List<MchManageVo> merchantList = merchantResponse.getData();
if (ObjectUtil.isNotEmpty(merchantList)) {
List<Long> 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<AsyncTaskProgressVO> submitDepartmentExport(@RequestBody HardDeptTotalDto dto) {
if (!applyCanteenDepartmentScope(dto)) {
return R.failed("当前账号没有可导出的部门交易数据");
}
return R.ok(hardOrderTotalExportService.submitDepartmentExport(dto), "部门交易导出任务已提交");
}
/**
* 将部门统计条件限制到当前用户可管理的食堂商户所属部门。
*
* <p>钱包服务根据当前用户主键和食堂商户类型一次性返回其所属食堂覆盖的用户部门。页面传入的
* 包含或排除条件只在该权限部门集合内生效,最终统一改写为明确的包含集合,避免空集合或排除
* 条件导致底层统计退化为全量查询。</p>
* <p>平台管理员保留全局部门查询能力,避免将整租户部门主键拼成大集合后传递给多个统计查询。
* 其他账号由钱包服务根据当前用户主键和食堂商户类型返回所属食堂覆盖的用户部门,页面传入的
* 包含或排除条件只在该权限部门集合内生效,最终统一改写为明确的包含集合。</p>
*
* @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<List<Long>> 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;
}
/**
* 判断当前用户是否以平台管理员身份查看全部部门统计。
*
* <p>仅 {@code ROLE_ADMIN} 绕过食堂商户部门范围,其他业务管理员仍需应用既有商户隔离,
* 避免为修复平台管理员查询而扩大普通管理账号的数据权限。</p>
*
* @param user 当前认证用户,调用前已保证用户主键有效
* @return 当前用户包含平台管理员角色时返回 true
* @throws IllegalStateException 当前用户信息远程查询失败时抛出,避免将权限异常伪装为空数据
*/
private boolean canViewAllDepartmentStatistics(PjcloudUser user) {
R<UserInfo> 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 = "部门交易记录导出")
@@ -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;
/**
* 部门与商户交易汇总异步导出服务。
*
* <p>该服务负责冻结查询条件、拆分统计查询、生成模板工作簿、上报任务进度并上传文件中心。</p>
*/
public interface HardOrderTotalExportService {
/**
* 提交部门交易汇总异步导出任务。
*
* @param dto 已由控制器收敛到当前用户权限范围的部门统计条件
* @return 可供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitDepartmentExport(HardDeptTotalDto dto);
/**
* 提交商户交易汇总异步导出任务。
*
* @param dto 已展开商户层级的商户统计条件
* @return 可供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitMerchantExport(HardOrderDto dto);
}
@@ -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);
}
@@ -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<SysUserFaceTerminalDeleteVo> 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<SysUserFaceTerminalQualityReportVo> 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<SysUserFaceTerminalDeleteVo> 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));
}
}
@@ -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;
/**
* 部门与商户交易汇总异步导出服务实现。
*
* <p>部门统计按部门主键批次查询,商户统计按自然日批次查询并按商户合并。两个任务继续使用原有
* XLSX 模板,完整文件生成后上传文件中心,由前端根据任务进度下载结果。</p>
*/
@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<Long> 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<Map<String, Object>> rows = new ArrayList<>(departmentIds.size());
long lineNumber = 1L;
for (int batchIndex = 0; batchIndex < batchCount; batchIndex++) {
int fromIndex = batchIndex * DEPARTMENT_EXPORT_BATCH_SIZE;
int toIndex = Math.min(fromIndex + DEPARTMENT_EXPORT_BATCH_SIZE, departmentIds.size());
HardDeptTotalDto batchQuery = createDepartmentBatchQuery(query,
departmentIds.subList(fromIndex, toIndex));
List<HardDeptTotalVo> batchRows = hardOrderTotalService.pageListDept(batchQuery);
for (HardDeptTotalVo row : batchRows) {
rows.add(createDepartmentRow(row, lineNumber++));
}
reporter.advance("已处理 " + (batchIndex + 1) + " / " + batchCount
+ " 个部门批次,共 " + rows.size() + " 个部门");
}
if (rows.isEmpty()) {
throw new IllegalStateException("当前筛选条件没有可导出的部门交易数据");
}
String fileName = "部门交易记录_" + LocalDateTime.now().format(EXPORT_FILE_TIME_FORMATTER) + ".xlsx";
return createAndUploadWorkbook(query.getCreateTimeList(), rows,
createDepartmentTemplateValues(query.getCreateTimeList(), rows), DEPARTMENT_EXPORT_TEMPLATE,
fileName, DEPARTMENT_EXPORT_FILE_TYPE, "部门", reporter);
}
/**
* 按自然日分批统计、合并同一商户并生成商户交易汇总文件。
*
* @param query 已冻结页面筛选条件和商户层级范围的商户查询
* @param reporter 当前异步任务进度报告器
* @return 文件中心下载地址和最终文件名
*/
private AsyncTaskResult exportMerchantToFile(HardOrderDto query, AsyncTaskProgressReporter reporter) {
List<LocalDateTime[]> dateBatches = createMerchantDateBatches(query.getPayTimeList());
reporter.updateTotal(dateBatches.size() + EXPORT_EXTRA_PROGRESS_STEPS,
"已拆分为 " + dateBatches.size() + " 个日期批次,正在生成统计文件");
Map<Long, HardOrderCsVo> statisticsMap = new LinkedHashMap<>();
for (int batchIndex = 0; batchIndex < dateBatches.size(); batchIndex++) {
HardOrderDto batchQuery = copyMerchantQuery(query);
LocalDateTime[] dateBatch = dateBatches.get(batchIndex);
if (dateBatch != null) {
batchQuery.setPayTimeList(dateBatch.clone());
}
mergeMerchantStatistics(statisticsMap, hardOrderTotalService.listCs(batchQuery));
reporter.advance("已处理 " + (batchIndex + 1) + " / " + dateBatches.size()
+ " 个日期批次,共 " + statisticsMap.size() + " 个商户");
}
if (statisticsMap.isEmpty()) {
throw new IllegalStateException("当前筛选条件没有可导出的商户交易数据");
}
List<Map<String, Object>> rows = createMerchantRows(statisticsMap);
String fileName = "商户交易记录_" + LocalDateTime.now().format(EXPORT_FILE_TIME_FORMATTER) + ".xlsx";
return createAndUploadWorkbook(query.getPayTimeList(), rows,
createMerchantTemplateValues(query.getPayTimeList(), rows), MERCHANT_EXPORT_TEMPLATE,
fileName, MERCHANT_EXPORT_FILE_TYPE, "商户", reporter);
}
/**
* 使用现有模板生成完整工作簿并上传文件中心。
*
* @param timeRange 页面选择的原始统计时间范围,用于异常日志定位
* @param rows 已完成金额转元和序号补充的模板明细行
* @param templateValues 标题日期和所有合计占位值
* @param templatePath 类路径下的 XLSX 模板
* @param fileName 用户下载时看到的业务文件名
* @param fileType 文件中心业务类型编码
* @param statisticsName 进度和错误信息中的统计对象名称
* @param reporter 当前异步任务进度报告器
* @return 文件中心下载地址和最终文件名
*/
private AsyncTaskResult createAndUploadWorkbook(LocalDateTime[] timeRange, List<Map<String, Object>> rows,
Map<String, Object> templateValues, String templatePath,
String fileName, String fileType, String statisticsName,
AsyncTaskProgressReporter reporter) {
Path outputPath = null;
try {
outputPath = Files.createTempFile("hard-order-total-export-", ".xlsx");
writeTemplateWorkbook(outputPath, templatePath, templateValues, rows);
reporter.advance(statisticsName + "统计文件已完成写入,共 " + rows.size() + " 行,正在上传文件中心");
String fileUrl = uploadWorkbook(outputPath, fileName, fileType, statisticsName);
reporter.advance(statisticsName + "统计文件已上传,共 " + rows.size() + " 行");
return AsyncTaskResult.file(fileUrl, fileName);
} catch (IllegalArgumentException | IllegalStateException exception) {
throw exception;
} catch (Exception exception) {
log.error("|{}|生成或上传{}统计文件失败,fileName:{},timeRange:{}", LOG_TAG, statisticsName,
fileName, timeRange, exception);
throw new IllegalStateException("生成" + statisticsName + "交易统计导出文件失败", exception);
} finally {
deleteTemporaryFile(outputPath);
}
}
/**
* 将标题、合计和明细写入现有 EasyExcel 模板。
*
* @param outputPath 完整工作簿输出路径
* @param templatePath 类路径下的模板资源
* @param templateValues 普通变量占位值
* @param rows 列表变量占位值
*/
private void writeTemplateWorkbook(Path outputPath, String templatePath, Map<String, Object> templateValues,
List<Map<String, Object>> rows) {
try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile())
.withTemplate(ResourceUtil.getStream(templatePath)).build()) {
WriteSheet writeSheet = EasyExcel.writerSheet().build();
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
excelWriter.fill(templateValues, writeSheet);
excelWriter.fill(rows, fillConfig, writeSheet);
}
}
/**
* 查询页面筛选条件覆盖的有序部门主键列表。
*
* @param query 已完成当前用户权限收敛的部门查询
* @return 去重后的部门主键;远程返回空数据时为空列表
*/
private List<Long> listDepartmentIds(HardDeptTotalDto query) {
DeptAllListDto departmentQuery = new DeptAllListDto();
departmentQuery.setIsInclude(query.getIsInclude());
if (query.getDeptId() != null) {
departmentQuery.setDeptId(query.getDeptId());
}
if (query.getDeptIds() != null && !query.getDeptIds().isEmpty()) {
departmentQuery.setDeptId(null);
departmentQuery.setDeptIds(query.getDeptIds());
}
R<List<SysDept>> response = remoteDeptService.getInnerAllDept(departmentQuery);
if (response == null || !response.isOk()) {
throw new IllegalStateException("查询部门交易导出的部门范围失败");
}
if (response.getData() == null || response.getData().isEmpty()) {
return List.of();
}
return response.getData().stream()
.map(SysDept::getDeptId)
.filter(id -> id != null && id > 0)
.distinct()
.toList();
}
/**
* 创建一个明确包含指定部门主键的查询副本。
*
* @param source 完整部门查询快照
* @param departmentIds 当前批次部门主键
* @return 仅统计当前部门批次的独立查询
*/
private HardDeptTotalDto createDepartmentBatchQuery(HardDeptTotalDto source, List<Long> departmentIds) {
HardDeptTotalDto target = copyDepartmentQuery(source);
target.setDeptId(null);
target.setDeptIds(new ArrayList<>(departmentIds));
target.setIsInclude(true);
return target;
}
/**
* 把部门统计行转换为模板数据,金额统一由分转换为两位小数元。
*
* @param source 服务层返回的部门统计
* @param lineNumber Excel 中从一开始的行序号
* @return 可直接填充模板列表占位符的字段映射
*/
private Map<String, Object> createDepartmentRow(HardDeptTotalVo source, long lineNumber) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("lineNumber", lineNumber);
row.put("deptName", source.getDeptName());
row.put("walletPrice", toYuan(source.getWalletPrice()));
row.put("walletCount", defaultLong(source.getWalletCount()));
row.put("walletOncePrice", toYuan(source.getWalletOncePrice()));
row.put("walletOnceCount", defaultLong(source.getWalletOnceCount()));
row.put("walletReducePrice", toYuan(source.getWalletReducePrice()));
row.put("walletReduceCount", defaultLong(source.getWalletReduceCount()));
row.put("walletOnceReducePrice", toYuan(source.getWalletOnceReducePrice()));
row.put("walletOnceReduceCount", defaultLong(source.getWalletOnceReduceCount()));
row.put("walletReturnCanteenPrice", toYuan(source.getWalletReturnCanteenPrice()));
row.put("walletReturnCanteenCount", defaultLong(source.getWalletReturnCanteenCount()));
row.put("orderPayPrice", toYuan(source.getOrderPayPrice()));
row.put("orderPayCount", defaultLong(source.getOrderPayCount()));
row.put("orderRefundPrice", toYuan(source.getOrderRefundPrice()));
row.put("orderRefundCont", defaultLong(source.getOrderRefundCont()));
row.put("orderProfitPrice", toYuan(source.getOrderProfitPrice()));
row.put("orderProfitCont", defaultLong(source.getOrderProfitCont()));
return row;
}
/**
* 创建部门模板标题时间和全部金额、次数合计值。
*
* @param timeRange 页面选择的创建时间范围
* @param rows 已转成元的部门模板行
* @return 模板普通变量占位值
*/
private Map<String, Object> createDepartmentTemplateValues(LocalDateTime[] timeRange,
List<Map<String, Object>> rows) {
Map<String, Object> values = createTimeTemplateValues(timeRange);
copySummary(values, rows, "walletPrice", "walletPriceSum");
copySummary(values, rows, "walletCount", "walletCountSum");
copySummary(values, rows, "walletOncePrice", "walletOncePriceSum");
copySummary(values, rows, "walletOnceCount", "walletOnceCountSum");
copySummary(values, rows, "walletReducePrice", "walletReducePriceSum");
copySummary(values, rows, "walletReduceCount", "walletReduceCountSum");
copySummary(values, rows, "walletOnceReducePrice", "walletOnceReducePriceSum");
copySummary(values, rows, "walletOnceReduceCount", "walletOnceReduceCountSum");
copySummary(values, rows, "walletReturnCanteenPrice", "walletReturnCanteenPriceSum");
copySummary(values, rows, "walletReturnCanteenCount", "walletReturnCanteenCountSum");
copySummary(values, rows, "orderPayPrice", "orderPayPriceSum");
copySummary(values, rows, "orderPayCount", "orderPayCountSum");
copySummary(values, rows, "orderRefundPrice", "orderRefundPriceSum");
copySummary(values, rows, "orderRefundCont", "orderRefundContSum");
copySummary(values, rows, "orderProfitPrice", "orderProfitPriceSum");
copySummary(values, rows, "orderProfitCont", "orderProfitContSum");
return values;
}
/**
* 将商户分片结果按商户主键累加支付和退款数据。
*
* @param target 跨日期批次共享的商户统计映射
* @param batchRows 当前日期批次统计结果
*/
private void mergeMerchantStatistics(Map<Long, HardOrderCsVo> target, List<HardOrderCsVo> batchRows) {
if (batchRows == null) {
return;
}
for (HardOrderCsVo source : batchRows) {
if (source == null || source.getMerchantId() == null) {
continue;
}
HardOrderCsVo merged = target.computeIfAbsent(source.getMerchantId(), id -> createMerchantTarget(source));
merged.setTotalPayPrice(add(merged.getTotalPayPrice(), source.getTotalPayPrice()));
merged.setTotalPayCount(add(merged.getTotalPayCount(), source.getTotalPayCount()));
merged.setTotalRefundPrice(add(merged.getTotalRefundPrice(), source.getTotalRefundPrice()));
merged.setTotalRefundCount(add(merged.getTotalRefundCount(), source.getTotalRefundCount()));
merged.setTotalProfitPrice(merged.getTotalPayPrice().subtract(merged.getTotalRefundPrice()));
merged.setTotalProfitCount(merged.getTotalPayCount().subtract(merged.getTotalRefundCount()));
}
}
/**
* 创建仅保留商户展示资料且统计值归零的合并目标。
*
* @param source 当前商户首次出现的日期批次行
* @return 可供后续日期批次累加的商户统计
*/
private HardOrderCsVo createMerchantTarget(HardOrderCsVo source) {
HardOrderCsVo target = BeanUtil.toBean(source, HardOrderCsVo.class);
target.setTotalPayPrice(BigDecimal.ZERO);
target.setTotalPayCount(BigDecimal.ZERO);
target.setTotalRefundPrice(BigDecimal.ZERO);
target.setTotalRefundCount(BigDecimal.ZERO);
target.setTotalProfitPrice(BigDecimal.ZERO);
target.setTotalProfitCount(BigDecimal.ZERO);
return target;
}
/**
* 将合并后的商户统计转换为模板行,并补充连续序号与元金额。
*
* @param statisticsMap 按首次出现顺序保存的商户统计
* @return 可直接填充商户模板的明细行
*/
private List<Map<String, Object>> createMerchantRows(Map<Long, HardOrderCsVo> statisticsMap) {
List<Map<String, Object>> rows = new ArrayList<>(statisticsMap.size());
long lineNumber = 1L;
for (HardOrderCsVo source : statisticsMap.values()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("lineNumber", lineNumber++);
row.put("merchantName", source.getMerchantName());
row.put("merchantDeptName", source.getMerchantDeptName());
row.put("totalPayPrice", toYuan(source.getTotalPayPrice()));
row.put("totalPayCount", defaultDecimal(source.getTotalPayCount()));
row.put("totalRefundPrice", toYuan(source.getTotalRefundPrice()));
row.put("totalRefundCount", defaultDecimal(source.getTotalRefundCount()));
row.put("totalProfitPrice", toYuan(source.getTotalProfitPrice()));
row.put("totalProfitCount", defaultDecimal(source.getTotalProfitCount()));
rows.add(row);
}
return rows;
}
/**
* 创建商户模板标题时间和支付、退款、净消费合计值。
*
* @param timeRange 页面选择的支付时间范围
* @param rows 已转成元的商户模板行
* @return 模板普通变量占位值
*/
private Map<String, Object> createMerchantTemplateValues(LocalDateTime[] timeRange,
List<Map<String, Object>> rows) {
Map<String, Object> values = createTimeTemplateValues(timeRange);
copySummary(values, rows, "totalPayPrice", "computeTotalPayPrice");
copySummary(values, rows, "totalPayCount", "computeTotalPayCount");
copySummary(values, rows, "totalRefundPrice", "computeTotalRefundPrice");
copySummary(values, rows, "totalRefundCount", "computeTotalRefundCount");
copySummary(values, rows, "totalProfitPrice", "computeTotalProfitPrice");
copySummary(values, rows, "totalProfitCount", "computeTotalProfitCount");
return values;
}
/**
* 创建模板标题使用的日期范围字段;未选择时间时三个占位符均填空。
*
* @param timeRange 页面原始日期时间范围
* @return 包含开始时间、连接文本和结束时间的模板变量
*/
private Map<String, Object> createTimeTemplateValues(LocalDateTime[] timeRange) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("startTime", "");
values.put("timeSpan", "");
values.put("endTime", "");
if (timeRange != null && timeRange.length == 2 && timeRange[0] != null && timeRange[1] != null) {
values.put("startTime", LocalDateTimeUtil.format(timeRange[0], DatePattern.NORM_DATETIME_PATTERN));
values.put("timeSpan", " 到 ");
values.put("endTime", LocalDateTimeUtil.format(timeRange[1], DatePattern.NORM_DATETIME_PATTERN));
}
return values;
}
/**
* 汇总模板行中的指定数值字段并写入合计占位符。
*
* @param target 模板普通变量映射
* @param rows 已完成展示单位转换的模板行
* @param rowKey 明细行字段名
* @param summaryKey 合计占位符字段名
*/
private void copySummary(Map<String, Object> target, List<Map<String, Object>> rows,
String rowKey, String summaryKey) {
BigDecimal total = BigDecimal.ZERO;
for (Map<String, Object> row : rows) {
Object value = row.get(rowKey);
if (value != null) {
total = total.add(new BigDecimal(value.toString()));
}
}
target.put(summaryKey, total.stripTrailingZeros().toPlainString());
}
/**
* 将商户统计时间闭区间拆分为自然日批次,并保留首尾日期的具体时分秒。
*
* @param timeRange 支付时间范围;为空时保留原有全量查询语义并返回一个无日期批次
* @return 有序日期批次,每个非空元素均为长度二的闭区间
*/
private List<LocalDateTime[]> createMerchantDateBatches(LocalDateTime[] timeRange) {
List<LocalDateTime[]> batches = new ArrayList<>();
if (timeRange == null) {
batches.add(null);
return batches;
}
if (timeRange.length != 2 || timeRange[0] == null || timeRange[1] == null) {
throw new IllegalArgumentException("商户交易导出的时间范围必须同时包含开始和结束时间");
}
LocalDateTime rangeStart = timeRange[0];
LocalDateTime rangeEnd = timeRange[1];
if (rangeEnd.isBefore(rangeStart)) {
throw new IllegalArgumentException("商户交易导出的结束时间不能早于开始时间");
}
for (LocalDate date = rangeStart.toLocalDate(); !date.isAfter(rangeEnd.toLocalDate());
date = date.plusDays(1)) {
LocalDateTime dayStart = date.atStartOfDay();
LocalDateTime dayEnd = date.atTime(LocalTime.MAX);
batches.add(new LocalDateTime[]{
rangeStart.isAfter(dayStart) ? rangeStart : dayStart,
rangeEnd.isBefore(dayEnd) ? rangeEnd : dayEnd
});
}
return batches;
}
/**
* 复制部门查询的可变集合和数组,隔离请求线程与后台任务。
*
* @param source 控制器接收并完成权限收敛的部门查询
* @return 后台任务独占的查询副本
*/
private HardDeptTotalDto copyDepartmentQuery(HardDeptTotalDto source) {
HardDeptTotalDto target = source == null ? new HardDeptTotalDto()
: BeanUtil.toBean(source, HardDeptTotalDto.class);
if (source != null) {
target.setDeptIds(copyList(source.getDeptIds()));
target.setCreateTimeList(cloneArray(source.getCreateTimeList()));
}
return target;
}
/**
* 复制商户查询中可能参与统计的可变集合和数组,隔离各日期批次之间的条件修改。
*
* @param source 控制器接收或后台任务持有的商户查询
* @return 可独立修改支付时间和权限条件的查询副本
*/
private HardOrderDto copyMerchantQuery(HardOrderDto source) {
HardOrderDto target = source == null ? new HardOrderDto() : BeanUtil.toBean(source, HardOrderDto.class);
if (source == null) {
return target;
}
target.setDeptIds(copyList(source.getDeptIds()));
target.setUserIds(copyList(source.getUserIds()));
target.setUserDeptIds(copyList(source.getUserDeptIds()));
target.setMerchantDeptIds(copyList(source.getMerchantDeptIds()));
target.setOrderIds(copyList(source.getOrderIds()));
target.setHardIds(copyList(source.getHardIds()));
target.setMchIds(copyList(source.getMchIds()));
target.setMchTypeCodes(copyList(source.getMchTypeCodes()));
target.setMerchantTypeCodes(copyList(source.getMerchantTypeCodes()));
target.setOrderStatusCodes(copyList(source.getOrderStatusCodes()));
target.setOrderTypeCodes(copyList(source.getOrderTypeCodes()));
target.setOrderPayTypeCodes(copyList(source.getOrderPayTypeCodes()));
target.setExcludeOrderPayTypeCodes(copyList(source.getExcludeOrderPayTypeCodes()));
target.setHardTypeCodes(copyList(source.getHardTypeCodes()));
target.setHourTypeCodes(copyList(source.getHourTypeCodes()));
target.setRefundStatusCodes(copyList(source.getRefundStatusCodes()));
target.setSaleSourceCodes(copyList(source.getSaleSourceCodes()));
target.setSaleStatusCodes(copyList(source.getSaleStatusCodes()));
target.setScopeDeptIds(copyList(source.getScopeDeptIds()));
target.setAppWardUserIds(copyList(source.getAppWardUserIds()));
target.setIdentityTypes(copyList(source.getIdentityTypes()));
target.setCreateTimeList(cloneArray(source.getCreateTimeList()));
target.setUpdateTimeList(cloneArray(source.getUpdateTimeList()));
target.setVerificationTimeList(cloneArray(source.getVerificationTimeList()));
target.setPayTimeList(cloneArray(source.getPayTimeList()));
target.setStatisticsDateTimeList(cloneArray(source.getStatisticsDateTimeList()));
target.setRefundTimeList(cloneArray(source.getRefundTimeList()));
target.setApptUseDateTimeList(cloneArray(source.getApptUseDateTimeList()));
return target;
}
/**
* 复制可变列表并保留空值语义。
*
* @param source 原始列表,允许为空
* @param <T> 列表元素类型
* @return 独立列表副本;原列表为空时返回空
*/
private <T> List<T> copyList(List<T> source) {
return source == null ? null : new ArrayList<>(source);
}
/**
* 复制日期时间范围数组并保留空值语义。
*
* @param source 原始日期时间范围,允许为空
* @return 独立数组副本;原数组为空时返回空
*/
private LocalDateTime[] cloneArray(LocalDateTime[] source) {
return source == null ? null : source.clone();
}
/**
* 计算正整数总量需要拆分的批次数。
*
* @param total 待处理总数
* @param batchSize 单批最大数量
* @return 向上取整后的批次数
*/
private int calculateBatchCount(int total, int batchSize) {
return (total - 1) / batchSize + 1;
}
/**
* 将分金额转换为两位小数元金额。
*
* @param amount 金额,单位为分;允许为空
* @return 金额,单位为元;空值按零处理
*/
private BigDecimal toYuan(Number amount) {
return amount == null ? BigDecimal.ZERO
: new BigDecimal(amount.toString()).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
/**
* 返回非空次数值。
*
* @param value 次数,允许为空
* @return 原次数;空值按零次处理
*/
private long defaultLong(Long value) {
return value == null ? 0L : value;
}
/**
* 返回非空十进制统计值。
*
* @param value 金额或次数,允许为空
* @return 原值;空值按零处理
*/
private BigDecimal defaultDecimal(BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
/**
* 累加两个允许为空的十进制统计值。
*
* @param left 当前累计值
* @param right 当前批次值
* @return 空值按零处理后的和
*/
private BigDecimal add(BigDecimal left, BigDecimal right) {
return defaultDecimal(left).add(defaultDecimal(right));
}
/**
* 上传完整工作簿并校验文件中心业务响应。
*
* @param outputPath 已关闭且可读取的 XLSX 文件
* @param fileName 用户看到的 UTF-8 文件名
* @param fileType 文件中心业务类型编码
* @param statisticsName 错误信息中的统计对象名称
* @return 文件中心返回的下载地址
* @throws Exception 文件读取或远程上传失败时抛出
*/
private String uploadWorkbook(Path outputPath, String fileName, String fileType,
String statisticsName) throws Exception {
MultipartFile exportFile = new MockMultipartFile("file", encodeUploadFileName(fileName),
EXCEL_CONTENT_TYPE, Files.readAllBytes(outputPath));
R<?> uploadResult = remoteFileService.innerUpload(exportFile, EXPORT_FILE_DIR, null, fileType);
if (uploadResult == null || !uploadResult.isOk()
|| !(uploadResult.getData() instanceof Map<?, ?> resultData)) {
throw new IllegalStateException("文件中心未返回有效的" + statisticsName + "统计导出结果");
}
Object resultUrl = resultData.get("url");
if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) {
throw new IllegalStateException("文件中心未返回" + statisticsName + "统计导出下载地址");
}
return resultUrl.toString();
}
/**
* 按文件中心现有约定转换中文原始文件名编码。
*
* @param fileName 用户看到的 UTF-8 文件名
* @return 文件中心上传接口使用的原始文件名
*/
private String encodeUploadFileName(String fileName) {
return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
}
/**
* 清理交易汇总导出的临时文件,清理失败只记录告警而不覆盖原始任务结果。
*
* @param outputPath 待删除的临时 XLSX 路径,允许为空
*/
private void deleteTemporaryFile(Path outputPath) {
if (outputPath == null) {
return;
}
try {
Files.deleteIfExists(outputPath);
} catch (Exception exception) {
log.warn("|{}|清理临时文件失败,path:{}", LOG_TAG, outputPath, exception);
}
}
}
@@ -1,29 +1,43 @@
package com.bsd.pjcloud.hard.service.impl.manager;
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 终端人脸删除硬件侧测试,验证设备绑定范围只能由服务端 DEVICE-ID 关系解析。
* 终端人脸维护硬件侧测试,验证设备与部门范围只能由服务端 DEVICE-ID 关系解析。
*/
class HardTerminalFaceServiceImplTest {
@@ -36,13 +50,21 @@ class HardTerminalFaceServiceImplTest {
/** 用户服务内部删除接口。 */
private final RemoteUserFaceService remoteUserFaceService = mock(RemoteUserFaceService.class);
/** 质量上报租约使用的 Redis 客户端。 */
private final StringRedisTemplate stringRedisTemplate = mock(StringRedisTemplate.class);
/** Redis 字符串操作对象,用于模拟租约竞争结果。 */
private final ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
/** 被测终端人脸维护服务。 */
private HardTerminalFaceServiceImpl service;
/** 每个用例创建无共享状态的服务实例。 */
@BeforeEach
void setUp() {
service = new HardTerminalFaceServiceImpl(hardManagerMapper, hardManagerService, remoteUserFaceService);
when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
service = new HardTerminalFaceServiceImpl(
hardManagerMapper, hardManagerService, remoteUserFaceService, stringRedisTemplate);
}
/** 缺少 DEVICE-ID 时必须在查询数据库或调用用户服务前拒绝。 */
@@ -122,10 +144,100 @@ class HardTerminalFaceServiceImplTest {
assertEquals("越权样本", exception.getMessage());
}
/** 质量上报必须注入可信部门和来源设备,并完整转发质量字段。 */
@Test
void reportQualityUsesTrustedDeviceAndMerchantDepartment() {
HardManagerEntity hard = new HardManagerEntity();
hard.setId(10L);
hard.setMerchantId(20L);
HardManagerVo detail = new HardManagerVo();
detail.setMerchantDeptId(30L);
SysUserFaceTerminalQualityReportVo response = SysUserFaceTerminalQualityReportVo.builder()
.updatedSampleIds(List.of("1001"))
.ignoredStaleSampleIds(List.of())
.alreadyAbsentSampleIds(List.of())
.build();
when(hardManagerMapper.selectById(10L)).thenReturn(hard);
when(hardManagerService.getByHardId(10L)).thenReturn(detail);
when(valueOperations.setIfAbsent(anyString(), eq("10"), eq(Duration.ofMinutes(10))))
.thenReturn(true);
when(remoteUserFaceService.terminalReportQuality(org.mockito.ArgumentMatchers.any()))
.thenReturn(R.ok(response));
SysUserFaceTerminalQualityReportVo result = service.reportQuality(10L, qualityRequest());
ArgumentCaptor<SysUserFaceTerminalQualityReportDto> captor =
ArgumentCaptor.forClass(SysUserFaceTerminalQualityReportDto.class);
verify(remoteUserFaceService).terminalReportQuality(captor.capture());
assertEquals(30L, captor.getValue().getMerchantDeptId());
assertEquals(10L, captor.getValue().getQualityDeviceId());
assertEquals("1001", captor.getValue().getItems().get(0).getSampleId());
assertEquals(new BigDecimal("0.58"), captor.getValue().getItems().get(0).getScore());
assertEquals(LocalDateTime.of(2026, 9, 1, 9, 0),
captor.getValue().getItems().get(0).getSourceUpdatedAt());
assertEquals(List.of("1001"), result.getUpdatedSampleIds());
}
/** 同一商户部门已有其他设备持有租约时,当前设备不得向用户服务发送质量结果。 */
@Test
void reportQualityRejectsDeviceWithoutLease() {
HardManagerEntity hard = new HardManagerEntity();
hard.setId(10L);
hard.setMerchantId(20L);
HardManagerVo detail = new HardManagerVo();
detail.setMerchantDeptId(30L);
when(hardManagerMapper.selectById(10L)).thenReturn(hard);
when(hardManagerService.getByHardId(10L)).thenReturn(detail);
when(valueOperations.setIfAbsent(anyString(), eq("10"), eq(Duration.ofMinutes(10))))
.thenReturn(false);
when(valueOperations.get("hard:face-quality:reporter:20:30")).thenReturn("11");
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.reportQuality(10L, qualityRequest()));
assertEquals("当前设备未取得人脸质量上报租约", exception.getMessage());
verify(remoteUserFaceService, never()).terminalReportQuality(any());
}
/** 租约预检返回竞争结果和剩余时间,供非持有设备直接跳过批量上报。 */
@Test
void acquireQualityLeaseReturnsRemainingTime() {
HardManagerEntity hard = new HardManagerEntity();
hard.setId(10L);
hard.setMerchantId(20L);
HardManagerVo detail = new HardManagerVo();
detail.setMerchantDeptId(30L);
when(hardManagerMapper.selectById(10L)).thenReturn(hard);
when(hardManagerService.getByHardId(10L)).thenReturn(detail);
when(valueOperations.setIfAbsent(anyString(), eq("10"), eq(Duration.ofMinutes(10))))
.thenReturn(false);
when(valueOperations.get("hard:face-quality:reporter:20:30")).thenReturn("11");
when(stringRedisTemplate.getExpire("hard:face-quality:reporter:20:30", TimeUnit.SECONDS))
.thenReturn(420L);
HardTerminalFaceQualityLeaseVo result = service.acquireQualityLease(10L);
assertEquals(false, result.isGranted());
assertEquals(420L, result.getRemainingSeconds());
}
/** 构造一份仅含样本编号的终端请求。 */
private static HardTerminalFaceDeleteDto request(String... sampleIds) {
HardTerminalFaceDeleteDto dto = new HardTerminalFaceDeleteDto();
dto.setSampleIds(List.of(sampleIds));
return dto;
}
/** 构造一份终端静态图片质量上报请求。 */
private static HardTerminalFaceQualityReportDto qualityRequest() {
HardTerminalFaceQualityReportItemDto item = new HardTerminalFaceQualityReportItemDto();
item.setSampleId("1001");
item.setStatus("detected");
item.setScore(new BigDecimal("0.58"));
item.setSourceUpdatedAt(LocalDateTime.of(2026, 9, 1, 9, 0));
item.setCheckedAt(LocalDateTime.of(2026, 9, 1, 10, 30));
HardTerminalFaceQualityReportDto dto = new HardTerminalFaceQualityReportDto();
dto.setItems(List.of(item));
return dto;
}
}
@@ -0,0 +1,49 @@
package com.bsd.pjcloud.admin.api.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 终端上报的一条人脸静态图片质量结果。
*/
@Data
public class SysUserFaceQualityReportItemDto {
/**
* 虹软 32 位搜索样本编号,对应 {@code sys_user_face.auto_code}。
*/
@NotBlank(message = "人脸样本编号不能为空")
private String sampleId;
/**
* 检测状态,跨端协议值仅允许 detected 或 failed。
*/
@NotBlank(message = "人脸质量检测状态不能为空")
private String status;
/**
* 虹软静态图片质量分,detected 时必填且范围为 0 到 1,failed 时必须为空。
*/
private BigDecimal score;
/**
* 终端检测所依据的人脸图片版本,必须与服务端当前人脸更新时间一致。
*/
@NotNull(message = "人脸图片版本不能为空")
private LocalDateTime sourceUpdatedAt;
/**
* 终端完成本次检测的本地时间,用于保证重复和延迟上报不会覆盖更新结果。
*/
@NotNull(message = "人脸质量检测时间不能为空")
private LocalDateTime checkedAt;
/**
* 虹软 SDK 或终端插件错误码,detected 时必须为空,failed 时允许为空。
*/
private Integer errorCode;
}
@@ -0,0 +1,36 @@
package com.bsd.pjcloud.admin.api.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* Hardware 向 UPMS 上报终端人脸质量的内部可信请求。
*/
@Data
public class SysUserFaceTerminalQualityReportDto {
/**
* Hardware 根据 DEVICE-ID 解析出的商户部门主键,终端请求体不得指定。
*/
@NotNull(message = "终端绑定部门不能为空")
private Long merchantDeptId;
/**
* Hardware 验证存在且已绑定商户的终端设备主键,用于质量结果来源追踪。
*/
@NotNull(message = "质量上报终端不能为空")
private Long qualityDeviceId;
/**
* 本批人脸质量结果,单次最多 500 条且不允许重复样本编号。
*/
@Valid
@NotEmpty(message = "人脸质量结果不能为空")
@Size(max = 500, message = "单次最多上报500条人脸质量结果")
private List<SysUserFaceQualityReportItemDto> items;
}
@@ -12,6 +12,7 @@ import lombok.NoArgsConstructor;
import com.bsd.pjcloud.common.core.util.TenantTable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
@@ -79,6 +80,36 @@ public class SysUserFaceEntity extends Model<SysUserFaceEntity> {
@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;
/**
* 创建人
*/
@@ -1,9 +1,11 @@
package com.bsd.pjcloud.admin.api.feign;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalQualityReportDto;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.feign.annotation.NoToken;
@@ -51,4 +53,14 @@ public interface RemoteUserFaceService {
@NoToken
@PostMapping("/user-face/inner/terminal-delete")
R<SysUserFaceTerminalDeleteVo> terminalDelete(@RequestBody SysUserFaceTerminalDeleteDto dto);
/**
* 按 Hardware 解析出的可信设备和部门批量保存终端人脸质量结果。
*
* @param dto 商户部门、来源设备及质量结果
* @return 已更新、按版本或状态优先级忽略和已不存在的样本编号
*/
@NoToken
@PostMapping("/user-face/inner/terminal-quality-report")
R<SysUserFaceTerminalQualityReportVo> terminalReportQuality(@RequestBody SysUserFaceTerminalQualityReportDto dto);
}
@@ -69,6 +69,24 @@ public interface RemoteUserService {
@PostMapping("/user/basic-page")
R<Page<UserVO>> getScopeBasicUserPage(@RequestParam("current") Long current, @RequestParam("size") Long size, @RequestBody UserDTO userDTO);
/**
* 按当前登录人的数据权限查询指定批次用户,并允许调用方关闭分页总数统计。
*
* <p>已知用户 ID 的业务列表补数不依赖总数,传入 {@code false} 可避免 UPMS 重复执行 COUNT;未知范围的身份筛选仍应使用
* {@link #getScopeBasicUserPage(Long, Long, UserDTO)} 保留准确总数。</p>
*
* @param current 当前页码,从 1 开始
* @param size 每页用户数量
* @param searchCount 是否查询符合条件的用户总数
* @param userDTO 用户关键字、部门树和指定用户筛选条件
* @return 轻量用户分页;关闭总数统计时调用方不得依赖 {@code total}
*/
@PostMapping("/user/basic-page")
R<Page<UserVO>> getScopeBasicUserPageWithCountOption(@RequestParam("current") Long current,
@RequestParam("size") Long size,
@RequestParam("searchCount") Boolean searchCount,
@RequestBody UserDTO userDTO);
/**
* 按当前登录人的数据权限分页查询导出所需的完整用户主资料和身份资料。
*
@@ -58,8 +58,8 @@ public class SysUserFaceSyncRecordVo {
private String faceImg;
/**
* 服务端人脸记录最后修改时间;终端将其持久化到现有更新时间字段作为图片版本依据。
* 服务端人脸图片版本时间;未修改的历史记录回退为创建时间,终端将其持久化作为上报版本依据。
*/
@Schema(description = "服务端最后修改时间")
@Schema(description = "服务端人脸图片版本时间")
private LocalDateTime sourceUpdatedAt;
}
@@ -0,0 +1,33 @@
package com.bsd.pjcloud.admin.api.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 终端批量上报人脸质量后的幂等处理结果。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SysUserFaceTerminalQualityReportVo {
/**
* 本次成功写入服务端的人脸样本编号。
*/
private List<String> updatedSampleIds;
/**
* 因图片版本不一致、服务端已有成功结果或已有更新失败结果而忽略的样本编号。
*/
private List<String> ignoredStaleSampleIds;
/**
* 请求时服务端已经不存在的样本编号,终端无需将其视为上报失败。
*/
private List<String> alreadyAbsentSampleIds;
}
@@ -12,6 +12,7 @@ import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceGuardianWardCreateDto;
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.SysUserFaceGuardianWardOptionVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo;
@@ -20,6 +21,7 @@ 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;
@@ -103,6 +105,19 @@ public class SysUserFaceController {
public R<SysUserFaceTerminalDeleteVo> 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<SysUserFaceTerminalQualityReportVo> terminalReportQuality(@Valid @RequestBody SysUserFaceTerminalQualityReportDto dto) {
return R.ok(sysUserFaceService.terminalReportQuality(dto));
}
/* ************************************* end 远程调用 end ************************************* */
/* ************************************* start app start ************************************* */
@@ -61,6 +61,21 @@ public interface SysUserFaceMapper extends PjcloudBaseMapper<SysUserFaceEntity>
return selectJoinList(SysUserFaceVo.class, wrapper);
}
/**
* 查询仍存在的目标样本、当前质量时间和用户部门,供终端质量上报执行整批范围校验。
*
* @param sampleIds 虹软样本编号
* @return 人脸记录、当前质量字段及用户部门;用户异常缺失时由服务层整批拒绝
*/
default List<SysUserFaceVo> terminalQualityCandidates(List<String> sampleIds) {
MPJLambdaWrapper<SysUserFaceEntity> wrapper = new MPJLambdaWrapper<SysUserFaceEntity>()
.selectAll(SysUserFaceEntity.class)
.selectAs(SysUser::getDeptId, SysUserFaceVo::getDeptId)
.leftJoin(SysUser.class, SysUser::getUserId, SysUserFaceEntity::getUserId)
.in(SysUserFaceEntity::getAutoCode, sampleIds);
return selectJoinList(SysUserFaceVo.class, wrapper);
}
Page<SysUserFaceVo> scopePageList(Page page, @Param("pa") SysUserFaceDto dto, DataScope dataScope);
List<SysUserFaceVo> groupConcatFaces(@Param("pa") SysUserFaceDto dto);
@@ -6,6 +6,7 @@ import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceGuardianWardCreateDto;
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.SysUserFaceGuardianWardOptionVo;
@@ -15,6 +16,7 @@ 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;
@@ -50,6 +52,14 @@ public interface SysUserFaceService extends IService<SysUserFaceEntity> {
*/
SysUserFaceTerminalDeleteVo terminalDelete(SysUserFaceTerminalDeleteDto dto);
/**
* 校验全部质量样本属于目标部门树后幂等保存结果,图片版本和成功状态优先级均不得倒退。
*
* @param dto 可信商户部门、来源设备和质量结果
* @return 已更新、按版本或状态优先级忽略和已不存在的样本编号
*/
SysUserFaceTerminalQualityReportVo terminalReportQuality(SysUserFaceTerminalQualityReportDto dto);
/**
* 当前用户为本人或有效被监护人提交人脸,并根据提交人角色决定是否进入审核流程。
*
@@ -12,6 +12,8 @@ import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceGuardianWardCreateDto;
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.SysRole;
import com.bsd.pjcloud.admin.api.entity.SysUser;
@@ -24,6 +26,7 @@ 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.user.SysUserGuardianWardOptionVo;
import com.bsd.pjcloud.admin.handler.FaceChangeEvent;
import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper;
@@ -60,9 +63,11 @@ import org.springframework.data.redis.core.StringRedisTemplate;
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;
@@ -115,6 +120,16 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
*/
private static final int FACE_SYNC_MAX_LIMIT = 500;
/**
* 质量检测成功的跨端协议状态值。
*/
private static final String FACE_QUALITY_STATUS_DETECTED = "detected";
/**
* 质量检测失败的跨端协议状态值。
*/
private static final String FACE_QUALITY_STATUS_FAILED = "failed";
/**
* App 关系人业务允许查看的人脸审核状态。
*/
@@ -364,16 +379,7 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
LinkedHashSet<String> normalizedSet = new LinkedHashSet<>();
for (String sampleId : dto.getSampleIds()) {
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("存在无效人脸样本编号");
}
normalizedSet.add(normalized);
normalizedSet.add(normalizeTerminalSampleId(sampleId));
}
List<String> sampleIds = new ArrayList<>(normalizedSet);
List<Long> deptIds = sysDeptService.listDescendant(dto.getMerchantDeptId()).stream()
@@ -415,6 +421,183 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
.build();
}
/**
* 按 Hardware 提供的可信设备和商户部门树幂等保存人脸质量结果。
*
* <p>先校验完整请求、审核通过状态和部门范围,再逐条按图片版本与状态优先级更新。
* 质量回写故意不修改 {@code update_time} 且不发布人脸变更事件,避免终端将
* 质量变化误判为图片版本变化并形成重复下载、检测和上报循环。</p>
*
* @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<String, SysUserFaceQualityReportItemDto> 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<Long> deptIds = sysDeptService.listDescendant(dto.getMerchantDeptId()).stream()
.map(SysDept::getDeptId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (deptIds.isEmpty()) {
throw new RuntimeException("终端绑定部门不存在或已删除");
}
List<SysUserFaceVo> candidates = baseMapper.terminalQualityCandidates(
new ArrayList<>(itemBySampleId.keySet()));
Set<Long> 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<String, SysUserFaceVo> 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<String> updatedSampleIds = new ArrayList<>();
List<String> ignoredStaleSampleIds = new ArrayList<>();
List<String> alreadyAbsentSampleIds = new ArrayList<>();
for (Map.Entry<String, SysUserFaceQualityReportItemDto> 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<SysUserFaceEntity> updateWrapper = new LambdaUpdateWrapper<SysUserFaceEntity>()
.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<SysUserFaceMapper, SysUs
if (userFace == null) {
throw new CheckedException("人脸样本表不存在");
}
SysUserFaceEntity updateObject = new SysUserFaceEntity();
updateObject.setId(userFace.getId());
updateObject.setFaceImg(sysUserFace.getFaceImg());
updateObject.setUpdateTime(LocalDateTime.now());
if (baseMapper.updateById(updateObject) <= 0) {
int updatedRows = baseMapper.update(null, new LambdaUpdateWrapper<SysUserFaceEntity>()
.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);
@@ -7,6 +7,11 @@
<result property="userId" column="user_id"/>
<result property="rejectReason" column="reject_reason"/>
<result property="faceImg" column="face_img"/>
<result property="qualityScore" column="quality_score"/>
<result property="qualityStatus" column="quality_status"/>
<result property="qualityCheckedAt" column="quality_checked_at"/>
<result property="qualityErrorCode" column="quality_error_code"/>
<result property="qualityDeviceId" column="quality_device_id"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
@@ -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'
@@ -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;
}
}
@@ -23,11 +23,34 @@ import java.util.List;
@Mapper
public interface WalletRechargeApplyMapper extends PjcloudBaseMapper<WalletRechargeApplyEntity> {
/**
* 按当前操作者数据权限分页查询充值申请。
*
* @param page 分页参数,第一页默认同时查询符合条件的申请总数
* @param dto 申请人、审批、钱包类型、发票及时间筛选条件
* @param dataScope 当前操作者的数据权限范围
* @return 保留分页顺序的充值申请;批量金额由 Service 在当前页查询后装配
*/
Page<WalletRechargeApplyVo> scopePageList(Page page, @Param("dto") WalletRechargeApplyDto dto, DataScope dataScope);
/**
* 按调用方显式传入的数据权限范围分页查询充值申请。
*
* @param page 分页参数
* @param dto 申请人、审批、钱包类型、发票及时间筛选条件
* @param dataScope 可选的数据权限对象;为空时不附加数据范围限制
* @return 保留分页顺序的充值申请;批量金额由 Service 在当前页查询后装配
*/
Page<WalletRechargeApplyVo> pageJoin(Page page, @Param("dto") WalletRechargeApplyDto dto, DataScope dataScope);
/**
* 按当前页申请主键批量汇总未删除充值明细的操作金额。
*
* @param applyIds 当前分页返回的申请主键,调用方保证非空并完成去重
* @return 仅包含申请主键和总金额的部分充值申请对象;没有有效明细的申请不返回记录
*/
List<WalletRechargeApplyVo> listTotalPriceByApplyIds(@Param("applyIds") List<Long> applyIds);
default Page<WalletRechargeApplyVo> scopePageList(Page page, WalletRechargeApplyDto dto) {
LambdaQueryWrapper<WalletRechargeApplyEntity> wrapper = new LambdaQueryWrapper<WalletRechargeApplyEntity>()
.eq(ObjectUtil.isNotEmpty(dto.getBizType()), WalletRechargeApplyEntity::getBizType, dto.getBizType())
@@ -60,4 +83,4 @@ public interface WalletRechargeApplyMapper extends PjcloudBaseMapper<WalletRecha
list.setRecords(BeanUtils.toBean(list.getRecords(), WalletRechargeApplyVo.class));
return list;
}
}
}
@@ -30,8 +30,24 @@ public interface WalletRechargeApplyService extends IService<WalletRechargeApply
*/
void delByIds(ArrayList<Long> list);
/**
* 分页查询充值申请,并批量补齐当前页金额和用户展示资料。
*
* @param page 分页参数
* @param dto 充值申请筛选条件
* @param dataScope 可选的数据权限范围
* @return 已完成当前页展示资料装配的充值申请分页
*/
Page<WalletRechargeApplyVo> pageJoin(Page page, WalletRechargeApplyDto dto, DataScope dataScope);
/**
* 按当前操作者数据权限分页查询充值申请,并批量补齐当前页展示资料和待办状态。
*
* @param page 分页参数
* @param dto 充值申请筛选条件
* @param dataScope 当前操作者的数据权限范围
* @return 已完成当前页展示资料装配的充值申请分页
*/
Page<WalletRechargeApplyVo> scopePageList(Page page, WalletRechargeApplyDto dto, DataScope dataScope);
/**
@@ -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<WalletRechargeAp
private final WalletTransactionMapper walletTransactionMapper;
private final WalletManageMapper walletManageMapper;
private final WalletTypeMapper walletTypeMapper;
private final WalletRechargeInvoiceService walletRechargeInvoiceService;
private final RemoteUserService userService;
@@ -206,11 +202,20 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
}
}
/**
* 分页查询充值申请,并在当前页范围内批量补齐金额和用户展示资料。
*
* @param page 分页参数
* @param dto 充值申请筛选条件
* @param dataScope 可选的数据权限范围
* @return 已补齐当前页总金额、用户和部门资料的分页结果
*/
@Override
public Page<WalletRechargeApplyVo> pageJoin(Page page, WalletRechargeApplyDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<WalletRechargeApplyVo> list = baseMapper.pageJoin(page, dto, dataScope);
if (ObjectUtil.isNotEmpty(list.getRecords())) {
fillTotalPrice(list.getRecords());
userIdentitySupport.fillUserInfo(list.getRecords(), true);
// 部门
List<Long> deptIds = list.getRecords().stream().map(WalletRechargeApplyVo::getDeptId).distinct().toList();
@@ -225,10 +230,19 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
}
/**
* 按当前操作者数据权限分页查询充值申请,并批量装配当前页展示资料。
*
* @param page 分页参数
* @param dto 充值申请筛选条件
* @param dataScope 当前操作者的数据权限范围
* @return 已补齐当前页总金额、用户、部门和待办状态的分页结果
*/
@Override
public Page<WalletRechargeApplyVo> scopePageList(Page page, WalletRechargeApplyDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<WalletRechargeApplyVo> list = baseMapper.scopePageList(page, dto, dataScope);
fillTotalPrice(list.getRecords());
disposeList(list.getRecords(), dto);
return list;
}
@@ -470,6 +484,12 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
return result;
}
/**
* 批量补齐当前页的用户、申请部门和待审批状态,避免随分页记录产生逐行远程调用。
*
* @param list 当前分页的充值申请
* @param dto 查询条件,其中 {@code isFindCheckUser} 决定是否查询当前用户待办
*/
private void disposeList(List<WalletRechargeApplyVo> list, WalletRechargeApplyDto dto) {
if (ObjectUtil.isEmpty(list)) return;
userIdentitySupport.fillUserInfo(list, true);
@@ -481,19 +501,14 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
DataUtil.convertDeptList(list, deptList);
}
}
// 饭卡类型
List<Long> walletTypeIds = list.stream().map(WalletRechargeApplyVo::getWalletTypeId).distinct().toList();
if (ObjectUtil.isNotEmpty(walletTypeIds)) {
WalletTypeDto walletTypeDto = new WalletTypeDto();
walletTypeDto.setWalletTypeIds(walletTypeIds);
List<WalletTypeEntity> walletTypeList = walletTypeMapper.list(walletTypeDto);
if (ObjectUtil.isNotEmpty(walletTypeList)) {
WalletDataUtil.convertWalletTypeList(list, CollectionUtils.convertMap(walletTypeList, WalletTypeEntity::getId));
}
}
// 审批流程
if (dto.getIsFindCheckUser()) {
List<String> processInstanceIds = list.stream().map(WalletRechargeApplyVo::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (Boolean.TRUE.equals(dto.getIsFindCheckUser())) {
List<String> 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<TaskDto> taskList = flowUtil.queryProcessInstanceList(SecurityUtils.getUser().getId().toString(), processInstanceIds);
if (ObjectUtil.isNotEmpty(taskList)) {
@@ -510,6 +525,25 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
}
}
/**
* 一次性汇总当前页申请的充值明细金额,替代分页主 SQL 中按记录执行的相关子查询。
*
* <p>没有有效充值明细的申请继续保留 {@code null},与原 {@code SUM} 子查询的返回语义一致。</p>
*
* @param list 当前分页的充值申请;为空时不访问数据库
*/
private void fillTotalPrice(List<WalletRechargeApplyVo> list) {
if (ObjectUtil.isEmpty(list)) return;
List<Long> applyIds = list.stream().map(WalletRechargeApplyVo::getId).filter(Objects::nonNull).distinct().toList();
if (ObjectUtil.isEmpty(applyIds)) return;
List<WalletRechargeApplyVo> totalPriceList = baseMapper.listTotalPriceByApplyIds(applyIds);
Map<Long, WalletRechargeApplyVo> totalPriceMap = CollectionUtils.convertMap(totalPriceList, WalletRechargeApplyVo::getId);
for (WalletRechargeApplyVo item : list) {
WalletRechargeApplyVo totalPrice = totalPriceMap.get(item.getId());
item.setTotalPrice(totalPrice == null ? null : totalPrice.getTotalPrice());
}
}
/**
* 将充值申请身份条件预筛选为申请人用户主键,保证分页和权限查询使用同一用户范围。
*
@@ -95,9 +95,7 @@ public class WalletUserIdentitySupport {
if (userIds.isEmpty()) {
return;
}
UserDTO userQuery = new UserDTO();
userQuery.setUserIds(userIds);
Map<Long, UserVO> userMap = CollectionUtils.convertMap(fetchAllUsers(userQuery), UserVO::getUserId);
Map<Long, UserVO> 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;
}
/**
* 分批查询已知主键的用户展示资料,并关闭无业务用途的分页总数统计。
*
* <p>调用方传入的用户主键已经来自完成数据权限过滤的业务记录;UPMS 仍按当前登录人执行数据权限,且每批最多
* {@value USER_BATCH_SIZE} 条,保证响应体和 SQL IN 条件受控。</p>
*
* @param userIds 当前业务记录中的去重用户主键
* @return UPMS 数据权限范围内存在的用户及身份资料
*/
private List<UserVO> fetchUsersByIds(List<Long> userIds) {
List<UserVO> 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<Page<UserVO>> 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<UserVO> records = response.getData().getRecords();
if (records != null) {
users.addAll(records);
}
}
return users;
}
/**
* 判断学生扩展对象是否包含有效筛选字段。
*
@@ -36,7 +36,7 @@
<if test="dto.userId != null">
and a.user_id = #{dto.userId}
</if>
<if test="dto.bizType != null">
<if test="dto.bizType != null and dto.bizType != ''">
and a.biz_type = #{dto.bizType}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
@@ -55,7 +55,7 @@
and a.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]}
</if>
<if test="dto.processTimeList != null and dto.processTimeList.length == 2">
and a.create_time between #{dto.processTimeList[0]} and #{dto.processTimeList[1]}
and a.process_time between #{dto.processTimeList[0]} and #{dto.processTimeList[1]}
</if>
</sql>
@@ -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'
<where>
<include refid="whereA"/>
<if test="dto.invoiceCode != null">
<if test="dto.walletTypeId != null">
and a.wallet_type_id = #{dto.walletTypeId}
</if>
<if test="dto.invoiceCode != null and dto.invoiceCode != ''">
and i.code = #{dto.invoiceCode}
</if>
</where>
group by a.id
<if test="dto.processStatus != null and dto.processStatus == 2">
order by a.process_time desc , a.id desc
</if>
<if test="dto.processStatus == null or dto.processStatus != 2">
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
</if>
</sql>
@@ -102,4 +94,17 @@
<include refid="selectPageA"/>
</select>
</mapper>
<!-- 只汇总当前分页中的申请,避免分页主 SQL 对每条申请重复扫描充值明细。 -->
<select id="listTotalPriceByApplyIds" resultType="com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeApplyVo">
select r.apply_id as id,
sum(r.price) as totalPrice
from wallet_recharge_import r
where r.del_flag = '0'
and r.apply_id in
<foreach collection="applyIds" item="applyId" open="(" close=")" separator=",">
#{applyId}
</foreach>
group by r.apply_id
</select>
</mapper>
@@ -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);
}
}
@@ -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<WalletRechargeApplyVo> 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<WalletRechargeApplyVo> result = service.scopePageList(page, dto, dataScope);
assertEquals(3600, result.getRecords().get(0).getTotalPrice());
assertNull(result.getRecords().get(1).getTotalPrice());
ArgumentCaptor<List<Long>> 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<WalletRechargeApplyVo> 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;
}
}
@@ -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<UserVO> 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<UserDTO> 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<UserVO> 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<Long> 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));
}
}