diff --git a/db/20260823_add_hard_order_evidence_image.sql b/db/20260823_add_hard_order_evidence_image.sql
new file mode 100644
index 00000000..d0561bd3
--- /dev/null
+++ b/db/20260823_add_hard_order_evidence_image.sql
@@ -0,0 +1,44 @@
+-- 订单识别证据图片从表。
+-- 本表独立于 hard_order,退款、撤销和订单逻辑删除均不自动清理证据文件或证据记录。
+CREATE TABLE `pjcloudx_hardware`.`hard_order_evidence_image`
+(
+ `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '证据记录自增主键',
+ `order_id` bigint(20) NOT NULL COMMENT '订单主键,来源hard_order.id',
+ `android_order_code` varchar(128) NOT NULL COMMENT '终端本地稳定订单号快照',
+ `evidence_type` varchar(32) NOT NULL COMMENT '证据类型,FACE_RECOGNITION或MENU_RECOGNITION',
+ `evidence_role` varchar(32) NOT NULL COMMENT '图片角色,FACE_REFERENCE、FACE_LIVE或MENU_LIVE',
+ `sequence_no` int(11) NOT NULL DEFAULT 0 COMMENT '同类型同角色图片序号,从0开始',
+ `user_id` bigint(20) DEFAULT NULL COMMENT '识别用户主键快照',
+ `face_sample_id` varchar(64) DEFAULT NULL COMMENT '虹软人脸样本编号快照',
+ `reference_image_url` varchar(512) DEFAULT NULL COMMENT '识别时后台参考人脸来源地址快照,不作为展示地址',
+ `similarity` decimal(8, 6) DEFAULT NULL COMMENT '识别相似度快照,取值0至1',
+ `similarity_threshold` decimal(8, 6) DEFAULT NULL COMMENT '终端生效的相似度阈值快照,取值0至1',
+ `hard_id` bigint(20) NOT NULL COMMENT '产生证据的设备主键',
+ `captured_at` datetime DEFAULT NULL COMMENT '终端实际采集时间',
+ `sys_file_id` bigint(20) DEFAULT NULL COMMENT '文件中心sys_file记录主键',
+ `storage_file_name` varchar(255) DEFAULT NULL COMMENT '文件中心对象名',
+ `image_url` varchar(512) DEFAULT NULL COMMENT '订单证据图片访问地址',
+ `image_hash` char(64) DEFAULT NULL COMMENT '实际文件SHA-256哈希',
+ `image_size` bigint(20) DEFAULT NULL COMMENT '实际文件大小,单位字节',
+ `image_width` int(11) DEFAULT NULL COMMENT '实际图片宽度,单位像素',
+ `image_height` int(11) DEFAULT NULL COMMENT '实际图片高度,单位像素',
+ `status` varchar(32) NOT NULL COMMENT '证据状态,PENDING、AVAILABLE或UNAVAILABLE',
+ `failure_reason` varchar(500) DEFAULT NULL COMMENT '永久缺失或复制失败摘要,不保存终端完整路径',
+ `app_version` varchar(64) DEFAULT NULL COMMENT '产生证据的终端应用版本',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime DEFAULT NULL COMMENT '修改时间',
+ `del_flag` char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除',
+ `tenant_id` bigint(20) NOT NULL COMMENT '租户主键',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_hard_order_evidence_position`
+ (`tenant_id`, `order_id`, `evidence_type`, `evidence_role`, `sequence_no`, `del_flag`),
+ KEY `idx_hard_order_evidence_android_order`
+ (`tenant_id`, `hard_id`, `android_order_code`, `del_flag`),
+ KEY `idx_hard_order_evidence_status`
+ (`tenant_id`, `status`, `update_time`, `del_flag`)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_general_ci
+ COMMENT = '硬件订单识别证据图片';
diff --git a/db/800pjcloudx_hardware.sql b/db/800pjcloudx_hardware.sql
index 0c2a37f1..173d7d84 100644
--- a/db/800pjcloudx_hardware.sql
+++ b/db/800pjcloudx_hardware.sql
@@ -301,6 +301,50 @@ CREATE TABLE hard_order
PRIMARY KEY (id)
) ENGINE = InnoDB COMMENT ='订单表';
+-- ----------------------------
+-- Table structure for hard_order_evidence_image
+-- ----------------------------
+DROP TABLE IF EXISTS `hard_order_evidence_image`;
+CREATE TABLE hard_order_evidence_image
+(
+ id bigint NOT NULL AUTO_INCREMENT COMMENT '证据记录自增主键',
+ order_id bigint NOT NULL COMMENT '订单主键,来源hard_order.id',
+ android_order_code varchar(128) NOT NULL COMMENT '终端本地稳定订单号快照',
+ evidence_type varchar(32) NOT NULL COMMENT '证据类型,FACE_RECOGNITION或MENU_RECOGNITION',
+ evidence_role varchar(32) NOT NULL COMMENT '图片角色,FACE_REFERENCE、FACE_LIVE或MENU_LIVE',
+ sequence_no int NOT NULL DEFAULT 0 COMMENT '同类型同角色图片序号,从0开始',
+ user_id bigint DEFAULT NULL COMMENT '识别用户主键快照',
+ face_sample_id varchar(64) DEFAULT NULL COMMENT '虹软人脸样本编号快照',
+ reference_image_url varchar(512) DEFAULT NULL COMMENT '识别时后台参考人脸来源地址快照,不作为展示地址',
+ similarity decimal(8, 6) DEFAULT NULL COMMENT '识别相似度快照,取值0至1',
+ similarity_threshold decimal(8, 6) DEFAULT NULL COMMENT '终端生效的相似度阈值快照,取值0至1',
+ hard_id bigint NOT NULL COMMENT '产生证据的设备主键',
+ captured_at datetime DEFAULT NULL COMMENT '终端实际采集时间',
+ sys_file_id bigint DEFAULT NULL COMMENT '文件中心sys_file记录主键',
+ storage_file_name varchar(255) DEFAULT NULL COMMENT '文件中心对象名',
+ image_url varchar(512) DEFAULT NULL COMMENT '订单证据图片访问地址',
+ image_hash char(64) DEFAULT NULL COMMENT '实际文件SHA-256哈希',
+ image_size bigint DEFAULT NULL COMMENT '实际文件大小,单位字节',
+ image_width int DEFAULT NULL COMMENT '实际图片宽度,单位像素',
+ image_height int DEFAULT NULL COMMENT '实际图片高度,单位像素',
+ status varchar(32) NOT NULL COMMENT '证据状态,PENDING、AVAILABLE或UNAVAILABLE',
+ failure_reason varchar(500) DEFAULT NULL COMMENT '永久缺失或复制失败摘要,不保存终端完整路径',
+ app_version varchar(64) DEFAULT NULL COMMENT '产生证据的终端应用版本',
+ create_by varchar(64) DEFAULT NULL COMMENT '创建人',
+ create_time datetime DEFAULT NULL COMMENT '创建时间',
+ update_by varchar(64) DEFAULT NULL COMMENT '修改人',
+ update_time datetime DEFAULT NULL COMMENT '修改时间',
+ del_flag char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除',
+ tenant_id bigint NOT NULL COMMENT '租户主键',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_hard_order_evidence_position
+ (tenant_id, order_id, evidence_type, evidence_role, sequence_no, del_flag),
+ KEY idx_hard_order_evidence_android_order
+ (tenant_id, hard_id, android_order_code, del_flag),
+ KEY idx_hard_order_evidence_status
+ (tenant_id, status, update_time, del_flag)
+) ENGINE = InnoDB COMMENT ='硬件订单识别证据图片';
+
-- ----------------------------
-- Table structure for hard_order_address
@@ -724,4 +768,3 @@ ALTER TABLE hard_xfj_log
ADD INDEX idx_start_time (start_time);
ALTER TABLE hard_xfj_log
ADD INDEX idx_end_time (end_time);
-
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java
new file mode 100644
index 00000000..64e0846f
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java
@@ -0,0 +1,58 @@
+package com.bsd.pjcloud.hard.api.constant.order;
+
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 订单证据图片角色,编码用于唯一位置约束和跨端展示。
+ */
+@Getter
+public enum HardOrderEvidenceRoleEnum {
+
+ /**
+ * 人脸库中被命中的后台参考人脸,由服务端从受信文件中心复制为订单快照。
+ */
+ FACE_REFERENCE("FACE_REFERENCE", "后台参考人脸"),
+
+ /**
+ * 终端实际通过比对的现场人脸裁剪图,由终端异步上传。
+ */
+ FACE_LIVE("FACE_LIVE", "现场识别人脸"),
+
+ /**
+ * 菜谱识别的现场餐盘图片预留角色,当前版本不创建该类证据。
+ */
+ MENU_LIVE("MENU_LIVE", "餐盘现场图");
+
+ /**
+ * 数据库和接口使用的稳定编码。
+ */
+ private final String code;
+
+ /**
+ * PC 订单详情使用的中文名称。
+ */
+ private final String description;
+
+ /**
+ * 构造订单证据图片角色。
+ *
+ * @param code 数据库与跨端协议编码
+ * @param description 中文展示名称
+ */
+ HardOrderEvidenceRoleEnum(String code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+
+ /**
+ * 按协议编码解析图片角色,未知编码返回 {@code null}。
+ *
+ * @param code 数据库或接口编码
+ * @return 对应角色;未知时返回 {@code null}
+ */
+ public static HardOrderEvidenceRoleEnum valueOfCode(String code) {
+ return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java
new file mode 100644
index 00000000..f74b4c13
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java
@@ -0,0 +1,58 @@
+package com.bsd.pjcloud.hard.api.constant.order;
+
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 服务端订单证据状态,表达证据是否仍待补传、已经可查看或已永久缺失。
+ */
+@Getter
+public enum HardOrderEvidenceStatusEnum {
+
+ /**
+ * 订单已记录识别元数据,但图片仍等待终端上传或服务端复制。
+ */
+ PENDING("PENDING", "待补传"),
+
+ /**
+ * 文件中心已经保存订单专属图片并可在订单详情中查看。
+ */
+ AVAILABLE("AVAILABLE", "可查看"),
+
+ /**
+ * 本地文件过期、损坏或参考图片变化,已确认无法继续补齐。
+ */
+ UNAVAILABLE("UNAVAILABLE", "永久缺失");
+
+ /**
+ * 数据库和接口使用的稳定编码。
+ */
+ private final String code;
+
+ /**
+ * PC 订单详情使用的中文名称。
+ */
+ private final String description;
+
+ /**
+ * 构造订单证据状态。
+ *
+ * @param code 数据库与跨端协议编码
+ * @param description 中文展示名称
+ */
+ HardOrderEvidenceStatusEnum(String code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+
+ /**
+ * 按协议编码解析证据状态,未知编码返回 {@code null}。
+ *
+ * @param code 数据库或接口编码
+ * @return 对应状态;未知时返回 {@code null}
+ */
+ public static HardOrderEvidenceStatusEnum valueOfCode(String code) {
+ return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java
new file mode 100644
index 00000000..53101e28
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java
@@ -0,0 +1,53 @@
+package com.bsd.pjcloud.hard.api.constant.order;
+
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 订单证据业务类型,编码同时用于数据库、终端 SQLite 和跨端接口,禁止随展示文案调整。
+ */
+@Getter
+public enum HardOrderEvidenceTypeEnum {
+
+ /**
+ * 人脸识别证据,当前版本保存后台参考人脸和现场识别人脸。
+ */
+ FACE_RECOGNITION("FACE_RECOGNITION", "人脸识别"),
+
+ /**
+ * 菜谱识别证据预留值,当前版本不执行菜谱识别或图片采集。
+ */
+ MENU_RECOGNITION("MENU_RECOGNITION", "菜谱识别");
+
+ /**
+ * 数据库和接口使用的稳定编码。
+ */
+ private final String code;
+
+ /**
+ * PC 订单详情使用的中文名称。
+ */
+ private final String description;
+
+ /**
+ * 构造订单证据业务类型。
+ *
+ * @param code 数据库与跨端协议编码
+ * @param description 中文展示名称
+ */
+ HardOrderEvidenceTypeEnum(String code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+
+ /**
+ * 按协议编码解析证据类型,未知编码返回 {@code null} 供调用方拒绝非法请求。
+ *
+ * @param code 数据库或接口编码
+ * @return 对应类型;未知时返回 {@code null}
+ */
+ public static HardOrderEvidenceTypeEnum valueOfCode(String code) {
+ return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java
index f3169e9e..f0be9f4d 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java
@@ -4,7 +4,9 @@ import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceMetadataDto;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
@@ -75,4 +77,17 @@ public class HardCreateOrderDto {
@Schema(description = "离线支付是否成功")
private Boolean offlinePaySuccess;
+
+ /**
+ * 当前终端应用版本,仅用于订单证据问题追溯,不参与订单计价和支付。
+ */
+ @Schema(description = "终端应用版本")
+ private String appVersion;
+
+ /**
+ * 人脸支付成功后需要挂接的证据元数据;旧终端不传时保持订单创建协议兼容。
+ */
+ @Schema(description = "订单识别证据元数据")
+ @Valid
+ private List evidenceMetadataList;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java
new file mode 100644
index 00000000..7d2f2895
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java
@@ -0,0 +1,127 @@
+package com.bsd.pjcloud.hard.api.dto.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.DecimalMax;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 订单创建和证据补传共用的识别元数据,不包含图片字节和服务端文件信息。
+ */
+@Data
+@Schema(description = "订单识别证据元数据")
+public class HardOrderEvidenceMetadataDto {
+
+ /**
+ * 证据业务类型,使用 HardOrderEvidenceTypeEnum 的稳定编码。
+ */
+ @NotBlank(message = "证据类型不能为空")
+ @Size(max = 32, message = "证据类型不能超过32个字符")
+ @Schema(description = "证据类型")
+ private String evidenceType;
+
+ /**
+ * 图片角色,使用 HardOrderEvidenceRoleEnum 的稳定编码。
+ */
+ @NotBlank(message = "证据图片角色不能为空")
+ @Size(max = 32, message = "证据图片角色不能超过32个字符")
+ @Schema(description = "证据图片角色")
+ private String evidenceRole;
+
+ /**
+ * 同类型同角色下的图片序号,从0开始;人脸证据固定为0,菜谱识别可递增。
+ */
+ @NotNull(message = "证据图片序号不能为空")
+ @Min(value = 0, message = "证据图片序号不能小于0")
+ @Max(value = 99, message = "证据图片序号不能大于99")
+ @Schema(description = "证据图片序号")
+ private Integer sequenceNo;
+
+ /**
+ * 识别用户主键;未识别到平台用户时为空,不能由终端使用其他用户替代。
+ */
+ @Schema(description = "识别用户主键")
+ private Long userId;
+
+ /**
+ * 虹软搜索样本编号;仅人脸识别角色使用,服务端据此校验后台参考人脸。
+ */
+ @Size(max = 64, message = "人脸样本编号不能超过64个字符")
+ @Schema(description = "人脸样本编号")
+ private String faceSampleId;
+
+ /**
+ * 识别时终端人脸库记录的参考图片地址,仅作为服务端复制参考图的线索,不直接作为可访问地址返回。
+ */
+ @Size(max = 512, message = "参考人脸地址不能超过512个字符")
+ @Schema(description = "参考人脸地址")
+ private String referenceImageUrl;
+
+ /**
+ * 识别时终端人脸快照对应的后台更新时间;旧终端允许为空,新终端用于识别同地址内容替换。
+ */
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ @Schema(description = "参考人脸来源版本")
+ private LocalDateTime referenceImageVersion;
+
+ /**
+ * 识别时实际相似度,取值0到1;为空表示当前证据类型不使用相似度。
+ */
+ @DecimalMin(value = "0", message = "相似度不能小于0")
+ @DecimalMax(value = "1", message = "相似度不能大于1")
+ @Schema(description = "识别相似度")
+ private BigDecimal similarity;
+
+ /**
+ * 识别时终端生效的相似度阈值快照,取值0到1。
+ */
+ @DecimalMin(value = "0", message = "相似度阈值不能小于0")
+ @DecimalMax(value = "1", message = "相似度阈值不能大于1")
+ @Schema(description = "相似度阈值快照")
+ private BigDecimal similarityThreshold;
+
+ /**
+ * 终端实际完成识别的本地时间;为空时服务端仅保留订单创建时间。
+ */
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ @Schema(description = "终端采集时间")
+ private LocalDateTime capturedAt;
+
+ /**
+ * 终端按原始证据文件计算的 SHA-256;图片尚未生成或永久缺失时允许为空。
+ */
+ @Pattern(regexp = "(?i)^[0-9a-f]{64}$", message = "图片哈希必须为64位SHA-256")
+ @Schema(description = "图片SHA-256")
+ private String imageHash;
+
+ /**
+ * 终端证据文件大小,单位字节;参考图无法读取或文件尚未生成时允许为空。
+ */
+ @Min(value = 0, message = "图片大小不能小于0")
+ @Schema(description = "图片大小,单位字节")
+ private Long imageSize;
+
+ /**
+ * 终端解码后的图片宽度,单位像素。
+ */
+ @Min(value = 1, message = "图片宽度必须大于0")
+ @Schema(description = "图片宽度,单位像素")
+ private Integer imageWidth;
+
+ /**
+ * 终端解码后的图片高度,单位像素。
+ */
+ @Min(value = 1, message = "图片高度必须大于0")
+ @Schema(description = "图片高度,单位像素")
+ private Integer imageHeight;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java
new file mode 100644
index 00000000..99cd0168
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java
@@ -0,0 +1,46 @@
+package com.bsd.pjcloud.hard.api.dto.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 终端提交订单证据元数据或永久缺失结果的请求。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "订单证据元数据提交请求")
+public class HardOrderEvidenceSubmitDto extends HardOrderEvidenceMetadataDto {
+
+ /**
+ * 终端本地稳定订单号,用于接口响应丢失后的服务端订单对账。
+ */
+ @NotBlank(message = "终端订单号不能为空")
+ @Size(max = 128, message = "终端订单号不能超过128个字符")
+ @Schema(description = "终端订单号")
+ private String androidOrderCode;
+
+ /**
+ * 终端请求写入的目标状态,仅允许 PENDING 或 UNAVAILABLE;现场图片上传成功由服务端改为 AVAILABLE。
+ */
+ @NotBlank(message = "证据状态不能为空")
+ @Size(max = 32, message = "证据状态不能超过32个字符")
+ @Schema(description = "证据状态")
+ private String status;
+
+ /**
+ * 永久缺失原因;状态为 UNAVAILABLE 时必填,禁止包含本地完整敏感路径。
+ */
+ @Size(max = 500, message = "失败原因不能超过500个字符")
+ @Schema(description = "永久缺失原因")
+ private String failureReason;
+
+ /**
+ * 产生识别证据的终端应用版本,用于问题追溯。
+ */
+ @Size(max = 64, message = "应用版本不能超过64个字符")
+ @Schema(description = "终端应用版本")
+ private String appVersion;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java
new file mode 100644
index 00000000..1b02f844
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java
@@ -0,0 +1,31 @@
+package com.bsd.pjcloud.hard.api.dto.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 终端上传订单现场证据图片时随 multipart 一并提交的元数据。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "订单现场证据上传请求")
+public class HardOrderEvidenceUploadDto extends HardOrderEvidenceMetadataDto {
+
+ /**
+ * 终端本地稳定订单号,服务端通过该编号解析真实订单主键。
+ */
+ @NotBlank(message = "终端订单号不能为空")
+ @Size(max = 128, message = "终端订单号不能超过128个字符")
+ @Schema(description = "终端订单号")
+ private String androidOrderCode;
+
+ /**
+ * 产生识别证据的终端应用版本,用于问题追溯。
+ */
+ @Size(max = 64, message = "应用版本不能超过64个字符")
+ @Schema(description = "终端应用版本")
+ private String appVersion;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java
new file mode 100644
index 00000000..77bd85aa
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java
@@ -0,0 +1,208 @@
+package com.bsd.pjcloud.hard.api.entity.order;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.activerecord.Model;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 订单识别证据图片,从属于 hard_order 并长期保存争议处理所需的文件快照和识别元数据。
+ */
+@Data
+@TenantTable
+@TableName("hard_order_evidence_image")
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "订单识别证据图片")
+public class HardOrderEvidenceImageEntity extends Model {
+
+ /**
+ * 证据记录自增主键。
+ */
+ @TableId(type = IdType.AUTO)
+ @Schema(description = "证据记录主键")
+ private Long id;
+
+ /**
+ * 服务端订单主键,来源于 hard_order.id。
+ */
+ @Schema(description = "订单主键")
+ private Long orderId;
+
+ /**
+ * 终端本地稳定订单号快照,来源于 hard_order.android_order_code。
+ */
+ @Schema(description = "终端订单号")
+ private String androidOrderCode;
+
+ /**
+ * 证据业务类型,保存 HardOrderEvidenceTypeEnum.code。
+ */
+ @Schema(description = "证据类型")
+ private String evidenceType;
+
+ /**
+ * 图片角色,保存 HardOrderEvidenceRoleEnum.code。
+ */
+ @Schema(description = "证据图片角色")
+ private String evidenceRole;
+
+ /**
+ * 同一订单、类型和角色下的图片序号,从0开始。
+ */
+ @Schema(description = "证据图片序号")
+ private Integer sequenceNo;
+
+ /**
+ * 识别用户主键快照;无法识别到平台用户时允许为空。
+ */
+ @Schema(description = "识别用户主键")
+ private Long userId;
+
+ /**
+ * 虹软人脸样本编号快照,来源于 sys_user_face.auto_code。
+ */
+ @Schema(description = "人脸样本编号")
+ private String faceSampleId;
+
+ /**
+ * 识别时终端命中的后台参考图片地址快照,仅用于与 UPMS 权威记录比对,不直接作为展示地址。
+ */
+ @Schema(description = "参考人脸来源地址快照")
+ private String referenceImageUrl;
+
+ /**
+ * 终端识别相似度快照,取值0到1。
+ */
+ @Schema(description = "识别相似度")
+ private BigDecimal similarity;
+
+ /**
+ * 终端识别相似度阈值快照,取值0到1。
+ */
+ @Schema(description = "识别相似度阈值")
+ private BigDecimal similarityThreshold;
+
+ /**
+ * 产生证据的服务端设备主键,来源于 hard_order.hard_id。
+ */
+ @Schema(description = "设备主键")
+ private Long hardId;
+
+ /**
+ * 终端实际完成识别的本地时间,用于与订单时间交叉核验。
+ */
+ @Schema(description = "终端采集时间")
+ private LocalDateTime capturedAt;
+
+ /**
+ * 文件中心 sys_file 主键;状态非 AVAILABLE 时允许为空。
+ */
+ @Schema(description = "文件中心记录主键")
+ private Long sysFileId;
+
+ /**
+ * 文件中心对象名,仅供服务端读取或下载,不由终端提交。
+ */
+ @Schema(description = "文件中心对象名")
+ private String storageFileName;
+
+ /**
+ * PC 可使用统一请求层访问的文件相对地址。
+ */
+ @Schema(description = "图片访问地址")
+ private String imageUrl;
+
+ /**
+ * 实际保存文件的 SHA-256,用于幂等和防止证据被静默替换。
+ */
+ @Schema(description = "图片SHA-256")
+ private String imageHash;
+
+ /**
+ * 实际保存文件大小,单位字节。
+ */
+ @Schema(description = "图片大小,单位字节")
+ private Long imageSize;
+
+ /**
+ * 实际解码图片宽度,单位像素。
+ */
+ @Schema(description = "图片宽度,单位像素")
+ private Integer imageWidth;
+
+ /**
+ * 实际解码图片高度,单位像素。
+ */
+ @Schema(description = "图片高度,单位像素")
+ private Integer imageHeight;
+
+ /**
+ * 证据状态,保存 HardOrderEvidenceStatusEnum.code。
+ */
+ @Schema(description = "证据状态")
+ private String status;
+
+ /**
+ * 永久缺失或服务端复制失败的摘要,不保存终端完整文件路径。
+ */
+ @Schema(description = "失败原因")
+ private String failureReason;
+
+ /**
+ * 产生证据的终端应用版本快照。
+ */
+ @Schema(description = "终端应用版本")
+ private String appVersion;
+
+ /**
+ * 创建人,由 MyBatis 填充器写入。
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建人")
+ private String createBy;
+
+ /**
+ * 证据占位记录创建时间,由 MyBatis 填充器写入。
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+
+ /**
+ * 修改人,由 MyBatis 填充器在状态变化时写入。
+ */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改人")
+ private String updateBy;
+
+ /**
+ * 最近一次状态、文件或错误信息更新时间。
+ */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改时间")
+ private LocalDateTime updateTime;
+
+ /**
+ * 逻辑删除标记;订单退款、取消和删除不会联动改变该值。
+ */
+ @TableLogic
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "删除标记")
+ private String delFlag;
+
+ /**
+ * 租户主键,由当前请求租户上下文写入并由数据层自动隔离。
+ */
+ @Schema(description = "租户主键")
+ private Long tenantId;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java
new file mode 100644
index 00000000..ee164957
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java
@@ -0,0 +1,33 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * PC 订单详情使用的订单证据图片,附带稳定编码对应的中文名称。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "订单识别证据图片详情")
+public class HardOrderEvidenceImageVo extends HardOrderEvidenceImageEntity {
+
+ /**
+ * 证据类型中文名称,未知历史编码时回退原编码。
+ */
+ @Schema(description = "证据类型名称")
+ private String evidenceTypeName;
+
+ /**
+ * 图片角色中文名称,未知历史编码时回退原编码。
+ */
+ @Schema(description = "证据图片角色名称")
+ private String evidenceRoleName;
+
+ /**
+ * 证据状态中文名称,未知历史编码时回退原编码。
+ */
+ @Schema(description = "证据状态名称")
+ private String statusName;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java
new file mode 100644
index 00000000..4c022089
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java
@@ -0,0 +1,34 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 终端按本地订单号对账时返回的服务端订单与证据快照。
+ */
+@Data
+@Builder
+@Schema(description = "订单证据对账结果")
+public class HardOrderEvidenceResolveVo {
+
+ /**
+ * 服务端订单主键。
+ */
+ @Schema(description = "服务端订单主键")
+ private Long orderId;
+
+ /**
+ * 服务端订单状态,终端仅在现有支付流程确认成功后上传证据。
+ */
+ @Schema(description = "订单状态")
+ private String orderStatus;
+
+ /**
+ * 当前服务端已登记的证据位置和状态,用于终端幂等跳过成功项。
+ */
+ @Schema(description = "证据列表")
+ private List evidenceImages;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java
new file mode 100644
index 00000000..43388162
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java
@@ -0,0 +1,50 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 终端提交元数据、复制参考图或上传现场图后的幂等处理结果。
+ */
+@Data
+@Builder
+@Schema(description = "订单证据处理结果")
+public class HardOrderEvidenceUploadResultVo {
+
+ /**
+ * 服务端证据记录主键。
+ */
+ @Schema(description = "证据记录主键")
+ private Long evidenceId;
+
+ /**
+ * 服务端订单主键。
+ */
+ @Schema(description = "订单主键")
+ private Long orderId;
+
+ /**
+ * 处理后的证据状态。
+ */
+ @Schema(description = "证据状态")
+ private String status;
+
+ /**
+ * AVAILABLE 状态下的图片访问地址,其他状态允许为空。
+ */
+ @Schema(description = "图片访问地址")
+ private String imageUrl;
+
+ /**
+ * 是否实际上传或复制了新文件;幂等命中时为 false。
+ */
+ @Schema(description = "是否新写入文件")
+ private Boolean uploaded;
+
+ /**
+ * 面向终端同步日志的简短处理结果。
+ */
+ @Schema(description = "处理结果说明")
+ private String message;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java
index 95cf42a0..753f2b7c 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java
@@ -102,4 +102,9 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac
* 当前访问者是否可以查看预约核销码。
*/
private Boolean canViewReserveCode;
+
+ /**
+ * 订单详情中的长期识别证据列表,按证据类型、角色和序号排序。
+ */
+ private List evidenceImages;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java
index a7e345f0..bdf91045 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java
@@ -11,7 +11,8 @@ import java.time.LocalDate;
/**
* 食堂预约菜品统计自定义导出行。
*
- * 一组日期和餐段按汇总行、商户部门行、商品明细行组织;不同层级只填写所属列,配合自定义合并处理器形成树形报表。
+ * 一组日期和餐段按汇总行、商户部门行、商品明细行组织;商品统计在同一商户部门块内按名称合并套餐展开后的明细,
+ * 不同层级只填写所属列,配合自定义合并处理器形成树形报表。
*/
@Data
public class HardCanteenProductApptExcelVo {
@@ -87,6 +88,19 @@ public class HardCanteenProductApptExcelVo {
@NumberFormat("#,##0.00")
private BigDecimal productTotalPrice;
+ /** 商品统计中的商品名称,同一商户部门内按规范化后的名称合并。 */
+ @ExcelProperty({"商品统计", "商品名称"})
+ private String statisticProductName;
+
+ /** 商品统计中的商品总数量,按退款扣减及套餐展开后的有效明细累计。 */
+ @ExcelProperty({"商品统计", "商品总数量"})
+ private Long statisticProductCount;
+
+ /** 商品统计中的商品总价,单位为元,按退款扣减及套餐展开后的有效明细累计。 */
+ @ExcelProperty({"商品统计", "商品总价格"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal statisticProductTotalPrice;
+
/** 导出行层级,仅供合并处理器识别,不写入 Excel。 */
@ExcelIgnore
private String rowLevel;
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java
new file mode 100644
index 00000000..a3d26519
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java
@@ -0,0 +1,85 @@
+package com.bsd.pjcloud.hard.controller.hard;
+
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceSubmitDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceUploadDto;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceResolveVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceUploadResultVo;
+import com.bsd.pjcloud.hard.service.HardOrderEvidenceImageService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 收银终端订单识别证据接口,使用统一 Token、租户头和 DEVICE-ID 校验调用方身份。
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/hard-order-evidence")
+@Tag(description = "hard-order-evidence", name = "订单识别证据")
+@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
+public class HardOrderEvidenceImageController {
+
+ /**
+ * 订单识别证据业务服务。
+ */
+ private final HardOrderEvidenceImageService hardOrderEvidenceImageService;
+
+ /**
+ * 对账终端订单并返回服务端证据状态。
+ *
+ * @param hardId 当前绑定设备主键
+ * @param androidOrderCode 终端本地订单号
+ * @return 服务端订单和证据列表
+ */
+ @GetMapping("/ad/resolve")
+ @Operation(summary = "按终端订单号对账识别证据")
+ public R resolve(@RequestHeader("DEVICE-ID") Long hardId,
+ @RequestParam String androidOrderCode) {
+ return R.ok(hardOrderEvidenceImageService.resolve(hardId, androidOrderCode));
+ }
+
+ /**
+ * 登记证据元数据、复制参考图或报告终端图片永久缺失。
+ *
+ * @param hardId 当前绑定设备主键
+ * @param dto 证据元数据和目标状态
+ * @return 处理后的证据状态
+ */
+ @PostMapping("/ad/submit")
+ @Operation(summary = "提交订单识别证据元数据")
+ public R submit(@RequestHeader("DEVICE-ID") Long hardId,
+ @Valid @RequestBody HardOrderEvidenceSubmitDto dto) {
+ return R.ok(hardOrderEvidenceImageService.submit(hardId, dto));
+ }
+
+ /**
+ * 上传现场识别图片,服务端校验图片内容后写入订单专属文件目录。
+ *
+ * @param hardId 当前绑定设备主键
+ * @param dto 订单与识别元数据
+ * @param file JPEG 或 PNG 现场图片
+ * @return 幂等上传结果
+ */
+ @PostMapping(value = "/ad/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @Operation(summary = "上传订单现场识别图片")
+ public R upload(@RequestHeader("DEVICE-ID") Long hardId,
+ @Valid @ModelAttribute HardOrderEvidenceUploadDto dto,
+ @RequestPart("file") MultipartFile file) {
+ return R.ok(hardOrderEvidenceImageService.upload(hardId, dto, file));
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
index d3780447..7bbedc38 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
@@ -621,7 +621,7 @@ public class HardOrderController {
@GetMapping("/{id}")
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')")
public R getById(@PathVariable("id") Long id) {
- return R.ok(hardwareOrderService.join(id));
+ return R.ok(hardwareOrderService.joinWithEvidence(id));
}
@Operation(summary = "离线订单手动支付", description = "离线订单手动支付")
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
index 48cf38fc..b8c95bab 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
@@ -23,7 +23,8 @@ import java.util.Map;
/**
* 食堂预约菜品统计导出合并处理器。
*
- * 数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列。
+ *
数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列;
+ * 第 17 至 19 列展示同一商户部门内按名称合并后的商品统计。
* Easy Excel 使用流式工作簿,较早的数据行会在关闭工作簿前刷出内存,因此必须在逐行写出时记录分组边界并及时合并。
*/
public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler, WorkbookWriteHandler {
@@ -38,7 +39,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
private static final int DATA_START_ROW = 2;
/** 导出列总数,用于逐行应用居中样式。 */
- private static final int COLUMN_COUNT = 17;
+ private static final int COLUMN_COUNT = 20;
/** 表头字体大小,单位为磅;使用紧凑字号降低双层表头占用空间。 */
private static final short HEAD_FONT_SIZE = 10;
@@ -55,7 +56,9 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
/**
* 各列宽度,单位为 Excel 字符宽度;常规统计列保持紧凑,商品名称列单独加宽以展示更多名称内容。
*/
- private static final int[] COLUMN_WIDTHS = {12, 11, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11};
+ private static final int[] COLUMN_WIDTHS = {
+ 12, 11, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11, 32, 14, 12
+ };
/** 原样式索引到居中样式的缓存,避免每个数据单元格重复创建样式。 */
private final Map centeredStyles = new HashMap<>();
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java
new file mode 100644
index 00000000..c00bf28f
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java
@@ -0,0 +1,47 @@
+package com.bsd.pjcloud.hard.mapper.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 订单识别证据 Mapper,所有查询由租户拦截器自动限定当前租户。
+ */
+@Mapper
+public interface HardOrderEvidenceImageMapper extends PjcloudBaseMapper {
+
+ /**
+ * 按订单读取未逻辑删除的证据,稳定按类型、角色、序号和主键排序。
+ *
+ * @param orderId 服务端订单主键
+ * @return 当前租户下的订单证据列表
+ */
+ default List listByOrderId(Long orderId) {
+ return selectList(new LambdaQueryWrapper()
+ .eq(HardOrderEvidenceImageEntity::getOrderId, orderId)
+ .orderByAsc(HardOrderEvidenceImageEntity::getEvidenceType)
+ .orderByAsc(HardOrderEvidenceImageEntity::getEvidenceRole)
+ .orderByAsc(HardOrderEvidenceImageEntity::getSequenceNo)
+ .orderByAsc(HardOrderEvidenceImageEntity::getId));
+ }
+
+ /**
+ * 查询订单中的唯一证据位置,用于上传幂等和内容冲突检查。
+ *
+ * @param orderId 服务端订单主键
+ * @param evidenceType 证据类型稳定编码
+ * @param evidenceRole 图片角色稳定编码
+ * @param sequenceNo 同角色图片序号
+ * @return 已存在的证据;不存在时返回 {@code null}
+ */
+ default HardOrderEvidenceImageEntity findPosition(Long orderId, String evidenceType, String evidenceRole, Integer sequenceNo) {
+ return selectOne(new LambdaQueryWrapper()
+ .eq(HardOrderEvidenceImageEntity::getOrderId, orderId)
+ .eq(HardOrderEvidenceImageEntity::getEvidenceType, evidenceType)
+ .eq(HardOrderEvidenceImageEntity::getEvidenceRole, evidenceRole)
+ .eq(HardOrderEvidenceImageEntity::getSequenceNo, sequenceNo));
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java
index c87698d7..f7e54249 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java
@@ -130,8 +130,43 @@ public interface HardOrderMapper extends PjcloudBaseMapper {
return selectListByScope(commonWrapper(dto), DataScope.of());
}
+ /**
+ * 按订单筛选条件和数据范围分页查询订单详情,并保持创建时间或更新时间倒序。
+ *
+ * @param page 分页参数;App 可见性查询会由服务层指定对应的轻量统计语句
+ * @param dto 订单、商户、状态、时间及 App 可见范围筛选条件
+ * @param dataScope 后台数据范围;App 查询传 {@code null}
+ * @return 当前页订单详情及符合相同筛选条件的总数
+ */
Page scopePageList(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
+ /**
+ * 先按 App 可见范围和全部筛选条件分页读取订单主键,避免展示表关联参与全量排序。
+ *
+ * @param page 主键分页参数,排序规则与订单列表保持一致
+ * @param dto 订单、商户、状态、时间及 App 可见范围筛选条件
+ * @param dataScope 保留的数据范围参数;当前 App 调用传 {@code null}
+ * @return 当前页有序订单主键及符合条件的总数
+ */
+ Page appVisibilityPageIds(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
+
+ /**
+ * 按已分页的订单主键批量读取展示详情,仅关联当前页设备、最新售后单和付款人。
+ *
+ * @param orderIds 当前页订单主键集合,调用方保证非空且已完成租户和可见范围过滤
+ * @return 当前租户内存在的订单详情;SQL 不承诺返回顺序,由服务层按主键页顺序重排
+ */
+ List listAppPageDetails(@Param("orderIds") List orderIds);
+
+ /**
+ * 统计 App 当前访问者可见的订单数量,不执行列表展示字段所需的多表关联。
+ *
+ * @param dto 订单基础筛选、设备类型、最新售后单号及 App 可见范围条件
+ * @param dataScope 保留的数据范围参数;当前 App 调用传 {@code null}
+ * @return 当前租户内符合全部筛选条件的未删除订单数量
+ */
+ Long appVisibilityCount(@Param("dto") HardOrderDto dto, DataScope dataScope);
+
List scopeList(@Param("dto") HardOrderDto dto, DataScope dataScope);
/**
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java
index fe8e1f9f..356b7b78 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java
@@ -25,6 +25,14 @@ public interface HardCarService extends IService {
List getByParentCarId(Long parentCarId);
+ /**
+ * 按父购物车主键批量查询未删除的购物车商品快照,供订单分页结果一次性装配商品列表。
+ *
+ * @param parentCarIds 父购物车主键集合,空集合不发起数据库查询
+ * @return 按父购物车主键和商品主键排序的商品快照
+ */
+ List getByParentCarIds(List parentCarIds);
+
List mchDetail(Long merchantId, Long userId, HardTypeEnum hardType);
Integer computerCarTotalPrice(@NotNull HardTypeEnum sysType, @NotNull HardOrderTypeEnum orderType, @NotEmpty List list);
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java
new file mode 100644
index 00000000..f46577a8
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java
@@ -0,0 +1,65 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceMetadataDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceSubmitDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceUploadDto;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceImageVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceResolveVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceUploadResultVo;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+/**
+ * 订单识别证据服务,负责设备与订单校验、幂等占位、现场图上传和参考图复制。
+ */
+public interface HardOrderEvidenceImageService extends IService {
+
+ /**
+ * 按终端订单号对账服务端订单和已登记证据。
+ *
+ * @param hardId 请求头中的当前设备主键
+ * @param androidOrderCode 终端本地稳定订单号
+ * @return 服务端订单及证据状态
+ */
+ HardOrderEvidenceResolveVo resolve(Long hardId, String androidOrderCode);
+
+ /**
+ * 提交证据元数据、触发参考图复制或报告永久缺失。
+ *
+ * @param hardId 请求头中的当前设备主键
+ * @param dto 证据位置、识别快照和目标状态
+ * @return 幂等处理结果
+ */
+ HardOrderEvidenceUploadResultVo submit(Long hardId, HardOrderEvidenceSubmitDto dto);
+
+ /**
+ * 上传终端现场证据图片,实际文件内容会与终端哈希和尺寸交叉校验。
+ *
+ * @param hardId 请求头中的当前设备主键
+ * @param dto 订单与证据元数据
+ * @param file JPEG 或 PNG 图片
+ * @return 幂等上传结果
+ */
+ HardOrderEvidenceUploadResultVo upload(Long hardId, HardOrderEvidenceUploadDto dto, MultipartFile file);
+
+ /**
+ * 为已成功创建的订单登记证据占位;该操作失败不得回滚订单事务。
+ *
+ * @param order 已创建订单快照
+ * @param metadataList 终端随订单提交的可选证据元数据
+ * @param appVersion 终端应用版本
+ */
+ void registerOrderEvidence(HardOrderEntity order, List metadataList, String appVersion);
+
+ /**
+ * 查询订单详情所需的证据列表并补齐中文展示名称。
+ *
+ * @param orderId 服务端订单主键
+ * @return 排序后的证据列表
+ */
+ List listByOrderId(Long orderId);
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
index 19026a2b..91874f94 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
@@ -52,6 +52,14 @@ public interface HardOrderService extends IService {
*/
Page appMchPageJoin(Page page, HardOrderDto dto, PjcloudUser user);
+ /**
+ * 按筛选条件和数据范围分页查询订单,并装配列表展示所需的关联信息。
+ *
+ * @param page 订单分页参数
+ * @param dto 订单筛选与关联信息加载选项
+ * @param dataScope 后台数据范围;App 查询传 {@code null}
+ * @return 订单分页结果
+ */
Page scopePageList(Page page, HardOrderDto dto, DataScope dataScope);
/**
@@ -68,6 +76,14 @@ public interface HardOrderService extends IService {
HardOrderVo join(Long id);
+ /**
+ * 查询后台管理端订单详情并填充长期识别证据;调用入口负责沿用订单查看权限。
+ *
+ * @param id 订单主键
+ * @return 订单详情及识别证据;订单不存在时返回 {@code null}
+ */
+ HardOrderVo joinWithEvidence(Long id);
+
/**
* 查询并校验 App 用户可查看的订单详情。
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java
index b8c40d3d..8a8698af 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java
@@ -1391,7 +1391,7 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
*
* @param query 日期批次查询条件
* @param fixedDate 已知的批次日期;同步查询传空时从查询结果读取
- * @return 当前批次的汇总、商户部门和商品明细行
+ * @return 当前批次的汇总、商户部门、商品明细和商品统计行
*/
private List buildProductRows(HardOrderDto query, LocalDate fixedDate) {
List summaries = hardCarMapper.listApptExportSummary(query);
@@ -1431,6 +1431,7 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
.flatMap(product -> toProductRows(product,
menuMap.get(menuKey(product, fixedDate != null ? fixedDate : date))).stream())
.toList();
+ List statisticRows = buildProductStatistics(detailRows);
long detailCount = detailRows.stream().mapToLong(detail -> valueOrZero(detail.getProductCount())).sum();
long detailPrice = detailRows.stream().mapToLong(detail -> yuanToCents(detail.getProductTotalPrice())).sum();
// 每日菜谱查询失败时仍保留 SQL 的有效预约统计,避免导出因远程菜谱暂时不可用而丢失金额。
@@ -1438,7 +1439,11 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
detailCount = valueOrZero(merchant.getPayCount());
detailPrice = valueOrZero(merchant.getTotalPrice());
}
- detailRowsByMerchant.put(merchantKey, detailRows);
+ List merchantProductRows = new ArrayList<>(
+ detailRows.size() + statisticRows.size());
+ merchantProductRows.addAll(detailRows);
+ merchantProductRows.addAll(statisticRows);
+ detailRowsByMerchant.put(merchantKey, merchantProductRows);
detailCountsByMerchant.put(merchantKey, detailCount);
detailPricesByMerchant.put(merchantKey, detailPrice);
summaryProductCount += detailCount;
@@ -1551,6 +1556,47 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
return row;
}
+ /**
+ * 在当前商户部门的商品明细右侧写入按商品名称合并后的统计结果。
+ *
+ * 套餐组成商品可能由多个套餐分别展开成同名明细,因此不能按原套餐 SPU 汇总;这里使用压缩空白后的名称作为键,
+ * 保留首次出现的展示名称,并使用整数分累计金额,确保统计总数量、总价格与左侧有效明细严格一致。
+ *
+ * @param detailRows 同一预约日期、餐段、商户和部门下,已经扣减完成退款并完成套餐展开的商品明细
+ * @return 独立的商品统计行,顺序与商品名称首次出现的顺序一致
+ */
+ private List buildProductStatistics(
+ List detailRows) {
+ Map statistics = new LinkedHashMap<>();
+ for (HardCanteenProductApptExcelVo detailRow : detailRows) {
+ String displayName = StrUtil.blankToDefault(detailRow.getProductName(), "未命名商品").trim();
+ String statisticKey = normalizeProductName(displayName);
+ HardCanteenProductApptExcelVo statistic = statistics.computeIfAbsent(statisticKey, key -> {
+ HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
+ row.setStatisticProductName(displayName);
+ row.setStatisticProductCount(0L);
+ row.setStatisticProductTotalPrice(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
+ return row;
+ });
+ statistic.setStatisticProductCount(valueOrZero(statistic.getStatisticProductCount())
+ + valueOrZero(detailRow.getProductCount()));
+ long statisticTotalPrice = yuanToCents(statistic.getStatisticProductTotalPrice())
+ + yuanToCents(detailRow.getProductTotalPrice());
+ statistic.setStatisticProductTotalPrice(centsToYuan(statisticTotalPrice));
+ }
+ return new ArrayList<>(statistics.values());
+ }
+
+ /**
+ * 规范化商品名称中的换行和连续空白,用于合并仅排版不同的同名套餐组成商品。
+ *
+ * @param productName 首次出现的商品展示名称
+ * @return 去除首尾空白并把连续空白压缩为单个空格的统计键
+ */
+ private String normalizeProductName(String productName) {
+ return productName.replaceAll("\\s+", " ").trim();
+ }
+
/** 查询当前批次涉及的每日菜谱,并以商户、日期、餐段和 SPU 组成缓存键。 */
private Map fetchDailyMenus(List products,
LocalDate fixedDate) {
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java
index 42c6e106..8686df17 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java
@@ -173,6 +173,17 @@ public class HardCarServiceImpl extends ServiceImpl getByParentCarIds(List parentCarIds) {
+ return baseMapper.listByParentCarIds(parentCarIds);
+ }
+
@Override
public HardParentCarEntity getParentCarById(Long id) {
return hardParentCarMapper.selectById(id);
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java
new file mode 100644
index 00000000..1533cc25
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java
@@ -0,0 +1,657 @@
+package com.bsd.pjcloud.hard.service.impl.order;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.lock.LockInfo;
+import com.baomidou.lock.LockTemplate;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
+import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService;
+import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo;
+import com.bsd.pjcloud.common.core.util.BeanUtils;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderEvidenceRoleEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderEvidenceStatusEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderEvidenceTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceMetadataDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceSubmitDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceUploadDto;
+import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceImageVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceResolveVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceUploadResultVo;
+import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
+import com.bsd.pjcloud.hard.mapper.order.HardOrderEvidenceImageMapper;
+import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
+import com.bsd.pjcloud.hard.service.HardOrderEvidenceImageService;
+import feign.Response;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.LocalDateTime;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * 订单识别证据服务实现,证据文件和订单事务相互独立,失败只影响证据状态。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class HardOrderEvidenceImageServiceImpl extends ServiceImpl
+ implements HardOrderEvidenceImageService {
+
+ /**
+ * 证据服务诊断日志标签,日志中不输出图片、Token 或本地完整路径。
+ */
+ private static final String TAG = "订单识别证据";
+
+ /**
+ * 终端现场图最大接收大小,单位字节;终端目标约100KB,服务端留出编码差异余量。
+ */
+ private static final int LIVE_IMAGE_MAX_BYTES = 512 * 1024;
+
+ /**
+ * 后台参考人脸原图最大读取大小,单位字节,防止异常文件耗尽服务内存。
+ */
+ private static final int REFERENCE_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
+
+ /**
+ * 终端现场图允许的最大边长,超过时拒绝而不在服务端二次改变证据内容。
+ */
+ private static final int LIVE_IMAGE_MAX_EDGE = 640;
+
+ /**
+ * 单个证据位置分布式锁的最长持有时间,单位毫秒。
+ */
+ private static final long EVIDENCE_LOCK_EXPIRE_MILLIS = 180_000L;
+
+ /**
+ * 并发上传同一证据时等待锁的最长时间,单位毫秒。
+ */
+ private static final long EVIDENCE_LOCK_WAIT_MILLIS = 5_000L;
+
+ /**
+ * 文件中心保存订单证据时使用的稳定目录前缀。
+ */
+ private static final String FILE_DIRECTORY_PREFIX = "order-evidence";
+
+ /**
+ * 文件中心标识订单证据文件的稳定业务类型。
+ */
+ private static final String FILE_TYPE = "order-evidence";
+
+ /**
+ * UPMS 人脸图片受信下载接口的路径后缀;其他来源地址一律拒绝。
+ */
+ private static final String TRUSTED_FACE_FILE_PATH = "/sys-file/oss/file";
+
+ /**
+ * 订单主表 Mapper,用于按终端订单号和设备校验订单归属。
+ */
+ private final HardOrderMapper hardOrderMapper;
+
+ /**
+ * 设备 Mapper,用于确认请求头 DEVICE-ID 属于当前租户的有效设备。
+ */
+ private final HardManagerMapper hardManagerMapper;
+
+ /**
+ * UPMS 人脸远程服务,用于校验用户、样本和审核通过原图的一致性。
+ */
+ private final RemoteUserFaceService remoteUserFaceService;
+
+ /**
+ * UPMS 文件中心远程服务,用于读取受信参考图并保存订单专属文件。
+ */
+ private final RemoteFileService remoteFileService;
+
+ /**
+ * 分布式锁模板,用于串行化同一订单证据位置的并发上传。
+ */
+ private final LockTemplate lockTemplate;
+
+ /**
+ * 按终端订单号解析服务端订单并返回当前证据状态。
+ */
+ @Override
+ public HardOrderEvidenceResolveVo resolve(Long hardId, String androidOrderCode) {
+ HardOrderEntity order = requireOrder(hardId, androidOrderCode, false);
+ return HardOrderEvidenceResolveVo.builder()
+ .orderId(order.getId())
+ .orderStatus(order.getOrderStatus())
+ .evidenceImages(listByOrderId(order.getId()))
+ .build();
+ }
+
+ /**
+ * 提交元数据时先建立占位;参考人脸继续执行受信复制,现场图等待 multipart 上传。
+ */
+ @Override
+ public HardOrderEvidenceUploadResultVo submit(Long hardId, HardOrderEvidenceSubmitDto dto) {
+ HardOrderEntity order = requireOrder(hardId, dto.getAndroidOrderCode(), true);
+ validateMetadata(dto, order);
+ HardOrderEvidenceStatusEnum requestedStatus = HardOrderEvidenceStatusEnum.valueOfCode(dto.getStatus());
+ if (requestedStatus != HardOrderEvidenceStatusEnum.PENDING
+ && requestedStatus != HardOrderEvidenceStatusEnum.UNAVAILABLE) {
+ throw new RuntimeException("终端只允许提交待补传或永久缺失状态");
+ }
+ if (requestedStatus == HardOrderEvidenceStatusEnum.UNAVAILABLE && StrUtil.isBlank(dto.getFailureReason())) {
+ throw new RuntimeException("永久缺失时必须说明失败原因");
+ }
+ return withEvidenceLock(order, dto, () -> {
+ HardOrderEvidenceImageEntity entity = upsertMetadata(order, dto, dto.getAppVersion());
+ if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus())) {
+ return result(entity, false, "证据已经上传,无需重复处理");
+ }
+ if (HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) {
+ return result(entity, false, "该证据位置已记录为永久缺失,不能重新补传");
+ }
+ if (requestedStatus == HardOrderEvidenceStatusEnum.UNAVAILABLE) {
+ entity.setStatus(HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode());
+ entity.setFailureReason(sanitizeFailure(dto.getFailureReason()));
+ updateById(entity);
+ return result(entity, false, "已记录证据永久缺失");
+ }
+ if (HardOrderEvidenceRoleEnum.FACE_REFERENCE.getCode().equals(dto.getEvidenceRole())) {
+ return copyReferenceImage(entity, order, dto);
+ }
+ return result(entity, false, "证据元数据已登记,等待现场图片上传");
+ });
+ }
+
+ /**
+ * 上传现场图片时严格校验哈希、格式、尺寸和唯一位置,文件中心成功后才切换 AVAILABLE。
+ */
+ @Override
+ public HardOrderEvidenceUploadResultVo upload(Long hardId, HardOrderEvidenceUploadDto dto, MultipartFile file) {
+ HardOrderEntity order = requireOrder(hardId, dto.getAndroidOrderCode(), true);
+ validateMetadata(dto, order);
+ if (HardOrderEvidenceRoleEnum.FACE_REFERENCE.getCode().equals(dto.getEvidenceRole())) {
+ throw new RuntimeException("后台参考人脸必须由服务端复制,终端不能直接上传");
+ }
+ if (StrUtil.isBlank(dto.getImageHash())) {
+ throw new RuntimeException("现场图片哈希不能为空");
+ }
+ ImageSnapshot image = readImage(file, LIVE_IMAGE_MAX_BYTES, true);
+ if (!dto.getImageHash().equalsIgnoreCase(image.hash())) {
+ throw new RuntimeException("现场图片内容与终端哈希不一致");
+ }
+ validateReportedImage(dto, image);
+ return withEvidenceLock(order, dto, () -> {
+ HardOrderEvidenceImageEntity entity = upsertMetadata(order, dto, dto.getAppVersion());
+ if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus())) {
+ if (!image.hash().equalsIgnoreCase(entity.getImageHash())) {
+ throw new RuntimeException("该证据位置已保存其他图片,禁止覆盖");
+ }
+ return result(entity, false, "相同证据已经上传");
+ }
+ if (HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) {
+ return result(entity, false, "该证据位置已记录为永久缺失,不能重新补传");
+ }
+ validateHashConflict(entity, image.hash());
+ storeImage(entity, order, image, evidenceFileName(order, dto, image.extension()));
+ return result(entity, true, "现场证据上传成功");
+ });
+ }
+
+ /**
+ * 订单提交的证据清单只创建 PENDING 占位,不执行远程文件调用,确保订单主链路快速完成。
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
+ public void registerOrderEvidence(HardOrderEntity order, List metadataList, String appVersion) {
+ if (order == null || metadataList == null || metadataList.isEmpty()) return;
+ for (HardOrderEvidenceMetadataDto metadata : metadataList) {
+ validateMetadata(metadata, order);
+ HardOrderEvidenceImageEntity existing = baseMapper.findPosition(order.getId(), metadata.getEvidenceType(),
+ metadata.getEvidenceRole(), metadata.getSequenceNo());
+ if (existing != null) {
+ validateHashConflict(existing, metadata.getImageHash());
+ continue;
+ }
+ HardOrderEvidenceImageEntity entity = buildEntity(order, metadata, appVersion);
+ entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode());
+ save(entity);
+ }
+ }
+
+ /**
+ * 转换数据库实体并补齐稳定编码的中文名称,未知历史编码保持原值便于排障。
+ */
+ @Override
+ public List listByOrderId(Long orderId) {
+ if (orderId == null) return List.of();
+ return baseMapper.listByOrderId(orderId).stream().map(entity -> {
+ HardOrderEvidenceImageVo vo = BeanUtils.toBean(entity, HardOrderEvidenceImageVo.class);
+ HardOrderEvidenceTypeEnum type = HardOrderEvidenceTypeEnum.valueOfCode(entity.getEvidenceType());
+ HardOrderEvidenceRoleEnum role = HardOrderEvidenceRoleEnum.valueOfCode(entity.getEvidenceRole());
+ HardOrderEvidenceStatusEnum status = HardOrderEvidenceStatusEnum.valueOfCode(entity.getStatus());
+ vo.setEvidenceTypeName(type == null ? entity.getEvidenceType() : type.getDescription());
+ vo.setEvidenceRoleName(role == null ? entity.getEvidenceRole() : role.getDescription());
+ vo.setStatusName(status == null ? entity.getStatus() : status.getDescription());
+ return vo;
+ }).toList();
+ }
+
+ /**
+ * 校验设备存在,并按设备和终端订单号限定订单,避免跨设备探测或写入证据。
+ */
+ private HardOrderEntity requireOrder(Long hardId, String androidOrderCode, boolean requirePaid) {
+ if (hardId == null) throw new RuntimeException("DEVICE-ID不能为空");
+ if (StrUtil.isBlank(androidOrderCode)) throw new RuntimeException("终端订单号不能为空");
+ HardManagerEntity hard = hardManagerMapper.selectById(hardId);
+ if (hard == null) throw new RuntimeException("当前租户下设备不存在");
+ HardOrderEntity order = hardOrderMapper.selectOne(new LambdaQueryWrapper()
+ .eq(HardOrderEntity::getHardId, hardId)
+ .eq(HardOrderEntity::getAndroidOrderCode, androidOrderCode));
+ if (order == null) throw new RuntimeException("订单不存在或不属于当前设备");
+ if (requirePaid && !HardOrderStatusEnum.getPayPrice().contains(order.getOrderStatus())) {
+ throw new RuntimeException("订单尚未确认支付成功,暂不能保存识别证据");
+ }
+ return order;
+ }
+
+ /**
+ * 校验类型、角色、序号、用户和人脸样本约束,阻止终端构造不一致的证据位置。
+ */
+ private void validateMetadata(HardOrderEvidenceMetadataDto dto, HardOrderEntity order) {
+ if (dto == null) throw new RuntimeException("证据元数据不能为空");
+ HardOrderEvidenceTypeEnum type = HardOrderEvidenceTypeEnum.valueOfCode(dto.getEvidenceType());
+ HardOrderEvidenceRoleEnum role = HardOrderEvidenceRoleEnum.valueOfCode(dto.getEvidenceRole());
+ if (type == null || role == null) throw new RuntimeException("证据类型或图片角色不合法");
+ if (dto.getSequenceNo() == null || dto.getSequenceNo() < 0 || dto.getSequenceNo() > 99) {
+ throw new RuntimeException("证据图片序号不合法");
+ }
+ if (type == HardOrderEvidenceTypeEnum.FACE_RECOGNITION) {
+ if (role != HardOrderEvidenceRoleEnum.FACE_REFERENCE && role != HardOrderEvidenceRoleEnum.FACE_LIVE) {
+ throw new RuntimeException("人脸识别证据角色不合法");
+ }
+ if (dto.getSequenceNo() != 0) throw new RuntimeException("人脸识别证据序号必须为0");
+ if (dto.getUserId() == null || dto.getUserId() <= 0 || StrUtil.isBlank(dto.getFaceSampleId())) {
+ throw new RuntimeException("人脸识别证据缺少用户或样本编号");
+ }
+ if (!Objects.equals(order.getUserId(), dto.getUserId())) {
+ throw new RuntimeException("识别用户与订单用户不一致");
+ }
+ if (role == HardOrderEvidenceRoleEnum.FACE_REFERENCE && StrUtil.isBlank(dto.getReferenceImageUrl())) {
+ throw new RuntimeException("参考人脸证据缺少识别时图片地址");
+ }
+ } else if (role != HardOrderEvidenceRoleEnum.MENU_LIVE) {
+ throw new RuntimeException("菜谱识别证据角色不合法");
+ }
+ }
+
+ /**
+ * 创建或更新 PENDING 元数据,AVAILABLE 和 UNAVAILABLE 终态只做哈希一致性检查并保持不可变。
+ */
+ private HardOrderEvidenceImageEntity upsertMetadata(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String appVersion) {
+ HardOrderEvidenceImageEntity entity = baseMapper.findPosition(order.getId(), dto.getEvidenceType(),
+ dto.getEvidenceRole(), dto.getSequenceNo());
+ if (entity == null) {
+ entity = buildEntity(order, dto, appVersion);
+ entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode());
+ save(entity);
+ return entity;
+ }
+ validateHashConflict(entity, dto.getImageHash());
+ if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus())
+ || HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) {
+ return entity;
+ }
+ fillMetadata(entity, dto, appVersion);
+ entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode());
+ entity.setFailureReason(null);
+ updateById(entity);
+ return entity;
+ }
+
+ /**
+ * 构造新的证据占位实体并写入订单、设备和租户快照。
+ */
+ private HardOrderEvidenceImageEntity buildEntity(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String appVersion) {
+ HardOrderEvidenceImageEntity entity = new HardOrderEvidenceImageEntity();
+ entity.setOrderId(order.getId());
+ entity.setAndroidOrderCode(order.getAndroidOrderCode());
+ entity.setHardId(order.getHardId());
+ entity.setTenantId(TenantContextHolder.getTenantId());
+ fillMetadata(entity, dto, appVersion);
+ return entity;
+ }
+
+ /**
+ * 更新终端识别快照;文件中心字段只能由 storeImage 写入,不能由终端覆盖。
+ */
+ private void fillMetadata(HardOrderEvidenceImageEntity entity, HardOrderEvidenceMetadataDto dto, String appVersion) {
+ entity.setEvidenceType(dto.getEvidenceType());
+ entity.setEvidenceRole(dto.getEvidenceRole());
+ entity.setSequenceNo(dto.getSequenceNo());
+ entity.setUserId(dto.getUserId());
+ entity.setFaceSampleId(dto.getFaceSampleId());
+ entity.setReferenceImageUrl(dto.getReferenceImageUrl());
+ entity.setSimilarity(dto.getSimilarity());
+ entity.setSimilarityThreshold(dto.getSimilarityThreshold());
+ entity.setCapturedAt(dto.getCapturedAt());
+ if (StrUtil.isNotBlank(dto.getImageHash())) entity.setImageHash(dto.getImageHash().toLowerCase());
+ if (dto.getImageSize() != null) entity.setImageSize(dto.getImageSize());
+ if (dto.getImageWidth() != null) entity.setImageWidth(dto.getImageWidth());
+ if (dto.getImageHeight() != null) entity.setImageHeight(dto.getImageHeight());
+ if (StrUtil.isNotBlank(appVersion)) entity.setAppVersion(appVersion);
+ }
+
+ /**
+ * 通过 UPMS 权威记录校验参考图,来源已变化时标记永久缺失,远程暂时失败则保留 PENDING 重试。
+ */
+ private HardOrderEvidenceUploadResultVo copyReferenceImage(HardOrderEvidenceImageEntity entity,
+ HardOrderEntity order,
+ HardOrderEvidenceMetadataDto dto) {
+ R sourceResult = remoteUserFaceService.getEvidenceSource(dto.getUserId(), dto.getFaceSampleId());
+ if (sourceResult == null || !sourceResult.isOk()) {
+ throw new RuntimeException(sourceResult == null || StrUtil.isBlank(sourceResult.getMsg())
+ ? "查询后台参考人脸失败" : sourceResult.getMsg());
+ }
+ SysUserFaceEvidenceSourceVo source = sourceResult.getData();
+ if (source == null || StrUtil.isBlank(source.getFaceImg())) {
+ return markUnavailable(entity, "识别命中的后台人脸已不存在或不再可用");
+ }
+ if (!Objects.equals(dto.getReferenceImageUrl(), source.getFaceImg())) {
+ return markUnavailable(entity, "后台参考人脸在识别后已发生变化");
+ }
+ if (dto.getReferenceImageVersion() != null
+ && !Objects.equals(dto.getReferenceImageVersion(), source.getSourceUpdatedAt())) {
+ return markUnavailable(entity, "后台参考人脸版本在识别后已发生变化");
+ }
+ String sourceFileName;
+ try {
+ sourceFileName = trustedFileName(source.getFaceImg());
+ } catch (RuntimeException exception) {
+ return markUnavailable(entity, exception.getMessage());
+ }
+ ImageSnapshot image;
+ try (Response response = remoteFileService.getFile(sourceFileName)) {
+ if (response == null || response.status() < 200 || response.status() >= 300 || response.body() == null) {
+ throw new RuntimeException("读取后台参考人脸失败");
+ }
+ image = readImage(response.body().asInputStream(), sourceFileName, REFERENCE_IMAGE_MAX_BYTES, false);
+ } catch (IOException exception) {
+ throw new RuntimeException("读取后台参考人脸失败", exception);
+ }
+ if (StrUtil.isNotBlank(dto.getImageHash()) && !dto.getImageHash().equalsIgnoreCase(image.hash())) {
+ return markUnavailable(entity, "后台参考人脸内容与识别时哈希不一致");
+ }
+ validateHashConflict(entity, image.hash());
+ storeImage(entity, order, image, evidenceFileName(order, dto, image.extension()));
+ return result(entity, true, "后台参考人脸已复制为订单证据");
+ }
+
+ /**
+ * 将不可恢复的参考图变化记录为 UNAVAILABLE,保留已有元数据供 PC 追溯。
+ */
+ private HardOrderEvidenceUploadResultVo markUnavailable(HardOrderEvidenceImageEntity entity, String reason) {
+ entity.setStatus(HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode());
+ entity.setFailureReason(sanitizeFailure(reason));
+ updateById(entity);
+ return result(entity, false, reason);
+ }
+
+ /**
+ * 写入文件中心并在成功响应后原子更新证据记录,不删除任何历史证据文件。
+ */
+ @SuppressWarnings("unchecked")
+ private void storeImage(HardOrderEvidenceImageEntity entity, HardOrderEntity order,
+ ImageSnapshot image, String originalName) {
+ MultipartFile uploadFile = new MockMultipartFile("file", originalName, image.contentType(),
+ new ByteArrayInputStream(image.bytes()));
+ R> uploadResult = remoteFileService.innerUpload(uploadFile,
+ FILE_DIRECTORY_PREFIX + "/" + order.getId(), null, FILE_TYPE);
+ if (uploadResult == null || !uploadResult.isOk() || !(uploadResult.getData() instanceof Map, ?> storage)) {
+ throw new RuntimeException(uploadResult == null || StrUtil.isBlank(uploadResult.getMsg())
+ ? "订单证据写入文件中心失败" : uploadResult.getMsg());
+ }
+ entity.setSysFileId(numberValue(storage.get("id")));
+ entity.setStorageFileName(requiredString(storage.get("fileName"), "文件中心未返回对象名"));
+ entity.setImageUrl(requiredString(storage.get("url"), "文件中心未返回访问地址"));
+ entity.setImageHash(image.hash());
+ entity.setImageSize((long) image.bytes().length);
+ entity.setImageWidth(image.width());
+ entity.setImageHeight(image.height());
+ entity.setStatus(HardOrderEvidenceStatusEnum.AVAILABLE.getCode());
+ entity.setFailureReason(null);
+ updateById(entity);
+ }
+
+ /**
+ * 读取终端 MultipartFile 并复用统一图片内容校验。
+ */
+ private ImageSnapshot readImage(MultipartFile file, int maxBytes, boolean liveImage) {
+ if (file == null || file.isEmpty()) throw new RuntimeException("上传图片不能为空");
+ try {
+ return readImage(new ByteArrayInputStream(file.getBytes()), file.getOriginalFilename(), maxBytes, liveImage);
+ } catch (IOException exception) {
+ throw new RuntimeException("读取上传图片失败", exception);
+ }
+ }
+
+ /**
+ * 有上限地读取并解码图片,拒绝伪造后缀、空内容和超大像素尺寸。
+ */
+ private ImageSnapshot readImage(InputStream inputStream, String originalName, int maxBytes, boolean liveImage) {
+ byte[] bytes = readLimitedBytes(inputStream, maxBytes);
+ try {
+ BufferedImage bufferedImage = ImageIO.read(new ByteArrayInputStream(bytes));
+ if (bufferedImage == null || bufferedImage.getWidth() <= 0 || bufferedImage.getHeight() <= 0) {
+ throw new RuntimeException("文件不是有效图片");
+ }
+ String extension = detectExtension(bytes, originalName);
+ if (liveImage && Math.max(bufferedImage.getWidth(), bufferedImage.getHeight()) > LIVE_IMAGE_MAX_EDGE) {
+ throw new RuntimeException("现场图片最长边不能超过640像素");
+ }
+ String contentType = "png".equals(extension) ? "image/png" : "image/jpeg";
+ return new ImageSnapshot(bytes, sha256(bytes), bufferedImage.getWidth(), bufferedImage.getHeight(), extension, contentType);
+ } catch (IOException exception) {
+ throw new RuntimeException("图片解码失败", exception);
+ }
+ }
+
+ /**
+ * 按字节签名识别 JPEG/PNG,扩展名只作为错误信息补充而不作为信任依据。
+ */
+ private String detectExtension(byte[] bytes, String originalName) {
+ if (bytes.length >= 3 && (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xd8 && (bytes[2] & 0xff) == 0xff) {
+ return "jpg";
+ }
+ if (bytes.length >= 8 && (bytes[0] & 0xff) == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47) {
+ return "png";
+ }
+ throw new RuntimeException("仅支持真实的JPEG或PNG图片:" + StrUtil.blankToDefault(originalName, "未命名文件"));
+ }
+
+ /**
+ * 按上限读取远程或上传字节流,超过上限立即拒绝以控制内存占用。
+ */
+ private byte[] readLimitedBytes(InputStream inputStream, int maxBytes) {
+ try (InputStream source = inputStream; ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ byte[] buffer = new byte[8192];
+ int total = 0;
+ int read;
+ while ((read = source.read(buffer)) >= 0) {
+ total += read;
+ if (total > maxBytes) throw new RuntimeException("图片文件超过允许大小");
+ output.write(buffer, 0, read);
+ }
+ if (total == 0) throw new RuntimeException("图片文件不能为空");
+ return output.toByteArray();
+ } catch (IOException exception) {
+ throw new RuntimeException("读取图片文件失败", exception);
+ }
+ }
+
+ /**
+ * 校验终端上报的大小和宽高,防止元数据与实际证据内容不一致。
+ */
+ private void validateReportedImage(HardOrderEvidenceMetadataDto dto, ImageSnapshot image) {
+ if (dto.getImageSize() != null && dto.getImageSize() != image.bytes().length) {
+ throw new RuntimeException("现场图片大小与终端上报不一致");
+ }
+ if (dto.getImageWidth() != null && !dto.getImageWidth().equals(image.width())) {
+ throw new RuntimeException("现场图片宽度与终端上报不一致");
+ }
+ if (dto.getImageHeight() != null && !dto.getImageHeight().equals(image.height())) {
+ throw new RuntimeException("现场图片高度与终端上报不一致");
+ }
+ }
+
+ /**
+ * 从受信文件接口地址提取对象名,禁止服务端按终端参数访问任意 URL。
+ */
+ private String trustedFileName(String sourceUrl) {
+ try {
+ var components = UriComponentsBuilder.fromUriString(sourceUrl).build();
+ String path = components.getPath();
+ String fileName = components.getQueryParams().getFirst("fileName");
+ if (StrUtil.isBlank(path) || !path.endsWith(TRUSTED_FACE_FILE_PATH) || StrUtil.isBlank(fileName)
+ || fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
+ throw new RuntimeException("后台参考人脸来源不受信任");
+ }
+ return fileName;
+ } catch (IllegalArgumentException exception) {
+ throw new RuntimeException("后台参考人脸地址不合法", exception);
+ }
+ }
+
+ /**
+ * 相同证据位置只接受相同哈希;未知哈希的 PENDING 占位可在首次文件上传时补齐。
+ */
+ private void validateHashConflict(HardOrderEvidenceImageEntity existing, String incomingHash) {
+ if (existing != null && StrUtil.isNotBlank(existing.getImageHash()) && StrUtil.isNotBlank(incomingHash)
+ && !existing.getImageHash().equalsIgnoreCase(incomingHash)) {
+ throw new RuntimeException("该证据位置已登记其他图片哈希,禁止覆盖");
+ }
+ }
+
+ /**
+ * 使用不含租户编号的稳定业务 Key 串行化证据位置,租户前缀由 Redis 基础设施自动处理。
+ */
+ private T withEvidenceLock(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, LockedAction action) {
+ String lockKey = String.format("hard:order-evidence:%s:%s:%s:%s", order.getId(), dto.getEvidenceType(),
+ dto.getEvidenceRole(), dto.getSequenceNo());
+ LockInfo lock = lockTemplate.lock(lockKey, EVIDENCE_LOCK_EXPIRE_MILLIS, EVIDENCE_LOCK_WAIT_MILLIS);
+ if (lock == null) throw new RuntimeException("该订单证据正在处理,请稍后重试");
+ try {
+ return action.run();
+ } finally {
+ if (!lockTemplate.releaseLock(lock)) log.warn("|{}|证据锁释放失败|orderId:{}|", TAG, order.getId());
+ }
+ }
+
+ /**
+ * 生成不包含用户姓名等敏感信息的订单证据文件名。
+ */
+ private String evidenceFileName(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String extension) {
+ return String.format("order-%s-%s-%s-%s.%s", order.getId(), dto.getEvidenceType().toLowerCase(),
+ dto.getEvidenceRole().toLowerCase(), dto.getSequenceNo(), extension);
+ }
+
+ /**
+ * 将任意失败说明限制为单行500字符,避免终端路径或异常栈进入证据主记录。
+ */
+ private String sanitizeFailure(String source) {
+ String value = StrUtil.blankToDefault(source, "证据文件不可用").replace('\n', ' ').replace('\r', ' ').trim();
+ return value.length() <= 500 ? value : value.substring(0, 500);
+ }
+
+ /**
+ * 计算图片 SHA-256,使用 Java 17 十六进制编码保持终端与服务端一致。
+ */
+ private String sha256(byte[] bytes) {
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
+ } catch (NoSuchAlgorithmException exception) {
+ throw new IllegalStateException("当前JVM不支持SHA-256", exception);
+ }
+ }
+
+ /**
+ * 将文件中心返回的数值主键安全转换为 Long。
+ */
+ private Long numberValue(Object value) {
+ if (value instanceof Number number) return number.longValue();
+ if (value != null && StrUtil.isNotBlank(value.toString())) {
+ try {
+ return Long.valueOf(value.toString());
+ } catch (NumberFormatException ignored) {
+ // 统一在下方抛出文件中心协议错误。
+ }
+ }
+ throw new RuntimeException("文件中心未返回有效文件主键");
+ }
+
+ /**
+ * 读取文件中心必需字符串字段,空值作为协议错误处理。
+ */
+ private String requiredString(Object value, String message) {
+ if (value == null || StrUtil.isBlank(value.toString())) throw new RuntimeException(message);
+ return value.toString();
+ }
+
+ /**
+ * 构造终端同步使用的简短幂等结果。
+ */
+ private HardOrderEvidenceUploadResultVo result(HardOrderEvidenceImageEntity entity, boolean uploaded, String message) {
+ return HardOrderEvidenceUploadResultVo.builder()
+ .evidenceId(entity.getId())
+ .orderId(entity.getOrderId())
+ .status(entity.getStatus())
+ .imageUrl(entity.getImageUrl())
+ .uploaded(uploaded)
+ .message(message)
+ .build();
+ }
+
+ /**
+ * 持锁执行单个证据位置动作的函数接口,允许统一保证锁释放。
+ */
+ @FunctionalInterface
+ private interface LockedAction {
+
+ /**
+ * 执行可能读写证据和文件中心的业务动作。
+ *
+ * @return 动作结果
+ */
+ T run();
+ }
+
+ /**
+ * 已校验图片的不可变内存快照,避免上传前后再次读取可变临时文件。
+ *
+ * @param bytes 图片原始字节
+ * @param hash SHA-256 哈希
+ * @param width 图片宽度,单位像素
+ * @param height 图片高度,单位像素
+ * @param extension 由字节签名确定的扩展名
+ * @param contentType 与扩展名一致的 MIME 类型
+ */
+ private record ImageSnapshot(byte[] bytes, String hash, int width, int height, String extension, String contentType) {
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
index f2d2a2e1..04574e76 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
@@ -52,7 +52,6 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
import com.bsd.pjcloud.hard.api.constant.xfj.HardXfjTypeEnum;
import com.bsd.pjcloud.hard.api.dto.hard.HandCancelActCodeDto;
import com.bsd.pjcloud.hard.api.dto.hard.HardDoneHardQrCodeScanOrderDto;
-import com.bsd.pjcloud.hard.api.dto.manager.HardManagerDto;
import com.bsd.pjcloud.hard.api.dto.hard.HandCancelCodeDto;
import com.bsd.pjcloud.hard.api.dto.hard.HardCreateOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto;
@@ -75,7 +74,6 @@ import com.bsd.pjcloud.hard.api.entity.address.HardUserMapAddressEntity;
import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
import com.bsd.pjcloud.hard.api.entity.order.*;
import com.bsd.pjcloud.hard.api.entity.spu.HardSpuEntity;
-import com.bsd.pjcloud.hard.api.utils.HardUtil;
import com.bsd.pjcloud.hard.api.vo.HardDoorFlowVo;
import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
@@ -89,7 +87,6 @@ import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo;
import com.bsd.pjcloud.hard.util.MchJobTimeUtils;
import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper;
-import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.*;
@@ -183,6 +180,8 @@ public class HardOrderServiceImpl extends ServiceImplApp 可见性查询使用不关联展示表的专用统计语句,避免总数统计重复执行最新售后单等关联逻辑。
+ *
+ * @param page 分页参数
+ * @param dto 订单筛选和结果补充选项
+ * @param dataScope 后台数据范围;App 查询传 {@code null}
+ * @return 已完成展示信息装配的订单分页结果
+ */
@Override
public Page scopePageList(Page page, HardOrderDto dto, DataScope dataScope) {
+ if (dto.getAppViewerUserId() != null) {
+ Page list = appVisibilityPageList(page, dto, dataScope);
+ disposeList(list.getRecords(), dto);
+ return list;
+ }
Page list = baseMapper.scopePageList(page, dto, dataScope);
disposeList(list.getRecords(), dto);
return list;
}
+ /**
+ * App 订单先分页主键、再关联当前页详情,避免售后和父购物车关联参与全部历史订单的排序与分页。
+ *
+ * @param page 原始订单分页参数
+ * @param dto 已由接口层写入当前访问者和被监护人范围的查询参数
+ * @param dataScope 保留的数据范围参数;当前 App 调用传 {@code null}
+ * @return 记录顺序与主键分页查询完全一致的订单页
+ */
+ private Page appVisibilityPageList(Page page, HardOrderDto dto, DataScope dataScope) {
+ Page idPage = new Page<>(page.getCurrent(), page.getSize(), page.searchCount());
+ idPage.setCountId("appVisibilityCount");
+ idPage.setMaxLimit(page.maxLimit());
+ idPage.setOptimizeCountSql(page.optimizeCountSql());
+ idPage.setOptimizeJoinOfCountSql(page.optimizeJoinOfCountSql());
+ idPage.setOrders(page.orders());
+ idPage = baseMapper.appVisibilityPageIds(idPage, dto, dataScope);
+
+ List orderIds = idPage.getRecords();
+ List orderedRecords = List.of();
+ if (ObjectUtil.isNotEmpty(orderIds)) {
+ Map detailMap = CollectionUtils.convertMap(baseMapper.listAppPageDetails(orderIds), HardOrderVo::getId);
+ orderedRecords = orderIds.stream().map(detailMap::get).filter(Objects::nonNull).toList();
+ }
+
+ Page result = new Page<>(idPage.getCurrent(), idPage.getSize(), idPage.getTotal(), idPage.searchCount());
+ result.setRecords(orderedRecords);
+ result.setOrders(page.orders());
+ result.setMaxLimit(idPage.maxLimit());
+ return result;
+ }
+
/**
* 分页查询未识别订单
*/
@@ -371,6 +414,18 @@ public class HardOrderServiceImpl extends ServiceImpl {
+ try {
+ hardOrderEvidenceImageService.registerOrderEvidence(order, dto.getEvidenceMetadataList(), dto.getAppVersion());
+ } catch (Exception exception) {
+ // 终端本地队列仍会按 androidOrderCode 补交,服务端占位失败不得改变订单支付结果。
+ log.warn("|{}|订单证据占位登记失败|orderId:{}|error:{}|", TAG, order.getId(), exception.getMessage());
+ }
+ };
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+ action.run();
+ return;
+ }
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ /**
+ * 主订单事务提交后使用独立事务登记证据占位,避免文件或证据表异常污染资金事务。
+ */
+ @Override
+ public void afterCommit() {
+ action.run();
+ }
+ });
+ }
+
/**
* 创建设备二维码扫一扫订单
*/
@@ -4536,6 +4636,12 @@ public class HardOrderServiceImpl extends ServiceImpl list, HardOrderDto dto) {
if (ObjectUtil.isEmpty(list)) return;
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
@@ -4583,19 +4689,6 @@ public class HardOrderServiceImpl extends ServiceImpl deviceFuture = CompletableFuture.runAsync(() -> {
- RequestContextHolder.setRequestAttributes(requestAttributes);
- List hardIds = list.stream().map(HardOrderVo::getHardId).filter(ObjectUtil::isNotEmpty).distinct().toList();
- if (ObjectUtil.isNotEmpty(hardIds)) {
- HardManagerDto hardManagerDto = new HardManagerDto();
- hardManagerDto.setIds(hardIds);
- List hardList = hardManagerMapper.list(hardManagerDto);
- if (ObjectUtil.isNotEmpty(hardList)) {
- HardUtil.convertHardList(list, CollectionUtils.convertMap(hardList, HardManagerEntity::getId));
- }
- }
- });
// 钱包
CompletableFuture walletFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
@@ -4613,11 +4706,18 @@ public class HardOrderServiceImpl extends ServiceImpl findCarListFuture = CompletableFuture.runAsync(() -> {
if (dto.getIsFindCarList()) {
+ List parentCarIds = list.stream().map(HardOrderVo::getParentCarId)
+ .filter(Objects::nonNull).distinct().toList();
Map> carListsByParentId = new LinkedHashMap<>();
+ if (!parentCarIds.isEmpty()) {
+ List carList = hardCarService.getByParentCarIds(parentCarIds);
+ for (HardCarEntity car : carList) {
+ carListsByParentId.computeIfAbsent(car.getParentCarId(), ignored -> new ArrayList<>())
+ .add(BeanUtils.toBean(car, HardCarVo.class));
+ }
+ }
for (HardOrderVo v : list) {
- List carList = hardCarService.getByParentCarId(v.getParentCarId());
- List carVoList = BeanUtils.toBean(carList, HardCarVo.class);
- if (v.getParentCarId() != null) carListsByParentId.put(v.getParentCarId(), carVoList);
+ List carVoList = carListsByParentId.getOrDefault(v.getParentCarId(), new ArrayList<>());
v.setCarList(carVoList);
}
hardAfterSaleCarService.fillRefundQuantityStats(carListsByParentId);
@@ -4642,6 +4742,6 @@ public class HardOrderServiceImpl extends ServiceImpl
+
+
+
+ and (
+ a.user_id = #{dto.appViewerUserId}
+ or (
+ a.merchant_type = '1'
+ and a.sys_type = '2'
+ and a.order_type = '2'
+ and (a.appt_id is not null or a.appt_rule_id is not null)
+ and (
+ exists (
+ select 1
+ from hard_parent_car visible_parent
+ where visible_parent.id = a.parent_car_id
+ and visible_parent.del_flag = '0'
+ and visible_parent.user_id = #{dto.appViewerUserId}
+ )
+
+ or a.user_id in
+
+ #{v}
+
+
+ )
+ )
+ )
+
+
+
+
+
+
+ and exists (
+ select 1
+ from hard_manager count_manager
+ where count_manager.id = a.hard_id
+ and count_manager.type = #{dto.hardTypeCode}
+ )
+
+
+ and exists (
+ select 1
+ from hard_manager count_manager
+ where count_manager.id = a.hard_id
+ and count_manager.type in
+
+ #{v}
+
+ )
+
+
+ and exists (
+ select 1
+ from hard_after_sale count_sale
+ where count_sale.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'
+ )
+ and count_sale.no = #{dto.afterSaleNo}
+ )
+
+
+
m.name as hardName,
@@ -234,26 +300,7 @@
left join hard_parent_car p on p.id = a.parent_car_id and p.del_flag = '0'
-
- and (
- a.user_id = #{dto.appViewerUserId}
- or (
- a.merchant_type = '1'
- and a.sys_type = '2'
- and a.order_type = '2'
- and (a.appt_id is not null or a.appt_rule_id is not null)
- and (
- p.user_id = #{dto.appViewerUserId}
-
- or a.user_id in
-
- #{v}
-
-
- )
- )
- )
-
+
and m.type = #{dto.hardTypeCode}
@@ -267,7 +314,6 @@
and s.no = #{dto.afterSaleNo}
- group by a.id
order by a.update_time desc, a.id desc
@@ -282,6 +328,61 @@
+
+
+
+
+
+
+
+
+
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java
index 7c3a1519..7ab76007 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java
@@ -41,12 +41,15 @@ class HardCanteenProductApptExportMergeHandlerTest {
rows.add(summaryRow(LocalDate.of(2026, 8, 23), "早餐"));
rows.add(merchantRow(LocalDate.of(2026, 8, 23), "早餐"));
addProductRows(rows, 130);
+ rows.add(statisticRow("早餐汇总商品"));
rows.add(summaryRow(LocalDate.of(2026, 8, 23), "午餐"));
rows.add(merchantRow(LocalDate.of(2026, 8, 23), "午餐"));
addProductRows(rows, 2);
+ rows.add(statisticRow("午餐汇总商品"));
rows.add(summaryRow(LocalDate.of(2026, 8, 24), "早餐"));
rows.add(merchantRow(LocalDate.of(2026, 8, 24), "早餐"));
addProductRows(rows, 5);
+ rows.add(statisticRow("次日早餐汇总商品"));
EasyExcel.write(output.toFile(), HardCanteenProductApptExcelVo.class)
.registerWriteHandler(new HardCanteenProductApptExportMergeHandler())
@@ -55,14 +58,14 @@ class HardCanteenProductApptExportMergeHandlerTest {
try (InputStream input = Files.newInputStream(output); Workbook workbook = new XSSFWorkbook(input)) {
Set mergedRegions = workbook.getSheetAt(0).getMergedRegions().stream()
.map(region -> region.formatAsString()).collect(Collectors.toSet());
- assertTrue(mergedRegions.contains("A3:A134"));
- assertTrue(mergedRegions.contains("F3:F134"));
- assertTrue(mergedRegions.contains("G4:G134"));
- assertTrue(mergedRegions.contains("N4:N134"));
- assertTrue(mergedRegions.contains("A135:A138"));
- assertTrue(mergedRegions.contains("G136:G138"));
- assertTrue(mergedRegions.contains("A139:A145"));
- assertTrue(mergedRegions.contains("G140:G145"));
+ assertTrue(mergedRegions.contains("A3:A135"));
+ assertTrue(mergedRegions.contains("F3:F135"));
+ assertTrue(mergedRegions.contains("G4:G135"));
+ assertTrue(mergedRegions.contains("N4:N135"));
+ assertTrue(mergedRegions.contains("A136:A140"));
+ assertTrue(mergedRegions.contains("G137:G140"));
+ assertTrue(mergedRegions.contains("A141:A148"));
+ assertTrue(mergedRegions.contains("G142:G148"));
assertEquals(10, workbook.getFontAt(workbook.getSheetAt(0).getRow(0).getCell(0)
.getCellStyle().getFontIndex()).getFontHeightInPoints());
assertEquals(12 * 256, workbook.getSheetAt(0).getColumnWidth(0));
@@ -70,19 +73,21 @@ class HardCanteenProductApptExportMergeHandlerTest {
assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(12));
assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(14));
assertEquals(9 * 256, workbook.getSheetAt(0).getColumnWidth(15));
+ assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(17));
+ assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(18));
XSSFCellStyle firstGroupTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(2)
.getCell(0).getCellStyle();
XSSFCellStyle firstGroupSideStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(3)
.getCell(0).getCellStyle();
XSSFCellStyle firstGroupRightStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(3)
- .getCell(16).getCellStyle();
- XSSFCellStyle sameDateMealTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(134)
+ .getCell(19).getCellStyle();
+ XSSFCellStyle sameDateMealTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(135)
.getCell(0).getCellStyle();
- XSSFCellStyle firstDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(137)
+ XSSFCellStyle firstDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(139)
.getCell(0).getCellStyle();
- XSSFCellStyle secondDateTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(138)
+ XSSFCellStyle secondDateTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(140)
.getCell(0).getCellStyle();
- XSSFCellStyle lastDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(144)
+ XSSFCellStyle lastDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(147)
.getCell(0).getCellStyle();
assertEquals(BorderStyle.MEDIUM, firstGroupTopStyle.getBorderTop());
assertEquals(BorderStyle.MEDIUM, firstGroupSideStyle.getBorderLeft());
@@ -154,4 +159,18 @@ class HardCanteenProductApptExportMergeHandlerTest {
rows.add(row);
}
}
+
+ /**
+ * 构造商户部门块末尾的独立商品统计行,用于验证纵向合并范围包含统计区域。
+ *
+ * @param productName 商品统计名称
+ * @return 仅填写商品统计列的导出行
+ */
+ private HardCanteenProductApptExcelVo statisticRow(String productName) {
+ HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
+ row.setStatisticProductName(productName);
+ row.setStatisticProductCount(1L);
+ row.setStatisticProductTotalPrice(BigDecimal.ONE);
+ return row;
+ }
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java
index 67654c95..548d8f5b 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java
@@ -26,12 +26,12 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
- * 菜品预约导出服务测试,验证日期餐段和部门合计与套餐展开后的商品明细保持同一数量口径。
+ * 菜品预约导出服务测试,验证日期餐段、部门合计及商品统计与套餐展开后的商品明细保持同一口径。
*/
class HardCanteenApptTotalExportServiceImplTest {
/**
- * 套餐商品展开后,日期餐段汇总应跨部门累计组成商品数量和金额,而不是累计套餐份数。
+ * 套餐商品展开后,日期餐段汇总应跨部门累计组成商品数量和金额,并在部门内合并同名单品。
*/
@Test
void shouldAggregateMealSegmentTotalsAcrossDepartmentsFromExpandedProductRows() {
@@ -64,8 +64,8 @@ class HardCanteenApptTotalExportServiceImplTest {
merchant.setUserDeptId(20L);
merchant.setPayUserCount(1L);
merchant.setTimeCount(1L);
- merchant.setPayCount(1L);
- merchant.setTotalPrice(300L);
+ merchant.setPayCount(3L);
+ merchant.setTotalPrice(500L);
HardCanteenProductApptExportRecord product = new HardCanteenProductApptExportRecord();
product.setApptDate(date.toString());
@@ -77,6 +77,16 @@ class HardCanteenApptTotalExportServiceImplTest {
product.setPayCount(1L);
product.setTotalPrice(300L);
+ HardCanteenProductApptExportRecord sameNameProduct = new HardCanteenProductApptExportRecord();
+ sameNameProduct.setApptDate(date.toString());
+ sameNameProduct.setHourType("1");
+ sameNameProduct.setMerchantId(10L);
+ sameNameProduct.setUserDeptId(20L);
+ sameNameProduct.setProductId(101L);
+ sameNameProduct.setProductName("主食");
+ sameNameProduct.setPayCount(2L);
+ sameNameProduct.setTotalPrice(200L);
+
HardCanteenProductApptExportRecord secondMerchant = new HardCanteenProductApptExportRecord();
secondMerchant.setApptDate(date.toString());
secondMerchant.setHourType("1");
@@ -124,7 +134,7 @@ class HardCanteenApptTotalExportServiceImplTest {
when(hardCarMapper.listApptExportSummary(any())).thenReturn(List.of(summary));
when(hardCarMapper.listApptExportMerchant(any())).thenReturn(List.of(merchant, secondMerchant));
- when(hardCarMapper.listApptExportProduct(any())).thenReturn(List.of(product, secondProduct));
+ when(hardCarMapper.listApptExportProduct(any())).thenReturn(List.of(product, sameNameProduct, secondProduct));
when(remoteDailyMenuService.getCanteenList(date.toString(), 1, null, 10L)).thenReturn(R.ok(List.of(menu)));
when(remoteMchService.getByIds(List.of(10L))).thenReturn(R.ok(List.of(merchantVo)));
when(remoteDeptService.getDeptByDeptIds(List.of(20L, 30L)))
@@ -134,16 +144,26 @@ class HardCanteenApptTotalExportServiceImplTest {
List rows = ReflectionTestUtils.invokeMethod(service, "buildProductRows",
null, date);
- assertEquals(6, rows.size());
- assertEquals(7L, rows.get(0).getSummaryPayCount());
- assertEquals("8.00", rows.get(0).getSummaryTotalPrice().toPlainString());
- assertEquals(3L, rows.get(1).getMerchantPayCount());
- assertEquals("3.00", rows.get(1).getMerchantTotalPrice().toPlainString());
+ assertEquals(10, rows.size());
+ assertEquals(9L, rows.get(0).getSummaryPayCount());
+ assertEquals("10.00", rows.get(0).getSummaryTotalPrice().toPlainString());
+ assertEquals(5L, rows.get(1).getMerchantPayCount());
+ assertEquals("5.00", rows.get(1).getMerchantTotalPrice().toPlainString());
assertEquals(1L, rows.get(2).getProductCount());
assertEquals(2L, rows.get(3).getProductCount());
- assertEquals(4L, rows.get(4).getMerchantPayCount());
- assertEquals("5.00", rows.get(4).getMerchantTotalPrice().toPlainString());
- assertEquals(4L, rows.get(5).getProductCount());
+ assertEquals(2L, rows.get(4).getProductCount());
+ assertEquals("主食", rows.get(5).getStatisticProductName());
+ assertEquals(3L, rows.get(5).getStatisticProductCount());
+ assertEquals("3.00", rows.get(5).getStatisticProductTotalPrice().toPlainString());
+ assertEquals("配菜", rows.get(6).getStatisticProductName());
+ assertEquals(2L, rows.get(6).getStatisticProductCount());
+ assertEquals("2.00", rows.get(6).getStatisticProductTotalPrice().toPlainString());
+ assertEquals(4L, rows.get(7).getMerchantPayCount());
+ assertEquals("5.00", rows.get(7).getMerchantTotalPrice().toPlainString());
+ assertEquals(4L, rows.get(8).getProductCount());
+ assertEquals("单品", rows.get(9).getStatisticProductName());
+ assertEquals(4L, rows.get(9).getStatisticProductCount());
+ assertEquals("5.00", rows.get(9).getStatisticProductTotalPrice().toPlainString());
assertEquals("早餐", rows.get(0).getSummaryHourType());
}
}
diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java
index bf97a3c2..f5eb95df 100644
--- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java
+++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java
@@ -1,12 +1,15 @@
package com.bsd.pjcloud.admin.api.feign;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo;
+import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.feign.annotation.NoToken;
import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@@ -25,4 +28,15 @@ public interface RemoteUserFaceService {
@NoToken
@PostMapping("/user-face/inner/status-count")
R> listStatusCounts(@RequestBody List userIds);
+
+ /**
+ * 按用户和虹软样本编号读取审核通过的人脸原图地址,供订单证据服务复制订单专属快照。
+ *
+ * @param userId 识别用户主键
+ * @param sampleId 虹软样本编号
+ * @return 受信人脸图片来源;不存在或未审核通过时为空
+ */
+ @NoToken
+ @GetMapping("/user-face/inner/evidence-source")
+ R getEvidenceSource(@RequestParam Long userId, @RequestParam String sampleId);
}
diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceEvidenceSourceVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceEvidenceSourceVo.java
new file mode 100644
index 00000000..c70843df
--- /dev/null
+++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceEvidenceSourceVo.java
@@ -0,0 +1,38 @@
+package com.bsd.pjcloud.admin.api.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 供 Hardware 订单证据服务使用的受信人脸原图来源,不包含特征数据和审核流程内部字段。
+ */
+@Data
+@Schema(description = "人脸证据原图来源")
+public class SysUserFaceEvidenceSourceVo {
+
+ /**
+ * 人脸所属用户主键。
+ */
+ @Schema(description = "用户主键")
+ private Long userId;
+
+ /**
+ * 虹软样本编号。
+ */
+ @Schema(description = "样本编号")
+ private String sampleId;
+
+ /**
+ * 审核通过的人脸图片地址。
+ */
+ @Schema(description = "人脸图片地址")
+ private String faceImg;
+
+ /**
+ * 人脸来源最后更新时间,用于证据审计和图片版本判断。
+ */
+ @Schema(description = "来源更新时间")
+ private LocalDateTime sourceUpdatedAt;
+}
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java
index b6e22ff5..b2d0d574 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java
@@ -16,6 +16,7 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo;
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.service.SysUserFaceService;
import com.bsd.pjcloud.common.core.util.R;
@@ -77,6 +78,16 @@ public class SysUserFaceController {
public R> listStatusCounts(@RequestBody List userIds) {
return R.ok(sysUserFaceService.listStatusCounts(userIds));
}
+
+ /**
+ * Hardware 内部复制订单参考人脸时读取审核通过的人脸来源。
+ */
+ @Inner
+ @Operation(summary = "查询订单证据参考人脸来源")
+ @GetMapping("/inner/evidence-source")
+ public R evidenceSource(@RequestParam Long userId, @RequestParam String sampleId) {
+ return R.ok(sysUserFaceService.evidenceSource(userId, sampleId));
+ }
/* ************************************* end 远程调用 end ************************************* */
/* ************************************* start app start ************************************* */
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java
index def07295..d088a570 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java
@@ -9,6 +9,7 @@ import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo;
+import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.data.datascope.DataScope;
@@ -23,6 +24,28 @@ import java.util.List;
@Mapper
public interface SysUserFaceMapper extends PjcloudBaseMapper {
+ /**
+ * 查询指定用户审核通过的人脸来源,供订单证据服务复制不可变参考图。
+ *
+ * @param userId 用户主键
+ * @param sampleId 虹软样本编号
+ * @param processStatus 审核通过状态编码
+ * @return 受信图片来源;不存在时为空
+ */
+ default SysUserFaceEvidenceSourceVo evidenceSource(Long userId, String sampleId, String processStatus) {
+ SysUserFaceEntity entity = selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
+ .eq(SysUserFaceEntity::getUserId, userId)
+ .eq(SysUserFaceEntity::getAutoCode, sampleId)
+ .eq(SysUserFaceEntity::getProcessStatus, processStatus));
+ if (entity == null) return null;
+ SysUserFaceEvidenceSourceVo source = new SysUserFaceEvidenceSourceVo();
+ source.setUserId(entity.getUserId());
+ source.setSampleId(entity.getAutoCode());
+ source.setFaceImg(entity.getFaceImg());
+ source.setSourceUpdatedAt(entity.getUpdateTime());
+ return source;
+ }
+
Page scopePageList(Page page, @Param("pa") SysUserFaceDto dto, DataScope dataScope);
List groupConcatFaces(@Param("pa") SysUserFaceDto dto);
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java
index e0868db9..85acfc2a 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java
@@ -11,6 +11,7 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo;
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.common.data.datascope.DataScope;
import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto;
@@ -57,6 +58,15 @@ public interface SysUserFaceService extends IService {
*/
List listStatusCounts(List userIds);
+ /**
+ * 读取审核通过人脸的受信原图来源,供订单证据服务复制订单快照。
+ *
+ * @param userId 用户主键
+ * @param sampleId 虹软样本编号
+ * @return 原图来源;不存在时为空
+ */
+ SysUserFaceEvidenceSourceVo evidenceSource(Long userId, String sampleId);
+
/**
* 分页查询当前用户的有效被监护人,或显式指定的本人/有效被监护人人脸审核记录。
*/
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java
index a0709552..62d2cf93 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java
@@ -19,6 +19,7 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo;
+import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo;
import com.bsd.pjcloud.admin.api.vo.user.SysUserGuardianWardOptionVo;
@@ -263,6 +264,15 @@ public class SysUserFaceServiceImpl extends ServiceImpl