|
|
@@ -5,6 +5,7 @@ import com.alibaba.fastjson.JSONObject;
|
|
|
import com.platform.api.request.XpPrintImageReqVo;
|
|
|
import com.platform.api.request.XpPrintReceiptReqVo;
|
|
|
import com.platform.config.XpCloudProperties;
|
|
|
+import com.platform.enums.ErrorCodeEnum;
|
|
|
import com.platform.exception.IotException;
|
|
|
import com.platform.external.client.PrintReceiptContentClient;
|
|
|
import com.platform.external.request.PrintReceiptContent;
|
|
|
@@ -73,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());
|
|
|
}
|
|
|
@@ -105,32 +129,63 @@ public class XpCloudPrintService {
|
|
|
* @param reqVo 小票订单打印请求
|
|
|
* @return 云端打印任务号
|
|
|
*/
|
|
|
+ /**
|
|
|
+ * 调用芯烨云执行小票订单打印
|
|
|
+ *
|
|
|
+ * @param reqVo 小票订单打印请求,包含打印机SN、任务单号、打印模式等参数
|
|
|
+ * @return 云端打印任务订单号
|
|
|
+ * @throws IotException 当参数校验失败、获取内容异常或打印服务调用异常时抛出
|
|
|
+ */
|
|
|
public String printReceipt(XpPrintReceiptReqVo reqVo) {
|
|
|
+ // 1. 记录入口日志,方便排查问题
|
|
|
log.info("芯烨云小票打印, 入参:{}", JSON.toJSONString(reqVo));
|
|
|
+
|
|
|
+ // 2. 参数校验:检查请求对象及核心字段(打印机SN、任务单号)是否为空
|
|
|
if (reqVo == null || StringUtils.isAnyBlank(reqVo.getSn(), reqVo.getTaskNo())) {
|
|
|
throw new IotException("打印参数不能为空");
|
|
|
}
|
|
|
+ // 3. 业务规则校验:如果设置了过期时间(expiresIn),则打印模式(mode)必须为1(即时打印)
|
|
|
if (reqVo.getExpiresIn() != null && (reqVo.getMode() == null || reqVo.getMode() != 1)) {
|
|
|
throw new IotException("设置expiresIn时,mode必须为1");
|
|
|
}
|
|
|
+
|
|
|
try {
|
|
|
+ // 4. 获取当前任务的下一次成功打印序号,用于生成“第N次打印”标识及防重
|
|
|
int nextSuccessfulOrdinal = kwsPrintReceiptRecordRepository.resolveNextPrintOrdinalOrThrow(reqVo.getTaskNo());
|
|
|
+
|
|
|
+ // 5. 远程调用获取小票所需的运输业务数据
|
|
|
TradeOrderTransportInfoResp transportInfo = getReceiptContent(reqVo.getTaskNo());
|
|
|
+
|
|
|
+ // 6. 将远程返回的业务数据组装成打印模板所需的内容对象
|
|
|
PrintReceiptContent content = assembleReceiptContent(transportInfo, reqVo.getTaskNo());
|
|
|
+
|
|
|
+ // 7. 填充打印元数据:当前时间及打印次数描述
|
|
|
LocalDateTime now = LocalDateTime.now();
|
|
|
content.setPrintTimesDesc(formatPrintTimes(nextSuccessfulOrdinal));
|
|
|
content.setPrintTime(now.format(DATE_TIME_FORMATTER));
|
|
|
+
|
|
|
+ // 8. 校验组装后的小票内容,确保关键字段不为空,避免打印出残缺小票
|
|
|
validateReceiptContent(content);
|
|
|
+
|
|
|
+ // 9. 将内容对象转换为芯烨云支持的XML格式字符串
|
|
|
String xmlContent = buildReceiptXml(content);
|
|
|
|
|
|
+ // 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"));
|
|
|
+ // 12. 仅在DEBUG级别记录完整请求体,避免生产环境日志过大
|
|
|
if (log.isDebugEnabled()) {
|
|
|
log.debug("芯烨云小票打印HTTP请求体: {}", printBody.toJSONString());
|
|
|
}
|
|
|
+
|
|
|
+ // 13. 发送HTTP请求调用芯烨云打印接口,获取云端订单号
|
|
|
String orderNo = postReceiptPrint(printBody);
|
|
|
+
|
|
|
+ // 14. 持久化打印成功记录,用于后续查询及打印序号累加
|
|
|
kwsPrintReceiptRecordRepository.saveSuccessfulReceiptPrint(
|
|
|
reqVo.getTaskNo(),
|
|
|
transportInfo.getTradeOrderNo(),
|
|
|
@@ -138,12 +193,18 @@ public class XpCloudPrintService {
|
|
|
xmlContent,
|
|
|
orderNo,
|
|
|
now);
|
|
|
+
|
|
|
+ // 15. 记录完成日志
|
|
|
log.info("芯烨云小票打印完成, sn={}, orderNo={}, 已累计成功序号={}",
|
|
|
reqVo.getSn(), orderNo, nextSuccessfulOrdinal);
|
|
|
+
|
|
|
+ // 16. 返回云端订单号
|
|
|
return orderNo;
|
|
|
} catch (IotException e) {
|
|
|
+ // 业务异常直接抛出
|
|
|
throw e;
|
|
|
} catch (Exception e) {
|
|
|
+ // 捕获其他未知异常,记录错误日志并转换为业务异常抛出
|
|
|
log.error("调用芯烨云小票打印异常, sn={}", reqVo.getSn(), e);
|
|
|
throw new IotException("调用芯烨云小票打印异常:" + e.getMessage());
|
|
|
}
|
|
|
@@ -155,26 +216,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());
|
|
|
}
|
|
|
@@ -185,38 +273,57 @@ public class XpCloudPrintService {
|
|
|
*
|
|
|
* @param content 小票内容
|
|
|
*/
|
|
|
+ /**
|
|
|
+ * 校验小票打印内容的关键字段完整性。
|
|
|
+ * <p>
|
|
|
+ * 该方法会检查 {@link PrintReceiptContent} 对象中所有必填字段是否为空或空白。
|
|
|
+ * 如果发现任何必填字段缺失,将抛出 {@link IotException} 异常,以防止生成内容残缺的小票。
|
|
|
+ *
|
|
|
+ * @param content 待校验的小票内容对象
|
|
|
+ * @throws IotException 当内容对象为空或包含空白的必填字段时抛出
|
|
|
+ */
|
|
|
private void validateReceiptContent(PrintReceiptContent content) {
|
|
|
+ // 1. 基础非空校验:确保传入的内容对象本身不为 null
|
|
|
if (content == null) {
|
|
|
throw new IotException("小票内容不能为空");
|
|
|
}
|
|
|
+
|
|
|
+ // 2. 定义必填字段列表:使用 SimpleEntry 映射字段名与其对应的值,便于后续定位缺失字段
|
|
|
List<AbstractMap.SimpleEntry<String, String>> requiredFields = List.of(
|
|
|
- new AbstractMap.SimpleEntry<>("companyName", content.getCompanyName()),
|
|
|
- new AbstractMap.SimpleEntry<>("customerName", content.getCustomerName()),
|
|
|
- new AbstractMap.SimpleEntry<>("taskNo", content.getTaskNo()),
|
|
|
- new AbstractMap.SimpleEntry<>("acceptTime", content.getAcceptTime()),
|
|
|
- new AbstractMap.SimpleEntry<>("finishTime", content.getFinishTime()),
|
|
|
- new AbstractMap.SimpleEntry<>("weigher", content.getWeigher()),
|
|
|
- new AbstractMap.SimpleEntry<>("driverName", content.getDriverName()),
|
|
|
- new AbstractMap.SimpleEntry<>("driverMobile", content.getDriverMobile()),
|
|
|
- new AbstractMap.SimpleEntry<>("driverIdCard", content.getDriverIdCard()),
|
|
|
- new AbstractMap.SimpleEntry<>("materialName", content.getMaterialName()),
|
|
|
- new AbstractMap.SimpleEntry<>("materialSpec", content.getMaterialSpec()),
|
|
|
- new AbstractMap.SimpleEntry<>("taskWeight", content.getTaskWeight()),
|
|
|
- new AbstractMap.SimpleEntry<>("weightSummary", content.getWeightSummary()),
|
|
|
- new AbstractMap.SimpleEntry<>("plateNo", content.getPlateNo()),
|
|
|
- new AbstractMap.SimpleEntry<>("vehicleAxleDesc", content.getVehicleAxleDesc()),
|
|
|
- new AbstractMap.SimpleEntry<>("destination", content.getDestination()),
|
|
|
- new AbstractMap.SimpleEntry<>("printTime", content.getPrintTime()),
|
|
|
- new AbstractMap.SimpleEntry<>("printTimesDesc", content.getPrintTimesDesc()),
|
|
|
- new AbstractMap.SimpleEntry<>("copyLabel", content.getCopyLabel())
|
|
|
+ new AbstractMap.SimpleEntry<>("companyName", content.getCompanyName()), // 公司名称
|
|
|
+ new AbstractMap.SimpleEntry<>("customerName", content.getCustomerName()), // 客户名称
|
|
|
+ new AbstractMap.SimpleEntry<>("taskNo", content.getTaskNo()), // 任务单号
|
|
|
+ new AbstractMap.SimpleEntry<>("acceptTime", content.getAcceptTime()), // 接单时间
|
|
|
+ new AbstractMap.SimpleEntry<>("finishTime", content.getFinishTime()), // 完成时间
|
|
|
+ new AbstractMap.SimpleEntry<>("weigher", content.getWeigher()), // 计重人
|
|
|
+ new AbstractMap.SimpleEntry<>("driverName", content.getDriverName()), // 司机姓名
|
|
|
+ new AbstractMap.SimpleEntry<>("driverMobile", content.getDriverMobile()), // 司机手机号
|
|
|
+ new AbstractMap.SimpleEntry<>("driverIdCard", content.getDriverIdCard()), // 司机身份证
|
|
|
+ new AbstractMap.SimpleEntry<>("materialName", content.getMaterialName()), // 物料名称
|
|
|
+ new AbstractMap.SimpleEntry<>("materialSpec", content.getMaterialSpec()), // 物料规格
|
|
|
+ new AbstractMap.SimpleEntry<>("taskWeight", content.getTaskWeight()), // 任务重量
|
|
|
+ new AbstractMap.SimpleEntry<>("weightSummary", content.getWeightSummary()), // 重量汇总(皮/毛/净)
|
|
|
+ new AbstractMap.SimpleEntry<>("plateNo", content.getPlateNo()), // 车牌号
|
|
|
+ new AbstractMap.SimpleEntry<>("vehicleAxleDesc", content.getVehicleAxleDesc()), // 车辆轴数描述
|
|
|
+ new AbstractMap.SimpleEntry<>("destination", content.getDestination()), // 目的地
|
|
|
+ new AbstractMap.SimpleEntry<>("printTime", content.getPrintTime()), // 打印时间
|
|
|
+ new AbstractMap.SimpleEntry<>("printTimesDesc", content.getPrintTimesDesc()), // 打印次数描述
|
|
|
+ new AbstractMap.SimpleEntry<>("copyLabel", content.getCopyLabel()) // 联次标签(如客户联)
|
|
|
);
|
|
|
+
|
|
|
+ // 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. 如果存在空白字段,则抛出业务异常,提示具体缺失的字段名
|
|
|
if (StringUtils.isNotBlank(blankField)) {
|
|
|
- // throw new IotException("小票内容字段不能为空:" + blankField);
|
|
|
+ throw new IotException("小票内容字段不能为空: " + blankField);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -227,35 +334,71 @@ public class XpCloudPrintService {
|
|
|
* @param taskNo 任务单号
|
|
|
* @return 小票打印模板参数
|
|
|
*/
|
|
|
+ /**
|
|
|
+ * 将远程运输信息重新组装为小票打印模板参数。
|
|
|
+ * <p>
|
|
|
+ * 该方法从 {@link TradeOrderTransportInfoResp} 中提取任务、司机、货物、车辆等关键业务数据,
|
|
|
+ * 并格式化为 {@link PrintReceiptContent} 对象,供后续生成小票 XML 使用。
|
|
|
+ *
|
|
|
+ * @param transportInfo 远程获取的运输订单详细信息
|
|
|
+ * @param taskNo 当前需要打印的任务单号,用于匹配具体的任务信息
|
|
|
+ * @return 组装好的小票打印内容对象
|
|
|
+ * @throws IotException 当运输信息为空时抛出异常
|
|
|
+ */
|
|
|
private PrintReceiptContent assembleReceiptContent(TradeOrderTransportInfoResp transportInfo, String taskNo) {
|
|
|
+ // 1. 基础校验:确保传入的运输信息对象不为 null
|
|
|
if (transportInfo == null) {
|
|
|
throw new IotException("小票运输信息不能为空");
|
|
|
}
|
|
|
+
|
|
|
+ // 2. 解析任务信息:根据 taskNo 从运输信息中匹配对应的任务详情
|
|
|
TradeOrderTransportInfoResp.TaskInfo taskInfo = resolveTaskInfo(transportInfo, taskNo);
|
|
|
+
|
|
|
+ // 3. 提取子模块信息:司机、货物、车辆、打印信息(可能为 null,需做空指针保护)
|
|
|
TradeOrderTransportInfoResp.DriverInfo driverInfo = taskInfo.getDriverInfo();
|
|
|
TradeOrderTransportInfoResp.GoodsInfo goodsInfo = taskInfo.getGoodsInfo();
|
|
|
TradeOrderTransportInfoResp.TruckInfo truckInfo = taskInfo.getTruckInfo();
|
|
|
+ // 注:printInfo 在当前逻辑中未直接使用,但保留获取以便后续扩展
|
|
|
TradeOrderTransportInfoResp.PrintInfo printInfo = taskInfo.getPrintInfo();
|
|
|
|
|
|
+ // 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());
|
|
|
- content.setDestination(taskInfo.getDestination());
|
|
|
+
|
|
|
+ // 9. 填充车辆信息(需判空处理)
|
|
|
+ content.setPlateNo(truckInfo == null ? null : truckInfo.getTruckNo()); // 车牌号
|
|
|
+ content.setVehicleAxleDesc(truckInfo == null ? null : truckInfo.getTruckAxle()); // 车辆轴数描述
|
|
|
+
|
|
|
+ // 10. 设置固定标识:联次标签
|
|
|
content.setCopyLabel("客户联");
|
|
|
+
|
|
|
+ // 11. 返回组装完成的内容对象
|
|
|
return content;
|
|
|
}
|
|
|
|
|
|
@@ -266,19 +409,38 @@ public class XpCloudPrintService {
|
|
|
* @param taskNo 任务单号
|
|
|
* @return 任务信息
|
|
|
*/
|
|
|
+ /**
|
|
|
+ * 根据任务单号解析并匹配具体的任务信息。
|
|
|
+ * <p>
|
|
|
+ * 该方法优先尝试从运输信息列表中查找与给定 {@code taskNo} 完全匹配的任务。
|
|
|
+ * 如果未找到精确匹配的任务,则作为降级策略,返回列表中的第一个非空任务。
|
|
|
+ * 如果列表为空或所有任务均为 null,则抛出业务异常。
|
|
|
+ *
|
|
|
+ * @param transportInfo 远程获取的运输订单详细信息,包含任务列表
|
|
|
+ * @param taskNo 需要匹配的目标任务单号
|
|
|
+ * @return 匹配到的任务信息对象
|
|
|
+ * @throws IotException 当任务列表为空、无有效任务或无法找到任何可用任务时抛出
|
|
|
+ */
|
|
|
private TradeOrderTransportInfoResp.TaskInfo resolveTaskInfo(TradeOrderTransportInfoResp transportInfo, String taskNo) {
|
|
|
+ // 1. 安全获取任务列表:若运输信息中的任务列表为 null,则初始化为空列表,避免 NPE
|
|
|
List<TradeOrderTransportInfoResp.TaskInfo> tasks = transportInfo.getTasks() == null
|
|
|
? Collections.emptyList()
|
|
|
: transportInfo.getTasks();
|
|
|
+
|
|
|
+ // 2. 校验任务列表是否为空:若没有任何任务数据,直接抛出异常
|
|
|
if (tasks.isEmpty()) {
|
|
|
throw new IotException("小票任务信息不能为空");
|
|
|
}
|
|
|
+
|
|
|
+ // 3. 优先精确匹配:在任务流中查找任务单号与入参 taskNo 一致且非空的任务
|
|
|
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("小票任务信息不能为空")));
|
|
|
}
|
|
|
|
|
|
@@ -376,13 +538,31 @@ public class XpCloudPrintService {
|
|
|
*
|
|
|
* @return [user, timestamp, sign]
|
|
|
*/
|
|
|
+ /**
|
|
|
+ * 构建芯烨开放平台鉴权三元组:user、10 位秒级时间戳、sign(SHA1 小写 40 位)。
|
|
|
+ * <p>
|
|
|
+ * 该方法用于生成调用芯烨云打印接口所需的身份验证信息。
|
|
|
+ * 签名算法为:SHA1(user + userKey + timestamp),确保请求的合法性和时效性。
|
|
|
+ *
|
|
|
+ * @return 包含 [user, timestamp, sign] 的字符串数组
|
|
|
+ * @throws IotException 当配置文件中缺失 user 或 userKey 时抛出异常
|
|
|
+ */
|
|
|
private String[] buildXpAuthTriple() {
|
|
|
+ // 1. 校验核心配置项:确保用户名和用户密钥已正确配置,避免后续签名计算失败
|
|
|
if (StringUtils.isAnyBlank(xpCloudProperties.getUser(), xpCloudProperties.getUserKey())) {
|
|
|
throw new IotException("芯烨配置不完整,请配置xp.dev.user和xp.dev.user-key");
|
|
|
}
|
|
|
+
|
|
|
+ // 2. 获取配置中的用户标识
|
|
|
String user = xpCloudProperties.getUser();
|
|
|
+
|
|
|
+ // 3. 生成当前时间的秒级时间戳(10位),用于防止重放攻击及满足接口时效要求
|
|
|
String timestamp = String.valueOf(Instant.now().getEpochSecond());
|
|
|
+
|
|
|
+ // 4. 计算签名:按照芯烨云规范,将 user、userKey 和 timestamp 拼接后进行 SHA1 哈希计算
|
|
|
String sign = sha1Hex(user + xpCloudProperties.getUserKey() + timestamp);
|
|
|
+
|
|
|
+ // 5. 返回鉴权三元组,供上层业务组装请求参数使用
|
|
|
return new String[]{user, timestamp, sign};
|
|
|
}
|
|
|
|
|
|
@@ -393,29 +573,59 @@ public class XpCloudPrintService {
|
|
|
* @param xmlContent 模板 XML 正文
|
|
|
* @return JSON 请求对象
|
|
|
*/
|
|
|
+ /**
|
|
|
+ * 构建芯烨云小票打印接口的 JSON 请求体。
|
|
|
+ * <p>
|
|
|
+ * 该方法将业务参数、鉴权信息、打印内容及配置项组装成符合芯烨云开放平台规范的 JSON 对象。
|
|
|
+ * 包含用户鉴权(user/timestamp/sign)、打印机序列号、打印内容(XML格式)、份数、模式等核心字段,
|
|
|
+ * 并根据配置动态添加调试标识及语音播报设置。
|
|
|
+ *
|
|
|
+ * @param reqVo 小票打印业务请求参数,包含打印机SN、份数、模式、幂等键等
|
|
|
+ * @param xmlContent 已组装好的小票打印 XML 内容字符串
|
|
|
+ * @return 完整的 JSON 请求对象,可直接用于 HTTP POST 请求
|
|
|
+ */
|
|
|
private JSONObject buildReceiptPrintJsonBody(XpPrintReceiptReqVo reqVo, String xmlContent) {
|
|
|
+ // 1. 初始化 JSON 对象,使用 LinkedHashMap 保持插入顺序(可选,便于日志查看)
|
|
|
JSONObject body = new JSONObject(true);
|
|
|
+
|
|
|
+ // 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. 根据配置决定是否开启调试模式
|
|
|
if (Boolean.TRUE.equals(xpCloudProperties.getDebug())) {
|
|
|
body.put("debug", "1");
|
|
|
}
|
|
|
- body.put("sn", reqVo.getSn());
|
|
|
- body.put("content", xmlContent);
|
|
|
+
|
|
|
+ // 4. 填充核心业务参数
|
|
|
+ body.put("sn", reqVo.getSn()); // 打印机终端编号
|
|
|
+ body.put("content", xmlContent); // 打印内容(XML 格式)
|
|
|
+
|
|
|
+ // 5. 处理打印份数:若未指定则默认为 1 份
|
|
|
int copies = reqVo.getCopies() != null ? reqVo.getCopies() : 1;
|
|
|
body.put("copies", copies);
|
|
|
+
|
|
|
+ // 6. 可选参数:打印模式(0:云端排队, 1:即时打印)
|
|
|
if (reqVo.getMode() != null) {
|
|
|
body.put("mode", reqVo.getMode());
|
|
|
}
|
|
|
+
|
|
|
+ // 7. 可选参数:幂等性标识,防止网络重试导致的重复打印
|
|
|
if (StringUtils.isNotBlank(reqVo.getIdempotent())) {
|
|
|
body.put("idempotent", reqVo.getIdempotent());
|
|
|
}
|
|
|
+
|
|
|
+ // 8. 可选参数:订单过期时间(秒),仅在 mode=1 时有效
|
|
|
if (reqVo.getExpiresIn() != null) {
|
|
|
body.put("expiresIn", reqVo.getExpiresIn());
|
|
|
}
|
|
|
- body.put("voice","2");
|
|
|
+
|
|
|
+ // 9. 固定设置:语音播报类型(2:不播报,具体值参考芯烨文档)
|
|
|
+ body.put("voice", "2");
|
|
|
+
|
|
|
+ // 10. 返回组装完成的请求体
|
|
|
return body;
|
|
|
}
|
|
|
|
|
|
@@ -425,38 +635,80 @@ public class XpCloudPrintService {
|
|
|
* @param body 请求 JSON
|
|
|
* @return {@code data} 字段订单号
|
|
|
*/
|
|
|
+ /**
|
|
|
+ * 发送 HTTP POST 请求调用芯烨云小票打印接口,并解析返回的订单号。
|
|
|
+ * <p>
|
|
|
+ * 该方法负责与芯烨云开放平台进行通信,发送组装好的 JSON 请求体,
|
|
|
+ * 并对响应状态码、业务代码及返回数据进行严格校验,确保打印任务成功提交。
|
|
|
+ *
|
|
|
+ * @param body 包含鉴权信息及打印内容的 JSON 请求体
|
|
|
+ * @return 芯烨云返回的打印订单号
|
|
|
+ * @throws IotException 当配置缺失、HTTP 请求失败、业务返回错误或数据为空时抛出异常
|
|
|
+ */
|
|
|
private String postReceiptPrint(JSONObject body) {
|
|
|
+ // 1. 获取配置中的小票打印接口地址
|
|
|
String url = xpCloudProperties.getReceiptPrintUrl();
|
|
|
+
|
|
|
+ // 2. 校验接口地址是否已配置,避免空指针或无效请求
|
|
|
if (StringUtils.isBlank(url)) {
|
|
|
throw new IotException("芯烨云小票打印接口地址未配置,请配置 xp.dev.receipt-print-url");
|
|
|
}
|
|
|
+
|
|
|
try {
|
|
|
+ // 3. 构建 HTTP 请求头,指定内容类型为 application/json,编码为 UTF-8
|
|
|
HttpHeaders headers = new HttpHeaders();
|
|
|
headers.setContentType(new MediaType("application", "json", StandardCharsets.UTF_8));
|
|
|
+
|
|
|
+ // 4. 将 JSON 对象转换为字符串,并封装为 HttpEntity
|
|
|
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
|
|
|
+
|
|
|
+ // 5. 使用 RestTemplate 发送 POST 请求,接收字符串类型的响应
|
|
|
ResponseEntity<String> responseEntity = xpOpenApiRestTemplate.postForEntity(url, entity, String.class);
|
|
|
+
|
|
|
+ // 6. 记录完整响应日志,便于后续排查问题(注意:生产环境若响应过大需评估性能影响)
|
|
|
log.info("芯烨云小票打印响应参数: {}", JSON.toJSONString(responseEntity));
|
|
|
+
|
|
|
+ // 7. 校验 HTTP 状态码,确保是 2xx 成功状态
|
|
|
if (!responseEntity.getStatusCode().is2xxSuccessful()) {
|
|
|
throw new IotException("芯烨云小票打印HTTP异常, status=" + responseEntity.getStatusCode());
|
|
|
}
|
|
|
+
|
|
|
+ // 8. 获取响应体原始字符串
|
|
|
String raw = responseEntity.getBody();
|
|
|
+
|
|
|
+ // 9. 校验响应体是否为空
|
|
|
if (StringUtils.isBlank(raw)) {
|
|
|
throw new IotException("芯烨云小票打印返回空响应");
|
|
|
}
|
|
|
+
|
|
|
+ // 10. 将响应字符串解析为 JSONObject
|
|
|
JSONObject resp = JSON.parseObject(raw);
|
|
|
+
|
|
|
+ // 11. 提取业务状态码和消息
|
|
|
Integer code = resp.getInteger("code");
|
|
|
String msg = resp.getString("msg");
|
|
|
+
|
|
|
+ // 12. 校验业务状态码:code 不为 null 且等于 0 表示业务成功
|
|
|
if (code == null || code != 0) {
|
|
|
throw new IotException("调用芯烨云小票打印失败:" + msg);
|
|
|
}
|
|
|
+
|
|
|
+ // 13. 提取业务数据字段(即打印订单号)
|
|
|
String data = resp.getString("data");
|
|
|
+
|
|
|
+ // 14. 校验订单号是否为空,确保后续流程有迹可循
|
|
|
if (StringUtils.isBlank(data)) {
|
|
|
throw new IotException("芯烨云小票打印成功但订单号为空");
|
|
|
}
|
|
|
+
|
|
|
+ // 15. 返回成功的打印订单号
|
|
|
return data;
|
|
|
} catch (IotException e) {
|
|
|
- throw e;
|
|
|
+ // 业务异常直接抛出,保持原始错误信息
|
|
|
+ log.error("调用芯烨云小票打印业务异常, url={}", url, e);
|
|
|
+ throw new IotException(ErrorCodeEnum.INTERFACE_CALL_FAIL, e.getErrorMsg(), e);
|
|
|
} catch (Exception e) {
|
|
|
+ // 捕获网络异常、序列化异常等非业务异常,记录详细堆栈并转换为业务异常
|
|
|
log.error("芯烨云小票打印HTTP调用异常, url={}", url, e);
|
|
|
throw new IotException("调用芯烨云小票打印HTTP异常:" + e.getMessage());
|
|
|
}
|
|
|
@@ -465,16 +717,38 @@ public class XpCloudPrintService {
|
|
|
/**
|
|
|
* 计算SHA1(小写16进制)
|
|
|
*/
|
|
|
+ /**
|
|
|
+ * 计算字符串的 SHA-1 哈希值,并返回小写十六进制字符串。
|
|
|
+ * <p>
|
|
|
+ * 该方法用于生成芯烨云开放平台接口调用所需的签名(sign)。
|
|
|
+ * 算法流程:
|
|
|
+ * 1. 使用 UTF-8 编码将输入字符串转换为字节数组。
|
|
|
+ * 2. 使用 SHA-1 算法对字节数组进行摘要计算。
|
|
|
+ * 3. 将摘要结果的每个字节转换为两位小写十六进制字符串并拼接。
|
|
|
+ *
|
|
|
+ * @param source 待计算签名的原始字符串(通常由 user + userKey + timestamp 拼接而成)
|
|
|
+ * @return SHA-1 哈希值的小写十六进制字符串表示(40位字符)
|
|
|
+ * @throws IotException 当 SHA-1 算法不可用或计算过程中发生异常时抛出
|
|
|
+ */
|
|
|
private String sha1Hex(String source) {
|
|
|
try {
|
|
|
+ // 1. 获取 SHA-1 消息摘要实例
|
|
|
MessageDigest messageDigest = MessageDigest.getInstance("SHA-1");
|
|
|
+
|
|
|
+ // 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 确保每个字节都转换为两位十六进制数,不足两位前补零
|
|
|
hex.append(String.format("%02x", digestByte));
|
|
|
}
|
|
|
+
|
|
|
+ // 4. 返回最终的十六进制签名字符串
|
|
|
return hex.toString();
|
|
|
} catch (Exception e) {
|
|
|
+ // 捕获 NoSuchAlgorithmException 等异常,转换为业务异常抛出
|
|
|
throw new IotException("SHA1计算失败:" + e.getMessage());
|
|
|
}
|
|
|
}
|