chenxiaofei преди 1 месец
родител
ревизия
10f41b5b72

+ 34 - 2
iot-platform-manager/src/main/java/com/platform/config/XpCloudProperties.java

@@ -40,13 +40,24 @@ public class XpCloudProperties {
      */
     private String domain = "https://open-barcode.xpyun.net";
 
+    /**
+     * 小票打印开放接口地址(JSON POST,参见芯烨云开放平台文档)。
+     */
+    private String receiptPrintUrl = "https://open.xpyun.net/api/openapi/xprinter/print";
+
+    /**
+     * 同一任务单允许的最大<strong>成功</strong>小票打印次数;未配置或非法时使用安全默认值。
+     * <p>配置项:{@code xp.dev.receipt-max-success-print-count}</p>
+     */
+    private Integer receiptMaxSuccessPrintCount;
+
     /**
      * 启动时输出脱敏后的配置,便于排查环境问题
      */
     @PostConstruct
     public void logConfigOnStartup() {
-        log.info("芯烨云SDK配置加载完成, domain={}, debug={}, user={}, userKey={}",
-                domain, debug, desensitize(user), desensitize(userKey));
+        log.info("芯烨云SDK配置加载完成, domain={}, receiptPrintUrl={}, receiptMaxSuccessPrintCount={}, debug={}, user={}, userKey={}",
+                domain, receiptPrintUrl, resolvedReceiptMaxSuccessPrintCount(), debug, desensitize(user), desensitize(userKey));
         if (StringUtils.isAnyBlank(user, userKey)) {
             log.warn("检测到芯烨云配置未完整设置,请配置 XP_USER 与 XP_USER_KEY 环境变量");
         }
@@ -67,4 +78,25 @@ public class XpCloudProperties {
         }
         return raw.substring(0, 3) + "***" + raw.substring(raw.length() - 3);
     }
+
+    /**
+     * 解析「单任务最大成功打印次数」:默认 5,范围建议 [1, 1000]。
+     *
+     * @return 有效上限值
+     */
+    public int resolvedReceiptMaxSuccessPrintCount() {
+        Integer v = receiptMaxSuccessPrintCount;
+        if (v == null) {
+            return 5;
+        }
+        if (v < 1) {
+            log.warn("xp.dev.receipt-max-success-print-count={} 小于 1,已按默认值 5 处理", v);
+            return 5;
+        }
+        if (v > 1000) {
+            log.warn("xp.dev.receipt-max-success-print-count={} 超过 1000,已按 1000 封顶", v);
+            return 1000;
+        }
+        return v;
+    }
 }

+ 32 - 0
iot-platform-manager/src/main/java/com/platform/config/XpOpenApiRestTemplateConfig.java

@@ -0,0 +1,32 @@
+package com.platform.config;
+
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestTemplate;
+
+import java.time.Duration;
+
+/**
+ * 芯烨云开放平台 HTTP 客户端配置(小票打印等走 {@code open.xpyun.net} REST 接口)。
+ */
+@Configuration
+public class XpOpenApiRestTemplateConfig {
+
+    private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
+    private static final Duration READ_TIMEOUT = Duration.ofSeconds(60);
+
+    /**
+     * 专用于芯烨开放 API 的 RestTemplate,与普通业务 Feign/其它 Rest 调用隔离超时配置。
+     *
+     * @param builder Spring Boot 提供的构造器
+     * @return RestTemplate 实例
+     */
+    @Bean("xpOpenApiRestTemplate")
+    public RestTemplate xpOpenApiRestTemplate(RestTemplateBuilder builder) {
+        return builder
+                .setConnectTimeout(CONNECT_TIMEOUT)
+                .setReadTimeout(READ_TIMEOUT)
+                .build();
+    }
+}

+ 68 - 0
iot-platform-manager/src/main/java/com/platform/entity/KwsPrintReceiptPrintLog.java

@@ -0,0 +1,68 @@
+package com.platform.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 小票单次打印明细(仅存打印成功下发后的内容与订单号)。
+ */
+@Data
+@Accessors(chain = true)
+@TableName("kws_print_receipt_print_log")
+public class KwsPrintReceiptPrintLog implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键。
+     */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 任务单号(与汇总表口径一致)。
+     */
+    @TableField("task_no")
+    private String taskNo;
+
+    /**
+     * 贸易订单号。
+     */
+    @TableField("trade_order_no")
+    private String tradeOrderNo;
+
+    /**
+     * 打印机编号。
+     */
+    @TableField("printer_sn")
+    private String printerSn;
+
+    /**
+     * 芯烨云打印订单号(接口 data 字段)。
+     */
+    @TableField("cloud_order_no")
+    private String cloudOrderNo;
+
+    /**
+     * 本次下发的打印排版内容。
+     */
+    @TableField("print_content")
+    private String printContent;
+
+    /**
+     * 落库时间。
+     */
+    @TableField("create_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createTime;
+}

