feat:消费机更新

feat:人员调用钱包部门不调动的bug
This commit is contained in:
dzc committed 2026-09-09 11:56:03 +08:00
1 parent 1c86c227ee
commit 2ca14aa1e0
38 files changed
+1903 -35

No files matched your search

@@ -0,0 +1,21 @@
package com.bsd.pjcloud.hard.api.dto.hard;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* Android 终端批量删除服务端人脸请求。
*/
@Data
public class HardTerminalFaceDeleteDto {
/**
* 待删除的虹软样本编号,设备部门由请求头解析,客户端不得指定。
*/
@NotNull(message = "人脸样本编号不能为空")
@Size(min = 1, max = 500, message = "单次最多删除500条人脸")
private List<String> sampleIds;
}
@@ -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;
}
@@ -1,6 +1,6 @@
package com.bsd.pjcloud.hard.api.dto.order;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeDeptMchTreeInterface;
import com.bsd.pjcloud.common.core.constant.enums.DateRangeTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -11,7 +11,7 @@ import java.time.LocalDateTime;
import java.util.List;
@Data
public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeInterface {
public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeDeptMchTreeInterface {
private DateRangeTypeEnum dateRangeType = DateRangeTypeEnum.YESTERDAY;
@@ -25,6 +25,8 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
private List<Long> merchantDeptIds;
private Boolean isIncludeMerchantDept = false;
private List<Long> orderIds;
private List<Long> hardIds;
@@ -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;
}
@@ -1,6 +1,8 @@
package com.bsd.pjcloud.hard.controller.hard;
import cn.hutool.core.util.ObjectUtil;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.idempotent.annotation.Idempotent;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
@@ -18,6 +20,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardChartLatitudeVo;
import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto;
import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo;
import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalFaceQualityLeaseVo;
import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo;
@@ -57,6 +60,11 @@ public class HardApiController {
*/
private final HardTerminalStatusService hardTerminalStatusService;
/**
* 终端在库人脸维护服务。
*/
private final HardTerminalFaceService hardTerminalFaceService;
@PostMapping("/bind")
@Operation(summary = "绑定服务端设备")
public R<HardManagerVo> bind(@RequestBody HardBindDto dto) {
@@ -77,6 +85,49 @@ public class HardApiController {
return R.ok();
}
/**
* 删除当前设备商户部门及子部门范围内的人脸。
*
* @param hardId 当前绑定设备 ID
* @param dto 最多 500 条样本编号
* @return 实际删除及已不存在的样本编号
*/
@PostMapping("/face/delete")
@Operation(summary = "终端批量删除人脸")
public R<SysUserFaceTerminalDeleteVo> deleteTerminalFaces(
@RequestHeader("DEVICE-ID") Long hardId,
@Valid @RequestBody HardTerminalFaceDeleteDto dto) {
return R.ok(hardTerminalFaceService.delete(hardId, dto));
}
/**
* 竞争当前商户及绑定部门的人脸质量上报租约。
*
* @param hardId 当前绑定设备 ID
* @return 是否允许当前设备执行本轮质量上报
*/
@PostMapping("/face/quality/lease")
@Operation(summary = "终端申请人脸质量上报租约")
public R<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) {
@@ -0,0 +1,39 @@
package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo;
import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceDeleteDto;
import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceQualityReportDto;
import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalFaceQualityLeaseVo;
/**
* 终端在库人脸维护服务。
*/
public interface HardTerminalFaceService {
/**
* 以 DEVICE-ID 解析真实商户部门后请求用户服务删除人脸。
*
* @param hardId 当前设备主键
* @param dto 样本编号集合
* @return 实际删除及已不存在的样本编号
*/
SysUserFaceTerminalDeleteVo delete(Long hardId, HardTerminalFaceDeleteDto dto);
/**
* 以 DEVICE-ID 竞争当前商户部门的人脸质量上报租约。
*
* @param hardId 当前设备主键
* @return 是否取得租约及当前租约剩余时间
*/
HardTerminalFaceQualityLeaseVo acquireQualityLease(Long hardId);
/**
* 以 DEVICE-ID 解析真实设备和商户部门后请求用户服务保存人脸质量结果。
*
* @param hardId 当前设备主键
* @param dto 最多 500 条质量结果
* @return 已更新、按版本或状态优先级忽略和已不存在的样本编号
*/
SysUserFaceTerminalQualityReportVo reportQuality(Long hardId, HardTerminalFaceQualityReportDto dto);
}
@@ -0,0 +1,202 @@
package com.bsd.pjcloud.hard.service.impl.manager;
import cn.hutool.core.util.StrUtil;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceQualityReportItemDto;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalQualityReportDto;
import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceDeleteDto;
import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceQualityReportDto;
import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceQualityReportItemDto;
import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalFaceQualityLeaseVo;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardTerminalFaceService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
/**
* 终端在库人脸维护服务实现。
*/
@Service
@RequiredArgsConstructor
public class HardTerminalFaceServiceImpl implements HardTerminalFaceService {
/**
* 商户部门人脸质量上报租约 Redis 键前缀,租户隔离由 Redis 基础设施统一处理。
*/
private static final String FACE_QUALITY_LEASE_KEY_PREFIX = "hard:face-quality:reporter:";
/**
* 单台终端持有人脸质量上报资格的时长,覆盖常规批量上报且允许异常设备自动让位。
*/
private static final Duration FACE_QUALITY_LEASE_TTL = Duration.ofMinutes(10);
/**
* 设备 Mapper,用于在当前租户内验证 DEVICE-ID。
*/
private final HardManagerMapper hardManagerMapper;
/**
* 设备服务,用于解析与终端人脸同步一致的商户部门。
*/
private final HardManagerService hardManagerService;
/**
* UPMS 人脸远程服务,负责最终部门树范围校验和事务删除。
*/
private final RemoteUserFaceService remoteUserFaceService;
/**
* Redis 字符串客户端,用于在同一商户部门的多台终端之间选出本轮唯一上报者。
*/
private final StringRedisTemplate stringRedisTemplate;
/**
* 根据当前设备真实绑定关系删除部门范围内人脸。
*
* @param hardId 当前设备主键
* @param dto 样本编号集合
* @return 实际删除及已不存在的样本编号
*/
@Override
public SysUserFaceTerminalDeleteVo delete(Long hardId, HardTerminalFaceDeleteDto dto) {
HardManagerVo trustedDevice = resolveTrustedDevice(hardId);
SysUserFaceTerminalDeleteDto request = new SysUserFaceTerminalDeleteDto();
request.setMerchantDeptId(trustedDevice.getMerchantDeptId());
request.setSampleIds(dto.getSampleIds());
R<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不能为空");
}
HardManagerEntity hard = hardManagerMapper.selectById(hardId);
if (hard == null) {
throw new RuntimeException("当前租户下设备不存在");
}
if (hard.getMerchantId() == null) {
throw new RuntimeException("设备尚未绑定商户");
}
HardManagerVo hardDetail = hardManagerService.getByHardId(hardId);
if (hardDetail == null || hardDetail.getMerchantDeptId() == null) {
throw new RuntimeException("设备绑定商户缺少部门");
}
hardDetail.setMerchantId(hard.getMerchantId());
return hardDetail;
}
/**
* 构造商户与绑定部门共同隔离的质量上报租约键。
*
* @param trustedDevice 已通过 DEVICE-ID 校验的设备详情
* @return 不包含人员、人脸或图片信息的 Redis 业务键
*/
private String qualityLeaseKey(HardManagerVo trustedDevice) {
return FACE_QUALITY_LEASE_KEY_PREFIX
+ trustedDevice.getMerchantId() + ":" + trustedDevice.getMerchantDeptId();
}
/**
* 原子创建租约;租约已存在时仅允许同一 DEVICE-ID 继续完成后续批次。
*
* @param leaseKey 当前商户部门租约键
* @param hardId 当前设备主键
* @return 当前设备是否为租约持有者
*/
private boolean tryAcquireQualityLease(String leaseKey, Long hardId) {
String owner = hardId.toString();
Boolean acquired = stringRedisTemplate.opsForValue()
.setIfAbsent(leaseKey, owner, FACE_QUALITY_LEASE_TTL);
return Boolean.TRUE.equals(acquired) || owner.equals(stringRedisTemplate.opsForValue().get(leaseKey));
}
}
@@ -164,7 +164,14 @@
</foreach>
</if>
<if test="dto.merchantDeptIds != null and dto.merchantDeptIds.size() != 0">
and a.merchant_dept_id in
<choose>
<when test="dto.isIncludeMerchantDept != null and dto.isIncludeMerchantDept == false">
and a.merchant_dept_id NOT IN
</when>
<otherwise>
and a.merchant_dept_id IN
</otherwise>
</choose>
<foreach collection="dto.merchantDeptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
@@ -275,13 +282,13 @@
on w_m.id = a.wallet_id and w_m.tenant_id = a.tenant_id and w_m.del_flag = '0'
left join pjcloudx_wallet.wallet_type w_t
on w_t.id = w_m.wallet_type_id and w_t.tenant_id = a.tenant_id and w_t.del_flag = '0'
left join (select tenant_id, order_id, max(id) as id
from hard_after_sale
where del_flag = '0'
group by tenant_id, order_id) sx
on sx.order_id = a.id and sx.tenant_id = a.tenant_id
left join hard_after_sale s
on s.id = sx.id and s.tenant_id = a.tenant_id and s.del_flag = '0'
<!-- 多次退款后只关联最新售后单:相关子查询按 order_id 走索引逐单取最新 ID,避免全表 GROUP BY 派生表物化拖慢分页与 COUNT。 -->
left join hard_after_sale s on s.id = (
select max(latest_sale.id)
from hard_after_sale latest_sale
where latest_sale.order_id = a.id
and latest_sale.del_flag = '0'
)
<where>
<include refid="whereA"/>
<if test="dto.hardTypeCode != null and dto.hardTypeCode != '' ">
@@ -306,7 +313,8 @@
</foreach>
</if>
</where>
order by a.create_time desc, a.user_id desc
<!-- id 作为唯一兜底排序,避免 (create_time, user_id) 相同的订单跨页读取时重复或遗漏。 -->
order by a.create_time desc, a.user_id desc, a.id desc
</sql>
<sql id="pageListCopy">
@@ -393,7 +401,13 @@
select count(0)
from hard_order a
left join hard_manager m on m.id = a.hard_id
left join hard_after_sale s on s.order_id = a.id
<!-- 只关联最新售后单,避免一单多次售后时计数被关联放大。 -->
left join hard_after_sale s on s.id = (
select max(latest_sale.id)
from hard_after_sale latest_sale
where latest_sale.order_id = a.id
and latest_sale.del_flag = '0'
)
<where>
<include refid="whereA"/>
<if test="dto.hardTypeCode != null and dto.hardTypeCode != '' ">