feat:订单列表右侧drawer统计
This commit is contained in:
1 parent
34d1e25760
commit
814f7f71fe
26 files changed
+1247
-2
No files matched your search
@@ -0,0 +1,106 @@
|
||||
# `biz-api-cloud-zax` 开发规则
|
||||
|
||||
> 适用于 Java 后端多模块项目。执行任务前必须先完整阅读 [HQ Platform 通用规则](../agents/common.md),并继续检查目标模块内更具体的说明和现有同类实现。
|
||||
|
||||
## 技术栈基线
|
||||
|
||||
以下为当前父 POM 和 BOM 的扫描结果。实际版本以根 `pom.xml` 与 `pjcloud-common/pjcloud-common-bom/pom.xml` 为准,禁止在子模块中随意覆盖统一版本。
|
||||
|
||||
- 工程版本:`com.bsd:pjcloud:5.7.0`,Maven 多模块工程。
|
||||
- 运行环境:Java 17、Spring Boot `3.3.6`、Spring Cloud `2023.0.4`、Spring Cloud Alibaba `2023.0.3.2`。
|
||||
- 服务治理:Nacos 注册发现与配置中心、Spring Cloud Gateway、OpenFeign、Feign OkHttp、Spring Cloud LoadBalancer、Sentinel、Caffeine。
|
||||
- 安全认证:Spring Security、OAuth2/Jose、Spring Authorization Server `1.4.0`,项目内统一安全组件与方法级 `@PreAuthorize` 权限校验。
|
||||
- 数据访问:MyBatis-Plus `3.5.9`、MyBatis-Plus Join `1.5.2`、动态数据源 `4.3.1`、Druid `1.2.23`、MySQL Connector/J `8.3.0`。
|
||||
- 缓存:Spring Data Redis;网关还使用 Reactive Redis。缓存访问应通过项目公共组件和既有模板,不自行创建不一致的序列化方案。
|
||||
- 分布式事务:Seata `1.7.0`,由 `pjcloud-common-seata` 统一封装。
|
||||
- 接口与文档:Spring MVC / WebFlux、Springdoc OpenAPI `2.1.0`、Knife4j;统一响应通常使用项目 `R<T>`。
|
||||
- 服务容器与构建:业务 Web 服务普遍使用 Undertow,Maven Spring Boot 插件负责打包,Fabric8 Docker Maven Plugin 提供镜像构建能力。
|
||||
- 流程与任务:Flowable `7.0.0`、XXL-Job、Quartz、ElasticJob 相关模块。
|
||||
- 消息与实时能力:Spring Integration MQTT、WebSocket、SSE。
|
||||
- 公共业务能力:OSS、Excel、日志、审计、幂等、接口加解密、数据脱敏、XSS 过滤、分布式序列、灰度路由、数据权限等均已有公共模块。
|
||||
- 常用基础库:Lombok、Hutool、Jasypt 等。是否可用仍需看目标模块 POM,不能因为 BOM 管理了版本就直接在任意模块引用。
|
||||
|
||||
## 顶层服务与模块职责
|
||||
|
||||
- `pjcloud-register`:内置 Nacos 注册/配置中心相关模块。
|
||||
- `pjcloud-gateway`:Spring Cloud Gateway 网关,负责动态路由、Reactive Redis、Sentinel 网关限流和聚合接口文档等。
|
||||
- `pjcloud-auth`:OAuth2 认证授权中心,依赖统一安全、Feign、Redis、验证码和灰度路由能力。
|
||||
- `pjcloud-upms`:用户、部门、角色、菜单、租户、字典等基础权限管理,拆分为 `api` 与 `biz`。
|
||||
- `pjcloud-app-server`:移动端应用配置、页面、TabBar 及 App 侧公共服务,拆分为 `api` 与 `biz`。
|
||||
- `pjcloud-canteen`:食堂、菜品、库存、预订、配送等业务,拆分为 `api` 与 `biz`。
|
||||
- `pjcloud-hardware`:门禁、设备、硬件商品/订单等能力,拆分为 `api` 与 `biz`。
|
||||
- `pjcloud-integrated`:访客、会议、反馈、公告、车辆、广告等综合业务,拆分为 `api` 与 `biz`。
|
||||
- `pjcloud-wallet`:钱包、充值、交易、商户结算等业务,拆分为 `api` 与 `biz`。
|
||||
- `pjcloud-supermarket`:商城、商品、SKU、库存和 ERP 等业务,拆分为 `api` 与 `biz`。
|
||||
- `pjcloud-thirdparty`:第三方认证、支付、门禁和外部系统集成,拆分为 `api` 与 `biz`。
|
||||
- `pjcloud-mqtt`:MQTT 接入与消息能力,拆分为 `api` 与 `biz`。
|
||||
- `pjcloud-pass`:通行相关业务,拆分为 `api` 与 `biz`。
|
||||
- `pjcloud-flow`:流程引擎与流程任务,内部继续拆分 engine/task 的 `api` 和 `biz`。
|
||||
- `pjcloud-visual`:代码生成、报表、监控、微信/支付平台、Quartz、ElasticJob、XXL-Job 管理端等辅助服务。
|
||||
- `pjcloud-boot`:聚合多个业务模块的单体启动方式;修改公共业务时需同时考虑微服务模式和 Boot 聚合模式。
|
||||
|
||||
## `api` / `biz` 分层规则
|
||||
|
||||
- 典型业务模块使用父聚合模块 + `*-api` + `*-biz` 结构。
|
||||
- `*-api` 放跨模块契约:Feign Client、DTO、VO、Entity、常量及必要接口。不得把 Controller、具体 Service 实现或仅本服务内部使用的实现细节放入 API 模块。
|
||||
- `*-biz` 放运行实现:启动类、Controller、Service/Impl、Mapper、配置、任务和资源文件。
|
||||
- 跨服务只能依赖对方 `*-api` 和 Feign 契约,不得直接依赖对方 `*-biz`、Mapper 或 ServiceImpl。
|
||||
- 新增类型前先搜索现有 DTO、VO、Entity 和公共 `R<T>`;请求对象、响应对象、持久化对象职责要分开,不用 Map 或 Entity 随意穿透所有层。
|
||||
- Controller 负责参数接收、权限和响应包装,复杂业务放 Service;Mapper 负责数据访问,不在 Controller 堆叠查询与事务逻辑。
|
||||
|
||||
## 数据、缓存、事务与远程调用
|
||||
|
||||
- 持久化沿用 MyBatis-Plus、项目 `PjcloudBaseMapper`、现有分页 `Page`、Wrapper 与 XML/Mapper 风格。使用 MP Join 或自定义 SQL 前先看同模块已有写法。
|
||||
- 多租户、数据权限和审计逻辑可能由公共 Mapper、拦截器或上下文提供。禁止用手写 SQL 绕过租户/数据范围约束。
|
||||
- **不得修改数据库字段和表结构。** 需求缺少字段时先使用现有模型评估替代方案;确实必须调整时停止实现并请求用户明确授权。
|
||||
- 事务方法应放在 Spring 管理的 Service 层,明确 `rollbackFor`;跨库/跨服务一致性需要评估本地事务、Seata、补偿和幂等,不能把一次远程调用误认为本地事务的一部分。
|
||||
- Redis 必须复用已有 Key、序列化、Template/工具类和过期策略。修改数据库数据时同步评估缓存更新或失效;不要新增永久缓存或无命名空间 Key。
|
||||
- Redis 基础设施会根据当前租户上下文自动处理租户 Key 前缀;使用 `RedisTemplate`、`StringRedisTemplate`、Redisson 锁及其他 Redis 对象时,业务代码只声明稳定的业务 Key,禁止读取或拼接 `TenantContextHolder.getTenantId()` 等租户编号。跨租户任务应通过 `TenantBroker` 等既有能力切换租户上下文;确需全局共享的 Key 必须沿用项目已有全局 Key 契约,不得用手工拼接方式绕过隔离。
|
||||
- 远程调用通过已有 Feign Client,复用统一请求头、内部调用标识、LoadBalancer、Sentinel 降级和异常约定。禁止在业务代码中手写服务地址进行 HTTP 调用。
|
||||
- Feign 返回 `R<T>` 时必须检查业务状态与空数据;不得只判断 HTTP 成功。
|
||||
- 接口加解密已有 `pjcloud-common-encrypt-api`,前端也已有对应拦截器。不得在单个接口另造不兼容的加密协议。
|
||||
|
||||
## 配置、安全与接口约定
|
||||
|
||||
- 各服务 `application.yml` 通常只保存启动端口、服务名和 Nacos 引导配置,并导入 `application-{profile}.yml` 与服务级配置。修改配置前同时检查本地文件、环境变量占位符和 Nacos 配置来源。
|
||||
- 不得把账号、密码、密钥、Token、证书或生产地址硬编码进仓库。发现现有敏感配置也不得在无关任务中扩散或复制。
|
||||
- 方法权限遵循现有 `@PreAuthorize("@pms.hasPermission('...')")` 风格,权限字符串需与管理端菜单/按钮权限对应。
|
||||
- 认证、Token、租户头、内部调用头和数据权限由公共安全组件处理;新增公开接口、匿名接口或内部接口必须先核对现有注解和安全白名单机制。
|
||||
- 接口路径、HTTP 方法、分页参数、统一返回码、异常处理和 OpenAPI 注解跟随同 Controller 的现有风格。
|
||||
- 删除或改变已有字段、枚举值、接口路径和返回结构前必须评估 PC、App 与其他服务调用方;默认保持向后兼容。
|
||||
|
||||
## 公共模块复用指引
|
||||
|
||||
- 核心工具与返回结构:`pjcloud-common-core`。
|
||||
- 数据与 MyBatis 扩展:`pjcloud-common-data`、`pjcloud-common-datasource`。
|
||||
- 认证授权:`pjcloud-common-security`。
|
||||
- 远程调用:`pjcloud-common-feign`。
|
||||
- 限流降级与分布式事务:`pjcloud-common-sentinel`、`pjcloud-common-seata`。
|
||||
- 文档与网关:`pjcloud-common-swagger`、`pjcloud-common-gateway`。
|
||||
- 日志、审计、幂等:`pjcloud-common-log`、`pjcloud-common-audit`、`pjcloud-common-idempotent`。
|
||||
- 文件、Excel、任务:`pjcloud-common-oss`、`pjcloud-common-excel`、`pjcloud-common-job`。
|
||||
- 实时通信:`pjcloud-common-websocket`、`pjcloud-common-sse`。
|
||||
- 安全辅助:`pjcloud-common-encrypt-api`、`pjcloud-common-sensitive`、`pjcloud-common-xss`。
|
||||
- 其他基础设施:`pjcloud-common-gray`、`pjcloud-common-sequence`。新增通用能力前先确认这里是否已有实现。
|
||||
|
||||
## 常用命令与验证
|
||||
|
||||
- 在后端根目录按模块验证:`mvn -pl <模块路径> -am test`。
|
||||
- 仅需编译/打包时:`mvn -pl <模块路径> -am -DskipTests package`,但不能用跳过测试的结果冒充测试通过。
|
||||
- 修改 `*-api`、公共模块或 BOM 时,应验证所有直接依赖模块,必要时执行更大范围构建。
|
||||
- 修改网关、安全、数据、Feign、缓存、加解密或 Boot 聚合模块时,属于高影响改动,必须扩大测试范围并检查微服务与单体两种启动关系。
|
||||
- 当前自动化测试分布不均。集中验证时,无现成测试的模块也应完成针对性编译,并对关键业务补充与现有测试风格一致的测试。
|
||||
|
||||
## 开发规则
|
||||
|
||||
- 修改前先确认所属业务模块及其 `api` / `biz` 边界,沿用现有 Controller、Service、Mapper、DTO、VO、Feign Client、异常处理、鉴权和统一响应结构。
|
||||
- 跨服务调用优先遵循项目现有 OpenFeign 封装、服务名、请求模型、降级与异常处理方式,不得绕过已有边界直接耦合其他服务内部实现。
|
||||
- Redis 使用必须复用已有缓存工具、序列化方式、键命名规则和过期策略。新增或修改缓存逻辑时,必须考虑缓存穿透、数据一致性、失效时机和空值处理。
|
||||
- MySQL 持久化必须沿用已有 Mapper/ORM、事务和分页方式。未经用户明确同意,不得以任何形式修改数据库结构。
|
||||
- 关键业务方法、事务边界、缓存一致性处理、远程调用容错和复杂查询必须添加必要的中文注释。
|
||||
- 接口变更必须评估后台管理端、移动端及其他服务调用方的兼容性;优先保持向后兼容。
|
||||
- 日常修改不自动运行 Maven 测试、编译或打包;收到用户明确的集中验证指令后,再验证受影响模块,多模块依赖变更同时验证直接依赖模块。
|
||||
|
||||
## 完成前项目检查
|
||||
|
||||
- [ ] 后端改动已考虑接口兼容、事务、缓存和远程调用影响。
|
||||
+21
@@ -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;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.bsd.pjcloud.hard.api.vo.order;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 指定预计使用日期的订单统计。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "订单每日统计")
|
||||
public class HardOrderDailyStatisticsVo extends HardOrderStatisticsSummaryVo {
|
||||
|
||||
@Schema(description = "统计日期")
|
||||
private LocalDate date;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.bsd.pjcloud.hard.api.vo.order;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 订单按餐段聚合的统计数据,所有金额统一使用分。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "订单餐段统计")
|
||||
public class HardOrderMealStatisticsVo {
|
||||
|
||||
@Schema(description = "餐段编码")
|
||||
private String hourType;
|
||||
|
||||
@Schema(description = "订单数")
|
||||
private Long orderCount = 0L;
|
||||
|
||||
@Schema(description = "商品份数")
|
||||
private Long productCount = 0L;
|
||||
|
||||
@Schema(description = "商品总价,单位为分")
|
||||
private Long totalPrice = 0L;
|
||||
|
||||
@Schema(description = "额外支付,单位为分")
|
||||
private Long extraPrice = 0L;
|
||||
|
||||
@Schema(description = "配送费,单位为分")
|
||||
private Long deliveryPrice = 0L;
|
||||
|
||||
@Schema(description = "实际支付,单位为分")
|
||||
private Long payPrice = 0L;
|
||||
|
||||
@Schema(description = "已退款,单位为分")
|
||||
private Long refundPrice = 0L;
|
||||
|
||||
@Schema(description = "订单实际,单位为分")
|
||||
private Long actualPrice = 0L;
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.bsd.pjcloud.hard.api.vo.order;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 订单统计的通用汇总数据,所有金额统一使用分。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "订单统计汇总")
|
||||
public class HardOrderStatisticsSummaryVo {
|
||||
|
||||
@Schema(description = "订单数")
|
||||
private Long orderCount = 0L;
|
||||
|
||||
@Schema(description = "商品份数")
|
||||
private Long productCount = 0L;
|
||||
|
||||
@Schema(description = "商品总价,单位为分")
|
||||
private Long totalPrice = 0L;
|
||||
|
||||
@Schema(description = "额外支付,单位为分")
|
||||
private Long extraPrice = 0L;
|
||||
|
||||
@Schema(description = "配送费,单位为分")
|
||||
private Long deliveryPrice = 0L;
|
||||
|
||||
@Schema(description = "实际支付,单位为分")
|
||||
private Long payPrice = 0L;
|
||||
|
||||
@Schema(description = "已退款,单位为分")
|
||||
private Long refundPrice = 0L;
|
||||
|
||||
@Schema(description = "订单实际,单位为分")
|
||||
private Long actualPrice = 0L;
|
||||
|
||||
@Schema(description = "餐段统计")
|
||||
private List<HardOrderMealStatisticsVo> meals = new ArrayList<>();
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.bsd.pjcloud.hard.api.vo.order;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 订单列表统计响应,同时提供当前筛选范围总计和每日明细。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "订单列表统计")
|
||||
public class HardOrderStatisticsVo {
|
||||
|
||||
@Schema(description = "当前筛选范围总计")
|
||||
private HardOrderStatisticsSummaryVo total = new HardOrderStatisticsSummaryVo();
|
||||
|
||||
@Schema(description = "按预计使用日期拆分的统计")
|
||||
private List<HardOrderDailyStatisticsVo> daily = new ArrayList<>();
|
||||
}
|
||||
+17
@@ -2,6 +2,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 cn.hutool.core.util.ObjectUtil;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
@@ -59,6 +60,7 @@ public class HardApiController {
|
||||
private final HardOrderTotalService hardOrderTotalService;
|
||||
private final ReservationMenuQueryService reservationMenuQueryService;
|
||||
private final VerificationRecordService verificationRecordService;
|
||||
private final HardTerminalFaceService hardTerminalFaceService;
|
||||
|
||||
/**
|
||||
* 食堂、超市 Android 终端心跳状态服务。
|
||||
@@ -92,6 +94,21 @@ 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));
|
||||
}
|
||||
|
||||
@GetMapping("/get")
|
||||
@Operation(summary = "获取硬件配置信息")
|
||||
public R<HardManagerVo> get(@RequestParam Long id) {
|
||||
|
||||
+25
-1
@@ -41,6 +41,7 @@ import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateShopStatusDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateTakeOutStatusDto;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardPendingReservationCountVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo;
|
||||
@@ -85,6 +86,7 @@ public class HardOrderController {
|
||||
private final HardOnlineCanteenStatsService hardOnlineCanteenStatsService;
|
||||
private final HardOrderSyncService hardOrderSyncService;
|
||||
private final HardCanteenTotalService canteenTotalService;
|
||||
private final HardOrderStatisticsService hardOrderStatisticsService;
|
||||
|
||||
/** 订单导出服务,负责异步任务、EasyExcel 分片写入和文件中心上传。 */
|
||||
private final HardOrderExportService hardOrderExportService;
|
||||
@@ -598,6 +600,28 @@ public class HardOrderController {
|
||||
@PostMapping("/page-join")
|
||||
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')")
|
||||
public R pageJoin(@ParameterObject Page page, @RequestBody HardOrderDto dto) {
|
||||
DataScope dataScope = prepareAdminOrderQuery(dto);
|
||||
return R.ok(hardwareOrderService.scopePageList(page, dto, dataScope));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按订单列表当前完整筛选条件统计全部命中订单,不受分页大小影响。
|
||||
*
|
||||
* @param dto 与订单分页请求相同的筛选条件
|
||||
* @return 范围金额总计,以及按预计使用日期、餐段拆分的商品份数和金额
|
||||
*/
|
||||
@Operation(summary = "订单列表统计", description = "按订单列表完整筛选条件统计范围总计及日期餐段明细")
|
||||
@PostMapping("/statistics")
|
||||
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')")
|
||||
public R<HardOrderStatisticsVo> statistics(@RequestBody HardOrderDto dto) {
|
||||
DataScope dataScope = prepareAdminOrderQuery(dto);
|
||||
return R.ok(hardOrderStatisticsService.statistics(dto, dataScope));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为管理端订单分页和统计统一注入设备类型权限、单商户子级范围及商户部门数据权限。
|
||||
*/
|
||||
private DataScope prepareAdminOrderQuery(HardOrderDto dto) {
|
||||
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
|
||||
dataScope.setScopeDeptName("merchant_dept_id");
|
||||
PjcloudUser user = SecurityUtils.getUser();
|
||||
@@ -614,7 +638,7 @@ public class HardOrderController {
|
||||
dto.setMchIds(mchDtoIds);
|
||||
}
|
||||
}
|
||||
return R.ok(hardwareOrderService.scopePageList(page, dto, dataScope));
|
||||
return dataScope;
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询未识别订单", description = "分页查询未识别订单")
|
||||
|
||||
+10
@@ -16,6 +16,7 @@ import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardParentCarEntity;
|
||||
import com.bsd.pjcloud.hard.api.vo.hard.PanelPorductVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.*;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsAggregate;
|
||||
import com.github.yulichang.wrapper.MPJLambdaWrapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@@ -169,6 +170,15 @@ public interface HardOrderMapper extends PjcloudBaseMapper<HardOrderEntity> {
|
||||
|
||||
List<HardOrderVo> scopeList(@Param("dto") HardOrderDto dto, DataScope dataScope);
|
||||
|
||||
/**
|
||||
* 按订单列表完整筛选条件统计订单金额、商品份数及日期餐段明细。
|
||||
*
|
||||
* @param dto 与管理端订单分页相同的查询条件
|
||||
* @param dataScope 与管理端订单分页相同的商户部门数据范围
|
||||
* @return 按日期、餐段和商户部门预聚合的统计行
|
||||
*/
|
||||
List<HardOrderStatisticsAggregate> listStatistics(@Param("dto") HardOrderDto dto, DataScope dataScope);
|
||||
|
||||
/**
|
||||
* 按订单筛选条件查询去重后的预约用户主键。
|
||||
*
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.bsd.pjcloud.hard.mapper.order.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 订单列表统计的数据库聚合行。
|
||||
*
|
||||
* <p>数据库先按统计日期、餐段和商户部门聚合,保留商户部门是为了让统一数据权限拦截器
|
||||
* 与订单分页查询应用完全相同的可见范围;服务层会把权限过滤后的商户部门行再次合并。</p>
|
||||
*/
|
||||
@Data
|
||||
public class HardOrderStatisticsAggregate {
|
||||
|
||||
/** 预计使用日期;历史空值由 SQL 回退到订单创建日期。 */
|
||||
private LocalDate statisticsDate;
|
||||
|
||||
/** 订单餐段协议编码;历史空值允许为空。 */
|
||||
private String hourType;
|
||||
|
||||
/** 商户所属部门,供统一数据权限拦截器过滤。 */
|
||||
private Long merchantDeptId;
|
||||
|
||||
/** 订单创建人账号,供“仅本人”及“本人 + 部门”数据权限过滤。 */
|
||||
private String createBy;
|
||||
|
||||
/** 命中筛选条件的订单数。 */
|
||||
private Long orderCount;
|
||||
|
||||
/** 订单购物车中的原始商品份数。 */
|
||||
private Long productCount;
|
||||
|
||||
/** 商品总价,单位为分。 */
|
||||
private Long totalPrice;
|
||||
|
||||
/** 额外支付金额,单位为分。 */
|
||||
private Long extraPrice;
|
||||
|
||||
/** 配送费,单位为分。 */
|
||||
private Long deliveryPrice;
|
||||
|
||||
/** 实际支付金额,单位为分。 */
|
||||
private Long payPrice;
|
||||
|
||||
/** 已退款金额,单位为分。 */
|
||||
private Long refundPrice;
|
||||
|
||||
/** 订单实际金额,即实际支付减已退款,单位为分。 */
|
||||
private Long actualPrice;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.bsd.pjcloud.hard.service;
|
||||
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
|
||||
|
||||
/**
|
||||
* 订单管理列表统计服务。
|
||||
*/
|
||||
public interface HardOrderStatisticsService {
|
||||
|
||||
/**
|
||||
* 按订单列表完整筛选条件和当前操作者数据范围统计全部命中订单。
|
||||
*
|
||||
* @param query 订单列表查询条件
|
||||
* @param dataScope 与订单分页一致的商户部门数据范围
|
||||
* @return 范围总计及按日期、餐段拆分的统计
|
||||
*/
|
||||
HardOrderStatisticsVo statistics(HardOrderDto query, DataScope dataScope);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.bsd.pjcloud.hard.service;
|
||||
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
|
||||
import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceDeleteDto;
|
||||
|
||||
/**
|
||||
* 终端在库人脸维护服务。
|
||||
*/
|
||||
public interface HardTerminalFaceService {
|
||||
|
||||
/**
|
||||
* 以 DEVICE-ID 解析真实商户部门后请求用户服务删除人脸。
|
||||
*
|
||||
* @param hardId 当前设备主键
|
||||
* @param dto 样本编号集合
|
||||
* @return 实际删除及已不存在的样本编号
|
||||
*/
|
||||
SysUserFaceTerminalDeleteVo delete(Long hardId, HardTerminalFaceDeleteDto dto);
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.bsd.pjcloud.hard.service.impl.manager;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceDeleteDto;
|
||||
import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
|
||||
import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
|
||||
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.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 终端在库人脸维护服务实现。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class HardTerminalFaceServiceImpl implements HardTerminalFaceService {
|
||||
|
||||
/**
|
||||
* 设备 Mapper,用于在当前租户内验证 DEVICE-ID。
|
||||
*/
|
||||
private final HardManagerMapper hardManagerMapper;
|
||||
|
||||
/**
|
||||
* 设备服务,用于解析与终端人脸同步一致的商户部门。
|
||||
*/
|
||||
private final HardManagerService hardManagerService;
|
||||
|
||||
/**
|
||||
* UPMS 人脸远程服务,负责最终部门树范围校验和事务删除。
|
||||
*/
|
||||
private final RemoteUserFaceService remoteUserFaceService;
|
||||
|
||||
/**
|
||||
* 根据当前设备真实绑定关系删除部门范围内人脸。
|
||||
*
|
||||
* @param hardId 当前设备主键
|
||||
* @param dto 样本编号集合
|
||||
* @return 实际删除及已不存在的样本编号
|
||||
*/
|
||||
@Override
|
||||
public SysUserFaceTerminalDeleteVo delete(Long hardId, HardTerminalFaceDeleteDto dto) {
|
||||
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("设备绑定商户缺少部门");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.bsd.pjcloud.hard.service.impl.order;
|
||||
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderDailyStatisticsVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderMealStatisticsVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsSummaryVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsAggregate;
|
||||
import com.bsd.pjcloud.hard.service.HardOrderStatisticsService;
|
||||
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 订单列表统计实现;数据库负责筛选和初次聚合,服务层合并权限范围内的商户部门行。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class HardOrderStatisticsServiceImpl implements HardOrderStatisticsService {
|
||||
|
||||
private final HardOrderMapper hardOrderMapper;
|
||||
private final HardUserIdentitySupport userIdentitySupport;
|
||||
|
||||
@Override
|
||||
public HardOrderStatisticsVo statistics(HardOrderDto query, DataScope dataScope) {
|
||||
applyIdentityQuery(query);
|
||||
List<HardOrderStatisticsAggregate> rows = hardOrderMapper.listStatistics(query, dataScope);
|
||||
return buildStatistics(rows == null ? List.of() : rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 身份和人脸条件必须先转换成用户主键,与订单分页服务的筛选流程保持一致。
|
||||
*/
|
||||
private void applyIdentityQuery(HardOrderDto query) {
|
||||
if (!userIdentitySupport.hasIdentityQuery(query)) {
|
||||
return;
|
||||
}
|
||||
query.setUserIds(userIdentitySupport.findIdentityUserIds(query, query.getUserId(), query.getUserIds()));
|
||||
query.setUserId(null);
|
||||
query.setIdentityTypes(null);
|
||||
query.setStudentInfo(null);
|
||||
query.setTeacherInfo(null);
|
||||
query.setHasFace(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把按商户部门拆分的数据库结果合并为范围总计、每日统计和餐段统计。
|
||||
*/
|
||||
private HardOrderStatisticsVo buildStatistics(List<HardOrderStatisticsAggregate> rows) {
|
||||
HardOrderStatisticsVo result = new HardOrderStatisticsVo();
|
||||
Map<LocalDate, HardOrderDailyStatisticsVo> dailyMap = new LinkedHashMap<>();
|
||||
|
||||
for (HardOrderStatisticsAggregate row : rows) {
|
||||
addAggregate(result.getTotal(), row);
|
||||
mergeMeal(result.getTotal(), row);
|
||||
|
||||
HardOrderDailyStatisticsVo daily = dailyMap.computeIfAbsent(row.getStatisticsDate(), date -> {
|
||||
HardOrderDailyStatisticsVo item = new HardOrderDailyStatisticsVo();
|
||||
item.setDate(date);
|
||||
return item;
|
||||
});
|
||||
addAggregate(daily, row);
|
||||
mergeMeal(daily, row);
|
||||
}
|
||||
|
||||
sortMeals(result.getTotal());
|
||||
List<HardOrderDailyStatisticsVo> daily = new ArrayList<>(dailyMap.values());
|
||||
daily.sort(Comparator.comparing(HardOrderDailyStatisticsVo::getDate,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
daily.forEach(this::sortMeals);
|
||||
result.setDaily(daily);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 累加订单级金额和商品份数。 */
|
||||
private void addAggregate(HardOrderStatisticsSummaryVo target, HardOrderStatisticsAggregate source) {
|
||||
target.setOrderCount(target.getOrderCount() + safe(source.getOrderCount()));
|
||||
target.setProductCount(target.getProductCount() + safe(source.getProductCount()));
|
||||
target.setTotalPrice(target.getTotalPrice() + safe(source.getTotalPrice()));
|
||||
target.setExtraPrice(target.getExtraPrice() + safe(source.getExtraPrice()));
|
||||
target.setDeliveryPrice(target.getDeliveryPrice() + safe(source.getDeliveryPrice()));
|
||||
target.setPayPrice(target.getPayPrice() + safe(source.getPayPrice()));
|
||||
target.setRefundPrice(target.getRefundPrice() + safe(source.getRefundPrice()));
|
||||
target.setActualPrice(target.getActualPrice() + safe(source.getActualPrice()));
|
||||
}
|
||||
|
||||
/** 同一日期餐段可能被数据权限范围内的多个商户部门拆开,需要再次合并。 */
|
||||
private void mergeMeal(HardOrderStatisticsSummaryVo target, HardOrderStatisticsAggregate source) {
|
||||
HardOrderMealStatisticsVo meal = target.getMeals().stream()
|
||||
.filter(item -> java.util.Objects.equals(item.getHourType(), source.getHourType()))
|
||||
.findFirst()
|
||||
.orElseGet(() -> {
|
||||
HardOrderMealStatisticsVo item = new HardOrderMealStatisticsVo();
|
||||
item.setHourType(source.getHourType());
|
||||
target.getMeals().add(item);
|
||||
return item;
|
||||
});
|
||||
meal.setOrderCount(meal.getOrderCount() + safe(source.getOrderCount()));
|
||||
meal.setProductCount(meal.getProductCount() + safe(source.getProductCount()));
|
||||
meal.setTotalPrice(meal.getTotalPrice() + safe(source.getTotalPrice()));
|
||||
meal.setExtraPrice(meal.getExtraPrice() + safe(source.getExtraPrice()));
|
||||
meal.setDeliveryPrice(meal.getDeliveryPrice() + safe(source.getDeliveryPrice()));
|
||||
meal.setPayPrice(meal.getPayPrice() + safe(source.getPayPrice()));
|
||||
meal.setRefundPrice(meal.getRefundPrice() + safe(source.getRefundPrice()));
|
||||
meal.setActualPrice(meal.getActualPrice() + safe(source.getActualPrice()));
|
||||
}
|
||||
|
||||
/** 餐段编码稳定排序,空餐段放到最后;页面会再按当前字典顺序显示。 */
|
||||
private void sortMeals(HardOrderStatisticsSummaryVo summary) {
|
||||
summary.getMeals().sort(Comparator.comparing(HardOrderMealStatisticsVo::getHourType,
|
||||
Comparator.nullsLast(String::compareTo)));
|
||||
}
|
||||
|
||||
private long safe(Long value) {
|
||||
return value == null ? 0L : value;
|
||||
}
|
||||
}
|
||||
+80
@@ -468,6 +468,86 @@
|
||||
<include refid="pageList"/>
|
||||
</select>
|
||||
|
||||
<!--
|
||||
订单统计与管理端分页共用 whereA、设备关联条件和数据权限。
|
||||
内层先按订单聚合购物车,避免一笔订单含多行商品时重复累计订单金额。
|
||||
商户部门和创建人保留到最外层结果,供统一数据权限拦截器处理部门及仅本人权限。
|
||||
-->
|
||||
<select id="listStatistics"
|
||||
resultType="com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsAggregate">
|
||||
select order_statistics.statistics_date,
|
||||
order_statistics.hour_type,
|
||||
order_statistics.merchant_dept_id,
|
||||
order_statistics.create_by,
|
||||
count(0) as order_count,
|
||||
sum(order_statistics.product_count) as product_count,
|
||||
sum(order_statistics.total_price) as total_price,
|
||||
sum(order_statistics.extra_price) as extra_price,
|
||||
sum(order_statistics.delivery_price) as delivery_price,
|
||||
sum(order_statistics.pay_price) as pay_price,
|
||||
sum(order_statistics.refund_price) as refund_price,
|
||||
sum(order_statistics.pay_price - order_statistics.refund_price) as actual_price
|
||||
from (
|
||||
select a.id,
|
||||
coalesce(a.appt_use_date, date(a.create_time)) as statistics_date,
|
||||
a.hour_type,
|
||||
a.merchant_dept_id,
|
||||
a.create_by,
|
||||
sum(greatest(coalesce(c.product_count, 0), 0)) as product_count,
|
||||
coalesce(a.total_price, 0) as total_price,
|
||||
coalesce(a.extra_price, 0) as extra_price,
|
||||
coalesce(a.delivery_price, 0) as delivery_price,
|
||||
coalesce(a.pay_price, 0) as pay_price,
|
||||
coalesce(a.refund_price, 0) as refund_price
|
||||
from hard_order a
|
||||
<if test="(dto.hardTypeCode != null and dto.hardTypeCode != '') or (dto.hardTypeCodes != null and dto.hardTypeCodes.size() != 0)">
|
||||
left join hard_manager m on m.id = a.hard_id
|
||||
</if>
|
||||
<if test="dto.afterSaleNo != null and dto.afterSaleNo != ''">
|
||||
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'
|
||||
)
|
||||
</if>
|
||||
left join hard_car c on c.parent_car_id = a.parent_car_id and c.del_flag = '0'
|
||||
<where>
|
||||
<include refid="whereA"/>
|
||||
<if test="dto.hardTypeCode != null and dto.hardTypeCode != '' ">
|
||||
and m.type = #{dto.hardTypeCode}
|
||||
</if>
|
||||
<if test="dto.hardTypeCodes != null and dto.hardTypeCodes.size() != 0">
|
||||
and m.type in
|
||||
<foreach collection="dto.hardTypeCodes" item="v" open="(" close=")" separator=",">
|
||||
#{v}
|
||||
</foreach>
|
||||
</if>
|
||||
<if test="dto.afterSaleNo != null and dto.afterSaleNo != ''">
|
||||
and s.no = #{dto.afterSaleNo}
|
||||
</if>
|
||||
</where>
|
||||
group by a.id,
|
||||
coalesce(a.appt_use_date, date(a.create_time)),
|
||||
a.hour_type,
|
||||
a.merchant_dept_id,
|
||||
a.create_by,
|
||||
a.total_price,
|
||||
a.extra_price,
|
||||
a.delivery_price,
|
||||
a.pay_price,
|
||||
a.refund_price
|
||||
) order_statistics
|
||||
group by order_statistics.statistics_date,
|
||||
order_statistics.hour_type,
|
||||
order_statistics.merchant_dept_id,
|
||||
order_statistics.create_by
|
||||
order by order_statistics.statistics_date desc,
|
||||
order_statistics.hour_type asc,
|
||||
order_statistics.merchant_dept_id asc,
|
||||
order_statistics.create_by asc
|
||||
</select>
|
||||
|
||||
<!-- 已订餐类查询先只读取去重用户主键,完整用户资料仍由 UPMS 按用户数据权限过滤。 -->
|
||||
<select id="listReservationUserIds" resultType="java.lang.Long">
|
||||
select distinct a.user_id
|
||||
|
||||
+6
-1
@@ -12,6 +12,7 @@ import com.bsd.pjcloud.hard.service.HardOrderService;
|
||||
import com.bsd.pjcloud.hard.service.HardOrderSyncService;
|
||||
import com.bsd.pjcloud.hard.service.HardOrderTotalService;
|
||||
import com.bsd.pjcloud.hard.service.HardTerminalStatusService;
|
||||
import com.bsd.pjcloud.hard.service.HardTerminalFaceService;
|
||||
import com.bsd.pjcloud.hard.service.ReservationMenuQueryService;
|
||||
import com.bsd.pjcloud.hard.service.VerificationRecordService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -57,10 +58,14 @@ class HardApiControllerTest {
|
||||
/** 终端心跳服务,本组测试不调用。 */
|
||||
private final HardTerminalStatusService hardTerminalStatusService = mock(HardTerminalStatusService.class);
|
||||
|
||||
/** 终端人脸删除服务,本组购物车测试不调用。 */
|
||||
private final HardTerminalFaceService hardTerminalFaceService = mock(HardTerminalFaceService.class);
|
||||
|
||||
/** 被测 Android 硬件接口控制器。 */
|
||||
private final HardApiController controller = new HardApiController(hardManagerService, hardInvoiceService,
|
||||
hardCarService, hardOrderService, hardOrderSyncService, hardApiService, hardOrderTotalService,
|
||||
reservationMenuQueryService, verificationRecordService, hardTerminalStatusService);
|
||||
reservationMenuQueryService, verificationRecordService, hardTerminalFaceService,
|
||||
hardTerminalStatusService);
|
||||
|
||||
/**
|
||||
* 验证食堂预约部分退款后数量和行金额按剩余份数投影,已全部退款商品不会返回消费机。
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package com.bsd.pjcloud.hard.service.impl.manager;
|
||||
|
||||
import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceDeleteDto;
|
||||
import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
|
||||
import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
|
||||
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 java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
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 关系解析。
|
||||
*/
|
||||
class HardTerminalFaceServiceImplTest {
|
||||
|
||||
/** 当前租户设备查询 Mapper。 */
|
||||
private final HardManagerMapper hardManagerMapper = mock(HardManagerMapper.class);
|
||||
|
||||
/** 设备详情服务,用于读取真实商户部门。 */
|
||||
private final HardManagerService hardManagerService = mock(HardManagerService.class);
|
||||
|
||||
/** 用户服务内部删除接口。 */
|
||||
private final RemoteUserFaceService remoteUserFaceService = mock(RemoteUserFaceService.class);
|
||||
|
||||
/** 被测终端人脸维护服务。 */
|
||||
private HardTerminalFaceServiceImpl service;
|
||||
|
||||
/** 每个用例创建无共享状态的服务实例。 */
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new HardTerminalFaceServiceImpl(hardManagerMapper, hardManagerService, remoteUserFaceService);
|
||||
}
|
||||
|
||||
/** 缺少 DEVICE-ID 时必须在查询数据库或调用用户服务前拒绝。 */
|
||||
@Test
|
||||
void deleteRejectsMissingDeviceId() {
|
||||
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.delete(null, request("1001")));
|
||||
|
||||
assertEquals("DEVICE-ID不能为空", exception.getMessage());
|
||||
verify(hardManagerMapper, never()).selectById(org.mockito.ArgumentMatchers.any());
|
||||
verify(remoteUserFaceService, never()).terminalDelete(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
/** 当前租户不存在该设备时不能调用用户服务。 */
|
||||
@Test
|
||||
void deleteRejectsUnknownDevice() {
|
||||
when(hardManagerMapper.selectById(10L)).thenReturn(null);
|
||||
|
||||
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.delete(10L, request("1001")));
|
||||
|
||||
assertEquals("当前租户下设备不存在", exception.getMessage());
|
||||
verify(remoteUserFaceService, never()).terminalDelete(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
/** 设备尚未绑定商户时不能向下游传递客户端样本。 */
|
||||
@Test
|
||||
void deleteRejectsDeviceWithoutMerchant() {
|
||||
HardManagerEntity hard = new HardManagerEntity();
|
||||
hard.setId(10L);
|
||||
when(hardManagerMapper.selectById(10L)).thenReturn(hard);
|
||||
|
||||
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.delete(10L, request("1001")));
|
||||
|
||||
assertEquals("设备尚未绑定商户", exception.getMessage());
|
||||
verify(remoteUserFaceService, never()).terminalDelete(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
/** 下游部门参数必须使用服务端设备详情的商户部门,并原样返回幂等删除结果。 */
|
||||
@Test
|
||||
void deleteUsesTrustedMerchantDepartmentAndReturnsConfirmation() {
|
||||
HardManagerEntity hard = new HardManagerEntity();
|
||||
hard.setId(10L);
|
||||
hard.setMerchantId(20L);
|
||||
HardManagerVo detail = new HardManagerVo();
|
||||
detail.setMerchantDeptId(30L);
|
||||
SysUserFaceTerminalDeleteVo response = SysUserFaceTerminalDeleteVo.builder()
|
||||
.deletedSampleIds(List.of("1001"))
|
||||
.alreadyAbsentSampleIds(List.of("1002"))
|
||||
.build();
|
||||
when(hardManagerMapper.selectById(10L)).thenReturn(hard);
|
||||
when(hardManagerService.getByHardId(10L)).thenReturn(detail);
|
||||
when(remoteUserFaceService.terminalDelete(org.mockito.ArgumentMatchers.any())).thenReturn(R.ok(response));
|
||||
|
||||
SysUserFaceTerminalDeleteVo result = service.delete(10L, request("1001", "1002"));
|
||||
|
||||
ArgumentCaptor<SysUserFaceTerminalDeleteDto> captor = ArgumentCaptor.forClass(SysUserFaceTerminalDeleteDto.class);
|
||||
verify(remoteUserFaceService).terminalDelete(captor.capture());
|
||||
assertEquals(30L, captor.getValue().getMerchantDeptId());
|
||||
assertEquals(List.of("1001", "1002"), captor.getValue().getSampleIds());
|
||||
assertEquals(List.of("1001"), result.getDeletedSampleIds());
|
||||
assertEquals(List.of("1002"), result.getAlreadyAbsentSampleIds());
|
||||
}
|
||||
|
||||
/** 用户服务业务失败时必须抛出并禁止终端误认为服务端已删除。 */
|
||||
@Test
|
||||
void deletePropagatesUserServiceFailure() {
|
||||
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(remoteUserFaceService.terminalDelete(org.mockito.ArgumentMatchers.any())).thenReturn(R.failed("越权样本"));
|
||||
|
||||
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.delete(10L, request("1001")));
|
||||
|
||||
assertEquals("越权样本", exception.getMessage());
|
||||
}
|
||||
|
||||
/** 构造一份仅含样本编号的终端请求。 */
|
||||
private static HardTerminalFaceDeleteDto request(String... sampleIds) {
|
||||
HardTerminalFaceDeleteDto dto = new HardTerminalFaceDeleteDto();
|
||||
dto.setSampleIds(List.of(sampleIds));
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.bsd.pjcloud.hard.service.impl.order;
|
||||
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
|
||||
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsAggregate;
|
||||
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 订单列表统计服务测试,验证商户部门预聚合行的二次合并及身份条件转换。
|
||||
*/
|
||||
class HardOrderStatisticsServiceImplTest {
|
||||
|
||||
private final HardOrderMapper hardOrderMapper = mock(HardOrderMapper.class);
|
||||
private final HardUserIdentitySupport userIdentitySupport = mock(HardUserIdentitySupport.class);
|
||||
private HardOrderStatisticsServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new HardOrderStatisticsServiceImpl(hardOrderMapper, userIdentitySupport);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一天、同餐段的多个商户部门行应合并,范围总计必须等于每日统计之和。
|
||||
*/
|
||||
@Test
|
||||
void statisticsMergesDepartmentRowsIntoDailyMealAndRangeTotals() {
|
||||
HardOrderDto query = new HardOrderDto();
|
||||
DataScope dataScope = DataScope.of();
|
||||
when(hardOrderMapper.listStatistics(query, dataScope)).thenReturn(List.of(
|
||||
aggregate(LocalDate.of(2026, 8, 30), "1", 10L, 2L, 3L, 1200L, 100L, 0L, 1300L, 200L),
|
||||
aggregate(LocalDate.of(2026, 8, 30), "1", 20L, 1L, 2L, 800L, 0L, 100L, 900L, 0L),
|
||||
aggregate(LocalDate.of(2026, 8, 29), "2", 10L, 4L, 5L, 2600L, 200L, 0L, 2800L, 300L)));
|
||||
|
||||
HardOrderStatisticsVo result = service.statistics(query, dataScope);
|
||||
|
||||
assertEquals(7L, result.getTotal().getOrderCount());
|
||||
assertEquals(10L, result.getTotal().getProductCount());
|
||||
assertEquals(4600L, result.getTotal().getTotalPrice());
|
||||
assertEquals(5000L, result.getTotal().getPayPrice());
|
||||
assertEquals(500L, result.getTotal().getRefundPrice());
|
||||
assertEquals(4500L, result.getTotal().getActualPrice());
|
||||
assertEquals(2, result.getTotal().getMeals().size());
|
||||
|
||||
assertEquals(LocalDate.of(2026, 8, 30), result.getDaily().get(0).getDate());
|
||||
assertEquals(3L, result.getDaily().get(0).getOrderCount());
|
||||
assertEquals(5L, result.getDaily().get(0).getProductCount());
|
||||
assertEquals(1, result.getDaily().get(0).getMeals().size());
|
||||
assertEquals(2200L, result.getDaily().get(0).getMeals().get(0).getPayPrice());
|
||||
assertEquals(2000L, result.getDaily().get(0).getMeals().get(0).getActualPrice());
|
||||
|
||||
assertEquals(LocalDate.of(2026, 8, 29), result.getDaily().get(1).getDate());
|
||||
assertEquals(4L, result.getDaily().get(1).getOrderCount());
|
||||
assertEquals(2500L, result.getDaily().get(1).getActualPrice());
|
||||
}
|
||||
|
||||
/**
|
||||
* 身份条件先转换成用户主键后再进入聚合查询,避免统计绕过列表的身份筛选。
|
||||
*/
|
||||
@Test
|
||||
void statisticsAppliesIdentityUserScopeBeforeQueryingMapper() {
|
||||
HardOrderDto query = new HardOrderDto();
|
||||
query.setUserId(9L);
|
||||
query.setIdentityTypes(List.of("STUDENT"));
|
||||
DataScope dataScope = DataScope.of();
|
||||
when(userIdentitySupport.hasIdentityQuery(query)).thenReturn(true);
|
||||
when(userIdentitySupport.findIdentityUserIds(query, 9L, null)).thenReturn(List.of(9L));
|
||||
when(hardOrderMapper.listStatistics(any(HardOrderDto.class), any(DataScope.class))).thenReturn(List.of());
|
||||
|
||||
service.statistics(query, dataScope);
|
||||
|
||||
ArgumentCaptor<HardOrderDto> queryCaptor = ArgumentCaptor.forClass(HardOrderDto.class);
|
||||
verify(hardOrderMapper).listStatistics(queryCaptor.capture(), any(DataScope.class));
|
||||
assertNull(queryCaptor.getValue().getUserId());
|
||||
assertEquals(List.of(9L), queryCaptor.getValue().getUserIds());
|
||||
assertNull(queryCaptor.getValue().getIdentityTypes());
|
||||
}
|
||||
|
||||
private HardOrderStatisticsAggregate aggregate(LocalDate date, String hourType, Long merchantDeptId,
|
||||
Long orderCount, Long productCount, Long totalPrice,
|
||||
Long extraPrice, Long deliveryPrice, Long payPrice,
|
||||
Long refundPrice) {
|
||||
HardOrderStatisticsAggregate row = new HardOrderStatisticsAggregate();
|
||||
row.setStatisticsDate(date);
|
||||
row.setHourType(hourType);
|
||||
row.setMerchantDeptId(merchantDeptId);
|
||||
row.setOrderCount(orderCount);
|
||||
row.setProductCount(productCount);
|
||||
row.setTotalPrice(totalPrice);
|
||||
row.setExtraPrice(extraPrice);
|
||||
row.setDeliveryPrice(deliveryPrice);
|
||||
row.setPayPrice(payPrice);
|
||||
row.setRefundPrice(refundPrice);
|
||||
row.setActualPrice(payPrice - refundPrice);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.bsd.pjcloud.admin.api.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 终端按可信部门批量删除人脸的内部请求。
|
||||
*/
|
||||
@Data
|
||||
public class SysUserFaceTerminalDeleteDto {
|
||||
|
||||
/**
|
||||
* Hardware 根据 DEVICE-ID 解析出的商户部门主键。
|
||||
*/
|
||||
@NotNull(message = "终端绑定部门不能为空")
|
||||
private Long merchantDeptId;
|
||||
|
||||
/**
|
||||
* 待删除的虹软样本编号,单批最多 500 条。
|
||||
*/
|
||||
@NotNull(message = "人脸样本编号不能为空")
|
||||
@Size(min = 1, max = 500, message = "单次最多删除500条人脸")
|
||||
private List<String> sampleIds;
|
||||
}
|
||||
+12
@@ -1,7 +1,9 @@
|
||||
package com.bsd.pjcloud.admin.api.feign;
|
||||
|
||||
import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto;
|
||||
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.common.core.constant.ServiceNameConstants;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.feign.annotation.NoToken;
|
||||
@@ -39,4 +41,14 @@ public interface RemoteUserFaceService {
|
||||
@NoToken
|
||||
@GetMapping("/user-face/inner/evidence-source")
|
||||
R<SysUserFaceEvidenceSourceVo> getEvidenceSource(@RequestParam Long userId, @RequestParam String sampleId);
|
||||
|
||||
/**
|
||||
* 按 Hardware 解析出的可信部门批量删除人脸。
|
||||
*
|
||||
* @param dto 商户部门与样本编号
|
||||
* @return 实际删除和已不存在的样本编号
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/user-face/inner/terminal-delete")
|
||||
R<SysUserFaceTerminalDeleteVo> terminalDelete(@RequestBody SysUserFaceTerminalDeleteDto dto);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
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 SysUserFaceTerminalDeleteVo {
|
||||
|
||||
/**
|
||||
* 本次实际逻辑删除的样本编号。
|
||||
*/
|
||||
private List<String> deletedSampleIds;
|
||||
|
||||
/**
|
||||
* 请求前已经不存在的样本编号,终端可继续清理本地副本。
|
||||
*/
|
||||
private List<String> alreadyAbsentSampleIds;
|
||||
}
|
||||
+15
@@ -11,6 +11,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
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.entity.SysUserFaceEntity;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo;
|
||||
@@ -18,6 +19,7 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
|
||||
import com.bsd.pjcloud.admin.service.SysUserFaceService;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
@@ -88,6 +90,19 @@ public class SysUserFaceController {
|
||||
public R<SysUserFaceEvidenceSourceVo> evidenceSource(@RequestParam Long userId, @RequestParam String sampleId) {
|
||||
return R.ok(sysUserFaceService.evidenceSource(userId, sampleId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 Hardware 提供的可信部门范围执行终端人脸删除。
|
||||
*
|
||||
* @param dto 商户部门和样本编号
|
||||
* @return 实际删除及已不存在的样本编号
|
||||
*/
|
||||
@Inner
|
||||
@Operation(summary = "终端按部门范围删除人脸")
|
||||
@PostMapping("/inner/terminal-delete")
|
||||
public R<SysUserFaceTerminalDeleteVo> terminalDelete(@Valid @RequestBody SysUserFaceTerminalDeleteDto dto) {
|
||||
return R.ok(sysUserFaceService.terminalDelete(dto));
|
||||
}
|
||||
/* ************************************* end 远程调用 end ************************************* */
|
||||
|
||||
/* ************************************* start app start ************************************* */
|
||||
|
||||
+15
@@ -46,6 +46,21 @@ public interface SysUserFaceMapper extends PjcloudBaseMapper<SysUserFaceEntity>
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询仍存在的目标样本及其用户当前部门,供终端删除执行整批范围校验。
|
||||
*
|
||||
* @param sampleIds 虹软样本编号
|
||||
* @return 人脸记录及用户部门;用户异常缺失时部门为空并由服务层拒绝
|
||||
*/
|
||||
default List<SysUserFaceVo> terminalDeleteCandidates(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);
|
||||
|
||||
+10
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
||||
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.entity.SysUser;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo;
|
||||
@@ -13,6 +14,7 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto;
|
||||
|
||||
@@ -40,6 +42,14 @@ public interface SysUserFaceService extends IService<SysUserFaceEntity> {
|
||||
*/
|
||||
SysUserFaceSyncSnapshotVo syncSnapshot(SysUserFaceSyncSnapshotDto dto);
|
||||
|
||||
/**
|
||||
* 校验全部样本属于目标部门树后执行逻辑删除,任何越权样本都会整批拒绝。
|
||||
*
|
||||
* @param dto 可信商户部门和样本编号
|
||||
* @return 实际删除及已不存在的样本编号
|
||||
*/
|
||||
SysUserFaceTerminalDeleteVo terminalDelete(SysUserFaceTerminalDeleteDto dto);
|
||||
|
||||
/**
|
||||
* 当前用户为本人或有效被监护人提交人脸,并根据提交人角色决定是否进入审核流程。
|
||||
*
|
||||
|
||||
+77
@@ -11,6 +11,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
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.entity.SysDept;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysRole;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysUser;
|
||||
@@ -22,6 +23,7 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
|
||||
import com.bsd.pjcloud.admin.api.vo.user.SysUserGuardianWardOptionVo;
|
||||
import com.bsd.pjcloud.admin.handler.FaceChangeEvent;
|
||||
import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper;
|
||||
@@ -61,9 +63,11 @@ import org.springframework.validation.annotation.Validated;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.redisson.Redisson;
|
||||
@@ -338,6 +342,79 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 Hardware 提供的可信商户部门树批量删除人脸。
|
||||
*
|
||||
* <p>先一次性查询所有仍存在的样本并校验用户当前部门;任意记录不在部门树内时
|
||||
* 立即抛出异常,删除逻辑尚未执行,因此不会产生部分删除。重复样本按首次顺序
|
||||
* 去重;服务端已经不存在的样本作为幂等成功返回给终端。</p>
|
||||
*
|
||||
* @param dto 商户部门和待删除样本编号
|
||||
* @return 实际删除及请求前已不存在的样本编号
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SysUserFaceTerminalDeleteVo terminalDelete(SysUserFaceTerminalDeleteDto dto) {
|
||||
if (dto == null || dto.getMerchantDeptId() == null || ObjectUtil.isEmpty(dto.getSampleIds())) {
|
||||
throw new RuntimeException("终端人脸删除参数不能为空");
|
||||
}
|
||||
if (dto.getSampleIds().size() > FACE_SYNC_MAX_LIMIT) {
|
||||
throw new RuntimeException("单次最多删除" + FACE_SYNC_MAX_LIMIT + "条人脸");
|
||||
}
|
||||
|
||||
LinkedHashSet<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);
|
||||
}
|
||||
List<String> sampleIds = new ArrayList<>(normalizedSet);
|
||||
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.terminalDeleteCandidates(sampleIds);
|
||||
Set<Long> allowedDeptIds = new HashSet<>(deptIds);
|
||||
boolean outOfScope = candidates.stream()
|
||||
.anyMatch(face -> face.getDeptId() == null || !allowedDeptIds.contains(face.getDeptId()));
|
||||
if (outOfScope) {
|
||||
throw new RuntimeException("请求包含不属于当前设备部门范围的人脸");
|
||||
}
|
||||
|
||||
Set<String> existingSampleIds = candidates.stream()
|
||||
.map(SysUserFaceVo::getAutoCode)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
List<String> deletedSampleIds = sampleIds.stream()
|
||||
.filter(existingSampleIds::contains)
|
||||
.toList();
|
||||
List<String> alreadyAbsentSampleIds = sampleIds.stream()
|
||||
.filter(sampleId -> !existingSampleIds.contains(sampleId))
|
||||
.toList();
|
||||
ArrayList<Long> databaseIds = candidates.stream()
|
||||
.map(SysUserFaceVo::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
delByIds(databaseIds);
|
||||
return SysUserFaceTerminalDeleteVo.builder()
|
||||
.deletedSampleIds(deletedSampleIds)
|
||||
.alreadyAbsentSampleIds(alreadyAbsentSampleIds)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验快照记录的虹软搜索标识可被 Android 端安全解析为 32 位正整数。
|
||||
*
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package com.bsd.pjcloud.admin.service.impl;
|
||||
|
||||
import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysDept;
|
||||
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
|
||||
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 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.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
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 SysUserFaceTerminalDeleteServiceImplTest {
|
||||
|
||||
/** 被测服务使用的人脸 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);
|
||||
}
|
||||
|
||||
/** 空请求、缺少部门或空样本集合均必须拒绝。 */
|
||||
@Test
|
||||
void terminalDeleteRejectsMissingParameters() {
|
||||
assertThrows(RuntimeException.class, () -> service.terminalDelete(null));
|
||||
assertThrows(RuntimeException.class, () -> service.terminalDelete(request(null, "1001")));
|
||||
assertThrows(RuntimeException.class, () -> service.terminalDelete(request(10L)));
|
||||
verify(faceMapper, never()).terminalDeleteCandidates(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
/** 非数字、非正数和超过 Android 32 位搜索标识范围的编号必须拒绝。 */
|
||||
@Test
|
||||
void terminalDeleteRejectsInvalidSampleIds() {
|
||||
assertThrows(RuntimeException.class, () -> service.terminalDelete(request(10L, "abc")));
|
||||
assertThrows(RuntimeException.class, () -> service.terminalDelete(request(10L, "0")));
|
||||
assertThrows(RuntimeException.class, () -> service.terminalDelete(request(10L, "2147483648")));
|
||||
verify(faceMapper, never()).terminalDeleteCandidates(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
/** 原始请求超过500条时必须在部门和数据库查询前拒绝。 */
|
||||
@Test
|
||||
void terminalDeleteRejectsMoreThanFiveHundredSamples() {
|
||||
List<String> sampleIds = java.util.stream.IntStream.rangeClosed(1, 501)
|
||||
.mapToObj(String::valueOf)
|
||||
.toList();
|
||||
|
||||
RuntimeException exception = assertThrows(RuntimeException.class,
|
||||
() -> service.terminalDelete(request(10L, sampleIds.toArray(String[]::new))));
|
||||
|
||||
assertEquals("单次最多删除500条人脸", exception.getMessage());
|
||||
verify(sysDeptService, never()).listDescendant(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
/** 请求中存在任意外部门人脸时整批拒绝,并且不能执行删除。 */
|
||||
@Test
|
||||
void terminalDeleteRejectsWholeBatchWhenAnyFaceIsOutOfScope() {
|
||||
when(sysDeptService.listDescendant(10L)).thenReturn(List.of(dept(10L), dept(11L)));
|
||||
when(faceMapper.terminalDeleteCandidates(List.of("1001", "1002"))).thenReturn(List.of(
|
||||
face(1L, "1001", 10L),
|
||||
face(2L, "1002", 99L)));
|
||||
|
||||
RuntimeException exception = assertThrows(RuntimeException.class,
|
||||
() -> service.terminalDelete(request(10L, "1001", "1002")));
|
||||
|
||||
assertEquals("请求包含不属于当前设备部门范围的人脸", exception.getMessage());
|
||||
verify(faceMapper, never()).deleteBatchIds(org.mockito.ArgumentMatchers.anyCollection());
|
||||
}
|
||||
|
||||
/** 重复编号按首次顺序去重,已不存在编号作为幂等成功返回。 */
|
||||
@Test
|
||||
void terminalDeleteDeduplicatesAndReturnsAlreadyAbsentSamples() {
|
||||
when(sysDeptService.listDescendant(10L)).thenReturn(List.of(dept(10L), dept(11L)));
|
||||
when(faceMapper.terminalDeleteCandidates(List.of("1001", "1002")))
|
||||
.thenReturn(List.of(face(1L, "1001", 11L)));
|
||||
|
||||
SysUserFaceTerminalDeleteVo result = service.terminalDelete(request(10L, "1001", "1001", "1002"));
|
||||
|
||||
assertEquals(List.of("1001"), result.getDeletedSampleIds());
|
||||
assertEquals(List.of("1002"), result.getAlreadyAbsentSampleIds());
|
||||
verify(faceMapper).deleteBatchIds(List.of(1L));
|
||||
}
|
||||
|
||||
/** 构造终端删除内部请求。 */
|
||||
private static SysUserFaceTerminalDeleteDto request(Long deptId, String... sampleIds) {
|
||||
SysUserFaceTerminalDeleteDto dto = new SysUserFaceTerminalDeleteDto();
|
||||
dto.setMerchantDeptId(deptId);
|
||||
dto.setSampleIds(List.of(sampleIds));
|
||||
return dto;
|
||||
}
|
||||
|
||||
/** 构造部门树节点。 */
|
||||
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) {
|
||||
SysUserFaceVo face = new SysUserFaceVo();
|
||||
face.setId(id);
|
||||
face.setAutoCode(sampleId);
|
||||
face.setDeptId(deptId);
|
||||
return face;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user