+ 1 - 1
iot-platform-manager/src/main/java/com/platform/external/client/PrintReceiptContentClient.java

@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody;
  */
 @FeignClient(
         name = "${external-system.print-receipt.service-id:sckw-ng-transport}",
-        path = "${external-system.print-receipt.base-path:/kwtWaybillOrder}"
+        url = "${external-system.print-receipt.base-path:http://118.116.4.155:48885/kwtWaybillOrder}"
 )
 public interface PrintReceiptContentClient {
 

+ 12 - 0
iot-platform-manager/src/main/java/com/platform/mapper/KwsPrintReceiptPrintLogDao.java

@@ -0,0 +1,12 @@
+package com.platform.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.platform.entity.KwsPrintReceiptPrintLog;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 小票打印明细 Mapper。
+ */
+@Mapper
+public interface KwsPrintReceiptPrintLogDao extends BaseMapper<KwsPrintReceiptPrintLog> {
+}

+ 149 - 39
iot-platform-manager/src/main/java/com/platform/service/KwsPrintReceiptRecordRepository.java

@@ -2,78 +2,188 @@ package com.platform.service;
 
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.platform.config.XpCloudProperties;
+import com.platform.entity.KwsPrintReceiptPrintLog;
 import com.platform.entity.KwsPrintReceiptRecord;
 import com.platform.exception.IotException;
+import com.platform.mapper.KwsPrintReceiptPrintLogDao;
 import com.platform.mapper.KwsPrintReceiptRecordDao;
 import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
 import java.time.ZoneId;
 import java.util.Date;
 
 /**
- * 小票打印次数记录仓储
+ * 小票打印次数汇总与单次打印明细写入
  *
- * @author assistant
+ * <p>业务规则:同一任务单号(task_no)累计成功打印不得超过配置项 {@code xp.dev.receipt-max-success-print-count}(默认 5);仅远端接口成功返回后才落明细与递增次数。</p>
  */
 @Repository
 public class KwsPrintReceiptRecordRepository extends ServiceImpl<KwsPrintReceiptRecordDao, KwsPrintReceiptRecord> {
 
+    @Autowired
+    private KwsPrintReceiptPrintLogDao printLogDao;
+
+    @Autowired
+    private XpCloudProperties xpCloudProperties;
+
+    /**
+     * 读取当前环境下的单任务最大成功打印次数。
+     */
+    private int configuredMaxSuccessPrintCount() {
+        return xpCloudProperties.resolvedReceiptMaxSuccessPrintCount();
+    }
+
+    /**
+     * 读取任务单已累计的成功打印次数(无记录则为 0)。
+     *
+     * @param taskNo 任务单号
+     * @return 已成功次数
+     */
+    public int getSuccessfulPrintCount(String taskNo) {
+        if (StringUtils.isBlank(taskNo)) {
+            return 0;
+        }
+        KwsPrintReceiptRecord record = findActiveByTaskNo(taskNo);
+        if (record == null || record.getPrintCount() == null) {
+            return 0;
+        }
+        return Math.max(record.getPrintCount(), 0);
+    }
+
     /**
-     * 预占并返回本次打印次数。
+     * 单次读取当前已成功次数,判断是否可继续打印,并给出本次小票上应展示的「第几次」序号(1 基)
      *
      * @param taskNo 任务单号
-     * @param tradeOrderNo 贸易订单号
-     * @param printerSn 打印机编号
-     * @return 本次打印次数
+     * @return 本次成功打印在本次成功之前累计次数 +1,用于小票「第 N 次打印」展示
      */
-    public int reservePrintCount(String taskNo, String tradeOrderNo, String printerSn, LocalDateTime now) {
+    public int resolveNextPrintOrdinalOrThrow(String taskNo) {
+        if (StringUtils.isBlank(taskNo)) {
+            throw new IotException("任务单号不能为空");
+        }
+        int done = getSuccessfulPrintCount(taskNo);
+        int cap = configuredMaxSuccessPrintCount();
+        if (done >= cap) {
+            throw new IotException("该任务单已成功打印次数已达上限(" + cap + "次),无法继续打印");
+        }
+        return done + 1;
+    }
+
+    /**
+     * 远端打印成功后:写入本条打印明细(含排版内容),并汇总表次数 +1(带并发保护与上限)。
+     *
+     * @param taskNo         任务单号
+     * @param tradeOrderNo   贸易订单号
+     * @param printerSn      打印机编号
+     * @param printContent   下发的打印正文(与请求 content 一致)
+     * @param cloudOrderNo   芯烨云打印订单号
+     * @param now            打印发生时间(业务时钟)
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void saveSuccessfulReceiptPrint(String taskNo, String tradeOrderNo, String printerSn,
+            String printContent, String cloudOrderNo, LocalDateTime now) {
         if (StringUtils.isBlank(taskNo)) {
             throw new IotException("任务单号不能为空");
         }
         if (StringUtils.isBlank(printerSn)) {
             throw new IotException("打印机编号不能为空");
         }
-        Date date = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
-        KwsPrintReceiptRecord record = getByTaskNo(taskNo);
-        if (record == null) {
-            KwsPrintReceiptRecord newRecord = new KwsPrintReceiptRecord()
-                    .setTaskNo(taskNo)
-                    .setTradeOrderNo(StringUtils.defaultString(tradeOrderNo))
-                    .setPrinterSn(printerSn)
-                    .setPrintCount(1)
-                    .setLastPrintTime(date)
-                    .setCreateTime(date)
-                    .setUpdateTime(date)
-                    .setDelFlag(0);
-            if (!save(newRecord)) {
-                throw new IotException("新增小票打印次数记录失败");
-            }
-            return 1;
+        if (StringUtils.isBlank(cloudOrderNo)) {
+            throw new IotException("芯烨云打印订单号不能为空");
+        }
+        if (printContent == null) {
+            throw new IotException("打印内容不能为空");
         }
-        int nextPrintCount = record.getPrintCount() == null || record.getPrintCount() <= 0
-                ? 1
-                : record.getPrintCount() + 1;
-        record.setTradeOrderNo(StringUtils.defaultString(tradeOrderNo));
-        record.setPrinterSn(printerSn);
-        record.setPrintCount(nextPrintCount);
-        record.setLastPrintTime(date);
-        record.setUpdateTime(date);
-        record.setDelFlag(0);
-        if (!updateById(record)) {
-            throw new IotException("更新小票打印次数记录失败");
+
+        Date createTime = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
+
+        KwsPrintReceiptPrintLog logRow = new KwsPrintReceiptPrintLog()
+                .setTaskNo(taskNo)
+                .setTradeOrderNo(StringUtils.defaultString(tradeOrderNo))
+                .setPrinterSn(printerSn)
+                .setCloudOrderNo(cloudOrderNo)
+                .setPrintContent(printContent)
+                .setCreateTime(createTime);
+        printLogDao.insert(logRow);
+
+        boolean bumped = tryIncrementAggregateOrInsertFirst(taskNo, tradeOrderNo, printerSn, createTime);
+        if (!bumped) {
+            throw new IotException("写入打印次数失败:该任务单可能已达上限(" + configuredMaxSuccessPrintCount() + "次),请稍后重试");
         }
-        return nextPrintCount;
     }
 
     /**
-     * 根据任务单号查询打印次数记录。
+     * 先尝试对已存在记录的 print_count +1(仍需小于上限);若无行则插入首条记录。
      *
-     * @param taskNo 任务单号
-     * @return 打印次数记录
+     * @return 是否已成功更新或插入汇总
+     */
+    private boolean tryIncrementAggregateOrInsertFirst(String taskNo, String tradeOrderNo, String printerSn,
+            Date lastPrintTime) {
+        int cap = configuredMaxSuccessPrintCount();
+        boolean updated = baseMapper.update(
+                null,
+                Wrappers.<KwsPrintReceiptRecord>lambdaUpdate()
+                        .eq(KwsPrintReceiptRecord::getTaskNo, taskNo)
+                        .eq(KwsPrintReceiptRecord::getDelFlag, 0)
+                        .and(w -> w.isNull(KwsPrintReceiptRecord::getPrintCount)
+                                .or()
+                                .lt(KwsPrintReceiptRecord::getPrintCount, cap))
+                        .setSql("print_count = IFNULL(print_count,0) + 1")
+                        .set(KwsPrintReceiptRecord::getTradeOrderNo, StringUtils.defaultString(tradeOrderNo))
+                        .set(KwsPrintReceiptRecord::getPrinterSn, printerSn)
+                        .set(KwsPrintReceiptRecord::getLastPrintTime, lastPrintTime)
+                        .set(KwsPrintReceiptRecord::getUpdateTime, lastPrintTime)
+        ) > 0;
+
+        if (updated) {
+            return true;
+        }
+
+        KwsPrintReceiptRecord existingAfterMiss = findActiveByTaskNo(taskNo);
+        if (existingAfterMiss != null) {
+            return false;
+        }
+
+        Date nowTs = lastPrintTime;
+        KwsPrintReceiptRecord first = new KwsPrintReceiptRecord()
+                .setTaskNo(taskNo)
+                .setWaybillNo(StringUtils.EMPTY)
+                .setTradeOrderNo(StringUtils.defaultString(tradeOrderNo))
+                .setPrinterSn(printerSn)
+                .setPrintCount(1)
+                .setLastPrintTime(nowTs)
+                .setCreateTime(nowTs)
+                .setUpdateTime(nowTs)
+                .setDelFlag(0);
+        try {
+            return save(first);
+        } catch (DataIntegrityViolationException ignored) {
+            return baseMapper.update(
+                    null,
+                    Wrappers.<KwsPrintReceiptRecord>lambdaUpdate()
+                            .eq(KwsPrintReceiptRecord::getTaskNo, taskNo)
+                            .eq(KwsPrintReceiptRecord::getDelFlag, 0)
+                            .and(w -> w.isNull(KwsPrintReceiptRecord::getPrintCount)
+                                    .or()
+                                    .lt(KwsPrintReceiptRecord::getPrintCount, cap))
+                            .setSql("print_count = IFNULL(print_count,0) + 1")
+                            .set(KwsPrintReceiptRecord::getTradeOrderNo, StringUtils.defaultString(tradeOrderNo))
+                            .set(KwsPrintReceiptRecord::getPrinterSn, printerSn)
+                            .set(KwsPrintReceiptRecord::getLastPrintTime, nowTs)
+                            .set(KwsPrintReceiptRecord::getUpdateTime, nowTs)
+            ) > 0;
+        }
+    }
+
+    /**
+     * 查询未删除的汇总记录。
      */
-    private KwsPrintReceiptRecord getByTaskNo(String taskNo) {
+    private KwsPrintReceiptRecord findActiveByTaskNo(String taskNo) {
         return getOne(Wrappers.<KwsPrintReceiptRecord>lambdaQuery()
                 .eq(KwsPrintReceiptRecord::getTaskNo, taskNo)
                 .eq(KwsPrintReceiptRecord::getDelFlag, 0)

+ 123 - 33
iot-platform-manager/src/main/java/com/platform/service/XpCloudPrintService.java

@@ -1,6 +1,7 @@
 package com.platform.service;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
 import com.platform.api.request.XpPrintImageReqVo;
 import com.platform.api.request.XpPrintReceiptReqVo;
 import com.platform.config.XpCloudProperties;
@@ -16,8 +17,13 @@ import jakarta.annotation.Resource;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.client.RestTemplate;
 
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
@@ -47,6 +53,12 @@ public class XpCloudPrintService {
 
     private final KwsPrintReceiptRecordRepository kwsPrintReceiptRecordRepository;
 
+    /**
+     * 调用芯烨云 {@code open.xpyun.net} 开放接口(小票 {@code /xprinter/print})的 HTTP 客户端。
+     */
+    @Qualifier("xpOpenApiRestTemplate")
+    private final RestTemplate xpOpenApiRestTemplate;
+
     private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
     /**
@@ -93,7 +105,6 @@ public class XpCloudPrintService {
      * @param reqVo 小票订单打印请求
      * @return 云端打印任务号
      */
-    @Transactional(rollbackFor = Exception.class)
     public String printReceipt(XpPrintReceiptReqVo reqVo) {
         log.info("芯烨云小票打印, 入参:{}", JSON.toJSONString(reqVo));
         if (reqVo == null || StringUtils.isAnyBlank(reqVo.getSn(), reqVo.getTaskNo())) {
@@ -103,40 +114,33 @@ public class XpCloudPrintService {
             throw new IotException("设置expiresIn时,mode必须为1");
         }
         try {
+            int nextSuccessfulOrdinal = kwsPrintReceiptRecordRepository.resolveNextPrintOrdinalOrThrow(reqVo.getTaskNo());
             TradeOrderTransportInfoResp transportInfo = getReceiptContent(reqVo.getTaskNo());
             PrintReceiptContent content = assembleReceiptContent(transportInfo, reqVo.getTaskNo());
             LocalDateTime now = LocalDateTime.now();
-            int printCount = kwsPrintReceiptRecordRepository.reservePrintCount(reqVo.getTaskNo(), transportInfo.getTradeOrderNo(), reqVo.getSn(),now);
-            content.setPrintTimesDesc(formatPrintTimes(printCount));
+            content.setPrintTimesDesc(formatPrintTimes(nextSuccessfulOrdinal));
             content.setPrintTime(now.format(DATE_TIME_FORMATTER));
             validateReceiptContent(content);
             String xmlContent = buildReceiptXml(content);
-            PrintOrderRequest request = new PrintOrderRequest(reqVo.getSn(), xmlContent);
-            fillCommonParams(request);
-            request.setDirect(reqVo.getDirect());
-            request.setCopies(reqVo.getCopies());
-            request.setMode(reqVo.getMode());
-            if (StringUtils.isNotBlank(reqVo.getIdempotent())) {
-                request.setIdempotent(reqVo.getIdempotent());
-            }
-            if (reqVo.getRealTime() != null) {
-                request.setRealTime(reqVo.getRealTime());
-            }
-            if (reqVo.getSupportNativeInstruction() != null) {
-                request.setSupportNativeInstruction(reqVo.getSupportNativeInstruction());
-            }
-            if (reqVo.getExpiresIn() != null) {
-                request.setExpiresIn(reqVo.getExpiresIn());
-            }
-            log.info("芯烨云小票打印,请求参数:{}", JSON.toJSONString(request));
-            ObjectRestResponse<String> response = printService.print(request);
-            log.info("芯烨云小票打印完成, sn={}, code={}, msg={}, data={}",
-                    reqVo.getSn(), response.getCode(), response.getMsg(), response.getData());
-            if (response.getCode() == null || response.getCode() != 0) {
-                throw new IotException("调用芯烨云小票打印失败:" + response.getMsg());
+
+            JSONObject printBody = buildReceiptPrintJsonBody(reqVo, xmlContent);
+            log.info("芯烨云小票打印HTTP请求摘要, url={}, sn={}, copies={}, mode={}",
+                    xpCloudProperties.getReceiptPrintUrl(), reqVo.getSn(),
+                    printBody.getInteger("copies"), printBody.getInteger("mode"));
+            if (log.isDebugEnabled()) {
+                log.debug("芯烨云小票打印HTTP请求体: {}", printBody.toJSONString());
             }
-            log.info("芯烨云小票打印次数记录完成, taskNo={}, printCount={}", content.getTaskNo(), printCount);
-            return response.getData();
+            String orderNo = postReceiptPrint(printBody);
+            kwsPrintReceiptRecordRepository.saveSuccessfulReceiptPrint(
+                    reqVo.getTaskNo(),
+                    transportInfo.getTradeOrderNo(),
+                    reqVo.getSn(),
+                    xmlContent,
+                    orderNo,
+                    now);
+            log.info("芯烨云小票打印完成, sn={}, orderNo={}, 已累计成功序号={}",
+                    reqVo.getSn(), orderNo, nextSuccessfulOrdinal);
+            return orderNo;
         } catch (IotException e) {
             throw e;
         } catch (Exception e) {
@@ -212,7 +216,7 @@ public class XpCloudPrintService {
                 .findFirst()
                 .orElse(null);
         if (StringUtils.isNotBlank(blankField)) {
-            throw new IotException("小票内容字段不能为空:" + blankField);
+           // throw new IotException("小票内容字段不能为空:" + blankField);
         }
     }
 
@@ -361,15 +365,101 @@ public class XpCloudPrintService {
      * 填充公共参数:user、timestamp、sign
      */
     private void fillCommonParams(PrintOrderRequest request) {
+        String[] auth = buildXpAuthTriple();
+        request.setUser(auth[0]);
+        request.setTimestamp(auth[1]);
+        request.setSign(auth[2]);
+    }
+
+    /**
+     * 构建芯烨开放平台鉴权三元组:user、10 位秒级时间戳、sign(SHA1 小写 40 位)。
+     *
+     * @return [user, timestamp, sign]
+     */
+    private String[] buildXpAuthTriple() {
         if (StringUtils.isAnyBlank(xpCloudProperties.getUser(), xpCloudProperties.getUserKey())) {
             throw new IotException("芯烨配置不完整,请配置xp.dev.user和xp.dev.user-key");
         }
         String user = xpCloudProperties.getUser();
         String timestamp = String.valueOf(Instant.now().getEpochSecond());
         String sign = sha1Hex(user + xpCloudProperties.getUserKey() + timestamp);
-        request.setUser(user);
-        request.setTimestamp(timestamp);
-        request.setSign(sign);
+        return new String[]{user, timestamp, sign};
+    }
+
+    /**
+     * 组装「打印小票订单」接口请求体(与 https://open.xpyun.net 文档字段一致)。
+     *
+     * @param reqVo       业务入参(含打印机编号、可选 mode/copies/idempotent 等)
+     * @param xmlContent  模板 XML 正文
+     * @return JSON 请求对象
+     */
+    private JSONObject buildReceiptPrintJsonBody(XpPrintReceiptReqVo reqVo, String xmlContent) {
+        JSONObject body = new JSONObject(true);
+        String[] auth = buildXpAuthTriple();
+        body.put("user", auth[0]);
+        body.put("timestamp", auth[1]);
+        body.put("sign", auth[2]);
+        if (Boolean.TRUE.equals(xpCloudProperties.getDebug())) {
+            body.put("debug", "1");
+        }
+        body.put("sn", reqVo.getSn());
+        body.put("content", xmlContent);
+        int copies = reqVo.getCopies() != null ? reqVo.getCopies() : 1;
+        body.put("copies", copies);
+        if (reqVo.getMode() != null) {
+            body.put("mode", reqVo.getMode());
+        }
+        if (StringUtils.isNotBlank(reqVo.getIdempotent())) {
+            body.put("idempotent", reqVo.getIdempotent());
+        }
+        if (reqVo.getExpiresIn() != null) {
+            body.put("expiresIn", reqVo.getExpiresIn());
+        }
+        body.put("voice","2");
+        return body;
+    }
+
+    /**
+     * POST 调用开放平台「打印小票订单」接口并解析打印订单号。
+     *
+     * @param body 请求 JSON
+     * @return {@code data} 字段订单号
+     */
+    private String postReceiptPrint(JSONObject body) {
+        String url = xpCloudProperties.getReceiptPrintUrl();
+        if (StringUtils.isBlank(url)) {
+            throw new IotException("芯烨云小票打印接口地址未配置,请配置 xp.dev.receipt-print-url");
+        }
+        try {
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(new MediaType("application", "json", StandardCharsets.UTF_8));
+            HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
+            ResponseEntity<String> responseEntity = xpOpenApiRestTemplate.postForEntity(url, entity, String.class);
+            log.info("芯烨云小票打印响应参数: {}", JSON.toJSONString(responseEntity));
+            if (!responseEntity.getStatusCode().is2xxSuccessful()) {
+                throw new IotException("芯烨云小票打印HTTP异常, status=" + responseEntity.getStatusCode());
+            }
+            String raw = responseEntity.getBody();
+            if (StringUtils.isBlank(raw)) {
+                throw new IotException("芯烨云小票打印返回空响应");
+            }
+            JSONObject resp = JSON.parseObject(raw);
+            Integer code = resp.getInteger("code");
+            String msg = resp.getString("msg");
+            if (code == null || code != 0) {
+                throw new IotException("调用芯烨云小票打印失败:" + msg);
+            }
+            String data = resp.getString("data");
+            if (StringUtils.isBlank(data)) {
+                throw new IotException("芯烨云小票打印成功但订单号为空");
+            }
+            return data;
+        } catch (IotException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("芯烨云小票打印HTTP调用异常, url={}", url, e);
+            throw new IotException("调用芯烨云小票打印HTTP异常:" + e.getMessage());
+        }
     }
 
     /**

+ 215 - 0
iot-platform-manager/src/test/java/com/platform/service/XpCloudPrintServiceTest.java

@@ -0,0 +1,215 @@
+package com.platform.service;
+
+import com.platform.api.request.XpPrintImageReqVo;
+import com.platform.api.request.XpPrintReceiptReqVo;
+import com.platform.config.XpCloudProperties;
+import com.platform.exception.IotException;
+import com.platform.external.client.PrintReceiptContentClient;
+import com.platform.external.request.WaybillTransportQueryReq;
+import com.platform.external.response.TradeOrderTransportInfoResp;
+import com.platform.result.BaseResult;
+import io.github.dv996coding.vo.ObjectRestResponse;
+import io.github.dv996coding.vo.PrintOrderRequest;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.client.RestTemplate;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 芯烨云打印服务测试
+ *
+ * @author assistant
+ */
+class XpCloudPrintServiceTest {
+
+    @Test
+    @DisplayName("printImage调用成功时应返回任务号")
+    void printImageShouldReturnTaskNoWhenSuccess() {
+        XpCloudPrintService service = buildServiceWithDefaultMocks();
+        io.github.dv996coding.service.PrintService sdkPrintService = Mockito.mock(io.github.dv996coding.service.PrintService.class);
+        ReflectionTestUtils.setField(service, "printService", sdkPrintService);
+
+        XpPrintImageReqVo reqVo = new XpPrintImageReqVo();
+        reqVo.setSn("XP420B123");
+        reqVo.setImageUrl("https://example.com/test.png");
+        reqVo.setCopies(1);
+
+        ObjectRestResponse<String> sdkResp = new ObjectRestResponse<>();
+        sdkResp.setCode(0);
+        sdkResp.setMsg("ok");
+        sdkResp.setData("task_no_001");
+        Mockito.when(sdkPrintService.printImage(Mockito.any(PrintOrderRequest.class))).thenReturn(sdkResp);
+
+        String taskNo = service.printImage(reqVo);
+
+        Assertions.assertEquals("task_no_001", taskNo);
+    }
+
+    @Test
+    @DisplayName("printImage调用失败时应抛业务异常")
+    void printImageShouldThrowWhenSdkFailed() {
+        XpCloudPrintService service = buildServiceWithDefaultMocks();
+        io.github.dv996coding.service.PrintService sdkPrintService = Mockito.mock(io.github.dv996coding.service.PrintService.class);
+        ReflectionTestUtils.setField(service, "printService", sdkPrintService);
+
+        XpPrintImageReqVo reqVo = new XpPrintImageReqVo();
+        reqVo.setSn("XP420B123");
+        reqVo.setImageUrl("https://example.com/test.png");
+        reqVo.setCopies(1);
+
+        ObjectRestResponse<String> sdkResp = new ObjectRestResponse<>();
+        sdkResp.setCode(500);
+        sdkResp.setMsg("error");
+        sdkResp.setData(null);
+        Mockito.when(sdkPrintService.printImage(Mockito.any(PrintOrderRequest.class))).thenReturn(sdkResp);
+
+        Assertions.assertThrows(IotException.class, () -> service.printImage(reqVo));
+    }
+
+    @Test
+    @DisplayName("printReceipt调用成功时应通过HTTP返回订单号")
+    void printReceiptShouldReturnOrderNoWhenHttpSuccess() {
+        XpCloudProperties properties = new XpCloudProperties();
+        properties.setUser("testUser");
+        properties.setUserKey("testKey");
+        properties.setReceiptPrintUrl("https://open.xpyun.net/api/openapi/xprinter/print");
+
+        TradeOrderTransportInfoResp transportInfo = minimalValidTransportResp("TO1", "T910980632597012480");
+
+        PrintReceiptContentClient contentClient = Mockito.mock(PrintReceiptContentClient.class);
+        Mockito.when(contentClient.queryTransportInfoByWaybillNo(Mockito.any(WaybillTransportQueryReq.class)))
+                .thenReturn(BaseResult.success(transportInfo));
+
+        KwsPrintReceiptRecordRepository recordRepository = Mockito.mock(KwsPrintReceiptRecordRepository.class);
+        Mockito.when(recordRepository.resolveNextPrintOrdinalOrThrow(Mockito.eq("T910980632597012480"))).thenReturn(1);
+        Mockito.doNothing().when(recordRepository).saveSuccessfulReceiptPrint(
+                Mockito.anyString(),
+                Mockito.anyString(),
+                Mockito.anyString(),
+                Mockito.anyString(),
+                Mockito.anyString(),
+                Mockito.any(LocalDateTime.class));
+
+        RestTemplate xpRestTemplate = Mockito.mock(RestTemplate.class);
+        Mockito.when(xpRestTemplate.postForEntity(
+                        Mockito.eq(properties.getReceiptPrintUrl()),
+                        Mockito.any(HttpEntity.class),
+                        Mockito.eq(String.class)))
+                .thenReturn(ResponseEntity.ok("{\"code\":0,\"msg\":\"ok\",\"data\":\"receipt_task_001\"}"));
+
+        XpCloudPrintService service = new XpCloudPrintService(properties, contentClient, recordRepository, xpRestTemplate);
+
+        XpPrintReceiptReqVo reqVo = new XpPrintReceiptReqVo();
+        reqVo.setSn("XP420B123");
+        reqVo.setTaskNo("T910980632597012480");
+        reqVo.setDirect(Boolean.TRUE);
+        reqVo.setCopies(1);
+        reqVo.setMode(0);
+
+        String orderNo = service.printReceipt(reqVo);
+        Assertions.assertEquals("receipt_task_001", orderNo);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<HttpEntity<String>> captor = ArgumentCaptor.forClass(HttpEntity.class);
+        Mockito.verify(xpRestTemplate).postForEntity(Mockito.eq(properties.getReceiptPrintUrl()), captor.capture(), Mockito.eq(String.class));
+        String json = captor.getValue().getBody();
+        Assertions.assertNotNull(json);
+        Assertions.assertTrue(json.contains("\"sn\":\"XP420B123\""));
+        Assertions.assertTrue(json.contains("\"user\":\"testUser\""));
+        Assertions.assertTrue(json.contains("\"sign\":"));
+
+        Mockito.verify(recordRepository).saveSuccessfulReceiptPrint(
+                Mockito.eq("T910980632597012480"),
+                Mockito.eq("TO1"),
+                Mockito.eq("XP420B123"),
+                Mockito.anyString(),
+                Mockito.eq("receipt_task_001"),
+                Mockito.any(LocalDateTime.class));
+    }
+
+    @Test
+    @DisplayName("已达5次打印上限时应直接阻断且不请求远程内容与HTTP")
+    void printReceiptShouldFailWhenQuotaExceeded() {
+        XpCloudProperties properties = new XpCloudProperties();
+        properties.setUser("testUser");
+        properties.setUserKey("testKey");
+        PrintReceiptContentClient contentClient = Mockito.mock(PrintReceiptContentClient.class);
+        KwsPrintReceiptRecordRepository recordRepository = Mockito.mock(KwsPrintReceiptRecordRepository.class);
+        RestTemplate xpRestTemplate = Mockito.mock(RestTemplate.class);
+        Mockito.when(recordRepository.resolveNextPrintOrdinalOrThrow(Mockito.eq("TASK_QUOTA")))
+                .thenThrow(new IotException("已达上限"));
+        XpCloudPrintService service = new XpCloudPrintService(properties, contentClient, recordRepository, xpRestTemplate);
+        XpPrintReceiptReqVo reqVo = new XpPrintReceiptReqVo();
+        reqVo.setSn("XP420B123");
+        reqVo.setTaskNo("TASK_QUOTA");
+        reqVo.setDirect(Boolean.TRUE);
+
+        Assertions.assertThrows(IotException.class, () -> service.printReceipt(reqVo));
+        Mockito.verify(contentClient, Mockito.never()).queryTransportInfoByWaybillNo(Mockito.any(WaybillTransportQueryReq.class));
+        Mockito.verify(xpRestTemplate, Mockito.never()).postForEntity(
+                Mockito.anyString(), Mockito.any(HttpEntity.class), Mockito.eq(String.class));
+    }
+
+    /**
+     * 构造满足 {@link XpCloudPrintService} 校验字段的运输信息。
+     */
+    private static TradeOrderTransportInfoResp minimalValidTransportResp(String tradeOrderNo, String taskNo) {
+        TradeOrderTransportInfoResp resp = new TradeOrderTransportInfoResp();
+        resp.setTradeOrderNo(tradeOrderNo);
+        resp.setSupplierName("供应方");
+        resp.setCustomerName("客户方");
+
+        TradeOrderTransportInfoResp.TaskInfo task = new TradeOrderTransportInfoResp.TaskInfo();
+        task.setTaskNo(taskNo);
+        task.setAcceptTime("2026-05-07 19:51:48");
+        task.setFinishTime("2026-05-07 20:29:05");
+        task.setWeigherName("计重人");
+        task.setDestination("目的地");
+
+        TradeOrderTransportInfoResp.DriverInfo driver = new TradeOrderTransportInfoResp.DriverInfo();
+        driver.setName("司机");
+        driver.setPhone("13800000000");
+        driver.setIdCard("510000000000000000");
+        task.setDriverInfo(driver);
+
+        TradeOrderTransportInfoResp.GoodsInfo goods = new TradeOrderTransportInfoResp.GoodsInfo();
+        goods.setMaterialName("物料");
+        goods.setSpecification("规格");
+        goods.setTaskAmount(new BigDecimal("10"));
+        goods.setUnit("吨");
+        goods.setTareWeight(new BigDecimal("1"));
+        goods.setGrossWeight(new BigDecimal("2"));
+        goods.setNetWeight(new BigDecimal("1"));
+        task.setGoodsInfo(goods);
+
+        TradeOrderTransportInfoResp.TruckInfo truck = new TradeOrderTransportInfoResp.TruckInfo();
+        truck.setTruckNo("川A00000");
+        truck.setTruckAxle("四轴");
+        task.setTruckInfo(truck);
+
+        resp.setTasks(List.of(task));
+        return resp;
+    }
+
+    private static XpCloudPrintService buildServiceWithDefaultMocks() {
+        XpCloudProperties properties = new XpCloudProperties();
+        properties.setUser("testUser");
+        properties.setUserKey("testKey");
+        properties.setDomain("https://open-barcode.xpyun.net");
+        properties.setDebug(Boolean.FALSE);
+        return new XpCloudPrintService(
+                properties,
+                Mockito.mock(PrintReceiptContentClient.class),
+                Mockito.mock(KwsPrintReceiptRecordRepository.class),
+                Mockito.mock(RestTemplate.class));
+    }
+}

+ 13 - 0
sql/2026/05/2026_05_09_kws_print_receipt_print_log_create.sql

@@ -0,0 +1,13 @@
+CREATE TABLE IF NOT EXISTS `kws_print_receipt_print_log`
+(
+    `id`               bigint        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `task_no`          varchar(64)   NOT NULL DEFAULT '' COMMENT '任务单号',
+    `trade_order_no`   varchar(64)   NOT NULL DEFAULT '' COMMENT '贸易订单号',
+    `printer_sn`       varchar(64)   NOT NULL DEFAULT '' COMMENT '打印机编号',
+    `cloud_order_no`   varchar(96)   NOT NULL DEFAULT '' COMMENT '芯烨云返回的打印订单号',
+    `print_content`    mediumtext    NOT NULL COMMENT '本次实际下发的打印正文(排版标签)',
+    `create_time`      datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '打印成功落库时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    KEY `idx_task_no` (`task_no`) USING BTREE,
+    KEY `idx_create_time` (`create_time`) USING BTREE
+) COMMENT ='小票打印明细(每次成功打印一条)';