feat: 芯烨云打印机接入

This commit is contained in:
Kroytz committed 2026-07-24 15:59:10 +08:00
1 parent 9681a18bd1
commit 1cc030d0db
19 files changed
+465 -17

No files matched your search

@@ -41,6 +41,11 @@ public interface ErrorCodeConstants {
ErrorCode WECHAT_SHIPPING_DELIVERY_NO_REQUIRED = new ErrorCode(1008007026, "请填写同城配送单号");
ErrorCode WECHAT_SHIPPING_UPLOAD_FAILED = new ErrorCode(1008007027, "微信发货信息上报失败");
ErrorCode STORE_ORDER_PRINT_DISABLED = new ErrorCode(1008007028, "芯烨云打印未启用或未配置开发者凭证");
ErrorCode STORE_ORDER_PRINT_NOT_PAID = new ErrorCode(1008007029, "订单未支付,无法打印");
ErrorCode STORE_ORDER_PRINT_TYPE_NOT_SUPPORT = new ErrorCode(1008007030, "仅支持自取/外卖订单打印");
ErrorCode STORE_ORDER_PRINT_SN_EMPTY = new ErrorCode(1008007031, "门店未配置打印机SN");
ErrorCode STORE_ORDER_PRINT_FAILED = new ErrorCode(1008007032, "打印失败:{}");
}
@@ -157,6 +157,13 @@ public class StoreOrderController {
return success(storeOrderService.orderNotice());
}
@GetMapping("/printOrder")
@Operation(summary = "补打小票")
@Parameter(name = "id", description = "订单编号", required = true)
@PreAuthorize("@ss.hasPermission('order:store-order:update')")
public CommonResult<Boolean> printOrder(@RequestParam("id") Long id) {
storeOrderService.printStoreOrder(id);
return success(true);
}
}
@@ -33,5 +33,8 @@ public interface RedisKeyConstants {
"yshop_web_print_machine_cache:%s", // 参数为访问shopid
STRING, String.class, RedisKeyDefine.TimeoutTypeEnum.DYNAMIC);
RedisKeyDefine YSHOP_ORDER_PRINT_KEY = new RedisKeyDefine("订单打印幂等",
"yshop_order_print:%s", // 参数为 orderId
STRING, String.class, RedisKeyDefine.TimeoutTypeEnum.DYNAMIC);
}
@@ -0,0 +1,36 @@
package co.yixiang.yshop.module.order.dal.redis.order;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
import java.util.concurrent.TimeUnit;
import static co.yixiang.yshop.module.order.dal.redis.RedisKeyConstants.YSHOP_ORDER_PRINT_KEY;
/**
* 订单自动打印幂等(防止支付回调重复出票)
*/
@Repository
public class PrintOrderRedisDAO {
private static final long TTL_HOURS = 24;
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* @return true 表示首次标记成功,可以打印;false 表示已打印过
*/
public boolean tryMarkPrinted(String orderId) {
String redisKey = formatKey(orderId);
Boolean ok = stringRedisTemplate.opsForValue()
.setIfAbsent(redisKey, "1", TTL_HOURS, TimeUnit.HOURS);
return Boolean.TRUE.equals(ok);
}
private static String formatKey(String orderId) {
return String.format(YSHOP_ORDER_PRINT_KEY.getKeyTemplate(), orderId);
}
}
@@ -0,0 +1,98 @@
package co.yixiang.yshop.module.order.print.xpyun;
import cn.hutool.core.util.StrUtil;
import co.yixiang.yshop.module.order.dal.dataobject.storeorder.StoreOrderDO;
import co.yixiang.yshop.module.order.dal.dataobject.storeordercartinfo.StoreOrderCartInfoDO;
import co.yixiang.yshop.module.order.enums.OrderLogEnum;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 自取 / 外卖小票内容组装(58mm)
*/
@Component
public class OrderReceiptBuilder {
private static final DateTimeFormatter PAY_TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final String LINE = "--------------------------------<BR>";
public String build(StoreOrderDO order, List<StoreOrderCartInfoDO> items) {
boolean takeout = OrderLogEnum.ORDER_TAKE_OUT.getValue().equals(order.getOrderType());
String typeLabel = takeout ? "外卖" : "自取";
StringBuilder sb = new StringBuilder();
sb.append("<C><B>").append(nullToEmpty(order.getShopName())).append("</B></C><BR>");
sb.append("<C>").append(typeLabel).append("</C><BR>");
if (order.getNumberId() != null) {
sb.append("<CB>#").append(order.getNumberId()).append("</CB><BR>");
}
sb.append(LINE);
sb.append("商品").append(pad("数量", 6)).append(pad("单价", 6)).append("<BR>");
sb.append(LINE);
if (items != null) {
for (StoreOrderCartInfoDO item : items) {
String name = nullToEmpty(item.getTitle());
if (StrUtil.isNotBlank(item.getSpec())) {
name = name + "(" + item.getSpec() + ")";
}
sb.append(name).append("<BR>");
Integer number = item.getNumber() == null ? 0 : item.getNumber();
BigDecimal price = item.getPrice() == null ? BigDecimal.ZERO : item.getPrice();
sb.append(" x").append(number)
.append(" ").append(price.toPlainString())
.append("<BR>");
}
}
sb.append(LINE);
if (order.getCouponPrice() != null && order.getCouponPrice().compareTo(BigDecimal.ZERO) > 0) {
sb.append("优惠:-").append(order.getCouponPrice().toPlainString()).append("<BR>");
}
if (takeout) {
BigDecimal freight = order.getPayPostage() != null ? order.getPayPostage()
: (order.getFreightPrice() != null ? order.getFreightPrice() : BigDecimal.ZERO);
sb.append("配送费:").append(freight.toPlainString()).append("<BR>");
}
BigDecimal payPrice = order.getPayPrice() == null ? BigDecimal.ZERO : order.getPayPrice();
sb.append("<R>实付:").append(payPrice.toPlainString()).append("元</R><BR>");
sb.append(LINE);
if (StrUtil.isNotBlank(order.getRealName())) {
sb.append("姓名:").append(order.getRealName()).append("<BR>");
}
if (StrUtil.isNotBlank(order.getUserPhone())) {
sb.append("电话:").append(order.getUserPhone()).append("<BR>");
}
if (takeout && StrUtil.isNotBlank(order.getUserAddress())) {
sb.append("地址:").append(order.getUserAddress()).append("<BR>");
}
if (order.getPayTime() != null) {
sb.append("支付时间:").append(order.getPayTime().format(PAY_TIME_FMT)).append("<BR>");
}
if (StrUtil.isNotBlank(order.getMark())) {
sb.append("备注:").append(order.getMark()).append("<BR>");
}
sb.append("订单号:").append(nullToEmpty(order.getOrderId())).append("<BR>");
sb.append("<BR><BR>");
return sb.toString();
}
private static String nullToEmpty(String s) {
return s == null ? "" : s;
}
private static String pad(String text, int width) {
if (text == null) {
text = "";
}
if (text.length() >= width) {
return text;
}
return " ".repeat(width - text.length()) + text;
}
}
@@ -0,0 +1,9 @@
package co.yixiang.yshop.module.order.print.xpyun;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(XpYunProperties.class)
public class XpYunConfiguration {
}
@@ -0,0 +1,71 @@
package co.yixiang.yshop.module.order.print.xpyun;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.http.HttpRequest;
import co.yixiang.yshop.framework.common.util.json.JsonUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* 芯烨云小票打印客户端
*/
@Component
@Slf4j
public class XpYunPrintClient {
@Resource
private XpYunProperties properties;
/**
* 打印小票
*
* @param sn 打印机编号
* @param content 打印内容(标签语法)
* @param copies 份数
* @param voice 声音模式:0 取消 / 1 静音 / 2 来单
* @param mode 0 检查在线;1 离线也入队
* @return 云端响应
*/
public XpYunPrintResponse print(String sn, String content, int copies, int voice, int mode) {
Map<String, Object> body = buildCommonParams();
body.put("sn", sn);
body.put("content", content);
body.put("copies", copies);
body.put("voice", voice);
body.put("mode", mode);
String url = properties.getBaseUrl() + "/xprinter/print";
String json = JsonUtils.toJsonString(body);
log.info("[XpYun][print][sn({})] request={}", sn, json);
String respText = HttpRequest.post(url)
.header("Content-Type", "application/json;charset=UTF-8")
.body(json)
.timeout(15000)
.execute()
.body();
log.info("[XpYun][print][sn({})] response={}", sn, respText);
return JsonUtils.parseObject(respText, XpYunPrintResponse.class);
}
public boolean isReady() {
return Boolean.TRUE.equals(properties.getEnabled())
&& properties.getUser() != null && !properties.getUser().isBlank()
&& properties.getUserKey() != null && !properties.getUserKey().isBlank();
}
private Map<String, Object> buildCommonParams() {
String timestamp = String.valueOf(System.currentTimeMillis());
String sign = DigestUtil.sha1Hex(properties.getUser() + properties.getUserKey() + timestamp);
Map<String, Object> params = new HashMap<>();
params.put("user", properties.getUser());
params.put("timestamp", timestamp);
params.put("sign", sign);
params.put("debug", "0");
return params;
}
}
@@ -0,0 +1,29 @@
package co.yixiang.yshop.module.order.print.xpyun;
import lombok.Data;
/**
* 芯烨云通用响应
*/
@Data
public class XpYunPrintResponse {
/**
* 0 表示成功
*/
private Integer code;
private String msg;
/**
* 成功时一般为打印订单号
*/
private String data;
private Long serverExecutedTime;
public boolean isSuccess() {
return code != null && code == 0;
}
}
@@ -0,0 +1,33 @@
package co.yixiang.yshop.module.order.print.xpyun;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 芯烨云打印配置
*/
@ConfigurationProperties(prefix = "yshop.xpyun")
@Data
public class XpYunProperties {
/**
* 是否启用芯烨云打印
*/
private Boolean enabled = false;
/**
* 开发者 ID(邮箱或开发者 ID)
*/
private String user = "";
/**
* 开发者密钥
*/
private String userKey = "";
/**
* OpenAPI 根地址
*/
private String baseUrl = "https://open.xpyun.net/api/openapi";
}
@@ -67,6 +67,8 @@ import org.redisson.client.codec.StringCodec;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.validation.annotation.Validated;
import java.io.UnsupportedEncodingException;
@@ -568,6 +570,17 @@ public class AppStoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper,Store
productName,orderInfo.getShopName());
}
// 支付成功后异步打印小票(事务提交后再触发,避免读到未提交状态)
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
asyncStoreOrderService.printOrderAsync(orderId);
}
});
} else {
asyncStoreOrderService.printOrderAsync(orderId);
}
}
@@ -1,16 +1,12 @@
package co.yixiang.yshop.module.order.service.storeorder;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.NumberUtil;
import cn.hutool.core.util.StrUtil;
import co.yixiang.yshop.framework.common.enums.OrderInfoEnum;
import co.yixiang.yshop.module.member.controller.app.user.vo.AppUserOrderCountVo;
import co.yixiang.yshop.module.member.service.user.MemberUserService;
import co.yixiang.yshop.module.order.controller.admin.storeorder.vo.ShoperOrderTimeDataVo;
import co.yixiang.yshop.module.order.controller.app.order.vo.AppStoreOrderQueryVo;
import co.yixiang.yshop.module.order.dal.dataobject.storeorder.StoreOrderDO;
import co.yixiang.yshop.module.order.dal.dataobject.storeordercartinfo.StoreOrderCartInfoDO;
import co.yixiang.yshop.module.order.dal.mysql.storeorder.StoreOrderMapper;
@@ -18,22 +14,28 @@ import co.yixiang.yshop.module.order.dal.mysql.storeordercartinfo.StoreOrderCart
import co.yixiang.yshop.module.order.dal.redis.order.AsyncCountRedisDAO;
import co.yixiang.yshop.module.order.dal.redis.order.AsyncOrderRedisDAO;
import co.yixiang.yshop.module.order.dal.redis.order.PrintMechinRedisDAO;
import co.yixiang.yshop.module.order.dal.redis.order.PrintOrderRedisDAO;
import co.yixiang.yshop.module.order.enums.OrderLogEnum;
import co.yixiang.yshop.module.order.print.xpyun.OrderReceiptBuilder;
import co.yixiang.yshop.module.order.print.xpyun.XpYunPrintClient;
import co.yixiang.yshop.module.order.print.xpyun.XpYunPrintResponse;
import co.yixiang.yshop.module.order.service.storeorder.dto.OrderTimeDataDto;
import co.yixiang.yshop.module.product.service.storeproduct.StoreProductService;
import co.yixiang.yshop.module.store.dal.dataobject.storeshop.StoreShopDO;
import co.yixiang.yshop.module.store.dal.mysql.storeshop.StoreShopMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import jakarta.annotation.Resource;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import static co.yixiang.yshop.framework.common.exception.util.ServiceExceptionUtil.exception;
import static co.yixiang.yshop.module.order.enums.ErrorCodeConstants.*;
/**
* 异步订单 Service 实现类
*
@@ -60,6 +62,12 @@ public class AsynStoreOrderServiceImpl implements AsyncStoreOrderService {
private StoreShopMapper storeShopMapper;
@Resource
private StoreOrderCartInfoMapper storeOrderCartInfoMapper;
@Resource
private PrintOrderRedisDAO printOrderRedisDAO;
@Resource
private XpYunPrintClient xpYunPrintClient;
@Resource
private OrderReceiptBuilder orderReceiptBuilder;
/**
* 获取某个用户的订单统计数据
@@ -236,9 +244,95 @@ public class AsynStoreOrderServiceImpl implements AsyncStoreOrderService {
}
@Override
@Async
public void printOrderAsync(String orderId) {
try {
printOrder(orderId, false);
} catch (Exception e) {
log.error("[printOrderAsync][orderId({}) 自动打印失败]", orderId, e);
}
}
@Override
public void printOrder(String orderId, boolean force) {
if (StrUtil.isBlank(orderId)) {
if (force) {
throw exception(PARAM_ERROR);
}
return;
}
if (!xpYunPrintClient.isReady()) {
failOrSkip(force, STORE_ORDER_PRINT_DISABLED, "芯烨云未启用或凭证未配置");
return;
}
StoreOrderDO order = storeOrderMapper.selectOne(new LambdaQueryWrapper<StoreOrderDO>()
.eq(StoreOrderDO::getOrderId, orderId));
if (order == null) {
failOrSkip(force, STORE_ORDER_NOT_EXISTS, "订单不存在");
return;
}
if (!OrderInfoEnum.PAY_STATUS_1.getValue().equals(order.getPaid())) {
failOrSkip(force, STORE_ORDER_PRINT_NOT_PAID, "订单未支付");
return;
}
String orderType = order.getOrderType();
if (!OrderLogEnum.ORDER_TAKE_IN.getValue().equals(orderType)
&& !OrderLogEnum.ORDER_TAKE_OUT.getValue().equals(orderType)) {
failOrSkip(force, STORE_ORDER_PRINT_TYPE_NOT_SUPPORT, "订单类型不支持打印:" + orderType);
return;
}
if (order.getShopId() == null) {
failOrSkip(force, STORE_ORDER_PRINT_SN_EMPTY, "订单无门店");
return;
}
StoreShopDO shop = storeShopMapper.selectById(order.getShopId());
if (shop == null || StrUtil.isBlank(shop.getUniprintId())) {
failOrSkip(force, STORE_ORDER_PRINT_SN_EMPTY, "门店未配置打印机SN");
return;
}
// 校验通过后再做幂等,避免空 SN / 未启用误占 key
if (!force && !printOrderRedisDAO.tryMarkPrinted(orderId)) {
log.info("[printOrder][orderId({}) 已打印过,跳过]", orderId);
return;
}
List<StoreOrderCartInfoDO> items = storeOrderCartInfoMapper.selectList(
new LambdaQueryWrapper<StoreOrderCartInfoDO>()
.eq(StoreOrderCartInfoDO::getOid, order.getId()));
String content = orderReceiptBuilder.build(order, items);
try {
// mode=1 离线入队;voice=2 来单播报
XpYunPrintResponse resp = xpYunPrintClient.print(shop.getUniprintId(), content, 1, 2, 1);
if (resp == null || !resp.isSuccess()) {
String msg = resp == null ? "无响应" : StrUtil.blankToDefault(resp.getMsg(), "未知错误");
if (force) {
throw exception(STORE_ORDER_PRINT_FAILED, msg);
}
log.error("[printOrder][orderId({}) 打印失败] {}", orderId, msg);
} else {
log.info("[printOrder][orderId({}) 打印成功] data={}", orderId, resp.getData());
}
} catch (co.yixiang.yshop.framework.common.exception.ServiceException e) {
throw e;
} catch (Exception e) {
if (force) {
throw exception(STORE_ORDER_PRINT_FAILED, e.getMessage());
}
log.error("[printOrder][orderId({}) 打印异常]", orderId, e);
}
}
private void failOrSkip(boolean force,
co.yixiang.yshop.framework.common.exception.ErrorCode errorCode,
String logMsg) {
if (force) {
throw exception(errorCode);
}
log.warn("[printOrder][skip] {}", logMsg);
}
}
@@ -1,11 +1,5 @@
package co.yixiang.yshop.module.order.service.storeorder;
import co.yixiang.yshop.module.member.controller.app.user.vo.AppUserOrderCountVo;
import co.yixiang.yshop.module.order.controller.admin.storeorder.vo.ShoperOrderTimeDataVo;
import co.yixiang.yshop.module.order.controller.app.order.vo.AppStoreOrderQueryVo;
import co.yixiang.yshop.module.order.dal.dataobject.storeorder.StoreOrderDO;
import co.yixiang.yshop.module.order.service.storeorder.dto.OrderTimeDataDto;
/**
* 异步订单 Service 接口
*
@@ -16,17 +10,27 @@ public interface AsyncStoreOrderService {
/**
* 计算某个用户的订单统计数据
* @param uid uid>0 取用户 否则取所有
* @return UserOrderCountVo
*/
void orderData(Long uid);
/**
* 异步后台数据统计
*/
void getOrderTimeData();
/**
* 支付成功后异步打印小票(失败只记日志,不抛异常)
*
* @param orderId 业务订单号
*/
void printOrderAsync(String orderId);
/**
* 打印小票
*
* @param orderId 业务订单号
* @param force false=自动打印(幂等,校验失败跳过);true=补打(跳过幂等,校验失败抛业务异常)
*/
void printOrder(String orderId, boolean force);
}
@@ -99,6 +99,11 @@ public interface StoreOrderService {
*/
Long orderNotice();
/**
* 补打小票(同步,跳过幂等)
*
* @param id 订单主键
*/
void printStoreOrder(Long id);
}
@@ -453,6 +453,15 @@ public class StoreOrderServiceImpl implements StoreOrderService {
}
@Override
public void printStoreOrder(Long id) {
StoreOrderDO storeOrderDO = storeOrderMapper.selectById(id);
if (storeOrderDO == null) {
throw exception(STORE_ORDER_NOT_EXISTS);
}
asyncStoreOrderService.printOrder(storeOrderDO.getOrderId(), true);
}
/**
* 退回库存
*
@@ -170,6 +170,11 @@ yshop:
pay:
order-notify-url: http://yshop.natapp1.cc/admin-api/pay/notify/order # 支付渠道的【支付】回调地址
refund-notify-url: http://yshop.natapp1.cc/admin-api/pay/notify/refund # 支付渠道的【退款】回调地址
xpyun: # 芯烨云打印机 https://www.xpyun.net
enabled: true
user: neko_chocola@qq.com # 开发者 ID
user-key: 36c8251d0ff340888dd0f5fa62aa81ee # 开发者密钥
base-url: https://open.xpyun.net/api/openapi
demo: true # 开启演示模式
tencent-lbs-key: TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E # QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
file-path: /www/wwwroot/drink/file/
@@ -206,6 +206,11 @@ yshop:
pay:
order-notify-url: http://yshop.natapp1.cc/admin-api/pay/notify/order # 支付渠道的【支付】回调地址
refund-notify-url: http://yshop.natapp1.cc/admin-api/pay/notify/refund # 支付渠道的【退款】回调地址
xpyun: # 芯烨云打印机 https://www.xpyun.net
enabled: true
user: neko_chocola@qq.com # 开发者 ID
user-key: 36c8251d0ff340888dd0f5fa62aa81ee # 开发者密钥
base-url: https://open.xpyun.net/api/openapi
access-log: # 访问日志的配置项
enable: false
demo: false # 关闭演示模式