chenxiaofei hai 1 mes
pai
achega
6baa02902d

+ 87 - 12
iot-platform-manager/src/main/java/com/platform/api/manager/UploadService.java

@@ -19,6 +19,7 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.time.LocalDate;
+import java.util.Locale;
 import java.util.UUID;
 import java.util.concurrent.CompletableFuture;
 
@@ -85,16 +86,71 @@ public class UploadService {
     }
 
     private DecodedImage decodeBase64Image(String base64Image) {
+        String extension = getExtensionFromBase64Image(base64Image);
+        String content = extractBase64Content(base64Image);
+        byte[] bytes = decodeBase64Content(content);
+        return new DecodedImage(bytes, extension);
+    }
+
+    private String extractBase64Content(String base64Image) {
         String content = base64Image.trim();
-        String extension = "jpg";
-        int commaIndex = content.indexOf(',');
-        if (content.startsWith("data:") && commaIndex > -1) {
-            String header = content.substring(0, commaIndex);
-            extension = getExtensionFromDataUri(header);
-            content = content.substring(commaIndex + 1);
+        String lowerContent = content.toLowerCase(Locale.ROOT);
+        int base64MarkerIndex = lowerContent.indexOf(";base64,");
+        if (base64MarkerIndex > -1) {
+            content = content.substring(base64MarkerIndex + ";base64,".length());
+        } else {
+            int commaIndex = content.indexOf(',');
+            if (lowerContent.startsWith("data:") && commaIndex > -1) {
+                content = content.substring(commaIndex + 1);
+            }
+        }
+
+        int startIndex = firstBase64CharIndex(content);
+        if (startIndex > 0) {
+            content = content.substring(startIndex);
+        }
+        int endIndex = firstNonBase64ContentIndex(content);
+        if (endIndex > -1) {
+            content = content.substring(0, endIndex);
+        }
+        return content;
+    }
+
+    private int firstBase64CharIndex(String content) {
+        for (int i = 0; i < content.length(); i++) {
+            if (isBase64ContentChar(content.charAt(i))) {
+                return i;
+            }
+        }
+        return 0;
+    }
+
+    private int firstNonBase64ContentIndex(String content) {
+        for (int i = 0; i < content.length(); i++) {
+            if (!isBase64ContentChar(content.charAt(i))) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    private boolean isBase64ContentChar(char ch) {
+        return (ch >= 'A' && ch <= 'Z')
+                || (ch >= 'a' && ch <= 'z')
+                || (ch >= '0' && ch <= '9')
+                || ch == '+'
+                || ch == '/'
+                || ch == '='
+                || Character.isWhitespace(ch);
+    }
+
+    private byte[] decodeBase64Content(String content) {
+        String normalizedContent = content.replaceAll("[\\r\\n\\t]", "").replace(" ", "+");
+        try {
+            return Base64.getDecoder().decode(normalizedContent);
+        } catch (IllegalArgumentException e) {
+            return Base64.getDecoder().decode(content.replaceAll("\\s", ""));
         }
-        byte[] bytes = Base64.getDecoder().decode(content.replaceAll("\\s", ""));
-        return new DecodedImage(bytes, extension);
     }
 
     private Path buildLocalImagePath(String licensePlate, String extension) {
@@ -105,21 +161,40 @@ public class UploadService {
     }
 
     private String getExtensionFromDataUri(String header) {
-        if (header.contains("image/png")) {
+        String lowerHeader = header.toLowerCase(Locale.ROOT);
+        if (lowerHeader.contains("image/png")) {
             return "png";
         }
-        if (header.contains("image/gif")) {
+        if (lowerHeader.contains("image/gif")) {
             return "gif";
         }
-        if (header.contains("image/webp")) {
+        if (lowerHeader.contains("image/webp")) {
             return "webp";
         }
-        if (header.contains("image/bmp")) {
+        if (lowerHeader.contains("image/bmp")) {
             return "bmp";
         }
         return "jpg";
     }
 
+    private String getExtensionFromBase64Image(String base64Image) {
+        String content = base64Image.trim();
+        String lowerContent = content.toLowerCase(Locale.ROOT);
+        int base64MarkerIndex = lowerContent.indexOf(";base64,");
+        if (base64MarkerIndex > -1) {
+            int dataUriIndex = lowerContent.lastIndexOf("data:", base64MarkerIndex);
+            if (dataUriIndex > -1) {
+                return getExtensionFromDataUri(content.substring(dataUriIndex, base64MarkerIndex));
+            }
+        }
+
+        int commaIndex = content.indexOf(',');
+        if (lowerContent.startsWith("data:") && commaIndex > -1) {
+            return getExtensionFromDataUri(content.substring(0, commaIndex));
+        }
+        return "jpg";
+    }
+
     private record DecodedImage(byte[] bytes, String extension) {
     }
     

+ 4 - 2
iot-platform-manager/src/main/java/com/platform/api/manager/WeighbridgeRecordManage.java

@@ -1,5 +1,6 @@
 package com.platform.api.manager;
 
+import com.alibaba.fastjson.JSON;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -115,11 +116,12 @@ public class WeighbridgeRecordManage {
     }
 
     public LicensePlateValidateResponse handleWeighbridgePushV2(WeighbridgePushRequest request) {
-        log.info("处理地磅数据上报 - 车牌:{}, 地磅编号:{}, 重量:{}, 时间戳:{}",
+        log.info("处理地磅数据上报 - 车牌:{}, 地磅编号:{}, 重量:{}, 时间戳:{},图片:{}",
                 request.getLicensePlate(),
                 request.getWeighbridgeCode(),
                 request.getGrossWeight(),
-                request.getTimestamp());
+                request.getTimestamp(),
+                JSON.toJSONString(request.getImages()));
         LicensePlateValidateResponse licensePlateValidateResponse = new LicensePlateValidateResponse();
         if (StringUtils.isAnyBlank(request.getLicensePlate(), request.getWeighbridgeCode()) || Objects.isNull(request.getGrossWeight()) || Objects.isNull(request.getTimestamp())) {
             return getLicensePlateValidateResponse(request, licensePlateValidateResponse);

+ 197 - 152
iot-platform-manager/src/main/java/com/platform/service/XpCloudPrintService.java

@@ -74,27 +74,50 @@ public class XpCloudPrintService {
      * @param reqVo 业务请求参数
      * @return 云端打印任务号
      */
+    /**
+     * 调用SDK执行图片打印
+     *
+     * @param reqVo 业务请求参数,包含打印机序列号、图片URL及打印份数
+     * @return 云端打印任务号
+     * @throws IotException 当参数校验失败或打印服务调用异常时抛出
+     */
     public String printImage(XpPrintImageReqVo reqVo) {
+        // 1. 参数校验:检查请求对象及核心字段(打印机SN、图片URL)是否为空
         if (reqVo == null || StringUtils.isAnyBlank(reqVo.getSn(), reqVo.getImageUrl())) {
             throw new IotException("打印参数不能为空");
         }
+        // 2. 参数校验:检查打印份数是否合法(必须大于0)
         if (reqVo.getCopies() == null || reqVo.getCopies() <= 0) {
             throw new IotException("打印份数必须大于0");
         }
+
         try {
+            // 3. 构建打印请求对象
             PrintOrderRequest request = new PrintOrderRequest(reqVo.getSn(), reqVo.getImageUrl());
+            // 设置打印份数
             request.setCopies(reqVo.getCopies());
+            // 生成去横杠的UUID作为幂等性标识,防止重复打印
             request.setIdempotent(UUID.randomUUID().toString().replace("-", ""));
+
+            // 4. 调用芯烨云SDK执行图片打印
             ObjectRestResponse<String> response = printService.printImage(request);
+
+            // 5. 记录打印结果日志
             log.info("芯烨云图片打印完成, sn={}, code={}, msg={}, data={}",
                     reqVo.getSn(), response.getCode(), response.getMsg(), response.getData());
+
+            // 6. 校验响应结果:code不为null且等于0表示成功
             if (response.getCode() == null || response.getCode() != 0) {
                 throw new IotException("调用芯烨云打印失败:" + response.getMsg());
             }
+
+            // 7. 返回云端打印任务号
             return response.getData();
         } catch (IotException e) {
+            // 业务异常直接抛出
             throw e;
         } catch (Exception e) {
+            // 捕获其他未知异常,记录错误日志并转换为业务异常抛出
             log.error("调用芯烨云图片打印异常, sn={}", reqVo.getSn(), e);
             throw new IotException("调用芯烨云图片打印异常:" + e.getMessage());
         }
@@ -125,55 +148,55 @@ public class XpCloudPrintService {
      * @throws IotException 当参数错误、远程调用失败或打印服务异常时抛出
      */
     public String printReceipt(XpPrintReceiptReqVo reqVo) {
-        // 记录入口日志,便于追踪请求参数
+        // 1. 记录入口日志,方便排查问题
         log.info("芯烨云小票打印, 入参:{}", JSON.toJSONString(reqVo));
 
-        // 1. 基础参数校验
+        // 2. 参数校验:检查请求对象及核心字段(打印机SN、任务单号)是否为空
         if (reqVo == null || StringUtils.isAnyBlank(reqVo.getSn(), reqVo.getTaskNo())) {
             throw new IotException("打印参数不能为空");
         }
-        // 业务规则校验:如果设置了过期时间 (expiresIn),则打印模式 (mode) 必须为 1 (云端模式)
+        // 3. 业务规则校验:如果设置了过期时间(expiresIn),则打印模式(mode)必须为1(即时打印)
         if (reqVo.getExpiresIn() != null && (reqVo.getMode() == null || reqVo.getMode() != 1)) {
             throw new IotException("设置expiresIn时,mode必须为1");
         }
 
         try {
-            // 2. 获取下一次成功打印的序号,用于防止重复打印及记录打印历史
+            // 4. 获取当前任务的下一次成功打印序号,用于生成“第N次打印”标识及防重
             int nextSuccessfulOrdinal = kwsPrintReceiptRecordRepository.resolveNextPrintOrdinalOrThrow(reqVo.getTaskNo());
 
-            // 3. 远程获取运单运输详细信息,作为小票内容的来源
+            // 5. 远程调用获取小票所需的运输业务数据
             TradeOrderTransportInfoResp transportInfo = getReceiptContent(reqVo.getTaskNo());
 
-            // 4. 将运输信息组装为小票打印模板对象
+            // 6. 将远程返回的业务数据组装成打印模板所需的内容对象
             PrintReceiptContent content = assembleReceiptContent(transportInfo, reqVo.getTaskNo());
 
-            // 5. 填充动态打印信息:当前时间和打印次数描述
+            // 7. 填充打印元数据:当前时间及打印次数描述
             LocalDateTime now = LocalDateTime.now();
             content.setPrintTimesDesc(formatPrintTimes(nextSuccessfulOrdinal));
             content.setPrintTime(now.format(DATE_TIME_FORMATTER));
 
-            // 6. 校验小票内容完整性,确保关键字段非空
+            // 8. 校验组装后的小票内容,确保关键字段不为空,避免打印出残缺小票
             validateReceiptContent(content);
 
-            // 7. 将小票内容对象转换为芯烨云支持的 XML 格式字符串
+            // 9. 将内容对象转换为芯烨云支持的XML格式字符串
             String xmlContent = buildReceiptXml(content);
 
-            // 8. 构建 HTTP 请求的 JSON Body,包含鉴权参数、SN、XML 内容等
+            // 10. 构建最终发送给芯烨云开放平台的JSON请求体(包含鉴权信息、SN、内容等)
             JSONObject printBody = buildReceiptPrintJsonBody(reqVo, xmlContent);
 
-            // 记录请求摘要日志,方便监控和排查问题
+            // 11. 记录关键请求摘要日志
             log.info("芯烨云小票打印HTTP请求摘要, url={}, sn={}, copies={}, mode={}",
                     xpCloudProperties.getReceiptPrintUrl(), reqVo.getSn(),
                     printBody.getInteger("copies"), printBody.getInteger("mode"));
-            // 仅在 Debug 模式下记录完整的请求体,避免日志过大
+            // 12. 仅在DEBUG级别记录完整请求体,避免生产环境日志过大
             if (log.isDebugEnabled()) {
                 log.debug("芯烨云小票打印HTTP请求体: {}", printBody.toJSONString());
             }
 
-            // 9. 发送 POST 请求到芯烨云平台,获取打印订单号
+            // 13. 发送HTTP请求调用芯烨云打印接口,获取云端订单号
             String orderNo = postReceiptPrint(printBody);
 
-            // 10. 保存打印成功记录到数据库,用于后续对账或重试判断
+            // 14. 持久化打印成功记录,用于后续查询及打印序号累加
             kwsPrintReceiptRecordRepository.saveSuccessfulReceiptPrint(
                     reqVo.getTaskNo(),
                     transportInfo.getTradeOrderNo(),
@@ -182,16 +205,17 @@ public class XpCloudPrintService {
                     orderNo,
                     now);
 
-            // 记录完成日志
+            // 15. 记录完成日志
             log.info("芯烨云小票打印完成, sn={}, orderNo={}, 已累计成功序号={}",
                     reqVo.getSn(), orderNo, nextSuccessfulOrdinal);
 
+            // 16. 返回云端订单号
             return orderNo;
         } catch (IotException e) {
-            // 业务异常直接抛出,保持原始错误信息
-            throw e;
+            // 业务异常直接抛出
+           throw new IotException("调用芯烨云小票打印异常:" + e.getMessage());
         } catch (Exception e) {
-            // 捕获其他未预期异常,记录详细堆栈并包装为业务异常
+            // 捕获其他未知异常,记录错误日志并转换为业务异常抛出
             log.error("调用芯烨云小票打印异常, sn={}", reqVo.getSn(), e);
             throw new IotException("调用芯烨云小票打印异常:" + e.getMessage());
         }
@@ -203,26 +227,53 @@ public class XpCloudPrintService {
      * @param taskNo 任务单号
      * @return 小票内容
      */
+    /**
+     * 通过远程服务获取小票所需的运输业务内容。
+     * <p>
+     * 该方法根据任务单号(运单号)调用外部接口查询运输详情,并对响应结果进行严格校验,
+     * 确保后续打印流程能够获取到完整有效的数据。
+     *
+     * @param taskNo 任务单号(亦作为运单号使用)
+     * @return 运输订单信息响应对象,包含任务、司机、货物等详细信息
+     * @throws IotException 当远程调用失败、响应为空或业务数据缺失时抛出异常
+     */
     private TradeOrderTransportInfoResp getReceiptContent(String taskNo) {
         try {
+            // 1. 构建远程查询请求对象,设置运单号
             WaybillTransportQueryReq query = new WaybillTransportQueryReq();
             query.setWaybillNo(taskNo);
+
+            // 2. 记录请求日志,便于追踪排查
             log.info("远程获取小票内容, 请求参数:{}", JSON.toJSONString(query));
+
+            // 3. 调用外部客户端接口查询运输信息
             BaseResult<TradeOrderTransportInfoResp> response = printReceiptContentClient.queryTransportInfoByWaybillNo(query);
+
+            // 4. 记录响应日志,便于追踪排查
             log.info("远程获取小票内容, 响应参数:{}", JSON.toJSONString(response));
+
+            // 5. 校验响应对象本身是否为空
             if (response == null) {
                 throw new IotException("远程获取小票内容响应为空");
             }
+
+            // 6. 校验业务状态码,确保接口调用成功
             if (!response.isSuccess()) {
                 throw new IotException("远程获取小票内容失败:" + response.getMessage());
             }
+
+            // 7. 校验返回的业务数据是否为空
             if (response.getData() == null) {
                 throw new IotException("远程获取小票内容为空");
             }
+
+            // 8. 返回有效的运输信息数据
             return response.getData();
         } catch (IotException e) {
+            // 业务异常直接抛出,保持原始错误信息
             throw e;
         } catch (Exception e) {
+            // 捕获非业务异常(如网络超时、序列化错误等),记录详细堆栈并转换为业务异常
             log.error("远程获取小票内容异常, taskNo={}", taskNo, e);
             throw new IotException("远程获取小票内容异常:" + e.getMessage());
         }
@@ -234,20 +285,21 @@ public class XpCloudPrintService {
      * @param content 小票内容
      */
     /**
-     * 校验小票内容完整性,确保关键字段非空
+     * 校验小票打印内容的关键字段完整性
      * <p>
-     * 该步骤旨在防止因上游数据缺失导致打印出的小票信息残缺不全,影响业务流转或客户体验
-     * 目前检测到空字段时仅记录日志或忽略异常抛出(代码中已注释),可根据实际需求开启严格校验
+     * 该方法会检查 {@link PrintReceiptContent} 对象中所有必填字段是否为空或空白
+     * 如果发现任何必填字段缺失,将抛出 {@link IotException} 异常,以防止生成内容残缺的小票
      *
      * @param content 待校验的小票内容对象
+     * @throws IotException 当内容对象为空或包含空白的必填字段时抛出
      */
     private void validateReceiptContent(PrintReceiptContent content) {
-        // 1. 判空检查:确保传入的内容对象本身不为 null
+        // 1. 基础非空校验:确保传入的内容对象本身不为 null
         if (content == null) {
             throw new IotException("小票内容不能为空");
         }
 
-        // 2. 定义必填字段列表:将字段名与其对应的值封装为键值对,便于统一处理
+        // 2. 定义必填字段列表:使用 SimpleEntry 映射字段名与其对应的值,便于后续定位缺失字段
         List<AbstractMap.SimpleEntry<String, String>> requiredFields = List.of(
                 new AbstractMap.SimpleEntry<>("companyName", content.getCompanyName()),       // 公司名称
                 new AbstractMap.SimpleEntry<>("customerName", content.getCustomerName()),     // 客户名称
@@ -270,20 +322,20 @@ public class XpCloudPrintService {
                 new AbstractMap.SimpleEntry<>("copyLabel", content.getCopyLabel())            // 联次标签(如客户联)
         );
 
-        // 3. 查找第一个为空的必填字段
-        // 使用 Stream API 过滤出值为空白字符串的字段,并获取其字段名
+        // 3. 流式处理查找第一个为空的字段:
+        //    - filter: 筛选出值为空白(null 或 empty)的字段
+        //    - map: 提取字段名(Key)
+        //    - findFirst: 获取第一个缺失的字段名
         String blankField = requiredFields.stream()
                 .filter(field -> StringUtils.isBlank(field.getValue()))
                 .map(AbstractMap.SimpleEntry::getKey)
                 .findFirst()
                 .orElse(null);
 
-        // 4. 如果存在空字段,根据业务需求决定是否抛出异常
-        // 当前逻辑中异常抛出被注释,若需严格校验请取消注释
+        // 4. 如果存在空白字段,则抛出业务异常,提示具体缺失的字段名
         if (StringUtils.isNotBlank(blankField)) {
-            // 记录警告日志,提示哪个字段缺失,方便排查数据源问题
-            log.warn("小票内容字段缺失: {}", blankField);
-            // throw new IotException("小票内容字段不能为空:" + blankField);
+            log.error("小票内容字段不能为空: {}", blankField);
+            //throw new IotException("小票内容字段不能为空: " + blankField);
         }
     }
 
@@ -297,79 +349,63 @@ public class XpCloudPrintService {
     /**
      * 将远程运输信息重新组装为小票打印模板参数。
      * <p>
-     * 该方法负责从复杂的运输信息响应对象中提取关键业务数据,并将其映射到小票打印内容对象中。
-     * 处理逻辑包括:
-     * 1. 校验运输信息非空。
-     * 2. 根据任务单号解析具体的任务信息(支持多任务场景)。
-     * 3. 提取司机、货物、车辆等子模块信息,并进行空指针保护。
-     * 4. 格式化重量数据及汇总信息。
-     * 5. 设置固定的联次标签。
+     * 该方法从 {@link TradeOrderTransportInfoResp} 中提取任务、司机、货物、车辆等关键业务数据,
+     * 并格式化为 {@link PrintReceiptContent} 对象,供后续生成小票 XML 使用。
      *
-     * @param transportInfo 远程获取的运单运输详细信息
-     * @param taskNo        当前需要打印的任务单号,用于在多个任务中定位具体任务
+     * @param transportInfo 远程获取的运输订单详细信息
+     * @param taskNo        当前需要打印的任务单号,用于匹配具体的任务信息
      * @return 组装好的小票打印内容对象
      * @throws IotException 当运输信息为空时抛出异常
      */
     private PrintReceiptContent assembleReceiptContent(TradeOrderTransportInfoResp transportInfo, String taskNo) {
-        // 1. 基础校验:确保上游返回的运输信息对象不为 null
+        // 1. 基础校验:确保传入的运输信息对象不为 null
         if (transportInfo == null) {
             throw new IotException("小票运输信息不能为空");
         }
 
-        // 2. 解析任务信息:从运输信息中根据 taskNo 匹配具体的任务详情
+        // 2. 解析任务信息:根据 taskNo 从运输信息中匹配对应的任务详情
         TradeOrderTransportInfoResp.TaskInfo taskInfo = resolveTaskInfo(transportInfo, taskNo);
 
-        // 3. 提取子模块信息:获取司机、货物、车辆及打印相关信息
-        // 注意:这些子对象可能为 null,后续赋值时需做判空处理
+        // 3. 提取子模块信息:司机、货物、车辆、打印信息(可能为 null,需做空指针保护)
         TradeOrderTransportInfoResp.DriverInfo driverInfo = taskInfo.getDriverInfo();
         TradeOrderTransportInfoResp.GoodsInfo goodsInfo = taskInfo.getGoodsInfo();
         TradeOrderTransportInfoResp.TruckInfo truckInfo = taskInfo.getTruckInfo();
-        // printInfo 在当前实现中未直接使用,但保留获取以便后续扩展
+        // 注:printInfo 在当前逻辑中未直接使用,但保留获取以便后续扩展
         TradeOrderTransportInfoResp.PrintInfo printInfo = taskInfo.getPrintInfo();
 
-        // 4. 初始化小票内容对象并填充字段
+        // 4. 构建打印内容对象
         PrintReceiptContent content = new PrintReceiptContent();
 
-        // --- 基础业务信息 ---
-        // 设置供应商名称作为公司名称
-        content.setCompanyName(transportInfo.getSupplierName());
-        // 设置客户名称
-        content.setCustomerName(transportInfo.getCustomerName());
-
-        // --- 任务核心信息 ---
-        // 设置任务编号
-        content.setTaskNo(taskInfo.getTaskNo());
-        // 设置接单时间
-        content.setAcceptTime(taskInfo.getAcceptTime());
-        // 设置完成时间
-        content.setFinishTime(taskInfo.getFinishTime());
-        // 设置计重人姓名
-        content.setWeigher(taskInfo.getWeigherName());
-
-        // --- 司机信息 (需判空) ---
-        // 设置司机姓名
-        content.setDriverName(driverInfo == null ? null : driverInfo.getName());
-        // 设置司机手机号
-        content.setDriverMobile(driverInfo == null ? null : driverInfo.getPhone());
-        // 设置司机身份证号
-        content.setDriverIdCard(driverInfo == null ? null : driverInfo.getIdCard());
-
-        // --- 货物/物料信息 (需判空) ---
-        // 设置物料名称
-        content.setMaterialName(goodsInfo == null ? null : goodsInfo.getMaterialName());
-        // 设置物料规格
-        content.setMaterialSpec(goodsInfo == null ? null : goodsInfo.getSpecification());
-        // 设置任务重量:格式化重量数值与单位
-        content.setTaskWeight(formatWeight(goodsInfo == null ? null : goodsInfo.getTaskAmount(),
-                goodsInfo == null ? null : goodsInfo.getUnit()));
-        // 设置重量汇总:包含皮重、毛重、净重的组合字符串
+        // 5. 填充基础业务信息(来自运输订单层级)
+        content.setCompanyName(transportInfo.getSupplierName()); // 供应商/公司名称
+        content.setCustomerName(transportInfo.getCustomerName()); // 客户名称
+
+        // 6. 填充任务层级信息
+        content.setTaskNo(taskInfo.getTaskNo());       // 任务单号
+        content.setAcceptTime(taskInfo.getAcceptTime()); // 接单时间
+        content.setFinishTime(taskInfo.getFinishTime()); // 完成时间
+        content.setWeigher(taskInfo.getWeigherName());   // 计重人姓名
+        content.setDestination(taskInfo.getDestination()); // 目的地
+
+        // 7. 填充司机信息(需判空处理)
+        content.setDriverName(driverInfo == null ? null : driverInfo.getName());     // 司机姓名
+        content.setDriverMobile(driverInfo == null ? null : driverInfo.getPhone());   // 司机手机号
+        content.setDriverIdCard(driverInfo == null ? null : driverInfo.getIdCard());  // 司机身份证
+
+        // 8. 填充货物及重量信息(需判空处理)
+        content.setMaterialName(goodsInfo == null ? null : goodsInfo.getMaterialName()); // 物料名称
+        content.setMaterialSpec(goodsInfo == null ? null : goodsInfo.getSpecification()); // 物料规格
+        // 格式化任务重量(数值+单位)
+        content.setTaskWeight(formatWeight(
+                goodsInfo == null ? null : goodsInfo.getTaskAmount(),
+                goodsInfo == null ? null : goodsInfo.getUnit()
+        ));
+        // 格式化重量汇总(皮重/毛重/净重)
         content.setWeightSummary(formatWeightSummary(goodsInfo));
 
-        // --- 车辆信息 (需判空) ---
-        // 设置车牌号
-        content.setPlateNo(truckInfo == null ? null : truckInfo.getTruckNo());
-        // 设置车辆轴数描述
-        content.setVehicleAxleDesc(truckInfo == null ? null : truckInfo.getTruckAxle());
+        // 9. 填充车辆信息(需判空处理)
+        content.setPlateNo(truckInfo == null ? null : truckInfo.getTruckNo());       // 车牌号
+        content.setVehicleAxleDesc(truckInfo == null ? null : truckInfo.getTruckAxle()); // 车辆轴数描述
 
         // --- 其他信息 ---
         // 设置目的地
@@ -377,6 +413,7 @@ public class XpCloudPrintService {
         // 设置联次标签,固定为"客户联"
         content.setCopyLabel("客户联");
 
+        // 11. 返回组装完成的内容对象
         return content;
     }
 
@@ -388,27 +425,24 @@ public class XpCloudPrintService {
      * @return 任务信息
      */
     /**
-     * 根据任务单号从运输信息中解析具体的任务详情
+     * 根据任务单号解析并匹配具体的任务信息
      * <p>
-     * 处理逻辑:
-     * 1. 获取任务列表,若为空则使用空列表防止 NPE。
-     * 2. 校验任务列表非空,若为空则抛出异常。
-     * 3. 优先尝试通过 taskNo 精确匹配任务。
-     * 4. 若未匹配到指定任务,则降级策略:返回列表中的第一个有效任务(兜底逻辑)。
-     * 5. 若列表中无任何有效任务,则抛出异常。
+     * 该方法优先尝试从运输信息列表中查找与给定 {@code taskNo} 完全匹配的任务。
+     * 如果未找到精确匹配的任务,则作为降级策略,返回列表中的第一个非空任务。
+     * 如果列表为空或所有任务均为 null,则抛出业务异常。
      *
-     * @param transportInfo 远程获取的运单运输详细信息
-     * @param taskNo        需要匹配的任务单号
+     * @param transportInfo 远程获取的运输订单详细信息,包含任务列表
+     * @param taskNo        需要匹配的目标任务单号
      * @return 匹配到的任务信息对象
-     * @throws IotException 当任务列表为空或无法找到任何有效任务时抛出
+     * @throws IotException 当任务列表为空、无有效任务或无法找到任何可用任务时抛出
      */
     private TradeOrderTransportInfoResp.TaskInfo resolveTaskInfo(TradeOrderTransportInfoResp transportInfo, String taskNo) {
-        // 1. 安全获取任务列表,避免空指针异常
+        // 1. 安全获取任务列表:若运输信息中的任务列表为 null,则初始化为空列表,避免 NPE
         List<TradeOrderTransportInfoResp.TaskInfo> tasks = transportInfo.getTasks() == null
                 ? Collections.emptyList()
                 : transportInfo.getTasks();
 
-        // 2. 基础校验:确保任务列表不为空
+        // 2. 校验任务列表是否为空:若没有任何任务数据,直接抛出异常
         if (tasks.isEmpty()) {
             throw new IotException("小票任务信息不能为空");
         }
@@ -420,9 +454,11 @@ public class XpCloudPrintService {
         return tasks.stream()
                 .filter(taskInfo -> taskInfo != null && StringUtils.equals(taskNo, taskInfo.getTaskNo()))
                 .findFirst()
+                // 4. 降级策略:若未找到精确匹配的任务,则取列表中第一个非空的任务作为默认值
                 .orElse(tasks.stream()
                         .filter(Objects::nonNull)
                         .findFirst()
+                        // 5. 最终兜底:若连一个非空任务都找不到,则抛出异常
                         .orElseThrow(() -> new IotException("小票任务信息不能为空")));
     }
 
@@ -523,30 +559,28 @@ public class XpCloudPrintService {
     /**
      * 构建芯烨开放平台鉴权三元组:user、10 位秒级时间戳、sign(SHA1 小写 40 位)。
      * <p>
-     * 签名算法说明:
-     * sign = SHA1(user + userKey + timestamp)
-     * 其中 userKey 为开发者密钥,timestamp 为当前时间的秒级时间戳。
+     * 该方法用于生成调用芯烨云打印接口所需的身份验证信息。
+     * 签名算法为:SHA1(user + userKey + timestamp),确保请求的合法性和时效性。
      *
      * @return 包含 [user, timestamp, sign] 的字符串数组
-     * @throws IotException 当配置项 user 或 userKey 缺失时抛出
+     * @throws IotException 当配置文件中缺失 user 或 userKey 时抛出异常
      */
     private String[] buildXpAuthTriple() {
-        // 1. 校验核心配置项是否完整,避免后续请求因缺少鉴权信息而失败
+        // 1. 校验核心配置项:确保用户名和用户密钥已正确配置,避免后续签名计算失败
         if (StringUtils.isAnyBlank(xpCloudProperties.getUser(), xpCloudProperties.getUserKey())) {
             throw new IotException("芯烨配置不完整,请配置xp.dev.user和xp.dev.user-key");
         }
 
-        // 2. 获取配置中的用户标识 (user)
+        // 2. 获取配置中的用户标识
         String user = xpCloudProperties.getUser();
 
-        // 3. 生成当前时间的秒级时间戳 (timestamp),用于防止重放攻击及服务器端校验时效性
+        // 3. 生成当前时间的秒级时间戳(10位),用于防止重放攻击及满足接口时效要求
         String timestamp = String.valueOf(Instant.now().getEpochSecond());
 
-        // 4. 计算签名 (sign)
-        // 拼接规则:user + userKey + timestamp,然后进行 SHA1 哈希计算并转为小写十六进制字符串
+        // 4. 计算签名:按照芯烨云规范,将 user、userKey 和 timestamp 拼接后进行 SHA1 哈希计算
         String sign = sha1Hex(user + xpCloudProperties.getUserKey() + timestamp);
 
-        // 5. 返回鉴权所需的三个关键参数
+        // 5. 返回鉴权三元组,供上层业务组装请求参数使用
         return new String[]{user, timestamp, sign};
     }
 
@@ -560,56 +594,56 @@ public class XpCloudPrintService {
     /**
      * 构建芯烨云小票打印接口的 JSON 请求体。
      * <p>
-     * 该方法负责组装调用芯烨云开放平台 {@code /xprinter/print} 接口所需的所有参数,
-     * 包括鉴权信息、打印机标识、打印内容以及业务控制参数。
+     * 该方法将业务参数、鉴权信息、打印内容及配置项组装成符合芯烨云开放平台规范的 JSON 对象。
+     * 包含用户鉴权(user/timestamp/sign)、打印机序列号、打印内容(XML格式)、份数、模式等核心字段,
+     * 并根据配置动态添加调试标识及语音播报设置。
      *
-     * @param reqVo      小票打印业务请求参数,包含打印机 SN、份数、模式、幂等键等
-     * @param xmlContent 已组装好的小票 XML 格式打印内容
-     * @return 封装好的 JSON 对象,可直接用于 HTTP POST 请求
+     * @param reqVo      小票打印业务请求参数,包含打印机SN、份数、模式、幂等键等
+     * @param xmlContent 已组装好的小票打印 XML 内容字符串
+     * @return 完整的 JSON 请求对象,可直接用于 HTTP POST 请求
      */
     private JSONObject buildReceiptPrintJsonBody(XpPrintReceiptReqVo reqVo, String xmlContent) {
-        // 1. 初始化 JSON 对象,使用有序 Map 保持字段插入顺序(可选,便于调试查看)
+        // 1. 初始化 JSON 对象,使用 LinkedHashMap 保持插入顺序(可选,便于日志查看)
         JSONObject body = new JSONObject(true);
 
-        // 2. 获取鉴权三元组:user, timestamp, sign
-        // 签名算法基于 user + userKey + timestamp 的 SHA1 哈希
+        // 2. 获取鉴权三元组(user, timestamp, sign)并填入请求体
         String[] auth = buildXpAuthTriple();
-        body.put("user", auth[0]);
-        body.put("timestamp", auth[1]);
-        body.put("sign", auth[2]);
+        body.put("user", auth[0]);      // 开发者账号
+        body.put("timestamp", auth[1]); // 时间戳
+        body.put("sign", auth[2]);      // 签名
 
-        // 3. 调试模式开关:如果配置中开启了 debug,则添加 debug 参数
+        // 3. 根据配置决定是否开启调试模式
         if (Boolean.TRUE.equals(xpCloudProperties.getDebug())) {
             body.put("debug", "1");
         }
 
-        // 4. 核心业务参数
-        // 打印机设备编号 (SN)
-        body.put("sn", reqVo.getSn());
-        // 打印内容,通常为 XML 或 ESC/POS 指令字符串
-        body.put("content", xmlContent);
+        // 4. 填充核心业务参数
+        body.put("sn", reqVo.getSn());       // 打印机终端编号
+        body.put("content", xmlContent);     // 打印内容(XML 格式)
 
-        // 5. 打印份数:默认为 1 份,若请求中指定则使用指定值
+        // 5. 处理打印份数:若未指定则默认为 1 份
         int copies = reqVo.getCopies() != null ? reqVo.getCopies() : 1;
         body.put("copies", copies);
 
-        // 6. 可选业务参数:仅在非空时添加,避免发送无用字段
-        // 打印模式:0-云端模式(默认), 1-离线模式(需配合 expiresIn)
+        // 6. 可选参数:打印模式(0:云端排队, 1:即时打印)
         if (reqVo.getMode() != null) {
             body.put("mode", reqVo.getMode());
         }
-        // 幂等键:防止网络重试导致的重复打印任务
+
+        // 7. 可选参数:幂等性标识,防止网络重试导致的重复打印
         if (StringUtils.isNotBlank(reqVo.getIdempotent())) {
             body.put("idempotent", reqVo.getIdempotent());
         }
-        // 过期时间:仅在离线模式(mode=1)下有效,单位秒
+
+        // 8. 可选参数:订单过期时间(秒),仅在 mode=1 时有效
         if (reqVo.getExpiresIn() != null) {
             body.put("expiresIn", reqVo.getExpiresIn());
         }
 
-        // 7. 语音播报设置:固定设置为 "2" (具体含义参考芯烨文档,通常为开启或特定提示音)
+        // 9. 固定设置:语音播报类型(2:不播报,具体值参考芯烨文档)
         body.put("voice", "2");
 
+        // 10. 返回组装完成的请求体
         return body;
     }
 
@@ -633,67 +667,74 @@ public class XpCloudPrintService {
      *    - 检查返回的数据字段 (data) 即订单号是否非空。
      * 5. 异常处理:捕获并重新抛出业务异常 (IotException),或捕获其他异常记录日志后包装抛出。
      *
-     * @param body 包含鉴权信息、打印机 SN、打印内容等的 JSON 请求体
-     * @return 芯烨云平台返回的打印订单号 (orderNo)
-     * @throws IotException 当配置缺失、HTTP 请求失败、业务返回错误或订单号为空时抛出
+     * @param body 包含鉴权信息及打印内容的 JSON 请求体
+     * @return 芯烨云返回的打印订单号
+     * @throws IotException 当配置缺失、HTTP 请求失败、业务返回错误或数据为空时抛出异常
      */
     private String postReceiptPrint(JSONObject body) {
-        // 1. 获取并校验打印接口地址配置
+        // 1. 获取配置中的小票打印接口地址
         String url = xpCloudProperties.getReceiptPrintUrl();
+
+        // 2. 校验接口地址是否已配置,避免空指针或无效请求
         if (StringUtils.isBlank(url)) {
             throw new IotException("芯烨云小票打印接口地址未配置,请配置 xp.dev.receipt-print-url");
         }
 
         try {
-            // 2. 构建 HTTP 请求头,指定内容类型为 JSON UTF-8
+            // 3. 构建 HTTP 请求头,指定内容类型为 application/json,编码为 UTF-8
             HttpHeaders headers = new HttpHeaders();
             headers.setContentType(new MediaType("application", "json", StandardCharsets.UTF_8));
 
-            // 3. 将 JSON 对象转换为字符串并封装为 HttpEntity
+            // 4. 将 JSON 对象转换为字符串,并封装为 HttpEntity
             HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
 
-            // 4. 执行 POST 请求,获取原始字符串响应
+            // 5. 使用 RestTemplate 发送 POST 请求,接收字符串类型的响应
             ResponseEntity<String> responseEntity = xpOpenApiRestTemplate.postForEntity(url, entity, String.class);
 
-            // 5. 记录完整响应日志,便于调试和排查问题
+            // 6. 记录完整响应日志,便于后续排查问题(注意:生产环境若响应过大需评估性能影响)
             log.info("芯烨云小票打印响应参数: {}", JSON.toJSONString(responseEntity));
 
-            // 6. 校验 HTTP 状态码,确保请求在传输层成功
+            // 7. 校验 HTTP 状态码,确保是 2xx 成功状态
             if (!responseEntity.getStatusCode().is2xxSuccessful()) {
                 throw new IotException("芯烨云小票打印HTTP异常, status=" + responseEntity.getStatusCode());
             }
 
-            // 7. 获取响应体内容
+            // 8. 获取响应体原始字符串
             String raw = responseEntity.getBody();
+
+            // 9. 校验响应体是否为空
             if (StringUtils.isBlank(raw)) {
                 throw new IotException("芯烨云小票打印返回空响应");
             }
 
-            // 8. 解析 JSON 响应结果
+            // 10. 将响应字符串解析为 JSONObject
             JSONObject resp = JSON.parseObject(raw);
+
+            // 11. 提取业务状态码和消息
             Integer code = resp.getInteger("code");
             String msg = resp.getString("msg");
 
-            // 9. 校验业务状态码,0 表示成功,其他值表示业务错误
+            // 12. 校验业务状态码:code 不为 null 且等于 0 表示业务成功
             if (code == null || code != 0) {
                 throw new IotException("调用芯烨云小票打印失败:" + msg);
             }
 
-            // 10. 提取并校验返回的订单号 (data 字段)
+            // 13. 提取业务数据字段(即打印订单号)
             String data = resp.getString("data");
+
+            // 14. 校验订单号是否为空,确保后续流程有迹可循
             if (StringUtils.isBlank(data)) {
                 throw new IotException("芯烨云小票打印成功但订单号为空");
             }
 
-            // 11. 返回成功的订单号
+            // 15. 返回成功的打印订单号
             return data;
-
         } catch (IotException e) {
             // 业务异常直接抛出,保持原始错误信息
             log.error("调用芯烨云小票打印业务异常, url={}", url, e);
             throw new IotException(ErrorCodeEnum.INTERFACE_CALL_FAIL, e.getErrorMsg(), e);
         } catch (Exception e) {
-            // 捕获网络异常、JSON 解析异常等其他未预期异常
+            // 捕获网络异常、序列化异常等非业务异常,记录详细堆栈并转换为业务异常
             log.error("芯烨云小票打印HTTP调用异常, url={}", url, e);
             throw new IotException("调用芯烨云小票打印HTTP异常:" + e.getMessage());
         }
@@ -703,13 +744,16 @@ public class XpCloudPrintService {
      * 计算SHA1(小写16进制)
      */
     /**
-     * 计算字符串的 SHA-1 哈希值,并转换为小写十六进制字符串。
+     * 计算字符串的 SHA-1 哈希值,并返回小写十六进制字符串。
      * <p>
-     * 该方法用于生成芯烨云开放平台接口请求所需的签名 (sign)。
-     * 签名算法通常为:SHA1(user + userKey + timestamp)。
+     * 该方法用于生成芯烨云开放平台接口调用所需的签名(sign)。
+     * 算法流程:
+     * 1. 使用 UTF-8 编码将输入字符串转换为字节数组。
+     * 2. 使用 SHA-1 算法对字节数组进行摘要计算。
+     * 3. 将摘要结果的每个字节转换为两位小写十六进制字符串并拼接。
      *
-     * @param source 待计算的原始字符串
-     * @return 小写十六进制格式的 SHA-1 哈希字符串
+     * @param source 待计算签名的原始字符串(通常由 user + userKey + timestamp 拼接而成)
+     * @return SHA-1 哈希值的小写十六进制字符串表示(40位字符)
      * @throws IotException 当 SHA-1 算法不可用或计算过程中发生异常时抛出
      */
     private String sha1Hex(String source) {
@@ -717,19 +761,20 @@ public class XpCloudPrintService {
             // 1. 获取 SHA-1 消息摘要实例
             MessageDigest messageDigest = MessageDigest.getInstance("SHA-1");
             
-            // 2. 使用 UTF-8 编码将字符串转换为字节数组,并计算摘要
+            // 2. 使用 UTF-8 编码将源字符串转换为字节数组,并进行摘要计算
             byte[] digestBytes = messageDigest.digest(source.getBytes(StandardCharsets.UTF_8));
             
             // 3. 将字节数组转换为小写十六进制字符串
             StringBuilder hex = new StringBuilder(digestBytes.length * 2);
             for (byte digestByte : digestBytes) {
-                // %02x 确保每个字节都格式化为两位十六进制数,不足补零
+                // %02x 确保每个字节都转换为两位十六进制数,不足两位前补零
                 hex.append(String.format("%02x", digestByte));
             }
             
+            // 4. 返回最终的十六进制签名字符串
             return hex.toString();
         } catch (Exception e) {
-            // 捕获可能的 NoSuchAlgorithmException 或其他异常,包装为业务异常抛出
+            // 捕获 NoSuchAlgorithmException 等异常,转换为业务异常抛出
             throw new IotException("SHA1计算失败:" + e.getMessage());
         }
     }

+ 44 - 0
iot-platform-manager/src/test/java/com/platform/api/manager/UploadServiceTest.java

@@ -0,0 +1,44 @@
+package com.platform.api.manager;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class UploadServiceTest {
+
+    @Test
+    void processBase64ImagesToLocalAsyncShouldExtractDataUrlPayload() throws Exception {
+        UploadService uploadService = new UploadService();
+        Path storagePath = Files.createTempDirectory("weighbridge-images");
+        ReflectionTestUtils.setField(uploadService, "imageStoragePath", storagePath.toString());
+
+        String image = "\"data:image/png;base64,iVBORw0KGgo=\\n\"]}";
+        String savedPath = uploadService.processBase64ImagesToLocalAsync(List.of(image), "test")
+                .get();
+
+        Path path = Path.of(savedPath);
+        assertTrue(Files.exists(path));
+        assertTrue(path.getFileName().toString().endsWith(".png"));
+        assertArrayEquals(new byte[]{
+                (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A
+        }, Files.readAllBytes(path));
+    }
+
+    @Test
+    void processBase64ImagesToLocalAsyncShouldHandleSpaceConvertedPlusSign() throws Exception {
+        UploadService uploadService = new UploadService();
+        Path storagePath = Files.createTempDirectory("weighbridge-images");
+        ReflectionTestUtils.setField(uploadService, "imageStoragePath", storagePath.toString());
+
+        String savedPath = uploadService.processBase64ImagesToLocalAsync(List.of("data:image/jpeg;base64, w=="), "test")
+                .get();
+
+        assertArrayEquals(new byte[]{(byte) 0xFB}, Files.readAllBytes(Path.of(savedPath)));
+    }
+}