From 10404de331a969e3d21fdb7fe351bc060d2a61c5 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Thu, 17 Sep 2026 18:30:11 +0800
Subject: [PATCH 1/6] =?UTF-8?q?feat:=E4=BB=A5=E9=80=9A=E9=81=93=E7=9A=84?=
=?UTF-8?q?=E6=96=B9=E5=BC=8F=E5=B1=95=E7=A4=BA=E6=91=84=E5=83=8F=E5=A4=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../hard/api/dto/order/HardOrderDto.java | 6 +
.../hard/api/vo/order/HardOrderVo.java | 6 +
.../hard/api/vo/order/WardMenuMapDayVo.java | 7 +-
.../controller/order/HardOrderController.java | 58 +++++--
.../hard/service/HardOrderService.java | 9 +-
.../impl/order/HardOrderServiceImpl.java | 52 ++++--
.../mapper/order/HardOrderMapper.xml | 14 ++
.../HardOrderServiceImplWardMenuTest.java | 152 ++++++++++++++++++
8 files changed, 273 insertions(+), 31 deletions(-)
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplWardMenuTest.java
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java
index 45ed8c735..56a07c424 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java
@@ -81,6 +81,12 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
/** 预约用餐日期下限,用于查询今天及之后的未核销预约。 */
private LocalDateTime apptUseDateStart;
+ /**
+ * 统一用餐日期范围:预约单按预约使用日匹配,线下等其他类型按支付时间匹配;
+ * 仅 ward 系列用餐接口使用。
+ */
+ private LocalDateTime[] mealDateRange;
+
private List orderTypeCodes;
private List orderPayTypeCodes;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java
index 407e340aa..c02b50c41 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java
@@ -133,6 +133,12 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac
*/
private Boolean canViewReserveCode;
+ /**
+ * 统一用餐日期(yyyy-MM-dd):预约单取预约使用日,线下等其他类型取支付日;
+ * 仅 ward 系列用餐接口回填,不对应 hard_order 表持久化字段。
+ */
+ private String mealDate;
+
/**
* 订单详情中的长期识别证据列表,按证据类型、角色和序号排序。
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/WardMenuMapDayVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/WardMenuMapDayVo.java
index 6bfdb8956..5785e18c3 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/WardMenuMapDayVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/WardMenuMapDayVo.java
@@ -6,10 +6,10 @@ import lombok.Data;
import java.time.LocalDate;
/**
- * 预约菜谱日历地图的单日聚合:按用餐日统计未退款预约订单的核销情况。
+ * 用餐日历地图的单日聚合:按统一用餐日统计未退款订单的核销与消费情况。
*/
@Data
-@Schema(description = "预约菜谱日历地图单日聚合")
+@Schema(description = "用餐日历地图单日聚合")
public class WardMenuMapDayVo {
@Schema(description = "用餐日")
@@ -20,4 +20,7 @@ public class WardMenuMapDayVo {
@Schema(description = "已核销预约单数(订单状态 20)")
private Integer verifiedCount;
+
+ @Schema(description = "线下等其他类型订单数(预约外全类型,已支付口径)")
+ private Integer offlineCount;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
index d6eae4c35..9f0a84891 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
@@ -177,7 +177,7 @@ public class HardOrderController {
return R.ok(orders);
}
- @Operation(summary = "app查询预约菜谱日历地图", description = "按日期范围聚合本人或被监护人每天的预约核销情况,整单退款的订单不展示")
+ @Operation(summary = "app查询用餐日历地图", description = "按日期范围聚合本人或被监护人每天的用餐情况:预约待核销/已核销与线下等其他类型订单数,整单退款的订单不统计")
@ApiCryptoAes
@GetMapping("/app/ward-menu-map")
public R> wardMenuMap(@RequestParam(required = false) List dinerUserIds,
@@ -190,22 +190,46 @@ public class HardOrderController {
: hardwareOrderService.validateReserveDinerUserIds(user.getId(), dinerUserIds));
dto.setIsFindCarList(false);
dto.setIsFindApptList(true);
- dto.setApptIdOrApptRuleIdNotNull(true);
- dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
- // 只统计待核销(5)与已核销(20);退款中(10)、已取消(30)和未支付订单不进入地图。
- dto.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.CANCEL.getType()));
- dto.setApptUseDateTimeList(new LocalDateTime[]{
+ // 只统计待核销(5)、线下本地支付(6)与已核销(20);退款中(10)、已取消(30)和未支付订单不进入地图。
+ dto.setOrderStatusCodes(List.of(
+ HardOrderStatusEnum.PAY.getType(),
+ HardOrderStatusEnum.OFFLINE_PAY.getType(),
+ HardOrderStatusEnum.CANCEL.getType()));
+ // 统一用餐日期口径:预约单按预约使用日匹配,线下等其他类型按支付时间匹配。
+ dto.setMealDateRange(new LocalDateTime[]{
LocalDate.parse(startDate).atStartOfDay(),
LocalDate.parse(endDate).atTime(23, 59, 59),
});
return R.ok(hardwareOrderService.wardMenuMap(dto));
}
- @Operation(summary = "app查询预约菜谱某日订单明细", description = "查询某天本人或被监护人的预约订单明细,整单退款的订单不返回")
+ @Operation(summary = "app查询用餐某日订单明细", description = "查询某天本人或被监护人的全类型用餐订单明细,整单退款的订单不返回")
@ApiCryptoAes
@GetMapping("/app/ward-menu-day-orders")
public R> wardMenuDayOrders(@RequestParam(required = false) List dinerUserIds,
@RequestParam String date) {
+ return R.ok(wardMenuOrdersResponse(dinerUserIds, date, date));
+ }
+
+ @Operation(summary = "app查询用餐订单窗口列表", description = "按日期窗口查询本人或被监护人的全类型用餐订单明细,整单退款的订单不返回")
+ @ApiCryptoAes
+ @GetMapping("/app/ward-menu-orders")
+ public R> wardMenuOrders(@RequestParam(required = false) List dinerUserIds,
+ @RequestParam String startDate,
+ @RequestParam String endDate) {
+ return R.ok(wardMenuOrdersResponse(dinerUserIds, startDate, endDate));
+ }
+
+ /**
+ * 组装 ward 系列用餐明细查询:校验查看对象后按统一用餐日期窗口查询全类型订单,
+ * 某日明细与日期窗口明细共用同一口径,避免两接口统计范围出现偏差。
+ *
+ * @param dinerUserIds 查看对象用户 ID,空表示当前登录用户本人
+ * @param startDate 用餐日期窗口起(含),格式 yyyy-MM-dd
+ * @param endDate 用餐日期窗口止(含),格式 yyyy-MM-dd
+ * @return 已回填统一用餐日期和操作权限的全类型用餐订单
+ */
+ private List wardMenuOrdersResponse(List dinerUserIds, String startDate, String endDate) {
HardOrderDto dto = new HardOrderDto();
PjcloudUser user = SecurityUtils.getUser();
dto.setUserIds(ObjectUtil.isEmpty(dinerUserIds)
@@ -213,17 +237,19 @@ public class HardOrderController {
: hardwareOrderService.validateReserveDinerUserIds(user.getId(), dinerUserIds));
dto.setIsFindCarList(true);
dto.setIsFindApptList(true);
- dto.setApptIdOrApptRuleIdNotNull(true);
- dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
- // 与日历地图同口径:只返回待核销(5)与已核销(20),整单退款在服务层过滤。
- dto.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.CANCEL.getType()));
- dto.setApptUseDateTimeList(new LocalDateTime[]{
- LocalDate.parse(date).atStartOfDay(),
- LocalDate.parse(date).atTime(23, 59, 59),
+ // 与用餐日历地图同口径:待核销(5)、线下本地支付(6)、已核销(20),整单退款在服务层过滤。
+ dto.setOrderStatusCodes(List.of(
+ HardOrderStatusEnum.PAY.getType(),
+ HardOrderStatusEnum.OFFLINE_PAY.getType(),
+ HardOrderStatusEnum.CANCEL.getType()));
+ // 统一用餐日期口径:预约单按预约使用日匹配,线下等其他类型按支付时间匹配。
+ dto.setMealDateRange(new LocalDateTime[]{
+ LocalDate.parse(startDate).atStartOfDay(),
+ LocalDate.parse(endDate).atTime(23, 59, 59),
});
- List orders = hardwareOrderService.wardMenuDayOrders(dto);
+ List orders = hardwareOrderService.wardMenuOrders(dto);
hardwareOrderService.applyAppPermissions(orders, user.getId());
- return R.ok(orders);
+ return orders;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
index f449fe26b..012ea9436 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
@@ -198,15 +198,20 @@ public interface HardOrderService extends IService {
List validateReserveDinerUserIds(Long payerUserId, List dinerUserIds);
/**
- * app 预约菜谱日历地图:按用餐日聚合未退款预约订单的待核销/已核销数量。
+ * app 用餐日历地图:按统一用餐日聚合未退款订单,预约单分待核销/已核销,其余类型计线下消费。
*/
List wardMenuMap(HardOrderDto dto);
/**
- * app 预约菜谱某日明细:返回当天未整单退款的待核销/已核销预约订单。
+ * app 用餐某日明细:返回当天未整单退款的全类型用餐订单,并回填统一用餐日期。
*/
List wardMenuDayOrders(HardOrderDto dto);
+ /**
+ * app 用餐订单窗口列表:返回日期窗口内未整单退款的全类型用餐订单,并回填统一用餐日期。
+ */
+ List wardMenuOrders(HardOrderDto dto);
+
/**
* app创建食堂外卖订单
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
index 5231fe3a6..a0f924914 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
@@ -2466,41 +2466,71 @@ public class HardOrderServiceImpl extends ServiceImpl wardMenuMap(HardOrderDto dto) {
List orders = scopeList(dto, null);
Map dayMap = new TreeMap<>();
for (HardOrderVo order : orders) {
- if (order.getApptUseDate() == null || isFullyRefundedOrder(order)) {
+ LocalDate mealDate = resolveOrderMealDate(order);
+ if (mealDate == null || isFullyRefundedOrder(order)) {
continue;
}
- WardMenuMapDayVo day = dayMap.computeIfAbsent(order.getApptUseDate(), date -> {
+ WardMenuMapDayVo day = dayMap.computeIfAbsent(mealDate, date -> {
WardMenuMapDayVo item = new WardMenuMapDayVo();
item.setApptUseDate(date);
item.setPendingCount(0);
item.setVerifiedCount(0);
+ item.setOfflineCount(0);
return item;
});
- if (HardOrderStatusEnum.PAY.getType().equals(order.getOrderStatus())) {
- day.setPendingCount(day.getPendingCount() + 1);
- } else if (HardOrderStatusEnum.CANCEL.getType().equals(order.getOrderStatus())) {
- day.setVerifiedCount(day.getVerifiedCount() + 1);
+ if (HardOrderTypeEnum.RESERVE.getType().equals(order.getOrderType())) {
+ if (HardOrderStatusEnum.PAY.getType().equals(order.getOrderStatus())) {
+ day.setPendingCount(day.getPendingCount() + 1);
+ } else if (HardOrderStatusEnum.CANCEL.getType().equals(order.getOrderStatus())) {
+ day.setVerifiedCount(day.getVerifiedCount() + 1);
+ }
+ } else {
+ day.setOfflineCount(day.getOfflineCount() + 1);
}
}
return new ArrayList<>(dayMap.values());
}
/**
- * app 预约菜谱某日明细:返回当天未整单退款的待核销/已核销预约订单,与日历地图同口径。
+ * app 用餐某日明细:返回当天未整单退款的全类型用餐订单,并回填统一用餐日期,与日历地图同口径。
*/
@Override
public List wardMenuDayOrders(HardOrderDto dto) {
- return scopeList(dto, null).stream()
- .filter(order -> !isFullyRefundedOrder(order))
+ return wardMenuOrders(dto);
+ }
+
+ /**
+ * app 用餐订单窗口列表:返回统一用餐日期窗口内未整单退款的全类型用餐订单,并回填统一用餐日期。
+ * 无法解析用餐日期的订单(预约缺使用日且无支付时间)不返回,避免前端落入脏数据兜底日。
+ */
+ @Override
+ public List wardMenuOrders(HardOrderDto dto) {
+ List orders = scopeList(dto, null).stream()
+ .filter(order -> resolveOrderMealDate(order) != null && !isFullyRefundedOrder(order))
.toList();
+ for (HardOrderVo order : orders) {
+ order.setMealDate(resolveOrderMealDate(order).toString());
+ }
+ return orders;
+ }
+
+ /**
+ * 解析订单统一用餐日期:预约单取预约使用日,线下消费、外卖等其他类型取支付日;
+ * 两者皆缺失时返回 null,调用方按无法归日处理。
+ */
+ private LocalDate resolveOrderMealDate(HardOrderVo order) {
+ if (HardOrderTypeEnum.RESERVE.getType().equals(order.getOrderType()) && order.getApptUseDate() != null) {
+ return order.getApptUseDate();
+ }
+ return order.getPayTime() == null ? null : order.getPayTime().toLocalDate();
}
/**
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
index 2551b4907..f35a44774 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
@@ -333,6 +333,19 @@
+
+
+
+ and (
+ (a.order_type = '2' and a.appt_use_date between #{dto.mealDateRange[0]} and #{dto.mealDateRange[1]})
+ or (a.order_type <> '2' and a.pay_time between #{dto.mealDateRange[0]} and #{dto.mealDateRange[1]})
+ )
+
+
+
@@ -431,6 +444,7 @@
+
and m.type = #{dto.hardTypeCode}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplWardMenuTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplWardMenuTest.java
new file mode 100644
index 000000000..8e3c58765
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplWardMenuTest.java
@@ -0,0 +1,152 @@
+package com.bsd.pjcloud.hard.service.impl.order;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
+import com.bsd.pjcloud.hard.api.vo.order.WardMenuMapDayVo;
+import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
+import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Answers.CALLS_REAL_METHODS;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 用餐日历地图与窗口明细测试:预约单按状态分待核销/已核销计数,线下等其他类型统一计入线下消费,
+ * 整单退款订单不统计,返回明细回填统一用餐日期。
+ */
+class HardOrderServiceImplWardMenuTest {
+
+ /** 订单 Mapper,scopeList 打桩返回构造订单,隔离 SQL 与展示装配逻辑。 */
+ private final HardOrderMapper hardOrderMapper = mock(HardOrderMapper.class);
+
+ /** 身份筛选支持,默认关闭身份条件避免测试触发用户主键预筛选。 */
+ private final HardUserIdentitySupport userIdentitySupport = mock(HardUserIdentitySupport.class);
+
+ /** 被测订单服务,仅执行真实方法。 */
+ private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS);
+
+ /**
+ * 为被测服务注入查询链路实际访问的基础依赖。
+ */
+ @BeforeEach
+ void setUp() {
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
+ HardOrderEntity.class);
+ ReflectionTestUtils.setField(service, "baseMapper", hardOrderMapper);
+ ReflectionTestUtils.setField(service, "userIdentitySupport", userIdentitySupport);
+ when(userIdentitySupport.hasIdentityQuery(any())).thenReturn(false);
+ }
+
+ /**
+ * 同一天内:待核销预约、已核销预约、线下消费分别计数;整单退款订单不计入任何维度。
+ */
+ @Test
+ void wardMenuMapAggregatesReserveStatusAndOfflineCount() {
+ LocalDate mealDate = LocalDate.of(2026, 9, 18);
+ List orders = List.of(
+ order(HardOrderTypeEnum.RESERVE.getType(), HardOrderStatusEnum.PAY.getType(), mealDate, null, 1500, 0),
+ order(HardOrderTypeEnum.RESERVE.getType(), HardOrderStatusEnum.CANCEL.getType(), mealDate, null, 1200, 0),
+ order(HardOrderTypeEnum.BELOW.getType(), HardOrderStatusEnum.CANCEL.getType(), null,
+ LocalDateTime.of(2026, 9, 18, 12, 10), 800, 0),
+ // 整单退款:退款金额不低于实付金额,不进入地图
+ order(HardOrderTypeEnum.BELOW.getType(), HardOrderStatusEnum.CANCEL.getType(), null,
+ LocalDateTime.of(2026, 9, 18, 18, 30), 900, 900));
+ when(hardOrderMapper.scopeList(any(HardOrderDto.class), isNull())).thenReturn(orders);
+
+ List days = service.wardMenuMap(new HardOrderDto());
+
+ assertEquals(1, days.size());
+ WardMenuMapDayVo day = days.get(0);
+ assertEquals(mealDate, day.getApptUseDate());
+ assertEquals(1, day.getPendingCount());
+ assertEquals(1, day.getVerifiedCount());
+ assertEquals(1, day.getOfflineCount());
+ }
+
+ /**
+ * 预约单按预约使用日归组,线下等其他类型按支付日归组,两类订单落在不同日期时分别出条目。
+ */
+ @Test
+ void wardMenuMapGroupsByUnifiedMealDate() {
+ List orders = List.of(
+ order(HardOrderTypeEnum.RESERVE.getType(), HardOrderStatusEnum.PAY.getType(),
+ LocalDate.of(2026, 9, 20), null, 1500, 0),
+ order(HardOrderTypeEnum.TAKE_OUT.getType(), HardOrderStatusEnum.PAY.getType(), null,
+ LocalDateTime.of(2026, 9, 17, 19, 0), 2000, 0));
+ when(hardOrderMapper.scopeList(any(HardOrderDto.class), isNull())).thenReturn(orders);
+
+ List days = service.wardMenuMap(new HardOrderDto());
+
+ assertEquals(2, days.size());
+ assertEquals(LocalDate.of(2026, 9, 17), days.get(0).getApptUseDate());
+ assertEquals(1, days.get(0).getOfflineCount());
+ assertEquals(LocalDate.of(2026, 9, 20), days.get(1).getApptUseDate());
+ assertEquals(1, days.get(1).getPendingCount());
+ }
+
+ /**
+ * 窗口明细回填统一用餐日期:预约单取预约使用日,线下等其他类型取支付日;
+ * 无法解析用餐日期的订单不返回。
+ */
+ @Test
+ void wardMenuOrdersFillsUnifiedMealDate() {
+ List orders = List.of(
+ order(HardOrderTypeEnum.RESERVE.getType(), HardOrderStatusEnum.PAY.getType(),
+ LocalDate.of(2026, 9, 20), null, 1500, 0),
+ order(HardOrderTypeEnum.BELOW.getType(), HardOrderStatusEnum.CANCEL.getType(), null,
+ LocalDateTime.of(2026, 9, 18, 12, 10), 800, 0),
+ // 无预约使用日且无支付时间的脏数据,不返回
+ order(HardOrderTypeEnum.BELOW.getType(), HardOrderStatusEnum.CANCEL.getType(), null, null, 100, 0));
+ when(hardOrderMapper.scopeList(any(HardOrderDto.class), isNull())).thenReturn(orders);
+
+ List result = service.wardMenuOrders(new HardOrderDto());
+
+ assertEquals(2, result.size());
+ assertTrue(result.stream().anyMatch(order -> "2026-09-20".equals(order.getMealDate())));
+ assertTrue(result.stream().anyMatch(order -> "2026-09-18".equals(order.getMealDate())));
+ }
+
+ /** 测试订单 ID 序号,仅保证列表内 ID 唯一。 */
+ private long orderIdSequence = 0L;
+
+ /**
+ * 构造一笔用餐订单快照。
+ *
+ * @param orderType 订单类型协议编码
+ * @param orderStatus 订单状态协议编码
+ * @param apptUseDate 预约使用日;非预约订单传 null
+ * @param payTime 支付完成时间;预约订单可传 null
+ * @param payPrice 实付金额,单位为分
+ * @param refundPrice 累计已完成退款金额,单位为分
+ * @return 可供聚合逻辑使用的订单
+ */
+ private HardOrderVo order(String orderType, String orderStatus, LocalDate apptUseDate,
+ LocalDateTime payTime, int payPrice, int refundPrice) {
+ HardOrderVo item = new HardOrderVo();
+ item.setId(++orderIdSequence);
+ item.setOrderType(orderType);
+ item.setOrderStatus(orderStatus);
+ item.setApptUseDate(apptUseDate);
+ item.setPayTime(payTime);
+ item.setPayPrice(payPrice);
+ item.setRefundPrice(refundPrice);
+ return item;
+ }
+}
From 8aa2cfa1042e2e6b71fee2239f420bfa9b92b0cb Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Fri, 18 Sep 2026 12:53:17 +0800
Subject: [PATCH 2/6] =?UTF-8?q?feat:app=E6=B5=81=E6=B0=B4=E9=80=9A?=
=?UTF-8?q?=E7=9F=A5=E6=98=BE=E7=A4=BA=E5=A4=87=E6=B3=A8=20feat:=E5=92=96?=
=?UTF-8?q?=E5=95=A1=E6=9C=BA=E5=88=9D=E6=AD=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
db/20260918_add_hard_coffee.sql | 64 ++
db/20260918_add_wallet_outbox_remark.sql | 4 +
db/20260918_hard_coffee_dict_menu.txt | 199 +++++
db/20260918_hard_coffee_h5_api.md | 72 ++
db/20260918_hard_coffee_integration_notes.md | 52 ++
.../common/core/exception/ErrorCodes.java | 1 +
.../resources/i18n/messages_en.properties | 1 +
.../resources/i18n/messages_zh_CN.properties | 1 +
.../resources/i18n/messages_zh_HK.properties | 1 +
.../hard/api/constant/HardTypeEnum.java | 2 +
.../coffee/HardCoffeeLogDirectionEnum.java | 19 +
.../coffee/HardCoffeeLogStageEnum.java | 33 +
.../coffee/HardCoffeeNotifyStatusEnum.java | 20 +
.../api/constant/order/HardOrderTypeEnum.java | 6 +-
.../api/dto/coffee/HardCoffeeCardPayDto.java | 30 +
.../hard/api/dto/coffee/HardCoffeeLogDto.java | 20 +
.../api/dto/coffee/HardCoffeeOrderDto.java | 30 +
.../api/dto/coffee/WuliQueryOrderDto.java | 42 +
.../entity/coffee/HardCoffeeLogEntity.java | 154 ++++
.../entity/coffee/HardCoffeeOrderEntity.java | 154 ++++
.../hard/api/vo/coffee/HardCoffeeLogVo.java | 18 +
.../vo/coffee/HardCoffeeOrderDetailVo.java | 26 +
.../api/vo/coffee/HardCoffeeOrderInfoVo.java | 74 ++
.../hard/api/vo/coffee/HardCoffeeOrderVo.java | 50 ++
.../api/vo/coffee/HardCoffeePayResultVo.java | 44 +
.../hard/api/vo/coffee/WuliQueryOrderVo.java | 42 +
.../bsd/pjcloud/hard/config/CoffeeConf.java | 25 +
.../coffee/HardCoffeeController.java | 50 ++
.../coffee/HardCoffeeManagerController.java | 64 ++
.../coffee/HardCoffeeWuliController.java | 46 ++
.../pjcloud/hard/job/HardCoffeeNotifyJob.java | 36 +
.../mapper/coffee/HardCoffeeLogMapper.java | 10 +
.../mapper/coffee/HardCoffeeOrderMapper.java | 10 +
.../hard/service/HardCoffeeLogService.java | 11 +
.../hard/service/HardCoffeeOrderService.java | 26 +
.../hard/service/HardCoffeeService.java | 83 ++
.../impl/coffee/HardCoffeeLogServiceImpl.java | 21 +
.../coffee/HardCoffeeOrderServiceImpl.java | 35 +
.../impl/coffee/HardCoffeeServiceImpl.java | 778 ++++++++++++++++++
.../service/impl/coffee/WuliCoffeeClient.java | 406 +++++++++
.../impl/order/HardAfterSaleServiceImpl.java | 39 +
.../src/main/resources/application.yml | 6 +
.../feign/RemoteThirdKeyManageService.java | 26 +
.../thirdparty/constant/ThirdKeyTypeEnum.java | 4 +
.../appmessage/WalletMessageDetailDto.java | 5 +
.../controller/ThirdKeyManageController.java | 12 +
.../service/ThirdKeyManageService.java | 6 +
.../impl/ThirdKeyManageServiceImpl.java | 23 +
.../ThirdAppMessageServiceImpl.java | 3 +
.../api/entity/WalletMessageOutboxEntity.java | 3 +
.../impl/WalletMessageOutboxServiceImpl.java | 1 +
.../impl/WalletTransactionServiceImpl.java | 1 +
52 files changed, 2887 insertions(+), 2 deletions(-)
create mode 100644 db/20260918_add_hard_coffee.sql
create mode 100644 db/20260918_add_wallet_outbox_remark.sql
create mode 100644 db/20260918_hard_coffee_dict_menu.txt
create mode 100644 db/20260918_hard_coffee_h5_api.md
create mode 100644 db/20260918_hard_coffee_integration_notes.md
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogDirectionEnum.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogStageEnum.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeNotifyStatusEnum.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeCardPayDto.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeLogDto.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeOrderDto.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/WuliQueryOrderDto.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeLogEntity.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeOrderEntity.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeLogVo.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderDetailVo.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderInfoVo.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderVo.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeePayResultVo.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/WuliQueryOrderVo.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/CoffeeConf.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeWuliController.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeLogMapper.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeOrderMapper.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeLogService.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeOrderService.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeLogServiceImpl.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeOrderServiceImpl.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/WuliCoffeeClient.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteThirdKeyManageService.java
diff --git a/db/20260918_add_hard_coffee.sql b/db/20260918_add_hard_coffee.sql
new file mode 100644
index 000000000..28d720f52
--- /dev/null
+++ b/db/20260918_add_hard_coffee.sql
@@ -0,0 +1,64 @@
+-- 屋里咖啡咖啡机对接一期(饭卡支付):厂商订单扩展表 + 生命周期日志表。
+-- 本脚本只新增表,不修改既有表结构,可安全重跑。
+-- 字典与菜单权限见同目录 20260918_hard_coffee_dict_menu.txt,由管理员手工添加。
+
+CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_coffee_order`
+(
+ `id` bigint NOT NULL COMMENT '编号',
+ `order_id` bigint NOT NULL COMMENT '本地硬件订单id(hard_order.id)',
+ `order_no` varchar(32) NOT NULL COMMENT '屋里咖啡订单号(19位数字)',
+ `hard_id` bigint DEFAULT NULL COMMENT '咖啡机设备id(hard_manager.id)',
+ `machine_name` varchar(64) DEFAULT NULL COMMENT '厂商机器号快照',
+ `product` varchar(255) DEFAULT NULL COMMENT '产品名称',
+ `img` varchar(512) DEFAULT NULL COMMENT '产品图片链接',
+ `is_cold` char(1) DEFAULT '0' COMMENT '1冷饮 0热饮',
+ `price` int NOT NULL DEFAULT 0 COMMENT '厂商订单价格(分)',
+ `phone_no` varchar(32) DEFAULT NULL COMMENT '支付者手机号',
+ `wuli_notify_status` char(1) NOT NULL DEFAULT '0' COMMENT '支付结果通知厂商状态 0未通知 1成功 2失败',
+ `wuli_notify_time` datetime DEFAULT NULL COMMENT '最近一次通知厂商时间',
+ `wuli_notify_count` int NOT NULL DEFAULT 0 COMMENT '通知厂商累计尝试次数',
+ `refund_sync_amount` int NOT NULL DEFAULT 0 COMMENT '累计已同步厂商退款金额(分)',
+ `refund_sync_time` datetime DEFAULT NULL COMMENT '最近一次退款同步时间',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime DEFAULT NULL COMMENT '修改时间',
+ `del_flag` char(1) DEFAULT '0' COMMENT '删除标记',
+ `tenant_id` bigint DEFAULT NULL COMMENT '租户ID',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_hard_coffee_order_no` (`tenant_id`, `order_no`),
+ KEY `idx_hard_coffee_order_hard` (`tenant_id`, `hard_id`, `create_time`),
+ KEY `idx_hard_coffee_order_order` (`tenant_id`, `order_id`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci
+ COMMENT = '咖啡机厂商订单扩展(屋里咖啡)';
+
+CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_coffee_log`
+(
+ `id` bigint NOT NULL COMMENT '编号',
+ `order_no` varchar(32) DEFAULT NULL COMMENT '厂商订单号',
+ `order_id` bigint DEFAULT NULL COMMENT '本地订单id(hard_order.id)',
+ `hard_id` bigint DEFAULT NULL COMMENT '设备id(hard_manager.id)',
+ `unique_code` varchar(64) DEFAULT NULL COMMENT '设备唯一编号(厂商机器号)',
+ `stage` varchar(32) NOT NULL COMMENT '生命周期阶段编码',
+ `direction` char(1) NOT NULL DEFAULT '1' COMMENT '交互方向 1我方调厂商 2厂商调我方',
+ `title` varchar(64) NOT NULL COMMENT '阶段中文说明',
+ `headers` varchar(2048) DEFAULT NULL COMMENT '请求头',
+ `req` varchar(2048) DEFAULT NULL COMMENT '请求报文',
+ `res` varchar(2048) DEFAULT NULL COMMENT '响应报文',
+ `error` varchar(2048) DEFAULT NULL COMMENT '错误信息',
+ `start_time` datetime DEFAULT NULL COMMENT '开始时间',
+ `end_time` datetime DEFAULT NULL COMMENT '结束时间',
+ `cost` int DEFAULT NULL COMMENT '耗时(毫秒)',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime DEFAULT NULL COMMENT '修改时间',
+ `del_flag` char(1) DEFAULT '0' COMMENT '删除标记',
+ `tenant_id` bigint DEFAULT NULL COMMENT '租户ID',
+ PRIMARY KEY (`id`),
+ KEY `idx_hard_coffee_log_order_no` (`tenant_id`, `order_no`),
+ KEY `idx_hard_coffee_log_order` (`tenant_id`, `order_id`),
+ KEY `idx_hard_coffee_log_hard` (`tenant_id`, `hard_id`, `create_time`),
+ KEY `idx_hard_coffee_log_stage` (`tenant_id`, `stage`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci
+ COMMENT = '咖啡机使用生命周期日志(屋里咖啡)';
diff --git a/db/20260918_add_wallet_outbox_remark.sql b/db/20260918_add_wallet_outbox_remark.sql
new file mode 100644
index 000000000..98e4e9ef0
--- /dev/null
+++ b/db/20260918_add_wallet_outbox_remark.sql
@@ -0,0 +1,4 @@
+-- 钱包消息Outbox补充流水备注快照:remark 随钱包流水同事务落库,供消息正文与推送通知展示后台补充的业务说明。
+-- 历史流水和未填写场景允许为空,最多保存256个字符,与 wallet_transaction.remark 保持同长度约束。
+ALTER TABLE `pjcloudx_wallet`.`wallet_message_outbox`
+ ADD COLUMN `remark` varchar(256) DEFAULT NULL COMMENT '流水备注快照,由后台操作人员补充业务说明,最多256字符' AFTER `balance`;
diff --git a/db/20260918_hard_coffee_dict_menu.txt b/db/20260918_hard_coffee_dict_menu.txt
new file mode 100644
index 000000000..41f72255a
--- /dev/null
+++ b/db/20260918_hard_coffee_dict_menu.txt
@@ -0,0 +1,199 @@
+========================================================================
+屋里咖啡咖啡机对接一期 - 字典/菜单/权限手工添加清单 (2026-09-18)
+========================================================================
+
+说明:以下内容需要管理员在对应环境手工添加(UPMS 管理端或直接执行 SQL)。
+SQL 按租户幂等,风格与 db/20260904_add_er_yin_printer.sql 一致,可直接参考执行;
+菜单 parent_id 依赖各环境实际菜单主键,请先确认父菜单再执行。
+
+------------------------------------------------------------------------
+一、字典项
+------------------------------------------------------------------------
+
+1. 设备类型字典 hard_type 增加咖啡机(所有已配置 hard_type 的租户):
+
+INSERT INTO `pjcloudx`.`sys_dict_item`
+(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `remarks`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, d.`id`),
+ d.`id`, '13', '咖啡机', 'hard_type', '屋里咖啡扫码支付咖啡机', 13,
+ 'system', NOW(), '屋里咖啡咖啡机', '0', d.`tenant_id`
+FROM `pjcloudx`.`sys_dict` d
+WHERE d.`dict_type` = 'hard_type'
+ AND d.`del_flag` = '0'
+ AND NOT EXISTS (
+ SELECT 1 FROM `pjcloudx`.`sys_dict_item` i
+ WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_type'
+ AND i.`item_value` = '13' AND i.`del_flag` = '0'
+ );
+
+2. 新字典 hard_coffee_stage(咖啡机生命周期阶段):
+
+-- 先建字典类型(按租户)
+INSERT INTO `pjcloudx`.`sys_dict`
+(`id`, `dict_type`, `description`, `create_by`, `create_time`, `remarks`, `system_flag`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict`) + ROW_NUMBER() OVER (ORDER BY tenant.`tenant_id`),
+ 'hard_coffee_stage', '咖啡机生命周期阶段', 'system', NOW(), '咖啡机生命周期阶段', '1', '0', tenant.`tenant_id`
+FROM (SELECT DISTINCT `tenant_id` FROM `pjcloudx`.`sys_dict` WHERE `del_flag` = '0') tenant
+WHERE NOT EXISTS (
+ SELECT 1 FROM `pjcloudx`.`sys_dict` d
+ WHERE d.`tenant_id` = tenant.`tenant_id` AND d.`dict_type` = 'hard_coffee_stage' AND d.`del_flag` = '0'
+);
+
+-- 再建字典项
+INSERT INTO `pjcloudx`.`sys_dict_item`
+(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, item.`sort_order`),
+ d.`id`, item.`item_value`, item.`label`, d.`dict_type`, item.`label`, item.`sort_order`,
+ 'system', NOW(), '0', d.`tenant_id`
+FROM `pjcloudx`.`sys_dict` d
+JOIN (
+ SELECT 'GET_ORDER' AS `item_value`, '扫码拉取厂商订单' AS `label`, 1 AS `sort_order`
+ UNION ALL SELECT 'CREATE_ORDER', '创建本地订单', 2
+ UNION ALL SELECT 'WALLET_PAY', '饭卡扣款', 3
+ UNION ALL SELECT 'NOTIFY_PAY', '通知厂商支付结果', 4
+ UNION ALL SELECT 'QUERY', '厂商查单', 5
+ UNION ALL SELECT 'REFUND_NOTIFY','通知厂商退款', 6
+) item ON item.`item_value` IS NOT NULL
+WHERE d.`dict_type` = 'hard_coffee_stage'
+ AND d.`del_flag` = '0'
+ AND NOT EXISTS (
+ SELECT 1 FROM `pjcloudx`.`sys_dict_item` i
+ WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_coffee_stage'
+ AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0'
+ );
+
+3. 新字典 hard_coffee_direction(交互方向):
+
+INSERT INTO `pjcloudx`.`sys_dict`
+(`id`, `dict_type`, `description`, `create_by`, `create_time`, `remarks`, `system_flag`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict`) + ROW_NUMBER() OVER (ORDER BY tenant.`tenant_id`),
+ 'hard_coffee_direction', '咖啡机日志交互方向', 'system', NOW(), '咖啡机日志交互方向', '1', '0', tenant.`tenant_id`
+FROM (SELECT DISTINCT `tenant_id` FROM `pjcloudx`.`sys_dict` WHERE `del_flag` = '0') tenant
+WHERE NOT EXISTS (
+ SELECT 1 FROM `pjcloudx`.`sys_dict` d
+ WHERE d.`tenant_id` = tenant.`tenant_id` AND d.`dict_type` = 'hard_coffee_direction' AND d.`del_flag` = '0'
+);
+
+INSERT INTO `pjcloudx`.`sys_dict_item`
+(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, item.`sort_order`),
+ d.`id`, item.`item_value`, item.`label`, d.`dict_type`, item.`label`, item.`sort_order`,
+ 'system', NOW(), '0', d.`tenant_id`
+FROM `pjcloudx`.`sys_dict` d
+JOIN (
+ SELECT '1' AS `item_value`, '我方调厂商' AS `label`, 1 AS `sort_order`
+ UNION ALL SELECT '2', '厂商调我方', 2
+) item ON item.`item_value` IS NOT NULL
+WHERE d.`dict_type` = 'hard_coffee_direction'
+ AND d.`del_flag` = '0'
+ AND NOT EXISTS (
+ SELECT 1 FROM `pjcloudx`.`sys_dict_item` i
+ WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_coffee_direction'
+ AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0'
+ );
+
+4. 新字典 hard_coffee_notify_status(通知厂商状态,用于订单列表筛选/展示):
+
+INSERT INTO `pjcloudx`.`sys_dict`
+(`id`, `dict_type`, `description`, `create_by`, `create_time`, `remarks`, `system_flag`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict`) + ROW_NUMBER() OVER (ORDER BY tenant.`tenant_id`),
+ 'hard_coffee_notify_status', '咖啡机通知厂商状态', 'system', NOW(), '咖啡机通知厂商状态', '1', '0', tenant.`tenant_id`
+FROM (SELECT DISTINCT `tenant_id` FROM `pjcloudx`.`sys_dict` WHERE `del_flag` = '0') tenant
+WHERE NOT EXISTS (
+ SELECT 1 FROM `pjcloudx`.`sys_dict` d
+ WHERE d.`tenant_id` = tenant.`tenant_id` AND d.`dict_type` = 'hard_coffee_notify_status' AND d.`del_flag` = '0'
+);
+
+INSERT INTO `pjcloudx`.`sys_dict_item`
+(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, item.`sort_order`),
+ d.`id`, item.`item_value`, item.`label`, d.`dict_type`, item.`label`, item.`sort_order`,
+ 'system', NOW(), '0', d.`tenant_id`
+FROM `pjcloudx`.`sys_dict` d
+JOIN (
+ SELECT '0' AS `item_value`, '未通知' AS `label`, 1 AS `sort_order`
+ UNION ALL SELECT '1', '通知成功', 2
+ UNION ALL SELECT '2', '通知失败', 3
+) item ON item.`item_value` IS NOT NULL
+WHERE d.`dict_type` = 'hard_coffee_notify_status'
+ AND d.`del_flag` = '0'
+ AND NOT EXISTS (
+ SELECT 1 FROM `pjcloudx`.`sys_dict_item` i
+ WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_coffee_notify_status'
+ AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0'
+ );
+
+------------------------------------------------------------------------
+二、菜单与按钮权限
+------------------------------------------------------------------------
+
+说明:
+- 「咖啡机订单」建议挂在硬件订单管理菜单下(父菜单 permission = hard_hardOrder_view 所在菜单);
+- 「咖啡机日志」建议挂在设备管理菜单下(父菜单 permission = hard_hardManager_view 所在菜单);
+- 如各环境父菜单定位不同,请先查 parent_id 再执行。
+
+1. 咖啡机订单菜单(menu_type 1=菜单,含页面路由,路由地址建议 /hard/coffee-order):
+
+INSERT INTO `pjcloudx`.`sys_menu`
+(`menu_id`, `name`, `permission`, `parent_id`, `path`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY parent_menu.`tenant_id`, parent_menu.`menu_id`),
+ '咖啡机订单', 'hard_hardCoffeeOrder_view', parent_menu.`menu_id`, 'coffee-order',
+ '1', 30, '0', '0', '1', 'system', NOW(), '0', parent_menu.`tenant_id`
+FROM `pjcloudx`.`sys_menu` parent_menu
+WHERE parent_menu.`permission` = 'hard_hardOrder_view' AND parent_menu.`del_flag` = '0'
+ AND NOT EXISTS (
+ SELECT 1 FROM `pjcloudx`.`sys_menu` existing
+ WHERE existing.`tenant_id` = parent_menu.`tenant_id`
+ AND existing.`permission` = 'hard_hardCoffeeOrder_view' AND existing.`del_flag` = '0'
+ );
+
+2. 咖啡机日志菜单:
+
+INSERT INTO `pjcloudx`.`sys_menu`
+(`menu_id`, `name`, `permission`, `parent_id`, `path`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY parent_menu.`tenant_id`, parent_menu.`menu_id`),
+ '咖啡机日志', 'hard_hardCoffeeLog_view', parent_menu.`menu_id`, 'coffee-log',
+ '1', 31, '0', '0', '1', 'system', NOW(), '0', parent_menu.`tenant_id`
+FROM `pjcloudx`.`sys_menu` parent_menu
+WHERE parent_menu.`permission` = 'hard_hardManager_view' AND parent_menu.`del_flag` = '0'
+ AND NOT EXISTS (
+ SELECT 1 FROM `pjcloudx`.`sys_menu` existing
+ WHERE existing.`tenant_id` = parent_menu.`tenant_id`
+ AND existing.`permission` = 'hard_hardCoffeeLog_view' AND existing.`del_flag` = '0'
+ );
+
+------------------------------------------------------------------------
+三、第三方密钥 third_key_manage(第三方库 pjcloudx_thirdparty)
+------------------------------------------------------------------------
+
+说明:屋里咖啡两对密钥通过 thirdparty 管理端「第三方密钥管理」录入,或按下面模板执行 SQL。
+字段映射:app_key=屋里概念里的 appid,app_secret=屋里概念里的 appkey(SHA1签名密钥)。
+密钥为平台级全局唯一(跨租户读取),建议维护在默认租户(tenant_id=1)下。
+修改密钥后 hardware 侧最多延迟 5 分钟生效(本地缓存)。
+
+1. 我方调厂商 getOrder/callback/refund 的鉴权对(bizType=11):
+
+INSERT INTO `pjcloudx_thirdparty`.`third_key_manage`
+(`id`, `name`, `biz_type`, `app_key`, `app_secret`, `status`, `remark`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
+VALUES
+(2026091800000000001, '屋里咖啡', '11', '<向屋里申请的16位appid>', '<向屋里申请的16位appkey>', '1',
+ '我方作为第三方支付服务商调厂商接口', 'system', NOW(), '0', 1);
+
+2. 厂商调我方 queryOrder 查单的验签对(bizType=12,我方自行生成后告知屋里):
+
+INSERT INTO `pjcloudx_thirdparty`.`third_key_manage`
+(`id`, `name`, `biz_type`, `app_key`, `app_secret`, `status`, `remark`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
+VALUES
+(2026091800000000002, '屋里咖啡查单', '12', '<我方生成并告知屋里的16位appid>', '<我方生成的16位appkey>', '1',
+ '厂商调我方queryOrder验签', 'system', NOW(), '0', 1);
+
+------------------------------------------------------------------------
+四、后端权限字符串与接口对应关系(前端页面取用)
+------------------------------------------------------------------------
+
+hard_hardCoffeeOrder_view:
+ GET /hard-coffee-manager/order/page 咖啡机订单分页
+ GET /hard-coffee-manager/order/{id} 咖啡机订单详情(含生命周期时间线)
+
+hard_hardCoffeeLog_view:
+ GET /hard-coffee-manager/log/page 生命周期日志分页
diff --git a/db/20260918_hard_coffee_h5_api.md b/db/20260918_hard_coffee_h5_api.md
new file mode 100644
index 000000000..9c1a1d42c
--- /dev/null
+++ b/db/20260918_hard_coffee_h5_api.md
@@ -0,0 +1,72 @@
+# 咖啡机(屋里咖啡)H5支付页对接契约 - 一期饭卡支付
+
+> 交付时间: 2026-09-18。H5 页面在前端仓库(biz_zax_app_cloud)实现,本文档为后端接口契约。
+
+## 1. 页面入口与二维码 URL
+
+用户在咖啡机上选择饮品后,机器展示二维码,URL 由屋里咖啡后台配置,指向我方 H5:
+
+```
+https://www.pjzhhq.cn/h5/#/pages/pay/coffee-pay?order_no={厂商订单号}
+```
+
+- `order_no`: 厂商订单号,19 位数字(测试环境 18 位)。
+- 屋里咖啡后台的「支付跳转 URL 模板」按上述地址配置,占位符以厂商后台实际支持为准。
+
+## 2. 接口(匿名,无需登录态)
+
+网关前缀: `https://www.pjzhhq.cn/api/hard` (StripPrefix 后到 hardware 服务)。
+所有响应为统一 `R` 结构 `{code:0, msg, data}`,`code != 0` 时展示 `msg`。
+
+### 2.1 拉取订单信息
+
+```
+GET /api/hard/hard-coffee/order-info?orderNo={order_no}
+```
+
+响应 `data`:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| orderId | long | 本地订单id,**支付时回传** |
+| orderNo | string | 厂商订单号 |
+| product | string | 产品名称 |
+| img | string | 产品图片完整链接 |
+| isCold | string | `1`冷饮 `0`热饮 |
+| price | int | 价格(分) |
+| priceYuan | string | 价格(元,两位小数),直接展示 |
+| machineName | string | 机器号 |
+| paid | boolean | 是否已支付。`true` 时展示"已支付"并禁止再次支付 |
+| remainSeconds | long | 支付剩余秒数(下单起5分钟),用于倒计时,超时提示重新发起 |
+
+页面逻辑: 进入页面即调用;`paid=true` 显示已支付结果;`remainSeconds<=0` 显示超时。
+
+### 2.2 饭卡支付(卡号即扣款)
+
+```
+POST /api/hard/hard-coffee/card-pay
+Content-Type: application/json
+
+{ "orderId": 1234567890, "cardNo": "0123456789" }
+```
+
+- 卡号即身份,与消费机刷卡一致,无密码。失败原因见 `msg`(非本单位饭卡/余额不足/订单已超时/订单已支付等)。
+
+响应 `data`:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| orderId | long | 本地订单id |
+| orderNo | string | 厂商订单号 |
+| name | string | 支付人姓名(脱敏展示由前端决定) |
+| cardNo | string | 饭卡卡号 |
+| balanceYuan | string | 支付后余额(元) |
+
+支付成功后展示"取咖啡"提示;出餐由后端回调厂商完成,前端无需轮询(如需兜底可再次调用 2.1 查看 `paid`)。
+
+## 3. 注意事项
+
+1. 匿名接口无租户头,后端通过机器号自动定位租户,前端无需传 TENANT-ID。
+2. 同一订单支付窗口 5 分钟,过期后在咖啡机重新发起生成新二维码。
+3. 防重复提交由后端并发锁保证,前端支付按钮加 loading 即可。
+4. 一期仅饭卡支付;微信/支付宝支付为二期,接口预留扩展。
diff --git a/db/20260918_hard_coffee_integration_notes.md b/db/20260918_hard_coffee_integration_notes.md
new file mode 100644
index 000000000..4a923992c
--- /dev/null
+++ b/db/20260918_hard_coffee_integration_notes.md
@@ -0,0 +1,52 @@
+# 屋里咖啡咖啡机对接一期 - 联调与部署说明 (2026-09-18)
+
+## 1. 角色与外部配置
+
+我方平台 = 厂商文档中的"第三方支付服务商"。需要在屋里咖啡侧完成配置:
+
+| 配置项 | 值 | 说明 |
+|---|---|---|
+| 支付跳转URL模板 | `https://www.pjzhhq.cn/h5/#/pages/pay/coffee-pay?order_no=厂商单号参数` | 用户扫码打开我方H5(前端页面由前端仓库实现) |
+| 我方提供的查单地址 | `https://www.pjzhhq.cn/api/hard/hard-coffee/wuli/query-order` | 文档3.4 queryOrder,POST form表单 |
+| 向屋里申请 | 我方调 getOrder/callback/refund 的 appid/appkey(16位) | 录入 third_key_manage bizType=11 |
+| 向屋里分配 | 厂商调我方 queryOrder 的 appid/appkey(16位) | 录入 third_key_manage bizType=12 |
+
+## 2. 密钥与配置
+
+密钥统一在 **thirdparty 服务 third_key_manage 表**维护(管理端「第三方密钥管理」录入或 SQL,模板见 `20260918_hard_coffee_dict_menu.txt` 第三节):
+
+| biz_type | 用途 | app_key | app_secret |
+|---|---|---|---|
+| 11 屋里咖啡 | 我方调 getOrder/callback/refund | 向屋里申请的 appid | 向屋里申请的 appkey(SHA1签名) |
+| 12 屋里咖啡查单 | 厂商调我方 queryOrder 验签 | 我方生成并告知屋里的 appid | 我方生成的 appkey |
+
+密钥为平台级全局唯一(跨租户读取),建议 tenant_id=1;修改后 hardware 侧最多延迟 5 分钟生效(本地缓存)。
+
+Nacos 的 pjcloud-hardware-biz 只保留环境参数:
+
+```yaml
+coffee:
+ base-url: https://test.wulicoffee.com # 生产切 https://www.wulicoffee.com
+ timeout-millis: 10000
+```
+
+## 3. 设备登记(管理端 hard-manager)
+
+1. 先执行 `db/20260918_add_hard_coffee.sql`(两张新表)。
+2. 管理端添加字典 hard_type=13 咖啡机(见 dict_menu txt 文件)。
+3. 设备管理新增设备: 类型=咖啡机(13), 设备唯一编号=屋里机器号(machine_name), 绑定商户。
+4. 未登记的机器扫码会返回"咖啡机未接入平台"。
+
+## 4. 部署顺序
+
+1. 执行 SQL: `db/20260918_add_hard_coffee.sql` → 字典/菜单/third_key_manage 密钥(手工,`20260918_hard_coffee_dict_menu.txt`)。
+2. 部署 **pjcloud-thirdparty-biz 先于 pjcloud-hardware-biz**(hardware 通过新增 Feign /third-key-manage/inner/get-by-biz-type 读密钥)。
+3. 前端 H5 加 coffee-pay 页面(契约见 `20260918_hard_coffee_h5_api.md`)。
+4. 在屋里测试环境发起一笔扫码支付联调,验证: 拉单 → 饭卡支付 → callback → 出餐 → 管理端订单/日志。
+
+## 5. 运维要点
+
+- 支付结果通知厂商失败: hard_coffee_order.wuli_notify_status=2,补偿任务每60秒重试,上限10次(超过需人工介入,管理端订单列表可见)。
+- 退款同步: 管理端售后退款成功后自动按差额通知厂商(refund_sync_amount 记账),补偿任务回看近7天补推缺口。
+- 生命周期日志: hard_coffee_log 按订单号/本地订单/设备关联,管理端"咖啡机订单详情"含完整时间线。
+- 查单验签: 时间戳窗口±5分钟,appid 必须等于 coffee.query-app-id。
diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java
index b8889a508..7039cb637 100644
--- a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java
+++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java
@@ -1339,6 +1339,7 @@ public interface ErrorCodes {
String APP_MSG_WALLET_DETAIL_INC = "app.msg.wallet.detail.inc";
String APP_MSG_WALLET_DETAIL_DEC = "app.msg.wallet.detail.dec";
String APP_MSG_WALLET_DETAIL_SEP = "app.msg.wallet.detail.sep";
+ String APP_MSG_WALLET_DETAIL_REMARK = "app.msg.wallet.detail.remark";
String APP_MSG_WALLET_CONTENT = "app.msg.wallet.content";
String APP_MSG_MEAL_DEFAULT_NAME = "app.msg.meal.default.name";
diff --git a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties
index 9f07d6bfd..6dae7b3d5 100644
--- a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties
+++ b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties
@@ -443,6 +443,7 @@ app.msg.wallet.default.name=Wallet
app.msg.wallet.detail.inc={0} increased by {1}, balance {2}
app.msg.wallet.detail.dec={0} decreased by {1}, balance {2}
app.msg.wallet.detail.sep=;
+app.msg.wallet.detail.remark= (Note: {0})
app.msg.wallet.content={0}: {1}
app.msg.meal.default.name=This reserved meal
app.msg.meal.content={0} has been verified. Enjoy your meal.
diff --git a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties
index f6b8a84c2..4b59d67c3 100644
--- a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties
+++ b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties
@@ -443,6 +443,7 @@ app.msg.wallet.default.name=钱包
app.msg.wallet.detail.inc={0}增加{1}元,余额{2}元
app.msg.wallet.detail.dec={0}减少{1}元,余额{2}元
app.msg.wallet.detail.sep=;
+app.msg.wallet.detail.remark=(备注:{0})
app.msg.wallet.content={0}:{1}
app.msg.meal.default.name=本次预约用餐
app.msg.meal.content={0}已核销,祝您用餐愉快。
diff --git a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties
index 0ff970060..a41bdfcaa 100644
--- a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties
+++ b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties
@@ -443,6 +443,7 @@ app.msg.wallet.default.name=錢包
app.msg.wallet.detail.inc={0}增加{1}元,餘額{2}元
app.msg.wallet.detail.dec={0}減少{1}元,餘額{2}元
app.msg.wallet.detail.sep=;
+app.msg.wallet.detail.remark=(備註:{0})
app.msg.wallet.content={0}:{1}
app.msg.meal.default.name=本次預約用餐
app.msg.meal.content={0}已核銷,祝您用餐愉快。
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java
index d842d8637..8de7e697a 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java
@@ -28,6 +28,8 @@ public enum HardTypeEnum {
YYX_WIFI("11", "wifi-云音响"),
/** 由绑定的超市或食堂 Android 终端代理驱动的无屏尔印 USB 打印机。 */
EY_PRINT("12", "尔印打印机器"),
+ /** 屋里咖啡扫码支付咖啡机,机器号写入 uniqueCode。 */
+ COFFEE("13", "咖啡机"),
;
private final String type;
private final String description;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogDirectionEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogDirectionEnum.java
new file mode 100644
index 000000000..6ba9e165e
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogDirectionEnum.java
@@ -0,0 +1,19 @@
+package com.bsd.pjcloud.hard.api.constant.coffee;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 咖啡机日志交互方向
+ */
+@AllArgsConstructor
+@Getter
+public enum HardCoffeeLogDirectionEnum {
+
+ OUT("1", "我方调厂商"),
+ IN("2", "厂商调我方"),
+ ;
+
+ private final String direction;
+ private final String description;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogStageEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogStageEnum.java
new file mode 100644
index 000000000..cfb976e0d
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogStageEnum.java
@@ -0,0 +1,33 @@
+package com.bsd.pjcloud.hard.api.constant.coffee;
+
+import cn.hutool.core.util.ObjectUtil;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 咖啡机生命周期日志阶段
+ */
+@AllArgsConstructor
+@Getter
+public enum HardCoffeeLogStageEnum {
+
+ GET_ORDER("GET_ORDER", "扫码拉取厂商订单"),
+ CREATE_ORDER("CREATE_ORDER", "创建本地订单"),
+ WALLET_PAY("WALLET_PAY", "饭卡扣款"),
+ NOTIFY_PAY("NOTIFY_PAY", "通知厂商支付结果"),
+ QUERY("QUERY", "厂商查单"),
+ REFUND_NOTIFY("REFUND_NOTIFY", "通知厂商退款"),
+ ;
+
+ private final String stage;
+ private final String title;
+
+ public static HardCoffeeLogStageEnum valueOfByStage(String stage) {
+ if (ObjectUtil.isEmpty(stage)) {
+ return null;
+ }
+ return Arrays.stream(values()).filter(v -> ObjectUtil.equals(stage, v.getStage())).findFirst().orElse(null);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeNotifyStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeNotifyStatusEnum.java
new file mode 100644
index 000000000..762e2f4bf
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeNotifyStatusEnum.java
@@ -0,0 +1,20 @@
+package com.bsd.pjcloud.hard.api.constant.coffee;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 支付结果通知厂商状态
+ */
+@AllArgsConstructor
+@Getter
+public enum HardCoffeeNotifyStatusEnum {
+
+ NONE("0", "未通知"),
+ SUCCESS("1", "通知成功"),
+ FAIL("2", "通知失败"),
+ ;
+
+ private final String status;
+ private final String description;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java
index 08295e994..035e10e9b 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java
@@ -23,6 +23,7 @@ public enum HardOrderTypeEnum {
TOC_VISITOR("8", "toc访客购买(不记名用户)"),
XFJ("9", "消费机支付"),
TAKE_OUT("10", "外卖"),
+ COFFEE("13", "咖啡机支付"),
;
private final String type;
private final String description;
@@ -34,10 +35,11 @@ public enum HardOrderTypeEnum {
// 简单支付(没有购物车)
public static final List isSimplePayList = List.of(
- XFJ
+ XFJ,
+ COFFEE
);
- public static final List refundList = List.of(BELOW, RESERVE, FOREIGN_TICKET, ZY, LB, FARMER, XFJ, TAKE_OUT);
+ public static final List refundList = List.of(BELOW, RESERVE, FOREIGN_TICKET, ZY, LB, FARMER, XFJ, TAKE_OUT, COFFEE);
public static final List payList = List.of(BELOW, RESERVE);
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeCardPayDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeCardPayDto.java
new file mode 100644
index 000000000..ec4dfd581
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeCardPayDto.java
@@ -0,0 +1,30 @@
+package com.bsd.pjcloud.hard.api.dto.coffee;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+/**
+ * 咖啡机饭卡支付请求(H5匿名)
+ */
+@Data
+@Schema(description = "咖啡机饭卡支付请求")
+public class HardCoffeeCardPayDto {
+
+ /**
+ * 本地订单id(拉单接口返回)
+ */
+ @NotNull(message = "订单id不能为空")
+ @Schema(description = "本地订单id")
+ private Long orderId;
+
+ /**
+ * 饭卡卡号
+ */
+ @NotBlank(message = "卡号不能为空")
+ @Pattern(regexp = "^\\d{1,32}$", message = "卡号格式错误")
+ @Schema(description = "饭卡卡号")
+ private String cardNo;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeLogDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeLogDto.java
new file mode 100644
index 000000000..71f05b778
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeLogDto.java
@@ -0,0 +1,20 @@
+package com.bsd.pjcloud.hard.api.dto.coffee;
+
+import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+/**
+ * 咖啡机生命周期日志管理端分页查询
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class HardCoffeeLogDto extends HardCoffeeLogEntity {
+
+ /**
+ * 开始时间范围
+ */
+ private LocalDateTime[] startTimeList;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeOrderDto.java
new file mode 100644
index 000000000..31d334d82
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeOrderDto.java
@@ -0,0 +1,30 @@
+package com.bsd.pjcloud.hard.api.dto.coffee;
+
+import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+/**
+ * 咖啡机订单管理端分页查询
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class HardCoffeeOrderDto extends HardCoffeeOrderEntity {
+
+ /**
+ * 创建时间范围
+ */
+ private LocalDateTime[] createTimeList;
+
+ /**
+ * 本地订单状态(hard_order.order_status)
+ */
+ private String orderStatus;
+
+ /**
+ * 本地订单支付人id
+ */
+ private Long userId;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/WuliQueryOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/WuliQueryOrderDto.java
new file mode 100644
index 000000000..2e06c7e10
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/WuliQueryOrderDto.java
@@ -0,0 +1,42 @@
+package com.bsd.pjcloud.hard.api.dto.coffee;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 屋里咖啡查单请求(文档3.4 queryOrder,厂商调我方)
+ */
+@Data
+@Schema(description = "屋里咖啡查单请求")
+public class WuliQueryOrderDto {
+
+ /**
+ * 订单号(19位数字)
+ */
+ @Schema(description = "订单号")
+ private String orderNo;
+
+ /**
+ * 应用id(我方分配给厂商)
+ */
+ @Schema(description = "应用id")
+ private String appId;
+
+ /**
+ * 随机字符串
+ */
+ @Schema(description = "随机字符串")
+ private String randstr;
+
+ /**
+ * Unix时间戳(秒)
+ */
+ @Schema(description = "Unix时间戳")
+ private String timestamp;
+
+ /**
+ * 签名
+ */
+ @Schema(description = "签名")
+ private String sign;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeLogEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeLogEntity.java
new file mode 100644
index 000000000..f293f4bbb
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeLogEntity.java
@@ -0,0 +1,154 @@
+package com.bsd.pjcloud.hard.api.entity.coffee;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.baomidou.mybatisplus.extension.activerecord.Model;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+/**
+ * 咖啡机使用生命周期日志
+ */
+@Data
+@TenantTable
+@TableName("hard_coffee_log")
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "咖啡机生命周期日志")
+public class HardCoffeeLogEntity extends Model {
+
+ /**
+ * 编号
+ */
+ @TableId(type = IdType.ASSIGN_ID)
+ @Schema(description = "编号")
+ private Long id;
+
+ /**
+ * 厂商订单号
+ */
+ @Schema(description = "厂商订单号")
+ private String orderNo;
+
+ /**
+ * 本地订单id
+ */
+ @Schema(description = "本地订单id")
+ private Long orderId;
+
+ /**
+ * 设备id
+ */
+ @Schema(description = "设备id")
+ private Long hardId;
+
+ /**
+ * 设备唯一编号(厂商机器号)
+ */
+ @Schema(description = "设备唯一编号")
+ private String uniqueCode;
+
+ /**
+ * 生命周期阶段编码
+ */
+ @Schema(description = "生命周期阶段编码")
+ private String stage;
+
+ /**
+ * 交互方向 1我方调厂商 2厂商调我方
+ */
+ @Schema(description = "交互方向")
+ private String direction;
+
+ /**
+ * 阶段中文说明
+ */
+ @Schema(description = "阶段说明")
+ private String title;
+
+ /**
+ * 请求头
+ */
+ @Schema(description = "请求头")
+ private String headers;
+
+ /**
+ * 请求报文
+ */
+ @Schema(description = "请求报文")
+ private String req;
+
+ /**
+ * 响应报文
+ */
+ @Schema(description = "响应报文")
+ private String res;
+
+ /**
+ * 错误信息
+ */
+ @Schema(description = "错误信息")
+ private String error;
+
+ /**
+ * 开始时间
+ */
+ @Schema(description = "开始时间")
+ private LocalDateTime startTime;
+
+ /**
+ * 结束时间
+ */
+ @Schema(description = "结束时间")
+ private LocalDateTime endTime;
+
+ /**
+ * 耗时(毫秒)
+ */
+ @Schema(description = "耗时(毫秒)")
+ private Long cost;
+
+ /**
+ * 创建人
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建人")
+ private String createBy;
+
+ /**
+ * 创建时间
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+
+ /**
+ * 修改人
+ */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改人")
+ private String updateBy;
+
+ /**
+ * 修改时间
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "修改时间")
+ private LocalDateTime updateTime;
+
+ /**
+ * 删除标记
+ */
+ @TableLogic
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "删除标记")
+ private String delFlag;
+
+ /**
+ * 租户ID
+ */
+ @Schema(description = "租户ID")
+ private Long tenantId;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeOrderEntity.java
new file mode 100644
index 000000000..155c45260
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeOrderEntity.java
@@ -0,0 +1,154 @@
+package com.bsd.pjcloud.hard.api.entity.coffee;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.baomidou.mybatisplus.extension.activerecord.Model;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+/**
+ * 咖啡机厂商订单扩展(屋里咖啡)
+ */
+@Data
+@TenantTable
+@TableName("hard_coffee_order")
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "咖啡机厂商订单扩展")
+public class HardCoffeeOrderEntity extends Model {
+
+ /**
+ * 编号
+ */
+ @TableId(type = IdType.ASSIGN_ID)
+ @Schema(description = "编号")
+ private Long id;
+
+ /**
+ * 本地硬件订单id(hard_order.id)
+ */
+ @Schema(description = "本地订单id")
+ private Long orderId;
+
+ /**
+ * 屋里咖啡订单号(19位数字)
+ */
+ @Schema(description = "厂商订单号")
+ private String orderNo;
+
+ /**
+ * 咖啡机设备id
+ */
+ @Schema(description = "设备id")
+ private Long hardId;
+
+ /**
+ * 厂商机器号快照
+ */
+ @Schema(description = "厂商机器号")
+ private String machineName;
+
+ /**
+ * 产品名称
+ */
+ @Schema(description = "产品名称")
+ private String product;
+
+ /**
+ * 产品图片链接
+ */
+ @Schema(description = "产品图片链接")
+ private String img;
+
+ /**
+ * 1冷饮 0热饮
+ */
+ @Schema(description = "1冷饮 0热饮")
+ private String isCold;
+
+ /**
+ * 厂商订单价格(分)
+ */
+ @Schema(description = "厂商订单价格(分)")
+ private Integer price;
+
+ /**
+ * 支付者手机号
+ */
+ @Schema(description = "支付者手机号")
+ private String phoneNo;
+
+ /**
+ * 支付结果通知厂商状态 0未通知 1成功 2失败
+ */
+ @Schema(description = "通知厂商状态")
+ private String wuliNotifyStatus;
+
+ /**
+ * 最近一次通知厂商时间
+ */
+ @Schema(description = "最近通知时间")
+ private LocalDateTime wuliNotifyTime;
+
+ /**
+ * 通知厂商累计尝试次数
+ */
+ @Schema(description = "通知尝试次数")
+ private Integer wuliNotifyCount;
+
+ /**
+ * 累计已同步厂商退款金额(分)
+ */
+ @Schema(description = "累计已同步退款金额(分)")
+ private Integer refundSyncAmount;
+
+ /**
+ * 最近一次退款同步时间
+ */
+ @Schema(description = "最近退款同步时间")
+ private LocalDateTime refundSyncTime;
+
+ /**
+ * 创建人
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建人")
+ private String createBy;
+
+ /**
+ * 创建时间
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+
+ /**
+ * 修改人
+ */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改人")
+ private String updateBy;
+
+ /**
+ * 修改时间
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "修改时间")
+ private LocalDateTime updateTime;
+
+ /**
+ * 删除标记
+ */
+ @TableLogic
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "删除标记")
+ private String delFlag;
+
+ /**
+ * 租户ID
+ */
+ @Schema(description = "租户ID")
+ private Long tenantId;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeLogVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeLogVo.java
new file mode 100644
index 000000000..b1dcafe66
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeLogVo.java
@@ -0,0 +1,18 @@
+package com.bsd.pjcloud.hard.api.vo.coffee;
+
+import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 咖啡机生命周期日志
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class HardCoffeeLogVo extends HardCoffeeLogEntity {
+
+ /**
+ * 设备名称
+ */
+ private String hardName;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderDetailVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderDetailVo.java
new file mode 100644
index 000000000..b9a791e0c
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderDetailVo.java
@@ -0,0 +1,26 @@
+package com.bsd.pjcloud.hard.api.vo.coffee;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 咖啡机订单详情(含生命周期时间线)
+ */
+@Data
+@Schema(description = "咖啡机订单详情")
+public class HardCoffeeOrderDetailVo {
+
+ /**
+ * 订单信息
+ */
+ @Schema(description = "订单信息")
+ private HardCoffeeOrderVo order;
+
+ /**
+ * 生命周期日志(按时间正序)
+ */
+ @Schema(description = "生命周期日志")
+ private List logs;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderInfoVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderInfoVo.java
new file mode 100644
index 000000000..359bc3870
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderInfoVo.java
@@ -0,0 +1,74 @@
+package com.bsd.pjcloud.hard.api.vo.coffee;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 咖啡机订单信息(H5匿名拉单返回)
+ */
+@Data
+@Builder
+@Schema(description = "咖啡机订单信息")
+public class HardCoffeeOrderInfoVo {
+
+ /**
+ * 本地订单id(支付时回传)
+ */
+ @Schema(description = "本地订单id")
+ private Long orderId;
+
+ /**
+ * 厂商订单号
+ */
+ @Schema(description = "厂商订单号")
+ private String orderNo;
+
+ /**
+ * 产品名称
+ */
+ @Schema(description = "产品名称")
+ private String product;
+
+ /**
+ * 产品图片链接
+ */
+ @Schema(description = "产品图片链接")
+ private String img;
+
+ /**
+ * 1冷饮 0热饮
+ */
+ @Schema(description = "1冷饮 0热饮")
+ private String isCold;
+
+ /**
+ * 价格(分)
+ */
+ @Schema(description = "价格(分)")
+ private Integer price;
+
+ /**
+ * 价格(元,两位小数)
+ */
+ @Schema(description = "价格(元)")
+ private String priceYuan;
+
+ /**
+ * 厂商机器号
+ */
+ @Schema(description = "厂商机器号")
+ private String machineName;
+
+ /**
+ * 是否已支付(已支付时禁止重复支付)
+ */
+ @Schema(description = "是否已支付")
+ private Boolean paid;
+
+ /**
+ * 支付剩余秒数(超时后不可支付)
+ */
+ @Schema(description = "支付剩余秒数")
+ private Long remainSeconds;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderVo.java
new file mode 100644
index 000000000..2db48eb05
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderVo.java
@@ -0,0 +1,50 @@
+package com.bsd.pjcloud.hard.api.vo.coffee;
+
+import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+/**
+ * 咖啡机订单管理端分页/详情
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class HardCoffeeOrderVo extends HardCoffeeOrderEntity {
+
+ /**
+ * 设备名称
+ */
+ private String hardName;
+
+ /**
+ * 支付人姓名
+ */
+ private String userName;
+
+ /**
+ * 本地订单状态
+ */
+ private String orderStatus;
+
+ /**
+ * 本地订单支付方式
+ */
+ private String payType;
+
+ /**
+ * 支付时间
+ */
+ private LocalDateTime payTime;
+
+ /**
+ * 本地订单退款金额(分)
+ */
+ private Integer refundPrice;
+
+ /**
+ * 本地订单退款状态
+ */
+ private String refundStatus;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeePayResultVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeePayResultVo.java
new file mode 100644
index 000000000..102f09bf2
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeePayResultVo.java
@@ -0,0 +1,44 @@
+package com.bsd.pjcloud.hard.api.vo.coffee;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 咖啡机饭卡支付结果(H5匿名)
+ */
+@Data
+@Builder
+@Schema(description = "咖啡机饭卡支付结果")
+public class HardCoffeePayResultVo {
+
+ /**
+ * 本地订单id
+ */
+ @Schema(description = "本地订单id")
+ private Long orderId;
+
+ /**
+ * 厂商订单号
+ */
+ @Schema(description = "厂商订单号")
+ private String orderNo;
+
+ /**
+ * 支付人姓名
+ */
+ @Schema(description = "支付人姓名")
+ private String name;
+
+ /**
+ * 饭卡卡号
+ */
+ @Schema(description = "饭卡卡号")
+ private String cardNo;
+
+ /**
+ * 支付后余额(元,两位小数)
+ */
+ @Schema(description = "支付后余额(元)")
+ private String balanceYuan;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/WuliQueryOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/WuliQueryOrderVo.java
new file mode 100644
index 000000000..574ee466a
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/WuliQueryOrderVo.java
@@ -0,0 +1,42 @@
+package com.bsd.pjcloud.hard.api.vo.coffee;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 屋里咖啡查单响应(文档3.4 queryOrder)。
+ * 厂商协议字段为蛇形命名(return_code/return_msg/phone_no),序列化必须保持一致
+ */
+@Data
+@Builder
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+@Schema(description = "屋里咖啡查单响应")
+public class WuliQueryOrderVo {
+
+ /**
+ * SUCCESS/FAIL
+ */
+ @Schema(description = "SUCCESS/FAIL")
+ private String returnCode;
+
+ /**
+ * 失败原因
+ */
+ @Schema(description = "失败原因")
+ private String returnMsg;
+
+ /**
+ * 支付状态 SUCCESS支付成功/USERPAYING支付中/FAIL失败或关闭
+ */
+ @Schema(description = "支付状态")
+ private String status;
+
+ /**
+ * 支付者手机号
+ */
+ @Schema(description = "支付者手机号")
+ private String phoneNo;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/CoffeeConf.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/CoffeeConf.java
new file mode 100644
index 000000000..77f8556b9
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/CoffeeConf.java
@@ -0,0 +1,25 @@
+package com.bsd.pjcloud.hard.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 屋里咖啡对接配置。密钥统一在 thirdparty 服务 third_key_manage 表维护
+ * (bizType=11 我方调厂商,bizType=12 厂商调我方查单),此处只保留环境相关参数。
+ */
+@Component
+@ConfigurationProperties(prefix = "coffee")
+@Data
+public class CoffeeConf {
+
+ /**
+ * 厂商接口域名,测试环境 https://test.wulicoffee.com,生产环境 https://www.wulicoffee.com
+ */
+ private String baseUrl = "https://test.wulicoffee.com";
+
+ /**
+ * HTTP 超时时间(毫秒)
+ */
+ private Integer timeoutMillis = 10000;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java
new file mode 100644
index 000000000..1b19092f2
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java
@@ -0,0 +1,50 @@
+package com.bsd.pjcloud.hard.controller.coffee;
+
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.security.annotation.Inner;
+import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderInfoVo;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeePayResultVo;
+import com.bsd.pjcloud.hard.service.HardCoffeeService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 咖啡机H5匿名支付接口。
+ * 用户扫描咖啡机二维码打开我方H5支付页,页面依次调用拉单和饭卡支付接口;
+ * 匿名安全由厂商订单号随机性+5分钟支付窗口+并发锁保证。
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/hard-coffee")
+@Tag(description = "hard-coffee", name = "咖啡机H5支付接口")
+@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
+public class HardCoffeeController {
+
+ private final HardCoffeeService hardCoffeeService;
+
+ /**
+ * 拉取咖啡机订单信息(幂等,已支付订单返回paid=true)
+ */
+ @Operation(summary = "拉取咖啡机订单信息", description = "根据厂商订单号拉单,H5进入页面时调用")
+ @GetMapping("/order-info")
+ @Inner(value = false)
+ public R orderInfo(@RequestParam("orderNo") String orderNo) {
+ return R.ok(hardCoffeeService.orderInfo(orderNo));
+ }
+
+ /**
+ * 饭卡支付(卡号即身份,与消费机刷卡一致)
+ */
+ @Operation(summary = "咖啡机饭卡支付", description = "卡号即扣款,支付窗口5分钟")
+ @PostMapping("/card-pay")
+ @Inner(value = false)
+ public R cardPay(@Valid @RequestBody HardCoffeeCardPayDto dto) {
+ return R.ok(hardCoffeeService.cardPay(dto));
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java
new file mode 100644
index 000000000..f019769a6
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java
@@ -0,0 +1,64 @@
+package com.bsd.pjcloud.hard.controller.coffee;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeLogDto;
+import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeOrderDto;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeLogVo;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderDetailVo;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderVo;
+import com.bsd.pjcloud.hard.service.HardCoffeeService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springdoc.core.annotations.ParameterObject;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 咖啡机订单与生命周期日志管理端查询
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/hard-coffee-manager")
+@Tag(description = "hard-coffee-manager", name = "咖啡机订单日志管理")
+@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
+public class HardCoffeeManagerController {
+
+ private final HardCoffeeService hardCoffeeService;
+
+ /**
+ * 咖啡机订单分页
+ */
+ @Operation(summary = "咖啡机订单分页", description = "分页查询咖啡机厂商订单")
+ @GetMapping("/order/page")
+ @PreAuthorize("@pms.hasPermission('hard_hardCoffeeOrder_view')")
+ public R> getOrderPage(@ParameterObject Page page, @ParameterObject HardCoffeeOrderDto dto) {
+ return R.ok(hardCoffeeService.orderPage(page, dto));
+ }
+
+ /**
+ * 咖啡机订单详情(含生命周期时间线)
+ */
+ @Operation(summary = "咖啡机订单详情", description = "订单详情与生命周期日志时间线")
+ @GetMapping("/order/{id}")
+ @PreAuthorize("@pms.hasPermission('hard_hardCoffeeOrder_view')")
+ public R getOrderDetail(@PathVariable("id") Long id) {
+ return R.ok(hardCoffeeService.orderDetail(id));
+ }
+
+ /**
+ * 生命周期日志分页
+ */
+ @Operation(summary = "生命周期日志分页", description = "按订单号/设备/阶段/方向筛选")
+ @GetMapping("/log/page")
+ @PreAuthorize("@pms.hasPermission('hard_hardCoffeeLog_view')")
+ public R> getLogPage(@ParameterObject Page page, @ParameterObject HardCoffeeLogDto dto) {
+ return R.ok(hardCoffeeService.logPage(page, dto));
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeWuliController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeWuliController.java
new file mode 100644
index 000000000..1ece23691
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeWuliController.java
@@ -0,0 +1,46 @@
+package com.bsd.pjcloud.hard.controller.coffee;
+
+import com.bsd.pjcloud.common.security.annotation.Inner;
+import com.bsd.pjcloud.hard.api.dto.coffee.WuliQueryOrderDto;
+import com.bsd.pjcloud.hard.api.vo.coffee.WuliQueryOrderVo;
+import com.bsd.pjcloud.hard.service.HardCoffeeService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 屋里咖啡服务器调我方接口(文档3.4 queryOrder)。
+ * 匿名访问+签名校验,响应体字段为厂商协议蛇形命名。
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/hard-coffee/wuli")
+@Tag(description = "hard-coffee-wuli", name = "屋里咖啡查单接口")
+@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
+public class HardCoffeeWuliController {
+
+ private final HardCoffeeService hardCoffeeService;
+
+ /**
+ * 厂商主动查单: 校验我方分配的appid/appkey签名后返回订单支付状态
+ */
+ @Operation(summary = "厂商查单", description = "屋里咖啡服务器调用,form表单提交蛇形参数")
+ @PostMapping("/query-order")
+ @Inner(value = false)
+ public WuliQueryOrderVo queryOrder(HttpServletRequest request) {
+ // 厂商以form表单提交order_no/appid/randstr/timestamp/sign,手动绑定避免驼峰映射丢失
+ WuliQueryOrderDto dto = new WuliQueryOrderDto();
+ dto.setOrderNo(request.getParameter("order_no"));
+ dto.setAppId(request.getParameter("appid"));
+ dto.setRandstr(request.getParameter("randstr"));
+ dto.setTimestamp(request.getParameter("timestamp"));
+ dto.setSign(request.getParameter("sign"));
+ return hardCoffeeService.wuliQueryOrder(dto);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java
new file mode 100644
index 000000000..decc9fac1
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java
@@ -0,0 +1,36 @@
+package com.bsd.pjcloud.hard.job;
+
+import com.bsd.pjcloud.hard.service.HardCoffeeService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 咖啡机补偿调度器: 重试通知失败的支付结果回调(callback)和退款同步缺口(refund)。
+ * 单轮批量受限,失败订单按 wuli_notify_count 上限熔断,超过后需人工在管理端介入。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HardCoffeeNotifyJob {
+
+ /**
+ * 调度间隔,单位毫秒,固定为 60 秒
+ */
+ private static final long SCAN_INTERVAL_MILLIS = 60_000L;
+
+ private final HardCoffeeService hardCoffeeService;
+
+ /**
+ * 定时扫描通知失败与退款同步缺口的咖啡机订单
+ */
+ @Scheduled(fixedDelay = SCAN_INTERVAL_MILLIS, initialDelay = SCAN_INTERVAL_MILLIS)
+ public void retryNotifyFailures() {
+ try {
+ hardCoffeeService.retryNotifyFailures();
+ } catch (Exception e) {
+ log.error("咖啡机补偿调度异常", e);
+ }
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeLogMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeLogMapper.java
new file mode 100644
index 000000000..39f80bd72
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeLogMapper.java
@@ -0,0 +1,10 @@
+package com.bsd.pjcloud.hard.mapper.coffee;
+
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HardCoffeeLogMapper extends PjcloudBaseMapper {
+
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeOrderMapper.java
new file mode 100644
index 000000000..b4ab5f629
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeOrderMapper.java
@@ -0,0 +1,10 @@
+package com.bsd.pjcloud.hard.mapper.coffee;
+
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HardCoffeeOrderMapper extends PjcloudBaseMapper {
+
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeLogService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeLogService.java
new file mode 100644
index 000000000..de99a1c0a
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeLogService.java
@@ -0,0 +1,11 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity;
+
+/**
+ * 咖啡机生命周期日志
+ */
+public interface HardCoffeeLogService extends IService {
+
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeOrderService.java
new file mode 100644
index 000000000..5cb313031
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeOrderService.java
@@ -0,0 +1,26 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity;
+
+/**
+ * 咖啡机厂商订单扩展
+ */
+public interface HardCoffeeOrderService extends IService {
+
+ /**
+ * 按厂商订单号查询(当前租户上下文)
+ *
+ * @param orderNo 厂商订单号
+ * @return 扩展订单;不存在返回 null
+ */
+ HardCoffeeOrderEntity getByOrderNo(String orderNo);
+
+ /**
+ * 按本地订单id查询(当前租户上下文)
+ *
+ * @param orderId 本地订单id
+ * @return 扩展订单;不存在返回 null
+ */
+ HardCoffeeOrderEntity getByOrderId(Long orderId);
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java
new file mode 100644
index 000000000..581233ef8
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java
@@ -0,0 +1,83 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.admin.api.dto.UserInfo;
+import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto;
+import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeLogDto;
+import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeOrderDto;
+import com.bsd.pjcloud.hard.api.dto.coffee.WuliQueryOrderDto;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderDetailVo;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderInfoVo;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderVo;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeLogVo;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeePayResultVo;
+import com.bsd.pjcloud.hard.api.vo.coffee.WuliQueryOrderVo;
+
+/**
+ * 咖啡机(屋里咖啡)对接主流程
+ */
+public interface HardCoffeeService {
+
+ /**
+ * H5匿名拉单: 根据厂商订单号调 getOrder 并落本地订单(幂等)
+ *
+ * @param orderNo 厂商订单号
+ * @return 订单展示信息
+ */
+ HardCoffeeOrderInfoVo orderInfo(String orderNo);
+
+ /**
+ * H5匿名饭卡支付: 卡号即身份,扣款成功后通知厂商出餐
+ *
+ * @param dto 订单id+饭卡卡号
+ * @return 支付结果(含余额展示)
+ */
+ HardCoffeePayResultVo cardPay(HardCoffeeCardPayDto dto);
+
+ /**
+ * 厂商主动查单(文档3.4 queryOrder,验签)
+ *
+ * @param dto 厂商查单参数
+ * @return 查单响应
+ */
+ WuliQueryOrderVo wuliQueryOrder(WuliQueryOrderDto dto);
+
+ /**
+ * 本地退款完成后向厂商同步退款(文档3.3 refund,支持分批)。
+ * 非咖啡机订单直接返回,零影响
+ *
+ * @param orderId 本地订单id
+ */
+ void syncRefundIfNeeded(Long orderId);
+
+ /**
+ * 管理端咖啡机订单分页
+ */
+ Page orderPage(Page page, HardCoffeeOrderDto dto);
+
+ /**
+ * 管理端生命周期日志分页
+ */
+ Page logPage(Page page, HardCoffeeLogDto dto);
+
+ /**
+ * 管理端订单详情(含生命周期时间线)
+ */
+ HardCoffeeOrderDetailVo orderDetail(Long id);
+
+ /**
+ * 绑定付款人并完成钱包扣款(独立事务,内部通过Spring代理调用保证事务生效)
+ *
+ * @param orderId 本地订单id
+ * @param user 付款人信息
+ * @param walletId 钱包id
+ * @return 扣款完成后的订单
+ */
+ HardOrderEntity bindAndDeduct(Long orderId, UserInfo user, Long walletId);
+
+ /**
+ * 补偿: 重试通知失败的支付结果回调 + 补推退款同步缺口
+ */
+ void retryNotifyFailures();
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeLogServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeLogServiceImpl.java
new file mode 100644
index 000000000..2d1b5586e
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeLogServiceImpl.java
@@ -0,0 +1,21 @@
+package com.bsd.pjcloud.hard.service.impl.coffee;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity;
+import com.bsd.pjcloud.hard.mapper.coffee.HardCoffeeLogMapper;
+import com.bsd.pjcloud.hard.service.HardCoffeeLogService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+/**
+ * 咖啡机生命周期日志
+ */
+@Validated
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class HardCoffeeLogServiceImpl extends ServiceImpl implements HardCoffeeLogService {
+
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeOrderServiceImpl.java
new file mode 100644
index 000000000..420cf6baa
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeOrderServiceImpl.java
@@ -0,0 +1,35 @@
+package com.bsd.pjcloud.hard.service.impl.coffee;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity;
+import com.bsd.pjcloud.hard.mapper.coffee.HardCoffeeOrderMapper;
+import com.bsd.pjcloud.hard.service.HardCoffeeOrderService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+/**
+ * 咖啡机厂商订单扩展
+ */
+@Validated
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class HardCoffeeOrderServiceImpl extends ServiceImpl implements HardCoffeeOrderService {
+
+ @Override
+ public HardCoffeeOrderEntity getByOrderNo(String orderNo) {
+ return getOne(new LambdaQueryWrapper()
+ .eq(HardCoffeeOrderEntity::getOrderNo, orderNo)
+ .last("limit 1"));
+ }
+
+ @Override
+ public HardCoffeeOrderEntity getByOrderId(Long orderId) {
+ return getOne(new LambdaQueryWrapper()
+ .eq(HardCoffeeOrderEntity::getOrderId, orderId)
+ .last("limit 1"));
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java
new file mode 100644
index 000000000..52afc7161
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java
@@ -0,0 +1,778 @@
+package com.bsd.pjcloud.hard.service.impl.coffee;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.extra.spring.SpringUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.admin.api.dto.UserInfo;
+import com.bsd.pjcloud.admin.api.entity.SysUser;
+import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
+import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.data.tenant.TenantBroker;
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogDirectionEnum;
+import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogStageEnum;
+import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeNotifyStatusEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
+import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto;
+import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeLogDto;
+import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeOrderDto;
+import com.bsd.pjcloud.hard.api.dto.coffee.WuliQueryOrderDto;
+import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity;
+import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity;
+import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeLogVo;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderDetailVo;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderInfoVo;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderVo;
+import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeePayResultVo;
+import com.bsd.pjcloud.hard.api.vo.coffee.WuliQueryOrderVo;
+import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
+import com.bsd.pjcloud.hard.service.HardCoffeeLogService;
+import com.bsd.pjcloud.hard.service.HardCoffeeOrderService;
+import com.bsd.pjcloud.hard.service.HardCoffeeService;
+import com.bsd.pjcloud.hard.service.HardManagerService;
+import com.bsd.pjcloud.hard.service.HardOrderService;
+import com.bsd.pjcloud.hard.service.impl.coffee.WuliCoffeeClient.WuliCallContext;
+import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
+import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
+import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
+import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.validation.annotation.Validated;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * 咖啡机(屋里咖啡)对接主流程。
+ * 角色说明: 我方平台是厂商文档中的"第三方支付服务商",
+ * 用户扫咖啡机二维码打开我方H5,由我方拉单、饭卡扣款并回调厂商出餐。
+ */
+@Validated
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class HardCoffeeServiceImpl implements HardCoffeeService {
+
+ private static final String TAG = "咖啡机";
+
+ /**
+ * 支付窗口Redis Key,拉单时写入、支付时校验,值存厂商订单号
+ */
+ private static final String PAY_WINDOW_KEY = "hard:coffee:order:non:pay:";
+
+ /**
+ * 支付窗口时长(秒),与商户扫码订单保持一致
+ */
+ private static final long PAY_WINDOW_SECONDS = 300;
+
+ /**
+ * 拉单并发锁Key前缀
+ */
+ private static final String CREATE_LOCK_KEY = "hard:coffee:order:create:lock:";
+
+ /**
+ * 支付并发锁Key前缀
+ */
+ private static final String PAY_LOCK_KEY = "hard:coffee:order:pay:lock:";
+
+ /**
+ * 并发锁时长(秒)
+ */
+ private static final long LOCK_SECONDS = 10;
+
+ /**
+ * 厂商订单号格式: 文档为19位数字,测试环境18位,放宽到17-20位兼容
+ */
+ private static final Pattern ORDER_NO_PATTERN = Pattern.compile("^\\d{17,20}$");
+
+ /**
+ * 支付结果通知厂商的最大重试次数,超过后需人工介入
+ */
+ private static final int NOTIFY_MAX_RETRY = 10;
+
+ /**
+ * 补偿任务单轮处理的订单量
+ */
+ private static final int RETRY_BATCH_SIZE = 50;
+
+ /**
+ * 退款同步缺口回看的订单天数
+ */
+ private static final int REFUND_SCAN_DAYS = 7;
+
+ private final HardCoffeeOrderService coffeeOrderService;
+ private final HardCoffeeLogService coffeeLogService;
+ private final HardManagerService hardManagerService;
+ private final HardOrderService hardOrderService;
+ private final WuliCoffeeClient wuliCoffeeClient;
+
+ private final RemoteMchService remoteMchService;
+ private final RemoteWalletService walletService;
+ private final RemoteUserService remoteUserService;
+
+ private final StringRedisTemplate redisTemplate;
+
+ /**
+ * H5匿名拉单: 幂等查询本地扩展订单,不存在时调厂商getOrder并落本地订单。
+ * 匿名请求无租户头,机器号定位设备后用 TenantBroker 切换到设备租户落单。
+ */
+ @Override
+ public HardCoffeeOrderInfoVo orderInfo(String orderNo) {
+ if (StrUtil.isBlank(orderNo) || !ORDER_NO_PATTERN.matcher(orderNo).matches()) {
+ throw new RuntimeException("订单号格式错误");
+ }
+ // 幂等: 已落单直接返回
+ HardCoffeeOrderEntity exist = queryAcrossTenants(() -> coffeeOrderService.getByOrderNo(orderNo));
+ if (ObjectUtil.isNotEmpty(exist)) {
+ return buildOrderInfoVo(exist);
+ }
+ // 并发锁防止同一厂商订单号重复落单
+ Boolean locked = redisTemplate.opsForValue().setIfAbsent(CREATE_LOCK_KEY + orderNo, "1", Duration.ofSeconds(LOCK_SECONDS));
+ if (!Boolean.TRUE.equals(locked)) {
+ throw new RuntimeException("订单处理中,请稍后重试");
+ }
+ try {
+ exist = queryAcrossTenants(() -> coffeeOrderService.getByOrderNo(orderNo));
+ if (ObjectUtil.isNotEmpty(exist)) {
+ return buildOrderInfoVo(exist);
+ }
+ // 拉取厂商订单详情(日志随结果带回,租户解析后落库)
+ WuliCallContext context = WuliCallContext.builder().orderNo(orderNo).build();
+ WuliCoffeeClient.WuliGetOrderResult getOrderResult = wuliCoffeeClient.getOrderWithLog(context);
+ WuliCoffeeClient.WuliOrderDetail detail = getOrderResult.getDetail();
+ if (ObjectUtil.isEmpty(detail.getPrice()) || detail.getPrice() <= 0) {
+ throw new RuntimeException("厂商订单金额异常");
+ }
+ // 机器号定位咖啡机设备
+ HardManagerVo machine = queryAcrossTenants(() -> hardManagerService.getByUniqueCode(detail.getMachineName()));
+ if (ObjectUtil.isEmpty(machine)) {
+ throw new RuntimeException("咖啡机未接入平台,机器号:" + detail.getMachineName());
+ }
+ if (!ObjectUtil.equals(HardTypeEnum.COFFEE.getType(), machine.getType())) {
+ throw new RuntimeException("设备类型不是咖啡机,机器号:" + detail.getMachineName());
+ }
+ Long tenantId = machine.getTenantId();
+ // 拉单日志补全租户后落库,保证租户内生命周期时间线完整
+ HardCoffeeLogEntity getOrderLog = getOrderResult.getLog();
+ if (ObjectUtil.isNotEmpty(getOrderLog)) {
+ getOrderLog.setTenantId(tenantId);
+ wuliCoffeeClient.saveLog(getOrderLog);
+ }
+ // 设备租户上下文内创建本地订单
+ Long orderId = TenantBroker.applyAs(tenantId, tenant -> createLocalOrder(machine, detail));
+ // 支付窗口写在匿名默认上下文,与cardPay读取保持一致
+ redisTemplate.opsForValue().set(PAY_WINDOW_KEY + orderId, orderNo, Duration.ofSeconds(PAY_WINDOW_SECONDS));
+ // 落单阶段日志
+ exist = queryAcrossTenants(() -> coffeeOrderService.getByOrderNo(orderNo));
+ saveStageLog(exist, HardCoffeeLogStageEnum.CREATE_ORDER, tenantId, null);
+ return buildOrderInfoVo(exist);
+ } finally {
+ redisTemplate.delete(CREATE_LOCK_KEY + orderNo);
+ }
+ }
+
+ /**
+ * 在设备租户上下文内创建 hard_order + hard_coffee_order
+ */
+ private Long createLocalOrder(HardManagerVo machine, WuliCoffeeClient.WuliOrderDetail detail) {
+ MchManageVo mch = remoteMchService.getById(machine.getMerchantId()).getData();
+ if (ObjectUtil.isEmpty(mch)) {
+ throw new RuntimeException("咖啡机绑定的商户不存在");
+ }
+ HardOrderEntity order = HardOrderEntity.builder()
+ .userId(-1L)
+ .orderNo(detail.getOrderNo())
+ .merchantId(mch.getId())
+ .merchantType(mch.getTypes())
+ .merchantDeptId(mch.getDeptId())
+ .hardId(machine.getId())
+ .sysType(HardTypeEnum.COFFEE.getType())
+ .orderStatus(HardOrderStatusEnum.CREATE.getType())
+ .orderType(HardOrderTypeEnum.COFFEE.getType())
+ .payType(HardPayTypeEnum.WALLET_CARD.getType())
+ .totalPrice(detail.getPrice())
+ .extraPrice(0)
+ .payPrice(detail.getPrice())
+ .isOfflineOrder(YesNoEnum.NO.getCode())
+ .apptUseDate(LocalDate.now())
+ .productDesc(detail.getProduct())
+ .build();
+ hardOrderService.save(order);
+
+ HardCoffeeOrderEntity coffeeOrder = new HardCoffeeOrderEntity();
+ coffeeOrder.setOrderId(order.getId());
+ coffeeOrder.setOrderNo(detail.getOrderNo());
+ coffeeOrder.setHardId(machine.getId());
+ coffeeOrder.setMachineName(detail.getMachineName());
+ coffeeOrder.setProduct(detail.getProduct());
+ coffeeOrder.setImg(detail.getImg());
+ coffeeOrder.setIsCold(detail.getIsCold());
+ coffeeOrder.setPrice(detail.getPrice());
+ coffeeOrder.setWuliNotifyStatus(HardCoffeeNotifyStatusEnum.NONE.getStatus());
+ coffeeOrder.setWuliNotifyCount(0);
+ coffeeOrder.setRefundSyncAmount(0);
+ coffeeOrder.setTenantId(machine.getTenantId());
+ coffeeOrderService.save(coffeeOrder);
+ return order.getId();
+ }
+
+ /**
+ * H5匿名饭卡支付: 卡号即身份(与消费机刷卡一致)。
+ * 扣款在设备租户的本地事务内完成,厂商回调在事务外执行,通知失败不回滚支付,由补偿任务重试。
+ */
+ @Override
+ public HardCoffeePayResultVo cardPay(HardCoffeeCardPayDto dto) {
+ Long orderId = dto.getOrderId();
+ String redisKey = PAY_WINDOW_KEY + orderId;
+ // 支付窗口校验(匿名上下文)
+ String orderNoInWindow = redisTemplate.opsForValue().get(redisKey);
+ if (StrUtil.isBlank(orderNoInWindow)) {
+ throw new RuntimeException("订单已超时,请在咖啡机上重新发起");
+ }
+ // 支付并发锁,防止同一订单重复提交
+ Boolean locked = redisTemplate.opsForValue().setIfAbsent(PAY_LOCK_KEY + orderId, "1", Duration.ofSeconds(LOCK_SECONDS));
+ if (!Boolean.TRUE.equals(locked)) {
+ throw new RuntimeException("支付处理中,请勿重复提交");
+ }
+ try {
+ HardCoffeeOrderEntity coffeeOrder = queryAcrossTenants(() -> coffeeOrderService.getByOrderId(orderId));
+ if (ObjectUtil.isEmpty(coffeeOrder)) {
+ throw new RuntimeException("订单不存在");
+ }
+ Long tenantId = coffeeOrder.getTenantId();
+ HardCoffeePayResultVo result = TenantBroker.applyAs(tenantId, tenant -> doCardPay(coffeeOrder, dto));
+ // 窗口Key删除必须在匿名上下文执行,与写入时前缀一致
+ redisTemplate.delete(redisKey);
+ return result;
+ } finally {
+ redisTemplate.delete(PAY_LOCK_KEY + orderId);
+ }
+ }
+
+ /**
+ * 设备租户上下文内的支付主逻辑
+ */
+ private HardCoffeePayResultVo doCardPay(HardCoffeeOrderEntity coffeeOrder, HardCoffeeCardPayDto dto) {
+ Long orderId = coffeeOrder.getOrderId();
+ HardOrderEntity order = hardOrderService.getById(orderId);
+ if (ObjectUtil.isEmpty(order) || !ObjectUtil.equals(HardTypeEnum.COFFEE.getType(), order.getSysType())) {
+ throw new RuntimeException("订单不存在");
+ }
+ if (!ObjectUtil.equals(HardOrderStatusEnum.CREATE.getType(), order.getOrderStatus())) {
+ throw new RuntimeException("订单已支付或已关闭");
+ }
+ // 卡号定位钱包(Feign携带当前租户头)
+ WalletManageEntity wallet = walletService.getWalletByCardNumber(dto.getCardNo()).getData();
+ if (ObjectUtil.isEmpty(wallet)) {
+ throw new RuntimeException("非本单位饭卡");
+ }
+ UserInfo user = remoteUserService.getInnerByUserId(wallet.getUserId()).getData();
+ if (ObjectUtil.isEmpty(user) || ObjectUtil.isEmpty(user.getSysUser())) {
+ throw new RuntimeException("饭卡用户不存在");
+ }
+ // 绑定付款人并扣款(事务),余额不足时抛异常回滚
+ HardOrderEntity paidOrder = SpringUtil.getBean(HardCoffeeService.class).bindAndDeduct(orderId, user, wallet.getId());
+ saveStageLog(coffeeOrder, HardCoffeeLogStageEnum.WALLET_PAY, coffeeOrder.getTenantId(),
+ "卡号:" + dto.getCardNo() + ",金额(分):" + paidOrder.getPayPrice());
+
+ // 事务已提交,通知厂商出餐;失败不回滚,标记后由补偿任务重试
+ String phone = user.getSysUser().getPhone();
+ boolean notified;
+ try {
+ WuliCallContext context = buildCallContext(coffeeOrder);
+ notified = wuliCoffeeClient.callback(context, "SUCCESS", phone);
+ } catch (Exception e) {
+ log.error("|{}|通知厂商支付结果异常,orderId:{},orderNo:{}", TAG, orderId, coffeeOrder.getOrderNo(), e);
+ notified = false;
+ }
+ updateNotifyStatus(coffeeOrder, notified);
+
+ // 回填支付者手机号,供厂商查单展示
+ HardCoffeeOrderEntity phoneUpdate = new HardCoffeeOrderEntity();
+ phoneUpdate.setId(coffeeOrder.getId());
+ phoneUpdate.setPhoneNo(phone);
+ coffeeOrderService.updateById(phoneUpdate);
+
+ // 支付后余额重新查询展示
+ WalletManageEntity latestWallet = walletService.getWalletById(wallet.getId()).getData();
+ String balanceYuan = fenToYuan(ObjectUtil.isNotEmpty(latestWallet) ? latestWallet.getBalance() : null);
+ return HardCoffeePayResultVo.builder()
+ .orderId(orderId)
+ .orderNo(coffeeOrder.getOrderNo())
+ .name(user.getSysUser().getName())
+ .cardNo(wallet.getCardNumber())
+ .balanceYuan(balanceYuan)
+ .build();
+ }
+
+ /**
+ * 绑定付款人并完成钱包扣款。独立事务方法,通过Spring代理调用保证事务生效
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public HardOrderEntity bindAndDeduct(Long orderId, UserInfo user, Long walletId) {
+ HardOrderEntity order = hardOrderService.getById(orderId);
+ if (ObjectUtil.isEmpty(order)) {
+ throw new RuntimeException("订单不存在");
+ }
+ if (!ObjectUtil.equals(HardOrderStatusEnum.CREATE.getType(), order.getOrderStatus())) {
+ throw new RuntimeException("订单状态已变化,请刷新重试");
+ }
+ HardOrderEntity bind = HardOrderEntity.builder()
+ .id(orderId)
+ .userId(user.getSysUser().getUserId())
+ .userDeptId(user.getSysUser().getDeptId())
+ .walletId(walletId)
+ .build();
+ hardOrderService.updateById(bind);
+ order.setUserId(user.getSysUser().getUserId());
+ order.setWalletId(walletId);
+ // 钱包扣款+订单置为已支付(余额不足抛异常整体回滚)
+ hardOrderService.fastReduceBalance(order);
+ return hardOrderService.getById(orderId);
+ }
+
+ /**
+ * 厂商主动查单(文档3.4): 验签通过后返回本地订单支付状态映射
+ */
+ @Override
+ public WuliQueryOrderVo wuliQueryOrder(WuliQueryOrderDto dto) {
+ WuliQueryOrderVo.WuliQueryOrderVoBuilder builder = WuliQueryOrderVo.builder();
+ if (!wuliCoffeeClient.verifyQuerySign(dto.getAppId(), dto.getRandstr(), dto.getTimestamp(), dto.getSign())) {
+ saveInboundQueryLog(dto, null, "验签失败");
+ return builder.returnCode("FAIL").returnMsg("验签失败").build();
+ }
+ HardCoffeeOrderEntity coffeeOrder = queryAcrossTenants(() -> coffeeOrderService.getByOrderNo(dto.getOrderNo()));
+ if (ObjectUtil.isEmpty(coffeeOrder)) {
+ saveInboundQueryLog(dto, null, "订单不存在");
+ return builder.returnCode("FAIL").returnMsg("订单不存在").build();
+ }
+ HardOrderEntity order = queryAcrossTenants(() -> hardOrderService.getById(coffeeOrder.getOrderId()));
+ String status = mapQueryStatus(ObjectUtil.isEmpty(order) ? null : order.getOrderStatus());
+ saveInboundQueryLog(dto, coffeeOrder, status);
+ return builder.returnCode("SUCCESS").status(status).phoneNo(coffeeOrder.getPhoneNo()).build();
+ }
+
+ /**
+ * 本地订单状态映射厂商查单状态: 已支付/已核销->SUCCESS,创建中->USERPAYING,退款/取消->FAIL
+ */
+ private String mapQueryStatus(String orderStatus) {
+ if (ObjectUtil.isEmpty(orderStatus)) {
+ return "FAIL";
+ }
+ if (List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.CANCEL.getType()).contains(orderStatus)) {
+ return "SUCCESS";
+ }
+ if (List.of(HardOrderStatusEnum.CREATE.getType(), HardOrderStatusEnum.OFFLINE_CREATE.getType()).contains(orderStatus)) {
+ return "USERPAYING";
+ }
+ return "FAIL";
+ }
+
+ /**
+ * 本地退款完成后同步通知厂商(文档3.3)。amount为本次差额,累计退款以refund_sync_amount记账
+ */
+ @Override
+ public void syncRefundIfNeeded(Long orderId) {
+ // 当前租户上下文查询;非咖啡机订单直接返回
+ HardCoffeeOrderEntity coffeeOrder = coffeeOrderService.getByOrderId(orderId);
+ if (ObjectUtil.isEmpty(coffeeOrder)) {
+ return;
+ }
+ HardOrderEntity order = hardOrderService.getById(orderId);
+ if (ObjectUtil.isEmpty(order)) {
+ return;
+ }
+ int refunded = ObjectUtil.defaultIfNull(order.getRefundPrice(), 0);
+ int synced = ObjectUtil.defaultIfNull(coffeeOrder.getRefundSyncAmount(), 0);
+ int delta = refunded - synced;
+ if (delta <= 0) {
+ return;
+ }
+ try {
+ WuliCallContext context = buildCallContext(coffeeOrder);
+ boolean ok = wuliCoffeeClient.refund(context, delta);
+ if (ok) {
+ HardCoffeeOrderEntity update = new HardCoffeeOrderEntity();
+ update.setId(coffeeOrder.getId());
+ update.setRefundSyncAmount(refunded);
+ update.setRefundSyncTime(LocalDateTime.now());
+ coffeeOrderService.updateById(update);
+ } else {
+ log.warn("|{}|退款同步厂商返回失败,等待补偿重试,orderId:{},差额(分):{}", TAG, orderId, delta);
+ }
+ } catch (Exception e) {
+ log.error("|{}|退款同步厂商异常,等待补偿重试,orderId:{},差额(分):{}", TAG, orderId, delta, e);
+ }
+ }
+
+ /**
+ * 补偿任务: 重试通知失败的支付回调 + 补推退款同步缺口。
+ * 定时线程无租户上下文,列表查询天然跨租户,逐单切回订单租户执行
+ */
+ @Override
+ public void retryNotifyFailures() {
+ // 1.支付结果通知失败重试
+ List notifyFails = coffeeOrderService.list(new LambdaQueryWrapper()
+ .eq(HardCoffeeOrderEntity::getWuliNotifyStatus, HardCoffeeNotifyStatusEnum.FAIL.getStatus())
+ .lt(HardCoffeeOrderEntity::getWuliNotifyCount, NOTIFY_MAX_RETRY)
+ .orderByAsc(HardCoffeeOrderEntity::getUpdateTime)
+ .last("limit " + RETRY_BATCH_SIZE));
+ for (HardCoffeeOrderEntity coffeeOrder : notifyFails) {
+ if (coffeeOrder.getTenantId() == null) {
+ continue;
+ }
+ try {
+ TenantBroker.runAs(coffeeOrder.getTenantId(), tenant -> retryOneNotify(coffeeOrder));
+ } catch (Exception e) {
+ log.warn("|{}|补偿通知异常,orderId:{}", TAG, coffeeOrder.getOrderId());
+ }
+ }
+ // 2.退款同步缺口补推(回看近N天订单)
+ List recent = coffeeOrderService.list(new LambdaQueryWrapper()
+ .ge(HardCoffeeOrderEntity::getCreateTime, LocalDateTime.now().minusDays(REFUND_SCAN_DAYS))
+ .last("limit " + RETRY_BATCH_SIZE));
+ for (HardCoffeeOrderEntity coffeeOrder : recent) {
+ if (coffeeOrder.getTenantId() == null) {
+ continue;
+ }
+ try {
+ TenantBroker.runAs(coffeeOrder.getTenantId(), tenant -> syncRefundIfNeeded(coffeeOrder.getOrderId()));
+ } catch (Exception e) {
+ log.warn("|{}|补偿退款同步异常,orderId:{}", TAG, coffeeOrder.getOrderId());
+ }
+ }
+ }
+
+ /**
+ * 重试单笔支付结果通知
+ */
+ private void retryOneNotify(HardCoffeeOrderEntity coffeeOrder) {
+ HardOrderEntity order = hardOrderService.getById(coffeeOrder.getOrderId());
+ // 只有已支付订单才需要补发通知
+ if (ObjectUtil.isEmpty(order) || !ObjectUtil.equals(HardOrderStatusEnum.PAY.getType(), order.getOrderStatus())) {
+ return;
+ }
+ boolean notified;
+ try {
+ WuliCallContext context = buildCallContext(coffeeOrder);
+ notified = wuliCoffeeClient.callback(context, "SUCCESS", coffeeOrder.getPhoneNo());
+ } catch (Exception e) {
+ log.error("|{}|补偿通知厂商异常,orderId:{}", TAG, coffeeOrder.getOrderId(), e);
+ notified = false;
+ }
+ updateNotifyStatus(coffeeOrder, notified);
+ }
+
+ /**
+ * 更新通知厂商状态与次数
+ */
+ private void updateNotifyStatus(HardCoffeeOrderEntity coffeeOrder, boolean notified) {
+ HardCoffeeOrderEntity update = new HardCoffeeOrderEntity();
+ update.setId(coffeeOrder.getId());
+ update.setWuliNotifyStatus(notified
+ ? HardCoffeeNotifyStatusEnum.SUCCESS.getStatus()
+ : HardCoffeeNotifyStatusEnum.FAIL.getStatus());
+ update.setWuliNotifyTime(LocalDateTime.now());
+ update.setWuliNotifyCount(ObjectUtil.defaultIfNull(coffeeOrder.getWuliNotifyCount(), 0) + 1);
+ coffeeOrderService.updateById(update);
+ }
+
+ /**
+ * 管理端咖啡机订单分页(当前租户),补齐订单状态/支付人/设备名
+ */
+ @Override
+ public Page orderPage(Page page, HardCoffeeOrderDto dto) {
+ Page entityPage = coffeeOrderService.page(page, buildOrderWrapper(dto));
+ Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
+ List records = entityPage.getRecords();
+ if (ObjectUtil.isEmpty(records)) {
+ return voPage;
+ }
+ Map orderMap = listByIdsMap(
+ records.stream().map(HardCoffeeOrderEntity::getOrderId).collect(Collectors.toList()),
+ ids -> hardOrderService.listByIds(ids).stream()
+ .collect(Collectors.toMap(HardOrderEntity::getId, Function.identity(), (a, b) -> a)));
+ Map hardNameMap = listByIdsMap(
+ records.stream().map(HardCoffeeOrderEntity::getHardId).filter(Objects::nonNull).collect(Collectors.toList()),
+ ids -> hardManagerService.listByIds(ids).stream()
+ .collect(Collectors.toMap(HardManagerEntity::getId, HardManagerEntity::getName, (a, b) -> a)));
+ Map userNameMap = listByIdsMap(
+ orderMap.values().stream().map(HardOrderEntity::getUserId).filter(id -> id != null && id > 0).collect(Collectors.toList()),
+ ids -> {
+ R> userResult = remoteUserService.getInnerUserById(ids);
+ List users = userResult != null && userResult.isOk() && ObjectUtil.isNotEmpty(userResult.getData())
+ ? userResult.getData() : List.of();
+ return users.stream().collect(Collectors.toMap(SysUser::getUserId, SysUser::getName, (a, b) -> a));
+ });
+ voPage.setRecords(records.stream().map(entity -> {
+ HardCoffeeOrderVo vo = BeanUtil.toBean(entity, HardCoffeeOrderVo.class);
+ HardOrderEntity order = orderMap.get(entity.getOrderId());
+ if (ObjectUtil.isNotEmpty(order)) {
+ vo.setOrderStatus(order.getOrderStatus());
+ vo.setPayType(order.getPayType());
+ vo.setPayTime(order.getPayTime());
+ vo.setRefundPrice(order.getRefundPrice());
+ vo.setRefundStatus(order.getRefundStatus());
+ if (order.getUserId() != null && order.getUserId() > 0) {
+ vo.setUserName(userNameMap.get(order.getUserId()));
+ }
+ }
+ if (entity.getHardId() != null) {
+ vo.setHardName(hardNameMap.get(entity.getHardId()));
+ }
+ return vo;
+ }).collect(Collectors.toList()));
+ return voPage;
+ }
+
+ private LambdaQueryWrapper buildOrderWrapper(HardCoffeeOrderDto dto) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ if (dto == null) {
+ return wrapper.orderByDesc(HardCoffeeOrderEntity::getId);
+ }
+ LocalDateTime createTimeStart = dto.getCreateTimeList() != null && dto.getCreateTimeList().length > 0 ? dto.getCreateTimeList()[0] : null;
+ LocalDateTime createTimeEnd = dto.getCreateTimeList() != null && dto.getCreateTimeList().length > 1 ? dto.getCreateTimeList()[1] : null;
+ wrapper.like(StrUtil.isNotBlank(dto.getOrderNo()), HardCoffeeOrderEntity::getOrderNo, dto.getOrderNo())
+ .eq(ObjectUtil.isNotEmpty(dto.getHardId()), HardCoffeeOrderEntity::getHardId, dto.getHardId())
+ .like(StrUtil.isNotBlank(dto.getMachineName()), HardCoffeeOrderEntity::getMachineName, dto.getMachineName())
+ .like(StrUtil.isNotBlank(dto.getPhoneNo()), HardCoffeeOrderEntity::getPhoneNo, dto.getPhoneNo())
+ .eq(StrUtil.isNotBlank(dto.getWuliNotifyStatus()), HardCoffeeOrderEntity::getWuliNotifyStatus, dto.getWuliNotifyStatus())
+ .ge(createTimeStart != null, HardCoffeeOrderEntity::getCreateTime, createTimeStart)
+ .le(createTimeEnd != null, HardCoffeeOrderEntity::getCreateTime, createTimeEnd)
+ .orderByDesc(HardCoffeeOrderEntity::getId);
+ return wrapper;
+ }
+
+ /**
+ * 管理端生命周期日志分页(当前租户)
+ */
+ @Override
+ public Page logPage(Page page, HardCoffeeLogDto dto) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ if (dto != null) {
+ wrapper.like(StrUtil.isNotBlank(dto.getOrderNo()), HardCoffeeLogEntity::getOrderNo, dto.getOrderNo())
+ .eq(ObjectUtil.isNotEmpty(dto.getOrderId()), HardCoffeeLogEntity::getOrderId, dto.getOrderId())
+ .eq(ObjectUtil.isNotEmpty(dto.getHardId()), HardCoffeeLogEntity::getHardId, dto.getHardId())
+ .like(StrUtil.isNotBlank(dto.getUniqueCode()), HardCoffeeLogEntity::getUniqueCode, dto.getUniqueCode())
+ .eq(StrUtil.isNotBlank(dto.getStage()), HardCoffeeLogEntity::getStage, dto.getStage())
+ .eq(StrUtil.isNotBlank(dto.getDirection()), HardCoffeeLogEntity::getDirection, dto.getDirection())
+ .ge(dto.getStartTimeList() != null && dto.getStartTimeList().length > 0,
+ HardCoffeeLogEntity::getStartTime, dto.getStartTimeList() != null ? dto.getStartTimeList()[0] : null)
+ .le(dto.getStartTimeList() != null && dto.getStartTimeList().length > 1,
+ HardCoffeeLogEntity::getStartTime, dto.getStartTimeList() != null ? dto.getStartTimeList()[1] : null);
+ }
+ wrapper.orderByDesc(HardCoffeeLogEntity::getId);
+ Page entityPage = coffeeLogService.page(page, wrapper);
+ Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
+ List records = entityPage.getRecords();
+ if (ObjectUtil.isEmpty(records)) {
+ return voPage;
+ }
+ Map hardNameMap = listByIdsMap(
+ records.stream().map(HardCoffeeLogEntity::getHardId).filter(Objects::nonNull).collect(Collectors.toList()),
+ ids -> hardManagerService.listByIds(ids).stream()
+ .collect(Collectors.toMap(HardManagerEntity::getId, HardManagerEntity::getName, (a, b) -> a)));
+ voPage.setRecords(records.stream().map(entity -> {
+ HardCoffeeLogVo vo = BeanUtil.toBean(entity, HardCoffeeLogVo.class);
+ if (entity.getHardId() != null) {
+ vo.setHardName(hardNameMap.get(entity.getHardId()));
+ }
+ return vo;
+ }).collect(Collectors.toList()));
+ return voPage;
+ }
+
+ /**
+ * 管理端订单详情: 订单信息+生命周期时间线(核心展示:日志与订单紧紧关联)
+ */
+ @Override
+ public HardCoffeeOrderDetailVo orderDetail(Long id) {
+ HardCoffeeOrderEntity coffeeOrder = coffeeOrderService.getById(id);
+ if (ObjectUtil.isEmpty(coffeeOrder)) {
+ throw new RuntimeException("咖啡机订单不存在");
+ }
+ // 直接复用VO组装逻辑
+ HardCoffeeOrderVo vo = BeanUtil.toBean(coffeeOrder, HardCoffeeOrderVo.class);
+ HardOrderEntity order = hardOrderService.getById(coffeeOrder.getOrderId());
+ if (ObjectUtil.isNotEmpty(order)) {
+ vo.setOrderStatus(order.getOrderStatus());
+ vo.setPayType(order.getPayType());
+ vo.setPayTime(order.getPayTime());
+ vo.setRefundPrice(order.getRefundPrice());
+ vo.setRefundStatus(order.getRefundStatus());
+ if (order.getUserId() != null && order.getUserId() > 0) {
+ R> userResult = remoteUserService.getInnerUserById(List.of(order.getUserId()));
+ if (userResult != null && userResult.isOk() && ObjectUtil.isNotEmpty(userResult.getData())) {
+ vo.setUserName(userResult.getData().get(0).getName());
+ }
+ }
+ }
+ if (coffeeOrder.getHardId() != null) {
+ HardManagerEntity hard = hardManagerService.getById(coffeeOrder.getHardId());
+ if (ObjectUtil.isNotEmpty(hard)) {
+ vo.setHardName(hard.getName());
+ }
+ }
+ HardCoffeeOrderDetailVo detail = new HardCoffeeOrderDetailVo();
+ detail.setOrder(vo);
+ detail.setLogs(coffeeLogService.list(new LambdaQueryWrapper()
+ .eq(HardCoffeeLogEntity::getOrderId, coffeeOrder.getOrderId())
+ .orderByAsc(HardCoffeeLogEntity::getStartTime)
+ .orderByAsc(HardCoffeeLogEntity::getId))
+ .stream().map(entity -> BeanUtil.toBean(entity, HardCoffeeLogVo.class))
+ .collect(Collectors.toList()));
+ return detail;
+ }
+
+ /**
+ * 构建H5拉单展示VO(已支付订单只返回状态禁止重复支付)
+ */
+ private HardCoffeeOrderInfoVo buildOrderInfoVo(HardCoffeeOrderEntity coffeeOrder) {
+ HardOrderEntity order = queryAcrossTenants(() -> hardOrderService.getById(coffeeOrder.getOrderId()));
+ boolean paid = ObjectUtil.isNotEmpty(order)
+ && !ObjectUtil.equals(HardOrderStatusEnum.CREATE.getType(), order.getOrderStatus())
+ && !ObjectUtil.equals(HardOrderStatusEnum.OFFLINE_CREATE.getType(), order.getOrderStatus());
+ Long remainSeconds = 0L;
+ if (!paid) {
+ String windowKey = PAY_WINDOW_KEY + coffeeOrder.getOrderId();
+ Long expire = redisTemplate.getExpire(windowKey);
+ remainSeconds = expire != null && expire > 0 ? expire : 0L;
+ }
+ return HardCoffeeOrderInfoVo.builder()
+ .orderId(coffeeOrder.getOrderId())
+ .orderNo(coffeeOrder.getOrderNo())
+ .product(coffeeOrder.getProduct())
+ .img(coffeeOrder.getImg())
+ .isCold(coffeeOrder.getIsCold())
+ .price(coffeeOrder.getPrice())
+ .priceYuan(fenToYuan(coffeeOrder.getPrice()))
+ .machineName(coffeeOrder.getMachineName())
+ .paid(paid)
+ .remainSeconds(remainSeconds)
+ .build();
+ }
+
+ /**
+ * 厂商查单入站日志(方向=厂商调我方)
+ */
+ private void saveInboundQueryLog(WuliQueryOrderDto dto, HardCoffeeOrderEntity coffeeOrder, String result) {
+ try {
+ HardCoffeeLogEntity logger = new HardCoffeeLogEntity();
+ logger.setOrderNo(dto.getOrderNo());
+ logger.setStage(HardCoffeeLogStageEnum.QUERY.getStage());
+ logger.setTitle(HardCoffeeLogStageEnum.QUERY.getTitle());
+ logger.setDirection(HardCoffeeLogDirectionEnum.IN.getDirection());
+ logger.setReq("appid:" + dto.getAppId() + ",randstr:" + dto.getRandstr() + ",timestamp:" + dto.getTimestamp());
+ logger.setRes(result);
+ logger.setStartTime(LocalDateTime.now());
+ if (ObjectUtil.isNotEmpty(coffeeOrder)) {
+ logger.setOrderId(coffeeOrder.getOrderId());
+ logger.setHardId(coffeeOrder.getHardId());
+ logger.setUniqueCode(coffeeOrder.getMachineName());
+ logger.setTenantId(coffeeOrder.getTenantId());
+ }
+ if (logger.getTenantId() != null) {
+ TenantBroker.runAs(logger.getTenantId(), tenantId -> coffeeLogService.save(logger));
+ } else {
+ coffeeLogService.save(logger);
+ }
+ } catch (Exception e) {
+ log.error("|{}|保存厂商查单日志失败,orderNo:{}", TAG, dto.getOrderNo(), e);
+ }
+ }
+
+ /**
+ * 保存业务阶段生命周期日志(无HTTP报文的本地阶段)
+ */
+ private void saveStageLog(HardCoffeeOrderEntity coffeeOrder, HardCoffeeLogStageEnum stage, Long tenantId, String extra) {
+ if (ObjectUtil.isEmpty(coffeeOrder)) {
+ return;
+ }
+ try {
+ HardCoffeeLogEntity logger = new HardCoffeeLogEntity();
+ logger.setOrderNo(coffeeOrder.getOrderNo());
+ logger.setOrderId(coffeeOrder.getOrderId());
+ logger.setHardId(coffeeOrder.getHardId());
+ logger.setUniqueCode(coffeeOrder.getMachineName());
+ logger.setStage(stage.getStage());
+ logger.setTitle(stage.getTitle());
+ logger.setDirection(HardCoffeeLogDirectionEnum.OUT.getDirection());
+ logger.setReq(extra);
+ logger.setRes("OK");
+ logger.setStartTime(LocalDateTime.now());
+ logger.setTenantId(tenantId);
+ if (tenantId != null) {
+ TenantBroker.runAs(tenantId, tenantId2 -> coffeeLogService.save(logger));
+ } else {
+ coffeeLogService.save(logger);
+ }
+ } catch (Exception e) {
+ log.error("|{}|保存阶段日志失败,stage:{},orderNo:{}", TAG, stage, coffeeOrder.getOrderNo(), e);
+ }
+ }
+
+ private WuliCallContext buildCallContext(HardCoffeeOrderEntity coffeeOrder) {
+ return WuliCallContext.builder()
+ .orderNo(coffeeOrder.getOrderNo())
+ .orderId(coffeeOrder.getOrderId())
+ .hardId(coffeeOrder.getHardId())
+ .uniqueCode(coffeeOrder.getMachineName())
+ .tenantId(coffeeOrder.getTenantId())
+ .build();
+ }
+
+ /**
+ * 匿名请求默认租户为1,跨租户定位设备/订单时临时跳过租户过滤,结束后恢复原上下文
+ */
+ private T queryAcrossTenants(Supplier supplier) {
+ Long origin = TenantContextHolder.getTenantId();
+ TenantContextHolder.setTenantSkip();
+ try {
+ return supplier.get();
+ } finally {
+ TenantContextHolder.clear();
+ TenantContextHolder.setTenantId(origin);
+ }
+ }
+
+ /**
+ * 批量补齐关联数据,空集合直接返回空Map,避免空IN查询
+ */
+ private Map listByIdsMap(List ids, Function, Map> loader) {
+ if (ObjectUtil.isEmpty(ids)) {
+ return Map.of();
+ }
+ return loader.apply(ids);
+ }
+
+ private String fenToYuan(Integer fen) {
+ if (ObjectUtil.isEmpty(fen)) {
+ return "0";
+ }
+ return new BigDecimal(fen).divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).toPlainString();
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/WuliCoffeeClient.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/WuliCoffeeClient.java
new file mode 100644
index 000000000..d2dd6a8bb
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/WuliCoffeeClient.java
@@ -0,0 +1,406 @@
+package com.bsd.pjcloud.hard.service.impl.coffee;
+
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.SecureUtil;
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.data.tenant.TenantBroker;
+import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogDirectionEnum;
+import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogStageEnum;
+import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity;
+import com.bsd.pjcloud.hard.config.CoffeeConf;
+import com.bsd.pjcloud.hard.service.HardCoffeeLogService;
+import com.bsd.pjcloud.thirdparty.api.feign.RemoteThirdKeyManageService;
+import com.bsd.pjcloud.thirdparty.constant.ThirdKeyTypeEnum;
+import com.bsd.pjcloud.thirdparty.entity.ThirdKeyManageEntity;
+import lombok.Builder;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * 屋里咖啡开放接口客户端(文档《第三方支付接口说明文档V1.1.1》)。
+ * 我方作为第三方支付服务商调用厂商 getOrder/callback/refund;
+ * 厂商调我方 queryOrder 的验签也在此统一实现。
+ * 密钥从 thirdparty 服务 third_key_manage 表读取(本地缓存5分钟),字段映射:
+ * app_key=屋里appid,app_secret=屋里appkey(SHA1签名密钥)。
+ * 每次调用均写入 hard_coffee_log 生命周期日志(方向=我方调厂商)。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class WuliCoffeeClient {
+
+ private static final String TAG = "屋里咖啡客户端";
+
+ /**
+ * randstr 长度,文档要求 16 位大小写字母或数字
+ */
+ private static final int RAND_STR_LENGTH = 16;
+
+ /**
+ * randstr 字符集,大小写字母加数字
+ */
+ private static final String RAND_STR_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+
+ /**
+ * 日志报文字段截断长度,对应表字段 varchar(2048)
+ */
+ private static final int LOG_TEXT_MAX_LENGTH = 2000;
+
+ /**
+ * 厂商查单时间戳允许误差(秒),文档建议 2 分钟,放宽到 5 分钟容忍时钟漂移
+ */
+ private static final long QUERY_TIMESTAMP_WINDOW_SECONDS = 300;
+
+ /**
+ * 密钥本地缓存时长(毫秒),修改 third_key_manage 后最多延迟5分钟生效
+ */
+ private static final long KEY_CACHE_MILLIS = 5 * 60 * 1000L;
+
+ private final CoffeeConf coffeeConf;
+ private final HardCoffeeLogService hardCoffeeLogService;
+ private final RemoteThirdKeyManageService thirdKeyManageService;
+
+ private final SecureRandom secureRandom = new SecureRandom();
+
+ /**
+ * postWithoutLog 与调用方之间传递日志实体的线程变量,用后即清
+ */
+ private final ThreadLocal currentLog = new ThreadLocal<>();
+
+ /**
+ * 密钥本地缓存: bizType -> 缓存条目(含空值缓存,避免未配置时反复打Feign)
+ */
+ private final Map keyCache = new ConcurrentHashMap<>();
+
+ /**
+ * 密钥缓存条目
+ */
+ private static class KeyCacheEntry {
+
+ private final ThirdKeyManageEntity entity;
+ private final long expireAt;
+
+ private KeyCacheEntry(ThirdKeyManageEntity entity, long expireAt) {
+ this.entity = entity;
+ this.expireAt = expireAt;
+ }
+ }
+
+ /**
+ * 厂商调用上下文,用于补全生命周期日志的订单与设备关联
+ */
+ @Data
+ @Builder
+ public static class WuliCallContext {
+
+ private String orderNo;
+ private Long orderId;
+ private Long hardId;
+ private String uniqueCode;
+ private Long tenantId;
+ }
+
+ /**
+ * 厂商订单详情(getOrder 返回的 order 对象)
+ */
+ @Data
+ public static class WuliOrderDetail {
+
+ private String orderNo;
+ private String product;
+ private Integer price;
+ private String img;
+ private String isCold;
+ private String machineName;
+ }
+
+ /**
+ * getOrder 调用结果: 详情 + 已填好的生命周期日志(不自动落库,由调用方在租户解析后落库)
+ */
+ @Data
+ public static class WuliGetOrderResult {
+
+ private WuliOrderDetail detail;
+ private HardCoffeeLogEntity log;
+ }
+
+ /**
+ * 根据订单号获得订单详情(文档3.1 getOrder)。
+ * 调用时机器租户尚未解析,日志随结果返回,由调用方确定租户后调用 {@link #saveLog} 落库
+ *
+ * @param context 调用上下文(此时本地订单尚未建立,只需 orderNo)
+ * @return 厂商订单详情与日志实体
+ */
+ public WuliGetOrderResult getOrderWithLog(WuliCallContext context) {
+ Map params = new HashMap<>();
+ params.put("order_no", context.getOrderNo());
+ WuliGetOrderResult result = new WuliGetOrderResult();
+ try {
+ JSONObject resJson = postWithoutLog("getOrder", params, context, HardCoffeeLogStageEnum.GET_ORDER);
+ JSONObject order = resJson.getJSONObject("order");
+ if (ObjectUtil.isEmpty(order)) {
+ throw new RuntimeException("厂商订单信息为空");
+ }
+ WuliOrderDetail detail = new WuliOrderDetail();
+ detail.setOrderNo(order.getStr("order_no"));
+ detail.setProduct(order.getStr("product"));
+ detail.setPrice(order.getInt("price"));
+ detail.setImg(order.getStr("img"));
+ detail.setIsCold(order.getStr("is_cold"));
+ detail.setMachineName(order.getStr("machine_name"));
+ result.setDetail(detail);
+ return result;
+ } catch (RuntimeException e) {
+ // 拉单失败时租户仍未知,日志落默认上下文保留失败痕迹
+ HardCoffeeLogEntity failLog = currentLog.get();
+ if (ObjectUtil.isNotEmpty(failLog)) {
+ saveLog(failLog);
+ }
+ throw e;
+ } finally {
+ result.setLog(currentLog.get());
+ currentLog.remove();
+ }
+ }
+
+ /**
+ * 通知厂商支付结果(文档3.2 callback)
+ *
+ * @param context 调用上下文
+ * @param status 支付状态 SUCCESS/FAIL
+ * @param phoneNo 支付者手机号,可为空
+ * @return 厂商是否确认接收成功
+ */
+ public boolean callback(WuliCallContext context, String status, String phoneNo) {
+ Map params = new HashMap<>();
+ params.put("order_no", context.getOrderNo());
+ params.put("status", status);
+ if (StrUtil.isNotBlank(phoneNo)) {
+ params.put("phone_no", phoneNo);
+ }
+ JSONObject resJson = post("callback", params, context, HardCoffeeLogStageEnum.NOTIFY_PAY);
+ return ObjectUtil.equals(resJson.getStr("return_code"), "SUCCESS");
+ }
+
+ /**
+ * 通知厂商退款(文档3.3 refund)。amount 为本次退款金额而非累计金额,允许分批多次调用
+ *
+ * @param context 调用上下文
+ * @param amountFen 本次退款金额(分)
+ * @return 厂商是否确认接收成功
+ */
+ public boolean refund(WuliCallContext context, int amountFen) {
+ Map params = new HashMap<>();
+ params.put("order_no", context.getOrderNo());
+ params.put("amount", String.valueOf(amountFen));
+ JSONObject resJson = post("refund", params, context, HardCoffeeLogStageEnum.REFUND_NOTIFY);
+ return ObjectUtil.equals(resJson.getStr("return_code"), "SUCCESS");
+ }
+
+ /**
+ * 校验厂商调我方 queryOrder 的签名(文档3.4 + 鉴权方法)。
+ * appid 必须为 third_key_manage(bizType=12) 配置的 app_key,时间戳误差不超过窗口值,
+ * sign = sha1(字典序排序(app_secret, timestamp, randstr) 后拼接)
+ */
+ public boolean verifyQuerySign(String appId, String randstr, String timestamp, String sign) {
+ if (StrUtil.hasBlank(appId, randstr, timestamp, sign)) {
+ return false;
+ }
+ ThirdKeyManageEntity keyConfig = getKeyConfig(ThirdKeyTypeEnum.WULI_COFFEE_QUERY);
+ if (ObjectUtil.isEmpty(keyConfig) || StrUtil.hasBlank(keyConfig.getAppKey(), keyConfig.getAppSecret())) {
+ log.error("|{}|查单密钥未配置,请在third_key_manage维护bizType={}", TAG, ThirdKeyTypeEnum.WULI_COFFEE_QUERY.getType());
+ return false;
+ }
+ if (!ObjectUtil.equals(appId, keyConfig.getAppKey())) {
+ return false;
+ }
+ long ts;
+ try {
+ ts = Long.parseLong(timestamp);
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ long now = System.currentTimeMillis() / 1000;
+ if (Math.abs(now - ts) > QUERY_TIMESTAMP_WINDOW_SECONDS) {
+ return false;
+ }
+ return ObjectUtil.equals(buildSign(keyConfig.getAppSecret(), timestamp, randstr), sign);
+ }
+
+ /**
+ * 带自动落库的统一POST: callback/refund 等租户已知的调用使用
+ */
+ private JSONObject post(String action, Map bizParams, WuliCallContext context, HardCoffeeLogStageEnum stage) {
+ HardCoffeeLogEntity logger = initLog(context, stage);
+ try {
+ return doPost(action, bizParams, context, stage, logger);
+ } finally {
+ saveLog(logger);
+ }
+ }
+
+ /**
+ * 不自动落库的统一POST: getOrder 场景租户未知,日志通过 {@link #currentLog} 带回由调用方落库
+ */
+ private JSONObject postWithoutLog(String action, Map bizParams, WuliCallContext context, HardCoffeeLogStageEnum stage) {
+ HardCoffeeLogEntity logger = initLog(context, stage);
+ try {
+ JSONObject resJson = doPost(action, bizParams, context, stage, logger);
+ currentLog.set(logger);
+ return resJson;
+ } catch (Exception e) {
+ currentLog.set(logger);
+ throw e;
+ }
+ }
+
+ /**
+ * 统一POST: 追加鉴权四参数、发起请求、解析JSON并把请求响应写入生命周期日志。
+ * 厂商返回 return_code=FAIL 时抛出异常,由调用方决定补偿策略。
+ */
+ private JSONObject doPost(String action, Map bizParams, WuliCallContext context, HardCoffeeLogStageEnum stage, HardCoffeeLogEntity logger) {
+ ThirdKeyManageEntity keyConfig = requireKeyConfig(ThirdKeyTypeEnum.WULI_COFFEE);
+ Map form = new HashMap<>(bizParams);
+ String randstr = randomStr();
+ String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
+ form.put("appid", keyConfig.getAppKey());
+ form.put("randstr", randstr);
+ form.put("timestamp", timestamp);
+ form.put("sign", buildSign(keyConfig.getAppSecret(), timestamp, randstr));
+
+ logger.setReq(truncate(JSONUtil.toJsonStr(form)));
+ try {
+ String url = StrUtil.removeSuffix(coffeeConf.getBaseUrl(), "/") + "/api/order/" + action;
+ String res = cn.hutool.http.HttpUtil.createPost(url)
+ .form(form)
+ .timeout(coffeeConf.getTimeoutMillis())
+ .execute()
+ .body();
+ logger.setRes(truncate(res));
+ if (StrUtil.isBlank(res)) {
+ throw new RuntimeException("厂商接口返回为空:" + action);
+ }
+ JSONObject resJson = JSONUtil.parseObj(res);
+ if (!ObjectUtil.equals(resJson.getStr("return_code"), "SUCCESS")) {
+ throw new RuntimeException("厂商接口返回失败:" + action + "," + resJson.getStr("return_msg"));
+ }
+ return resJson;
+ } catch (Exception e) {
+ logger.setError(truncate(e.getMessage()));
+ throw e instanceof RuntimeException re ? re : new RuntimeException(e.getMessage(), e);
+ }
+ }
+
+ /**
+ * 读取屋里咖啡密钥配置,本地缓存5分钟(空值同样缓存)。
+ * 匿名扫码场景无租户上下文,密钥按平台级全局唯一维护,由thirdparty跨租户查询
+ */
+ private ThirdKeyManageEntity getKeyConfig(ThirdKeyTypeEnum type) {
+ KeyCacheEntry cached = keyCache.get(type.getType());
+ long now = System.currentTimeMillis();
+ if (cached != null && cached.expireAt > now) {
+ return cached.entity;
+ }
+ ThirdKeyManageEntity entity = null;
+ try {
+ R result = thirdKeyManageService.getByBizType(type.getType());
+ if (result != null && result.isOk()) {
+ entity = result.getData();
+ }
+ } catch (Exception e) {
+ log.error("|{}|读取third_key_manage失败,bizType:{}", TAG, type.getType(), e);
+ }
+ keyCache.put(type.getType(), new KeyCacheEntry(entity, now + KEY_CACHE_MILLIS));
+ return entity;
+ }
+
+ /**
+ * 读取必填密钥配置,未配置时直接抛错阻断调用
+ */
+ private ThirdKeyManageEntity requireKeyConfig(ThirdKeyTypeEnum type) {
+ ThirdKeyManageEntity keyConfig = getKeyConfig(type);
+ if (ObjectUtil.isEmpty(keyConfig) || StrUtil.hasBlank(keyConfig.getAppKey(), keyConfig.getAppSecret())) {
+ throw new RuntimeException("屋里咖啡密钥未配置,请在third_key_manage维护bizType=" + type.getType());
+ }
+ return keyConfig;
+ }
+
+ /**
+ * 签名: 将 appkey、timestamp、randstr 字典序排序后拼接做 sha1。
+ * 注意: SHA1 是屋里咖啡接口文档第4章强制规定的签名算法,属于厂商协议约束,
+ * 此处仅用于参数防篡改,不承担机密性保护,不可单方面更换为 SHA-256。
+ */
+ private String buildSign(String appKey, String timestamp, String randstr) {
+ String joined = Stream.of(appKey, timestamp, randstr).sorted().collect(Collectors.joining());
+ return SecureUtil.sha1(joined);
+ }
+
+ /**
+ * 生成 16 位随机字符串,使用 SecureRandom 保证不可预测
+ */
+ private String randomStr() {
+ StringBuilder sb = new StringBuilder(RAND_STR_LENGTH);
+ for (int i = 0; i < RAND_STR_LENGTH; i++) {
+ sb.append(RAND_STR_CHARS.charAt(secureRandom.nextInt(RAND_STR_CHARS.length())));
+ }
+ return sb.toString();
+ }
+
+ private HardCoffeeLogEntity initLog(WuliCallContext context, HardCoffeeLogStageEnum stage) {
+ HardCoffeeLogEntity logger = new HardCoffeeLogEntity();
+ logger.setOrderNo(context.getOrderNo());
+ logger.setOrderId(context.getOrderId());
+ logger.setHardId(context.getHardId());
+ logger.setUniqueCode(context.getUniqueCode());
+ logger.setStage(stage.getStage());
+ logger.setTitle(stage.getTitle());
+ logger.setDirection(HardCoffeeLogDirectionEnum.OUT.getDirection());
+ logger.setTenantId(context.getTenantId());
+ logger.setHeaders("url:" + StrUtil.removeSuffix(coffeeConf.getBaseUrl(), "/"));
+ logger.setStartTime(LocalDateTime.now());
+ return logger;
+ }
+
+ /**
+ * 日志落库。携带租户id时切换租户上下文写入,保证匿名请求下 tenant_id 正确。
+ * getOrder 场景由服务层在机器租户解析后调用
+ */
+ public void saveLog(HardCoffeeLogEntity logger) {
+ LocalDateTime now = LocalDateTime.now();
+ logger.setEndTime(now);
+ if (logger.getStartTime() != null) {
+ logger.setCost(Duration.between(logger.getStartTime(), now).toMillis());
+ }
+ try {
+ if (logger.getTenantId() != null) {
+ TenantBroker.runAs(logger.getTenantId(), tenantId -> hardCoffeeLogService.save(logger));
+ } else {
+ hardCoffeeLogService.save(logger);
+ }
+ } catch (Exception e) {
+ // 日志失败不影响主流程
+ log.error("|{}|保存生命周期日志失败,stage:{},orderNo:{}", TAG, logger.getStage(), logger.getOrderNo(), e);
+ }
+ }
+
+ private String truncate(String text) {
+ if (text == null) {
+ return null;
+ }
+ return text.length() <= LOG_TEXT_MAX_LENGTH ? text : text.substring(0, LOG_TEXT_MAX_LENGTH);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java
index fffd5cbe1..f9330745d 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java
@@ -66,6 +66,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
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.math.BigDecimal;
@@ -143,6 +145,8 @@ public class HardAfterSaleServiceImpl extends ServiceImpl pageJoin(Page page, HardAfterSaleDto dto) {
@@ -1664,6 +1668,41 @@ public class HardAfterSaleServiceImpl extends ServiceImpl getByBizType(@RequestParam("bizType") String bizType);
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdKeyTypeEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdKeyTypeEnum.java
index a58fbcbc6..bf070ff72 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdKeyTypeEnum.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdKeyTypeEnum.java
@@ -15,6 +15,10 @@ import java.util.Arrays;
public enum ThirdKeyTypeEnum {
LB("10", "宁波银行"),
+ /** 屋里咖啡咖啡机: 我方作为第三方支付服务商调厂商 getOrder/callback/refund 的鉴权对。 */
+ WULI_COFFEE("11", "屋里咖啡"),
+ /** 屋里咖啡咖啡机: 厂商调我方 queryOrder 查单的验签对(app_key=我方分配的appid)。 */
+ WULI_COFFEE_QUERY("12", "屋里咖啡查单"),
;
private final String type;
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageDetailDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageDetailDto.java
index bd1a6716a..426b1d1e3 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageDetailDto.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageDetailDto.java
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.thirdparty.dto.appmessage;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
@@ -31,6 +32,10 @@ public class WalletMessageDetailDto implements Serializable {
@NotNull(message = "钱包结果余额不能为空")
private Integer balance;
+ /** 流水备注快照,由后台操作人员补充业务说明;历史流水和未填写场景允许为空,最多256个字符。 */
+ @Size(max = 256, message = "流水备注不能超过256个字符")
+ private String remark;
+
/** WalletBizEnum持久化编码,用于服务端再次校验聚合明细一致性。 */
@NotBlank(message = "钱包业务类型不能为空")
private String walletBizType;
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/ThirdKeyManageController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/ThirdKeyManageController.java
index c12a39071..9d394aafd 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/ThirdKeyManageController.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/ThirdKeyManageController.java
@@ -7,7 +7,9 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.feign.annotation.NoToken;
import com.bsd.pjcloud.common.log.annotation.SysLog;
+import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.thirdparty.entity.ThirdKeyManageEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdKeyManageService;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -77,4 +79,14 @@ public class ThirdKeyManageController {
return R.ok(thirdKeyManageService.removeBatchByIds(CollUtil.toList(ids)));
}
+ /**
+ * 内部服务按bizType跨租户读取平台级密钥(咖啡机等匿名设备场景)
+ */
+ @Inner
+ @NoToken
+ @GetMapping("/inner/get-by-biz-type")
+ public R getByBizType(@RequestParam("bizType") String bizType) {
+ return R.ok(thirdKeyManageService.getGlobalByBizType(bizType));
+ }
+
}
\ No newline at end of file
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdKeyManageService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdKeyManageService.java
index 25b9ea4df..f549dcb3d 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdKeyManageService.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdKeyManageService.java
@@ -23,6 +23,12 @@ public interface ThirdKeyManageService extends IService {
*/
ThirdKeyManageEntity get(ThirdKeyTypeEnum type);
+ /**
+ * 跨租户按bizType获取平台级key配置(咖啡机等匿名设备场景无租户上下文)。
+ * 该类密钥要求全平台唯一,建议统一维护在默认租户下
+ */
+ ThirdKeyManageEntity getGlobalByBizType(String bizType);
+
/**
* 验签并且返回配置
*/
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/ThirdKeyManageServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/ThirdKeyManageServiceImpl.java
index d18ebc973..be0b8c748 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/ThirdKeyManageServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/ThirdKeyManageServiceImpl.java
@@ -4,6 +4,7 @@ import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.thirdparty.constant.ThirdKeyTypeEnum;
import com.bsd.pjcloud.thirdparty.entity.ThirdKeyManageEntity;
import com.bsd.pjcloud.thirdparty.mapper.ThirdKeyManageMapper;
@@ -58,6 +59,28 @@ public class ThirdKeyManageServiceImpl extends ServiceImpl().eq(ThirdKeyManageEntity::getBizType, type.getType()));
}
+ /**
+ * 跨租户按bizType获取平台级key配置。咖啡机扫码支付等匿名场景无租户头,
+ * 该类密钥按平台唯一维护,取id最小的一条保证多副本数据时结果稳定
+ */
+ @Override
+ public ThirdKeyManageEntity getGlobalByBizType(String bizType) {
+ if (ObjectUtil.isEmpty(bizType)) {
+ return null;
+ }
+ Long originTenant = TenantContextHolder.getTenantId();
+ TenantContextHolder.setTenantSkip();
+ try {
+ return baseMapper.selectOne(new LambdaQueryWrapper()
+ .eq(ThirdKeyManageEntity::getBizType, bizType)
+ .orderByAsc(ThirdKeyManageEntity::getId)
+ .last("limit 1"));
+ } finally {
+ TenantContextHolder.clear();
+ TenantContextHolder.setTenantId(originTenant);
+ }
+ }
+
/**
* 验签并且返回配置
*/
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java
index 7d405b22e..6e401b094 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java
@@ -440,6 +440,9 @@ public class ThirdAppMessageServiceImpl implements ThirdAppMessageService {
? ErrorCodes.APP_MSG_WALLET_DETAIL_INC
: ErrorCodes.APP_MSG_WALLET_DETAIL_DEC;
detailsText.append(MsgUtils.getMessage(detailCode, walletName, amount, balance));
+ if (StrUtil.isNotBlank(detail.getRemark())) {
+ detailsText.append(MsgUtils.getMessage(ErrorCodes.APP_MSG_WALLET_DETAIL_REMARK, detail.getRemark().trim()));
+ }
}
return MsgUtils.getMessage(ErrorCodes.APP_MSG_WALLET_CONTENT, eventLabel(event), detailsText.toString());
}
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletMessageOutboxEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletMessageOutboxEntity.java
index 9d09615ce..81092719a 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletMessageOutboxEntity.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletMessageOutboxEntity.java
@@ -63,6 +63,9 @@ public class WalletMessageOutboxEntity implements Serializable {
/** 本次变动完成后的余额,单位分。 */
private Integer balance;
+ /** 流水备注快照,由后台操作人员补充业务说明;历史流水和未填写场景允许为空,最多256个字符。 */
+ private String remark;
+
/** 钱包流水实际创建时间。 */
private LocalDateTime occurredTime;
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletMessageOutboxServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletMessageOutboxServiceImpl.java
index 3abebb907..2676a1fac 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletMessageOutboxServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletMessageOutboxServiceImpl.java
@@ -263,6 +263,7 @@ public class WalletMessageOutboxServiceImpl implements WalletMessageOutboxServic
detail.setWalletName(outbox.getWalletName());
detail.setAmount(outbox.getAmount());
detail.setBalance(outbox.getBalance());
+ detail.setRemark(outbox.getRemark());
detail.setWalletBizType(outbox.getSourceBizType());
return detail;
}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java
index 01e8bc85e..68366d4fc 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java
@@ -243,6 +243,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl
Date: Fri, 18 Sep 2026 14:38:29 +0800
Subject: [PATCH 3/6] =?UTF-8?q?feat:=E5=92=96=E5=95=A1=E6=9C=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
db/20260918_hard_coffee_dict_menu.txt | 73 ++++++---
db/20260918_hard_coffee_h5_api.md | 61 ++++---
db/20260918_hard_coffee_pay_flow.svg | 154 ++++++++++++++++++
.../dto/coffee/HardCoffeeWalletPayDto.java | 20 +++
.../coffee/HardCoffeeController.java | 13 +-
.../coffee/HardCoffeeManagerController.java | 12 ++
.../hard/service/HardCoffeeService.java | 20 +++
.../impl/coffee/HardCoffeeServiceImpl.java | 116 +++++++++++--
8 files changed, 407 insertions(+), 62 deletions(-)
create mode 100644 db/20260918_hard_coffee_pay_flow.svg
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeWalletPayDto.java
diff --git a/db/20260918_hard_coffee_dict_menu.txt b/db/20260918_hard_coffee_dict_menu.txt
index 41f72255a..cb2fa7c10 100644
--- a/db/20260918_hard_coffee_dict_menu.txt
+++ b/db/20260918_hard_coffee_dict_menu.txt
@@ -123,50 +123,81 @@ WHERE d.`dict_type` = 'hard_coffee_notify_status'
AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0'
);
+5. 第三方密钥类型字典 third_key_type 增加屋里咖啡两项(所有已配置该字典的租户)。
+ third_key_manage 表单的「类型」下拉即读此字典,不加则无法在管理端选择咖啡机类型录入密钥。
+ item_value 必须与后端 ThirdKeyTypeEnum 一致(11 调用对、12 查单对):
+
+INSERT INTO `pjcloudx`.`sys_dict_item`
+(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `remarks`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, item.`sort_order`),
+ d.`id`, item.`item_value`, item.`label`, d.`dict_type`, item.`label`, item.`sort_order`,
+ 'system', NOW(), item.`label`, '0', d.`tenant_id`
+FROM `pjcloudx`.`sys_dict` d
+JOIN (
+ SELECT '11' AS `item_value`, '屋里咖啡' AS `label`, 11 AS `sort_order`
+ UNION ALL SELECT '12', '屋里咖啡查单', 12
+) item ON item.`item_value` IS NOT NULL
+WHERE d.`dict_type` = 'third_key_type'
+ AND d.`del_flag` = '0'
+ AND NOT EXISTS (
+ SELECT 1 FROM `pjcloudx`.`sys_dict_item` i
+ WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'third_key_type'
+ AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0'
+ );
+
------------------------------------------------------------------------
-二、菜单与按钮权限
+二、菜单与页面路由
------------------------------------------------------------------------
-说明:
-- 「咖啡机订单」建议挂在硬件订单管理菜单下(父菜单 permission = hard_hardOrder_view 所在菜单);
-- 「咖啡机日志」建议挂在设备管理菜单下(父菜单 permission = hard_hardManager_view 所在菜单);
-- 如各环境父菜单定位不同,请先查 parent_id 再执行。
+说明(重要):
+- 前端页面已实现:咖啡机订单页 views/hard/hard-coffee-order/index.vue、
+ 生命周期日志页 views/hard/hard-coffee-log/index.vue;
+- 「咖啡机订单」与现有订单管理菜单同级(参照 permission=hard_hardOrder_view 的菜单取父级);
+- 「咖啡机日志」与设备管理菜单同级(参照 permission=hard_hardManager_view 的菜单取父级);
+- menu_type='0' 为页面菜单,path/component 与前端路由约定一致;
+- 页面菜单自身的 permission 即查看权限,无需再单独加按钮。
-1. 咖啡机订单菜单(menu_type 1=菜单,含页面路由,路由地址建议 /hard/coffee-order):
+1. 咖啡机订单页面菜单:
INSERT INTO `pjcloudx`.`sys_menu`
-(`menu_id`, `name`, `permission`, `parent_id`, `path`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
-SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY parent_menu.`tenant_id`, parent_menu.`menu_id`),
- '咖啡机订单', 'hard_hardCoffeeOrder_view', parent_menu.`menu_id`, 'coffee-order',
- '1', 30, '0', '0', '1', 'system', NOW(), '0', parent_menu.`tenant_id`
-FROM `pjcloudx`.`sys_menu` parent_menu
-WHERE parent_menu.`permission` = 'hard_hardOrder_view' AND parent_menu.`del_flag` = '0'
+(`menu_id`, `name`, `permission`, `path`, `component`, `parent_id`, `icon`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY sibling.`tenant_id`, sibling.`menu_id`),
+ '咖啡机订单', 'hard_hardCoffeeOrder_view', '/hard/coffee-order', 'hard/hard-coffee-order/index',
+ sibling.`parent_id`, NULL, '1', 40, '0', '0', '0',
+ 'system', NOW(), 'system', NOW(), '0', sibling.`tenant_id`
+FROM `pjcloudx`.`sys_menu` sibling
+WHERE sibling.`permission` = 'hard_hardOrder_view' AND sibling.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_menu` existing
- WHERE existing.`tenant_id` = parent_menu.`tenant_id`
+ WHERE existing.`tenant_id` = sibling.`tenant_id`
AND existing.`permission` = 'hard_hardCoffeeOrder_view' AND existing.`del_flag` = '0'
);
-2. 咖啡机日志菜单:
+2. 咖啡机日志页面菜单:
INSERT INTO `pjcloudx`.`sys_menu`
-(`menu_id`, `name`, `permission`, `parent_id`, `path`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
-SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY parent_menu.`tenant_id`, parent_menu.`menu_id`),
- '咖啡机日志', 'hard_hardCoffeeLog_view', parent_menu.`menu_id`, 'coffee-log',
- '1', 31, '0', '0', '1', 'system', NOW(), '0', parent_menu.`tenant_id`
-FROM `pjcloudx`.`sys_menu` parent_menu
-WHERE parent_menu.`permission` = 'hard_hardManager_view' AND parent_menu.`del_flag` = '0'
+(`menu_id`, `name`, `permission`, `path`, `component`, `parent_id`, `icon`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY sibling.`tenant_id`, sibling.`menu_id`),
+ '咖啡机日志', 'hard_hardCoffeeLog_view', '/hard/coffee-log', 'hard/hard-coffee-log/index',
+ sibling.`parent_id`, NULL, '1', 41, '0', '0', '0',
+ 'system', NOW(), 'system', NOW(), '0', sibling.`tenant_id`
+FROM `pjcloudx`.`sys_menu` sibling
+WHERE sibling.`permission` = 'hard_hardManager_view' AND sibling.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_menu` existing
- WHERE existing.`tenant_id` = parent_menu.`tenant_id`
+ WHERE existing.`tenant_id` = sibling.`tenant_id`
AND existing.`permission` = 'hard_hardCoffeeLog_view' AND existing.`del_flag` = '0'
);
+注意:菜单配置更推荐在管理端「菜单管理」页面按上述参数手工添加(组件路径
+hard/hard-coffee-order/index 与 hard/hard-coffee-log/index),SQL 仅为模板参考。
+
------------------------------------------------------------------------
三、第三方密钥 third_key_manage(第三方库 pjcloudx_thirdparty)
------------------------------------------------------------------------
说明:屋里咖啡两对密钥通过 thirdparty 管理端「第三方密钥管理」录入,或按下面模板执行 SQL。
+**通过表单录入前必须先完成上面第 5 项 third_key_type 字典项的添加,否则「类型」下拉选不到屋里咖啡。**
字段映射:app_key=屋里概念里的 appid,app_secret=屋里概念里的 appkey(SHA1签名密钥)。
密钥为平台级全局唯一(跨租户读取),建议维护在默认租户(tenant_id=1)下。
修改密钥后 hardware 侧最多延迟 5 分钟生效(本地缓存)。
diff --git a/db/20260918_hard_coffee_h5_api.md b/db/20260918_hard_coffee_h5_api.md
index 9c1a1d42c..f1b730ee5 100644
--- a/db/20260918_hard_coffee_h5_api.md
+++ b/db/20260918_hard_coffee_h5_api.md
@@ -1,6 +1,6 @@
# 咖啡机(屋里咖啡)H5支付页对接契约 - 一期饭卡支付
-> 交付时间: 2026-09-18。H5 页面在前端仓库(biz_zax_app_cloud)实现,本文档为后端接口契约。
+> 更新: 2026-09-18。**页面已实现**(biz_zax_app_cloud `src/pages/pay/coffee-pay.vue`),本文档保留为接口契约与行为说明。
## 1. 页面入口与二维码 URL
@@ -11,46 +11,53 @@ https://www.pjzhhq.cn/h5/#/pages/pay/coffee-pay?order_no={厂商订单号}
```
- `order_no`: 厂商订单号,19 位数字(测试环境 18 位)。
-- 屋里咖啡后台的「支付跳转 URL 模板」按上述地址配置,占位符以厂商后台实际支持为准。
+- 屋里咖啡后台的「支付跳转 URL 模板」按上述地址配置,占位符拼接方式以屋里后台实际支持为准(联调时确认,若模板系统不支持 hash 路由带参需调整为 query 形式)。
-## 2. 接口(匿名,无需登录态)
+## 2. 页面行为(biz_zax_app_cloud 已实现)
-网关前缀: `https://www.pjzhhq.cn/api/hard` (StripPrefix 后到 hardware 服务)。
-所有响应为统一 `R` 结构 `{code:0, msg, data}`,`code != 0` 时展示 `msg`。
+页面路由: `/pages/pay/coffee-pay`,pages.json 已注册(pages/pay 分包)。
-### 2.1 拉取订单信息
+**必须登录**: 页面 onLoad 检查登录态,未登录 toast 提示后跳转 `/pages/root/login`(登录后重新扫码进入)。所有支付走登录用户自己的饭卡钱包,无需输卡号。
+
+页面流程:
+1. 进入即调拉单接口,展示商品图片/名称/冷热/机器号/金额与支付倒计时(5分钟)。
+2. 展示登录用户饭卡余额(钱包类型编码 `1`)。
+3. 点击支付 → 确认弹窗 → 调支付接口 → 页内展示成功结果(支付人/支付后余额/取咖啡提示)。
+4. 订单已支付(paid=true)展示已支付态;倒计时归零展示超时态,禁用支付。
+
+## 3. 接口契约
+
+网关前缀: `https://www.pjzhhq.cn/api/hard`。响应为统一 `R` 结构,`code != 0` 时展示 `msg`。
+
+### 3.1 拉取订单信息(匿名可用)
```
GET /api/hard/hard-coffee/order-info?orderNo={order_no}
```
-响应 `data`:
-
| 字段 | 类型 | 说明 |
|---|---|---|
| orderId | long | 本地订单id,**支付时回传** |
| orderNo | string | 厂商订单号 |
-| product | string | 产品名称 |
-| img | string | 产品图片完整链接 |
-| isCold | string | `1`冷饮 `0`热饮 |
+| product / img / isCold | string | 产品名/图片链接/`1`冷`0`热 |
| price | int | 价格(分) |
| priceYuan | string | 价格(元,两位小数),直接展示 |
| machineName | string | 机器号 |
-| paid | boolean | 是否已支付。`true` 时展示"已支付"并禁止再次支付 |
-| remainSeconds | long | 支付剩余秒数(下单起5分钟),用于倒计时,超时提示重新发起 |
+| paid | boolean | 是否已支付,`true` 时禁止再次支付 |
+| remainSeconds | long | 支付剩余秒数(下单起5分钟),超时提示重新发起 |
-页面逻辑: 进入页面即调用;`paid=true` 显示已支付结果;`remainSeconds<=0` 显示超时。
-
-### 2.2 饭卡支付(卡号即扣款)
+### 3.2 饭卡支付(登录态,扣本人饭卡)
```
-POST /api/hard/hard-coffee/card-pay
+POST /api/hard/hard-coffee/wallet-pay
Content-Type: application/json
+Authorization: Bearer {token}
-{ "orderId": 1234567890, "cardNo": "0123456789" }
+{ "orderId": 1234567890 }
```
-- 卡号即身份,与消费机刷卡一致,无密码。失败原因见 `msg`(非本单位饭卡/余额不足/订单已超时/订单已支付等)。
+- 需登录(OAuth2),未登录返回 401/424 由移动端拦截器跳登录。
+- 扣登录用户饭卡钱包(WalletTypeEnum.EAT),前置校验余额;失败原因见 `msg`(未开通饭卡/余额不足/订单已超时/订单已支付等)。
响应 `data`:
@@ -58,15 +65,21 @@ Content-Type: application/json
|---|---|---|
| orderId | long | 本地订单id |
| orderNo | string | 厂商订单号 |
-| name | string | 支付人姓名(脱敏展示由前端决定) |
+| name | string | 支付人姓名 |
| cardNo | string | 饭卡卡号 |
| balanceYuan | string | 支付后余额(元) |
-支付成功后展示"取咖啡"提示;出餐由后端回调厂商完成,前端无需轮询(如需兜底可再次调用 2.1 查看 `paid`)。
+### 3.3 卡号支付(匿名备用通道,默认不使用)
-## 3. 注意事项
+```
+POST /api/hard/hard-coffee/card-pay { "orderId": ..., "cardNo": ... }
+```
-1. 匿名接口无租户头,后端通过机器号自动定位租户,前端无需传 TENANT-ID。
+保留作运维备用(卡号即扣,与消费机刷卡一致),正常流程走 3.2。
+
+## 4. 注意事项
+
+1. 前端固定 TENANT-ID:1,后端通过机器号自动定位设备租户,多租户无需前端处理。
2. 同一订单支付窗口 5 分钟,过期后在咖啡机重新发起生成新二维码。
3. 防重复提交由后端并发锁保证,前端支付按钮加 loading 即可。
-4. 一期仅饭卡支付;微信/支付宝支付为二期,接口预留扩展。
+4. 支付出餐由后端回调厂商完成,前端无需轮询(如需兜底可再调 3.1 查看 `paid`)。
diff --git a/db/20260918_hard_coffee_pay_flow.svg b/db/20260918_hard_coffee_pay_flow.svg
new file mode 100644
index 000000000..d13e69fdd
--- /dev/null
+++ b/db/20260918_hard_coffee_pay_flow.svg
@@ -0,0 +1,154 @@
+
+
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeWalletPayDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeWalletPayDto.java
new file mode 100644
index 000000000..486dc68d9
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeWalletPayDto.java
@@ -0,0 +1,20 @@
+package com.bsd.pjcloud.hard.api.dto.coffee;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+/**
+ * 咖啡机饭卡支付请求(H5登录用户)
+ */
+@Data
+@Schema(description = "咖啡机登录用户支付请求")
+public class HardCoffeeWalletPayDto {
+
+ /**
+ * 本地订单id(拉单接口返回)
+ */
+ @NotNull(message = "订单id不能为空")
+ @Schema(description = "本地订单id")
+ private Long orderId;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java
index 1b19092f2..7d33512be 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.hard.controller.coffee;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto;
+import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeWalletPayDto;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderInfoVo;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeePayResultVo;
import com.bsd.pjcloud.hard.service.HardCoffeeService;
@@ -39,7 +40,7 @@ public class HardCoffeeController {
}
/**
- * 饭卡支付(卡号即身份,与消费机刷卡一致)
+ * 饭卡支付(卡号即身份,与消费机刷卡一致,匿名备用通道)
*/
@Operation(summary = "咖啡机饭卡支付", description = "卡号即扣款,支付窗口5分钟")
@PostMapping("/card-pay")
@@ -47,4 +48,14 @@ public class HardCoffeeController {
public R cardPay(@Valid @RequestBody HardCoffeeCardPayDto dto) {
return R.ok(hardCoffeeService.cardPay(dto));
}
+
+ /**
+ * 登录用户饭卡支付(默认走此通道): 扣当前登录用户自己的饭卡钱包,无需输卡号。
+ * 不加 @Inner(false),走 OAuth2 认证,未登录返回 401
+ */
+ @Operation(summary = "咖啡机登录用户支付", description = "扣登录用户饭卡钱包,支付窗口5分钟")
+ @PostMapping("/wallet-pay")
+ public R walletPay(@Valid @RequestBody HardCoffeeWalletPayDto dto) {
+ return R.ok(hardCoffeeService.walletPay(dto));
+ }
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java
index f019769a6..09a4955e6 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java
@@ -20,6 +20,8 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import java.util.List;
+
/**
* 咖啡机订单与生命周期日志管理端查询
*/
@@ -52,6 +54,16 @@ public class HardCoffeeManagerController {
return R.ok(hardCoffeeService.orderDetail(id));
}
+ /**
+ * 按本地订单id查询生命周期日志(订单售后详情页内嵌时间线用)
+ */
+ @Operation(summary = "按订单查询生命周期日志", description = "咖啡机订单的厂商交互时间线")
+ @GetMapping("/log/list-by-order/{orderId}")
+ @PreAuthorize("@pms.hasPermission('hard_hardCoffeeLog_view')")
+ public R> getLogListByOrderId(@PathVariable("orderId") Long orderId) {
+ return R.ok(hardCoffeeService.logListByOrderId(orderId));
+ }
+
/**
* 生命周期日志分页
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java
index 581233ef8..10e7f9568 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.hard.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto;
+import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeWalletPayDto;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeLogDto;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeOrderDto;
import com.bsd.pjcloud.hard.api.dto.coffee.WuliQueryOrderDto;
@@ -14,6 +15,8 @@ import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeLogVo;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeePayResultVo;
import com.bsd.pjcloud.hard.api.vo.coffee.WuliQueryOrderVo;
+import java.util.List;
+
/**
* 咖啡机(屋里咖啡)对接主流程
*/
@@ -35,6 +38,14 @@ public interface HardCoffeeService {
*/
HardCoffeePayResultVo cardPay(HardCoffeeCardPayDto dto);
+ /**
+ * H5登录用户饭卡支付: 扣登录用户自己的饭卡钱包,扣款成功后通知厂商出餐
+ *
+ * @param dto 订单id
+ * @return 支付结果(含余额展示)
+ */
+ HardCoffeePayResultVo walletPay(HardCoffeeWalletPayDto dto);
+
/**
* 厂商主动查单(文档3.4 queryOrder,验签)
*
@@ -66,6 +77,15 @@ public interface HardCoffeeService {
*/
HardCoffeeOrderDetailVo orderDetail(Long id);
+ /**
+ * 按本地订单id查询生命周期日志(订单售后详情页内嵌时间线用)。
+ * 非咖啡机订单返回空集合
+ *
+ * @param orderId 本地订单id(hard_order.id)
+ * @return 生命周期日志(按时间正序)
+ */
+ List logListByOrderId(Long orderId);
+
/**
* 绑定付款人并完成钱包扣款(独立事务,内部通过Spring代理调用保证事务生效)
*
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java
index 52afc7161..49d5ae8c3 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java
@@ -13,6 +13,8 @@ import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import com.bsd.pjcloud.common.security.service.PjcloudUser;
+import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogDirectionEnum;
import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogStageEnum;
@@ -23,6 +25,7 @@ import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeLogDto;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeOrderDto;
+import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeWalletPayDto;
import com.bsd.pjcloud.hard.api.dto.coffee.WuliQueryOrderDto;
import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity;
import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity;
@@ -41,6 +44,7 @@ import com.bsd.pjcloud.hard.service.HardCoffeeService;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.hard.service.impl.coffee.WuliCoffeeClient.WuliCallContext;
+import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
@@ -240,14 +244,29 @@ public class HardCoffeeServiceImpl implements HardCoffeeService {
}
/**
- * H5匿名饭卡支付: 卡号即身份(与消费机刷卡一致)。
- * 扣款在设备租户的本地事务内完成,厂商回调在事务外执行,通知失败不回滚支付,由补偿任务重试。
+ * H5匿名饭卡支付: 卡号即身份(与消费机刷卡一致),保留作备用通道。
+ * 扣款在设备租户的本地事务内完成,厂商回调在事务外执行,通知失败不回滚支付,由补偿任务重试
*/
@Override
public HardCoffeePayResultVo cardPay(HardCoffeeCardPayDto dto) {
- Long orderId = dto.getOrderId();
+ return payInWindow(dto.getOrderId(), coffeeOrder -> doCardPay(coffeeOrder, dto));
+ }
+
+ /**
+ * H5登录用户饭卡支付: 直接扣登录用户自己的饭卡钱包,无需输卡号
+ */
+ @Override
+ public HardCoffeePayResultVo walletPay(HardCoffeeWalletPayDto dto) {
+ return payInWindow(dto.getOrderId(), this::doWalletPay);
+ }
+
+ /**
+ * 支付公共外壳: 支付窗口校验、并发锁、跨租户定位、窗口Key清理。
+ * 窗口Key的读写固定在匿名默认上下文,避免租户前缀漂移
+ */
+ private HardCoffeePayResultVo payInWindow(Long orderId, Function tenantPayer) {
String redisKey = PAY_WINDOW_KEY + orderId;
- // 支付窗口校验(匿名上下文)
+ // 支付窗口校验
String orderNoInWindow = redisTemplate.opsForValue().get(redisKey);
if (StrUtil.isBlank(orderNoInWindow)) {
throw new RuntimeException("订单已超时,请在咖啡机上重新发起");
@@ -262,8 +281,7 @@ public class HardCoffeeServiceImpl implements HardCoffeeService {
if (ObjectUtil.isEmpty(coffeeOrder)) {
throw new RuntimeException("订单不存在");
}
- Long tenantId = coffeeOrder.getTenantId();
- HardCoffeePayResultVo result = TenantBroker.applyAs(tenantId, tenant -> doCardPay(coffeeOrder, dto));
+ HardCoffeePayResultVo result = TenantBroker.applyAs(coffeeOrder.getTenantId(), tenant -> tenantPayer.apply(coffeeOrder));
// 窗口Key删除必须在匿名上下文执行,与写入时前缀一致
redisTemplate.delete(redisKey);
return result;
@@ -273,17 +291,10 @@ public class HardCoffeeServiceImpl implements HardCoffeeService {
}
/**
- * 设备租户上下文内的支付主逻辑
+ * 设备租户上下文内的卡号支付主逻辑
*/
private HardCoffeePayResultVo doCardPay(HardCoffeeOrderEntity coffeeOrder, HardCoffeeCardPayDto dto) {
- Long orderId = coffeeOrder.getOrderId();
- HardOrderEntity order = hardOrderService.getById(orderId);
- if (ObjectUtil.isEmpty(order) || !ObjectUtil.equals(HardTypeEnum.COFFEE.getType(), order.getSysType())) {
- throw new RuntimeException("订单不存在");
- }
- if (!ObjectUtil.equals(HardOrderStatusEnum.CREATE.getType(), order.getOrderStatus())) {
- throw new RuntimeException("订单已支付或已关闭");
- }
+ HardOrderEntity order = requireUnpaidCoffeeOrder(coffeeOrder);
// 卡号定位钱包(Feign携带当前租户头)
WalletManageEntity wallet = walletService.getWalletByCardNumber(dto.getCardNo()).getData();
if (ObjectUtil.isEmpty(wallet)) {
@@ -293,10 +304,66 @@ public class HardCoffeeServiceImpl implements HardCoffeeService {
if (ObjectUtil.isEmpty(user) || ObjectUtil.isEmpty(user.getSysUser())) {
throw new RuntimeException("饭卡用户不存在");
}
+ return payWithWallet(coffeeOrder, order, wallet, user);
+ }
+
+ /**
+ * 设备租户上下文内的登录用户支付主逻辑: 扣当前登录用户自己的饭卡钱包
+ */
+ private HardCoffeePayResultVo doWalletPay(HardCoffeeOrderEntity coffeeOrder) {
+ HardOrderEntity order = requireUnpaidCoffeeOrder(coffeeOrder);
+ PjcloudUser principal = SecurityUtils.getUser();
+ if (ObjectUtil.isEmpty(principal) || ObjectUtil.isEmpty(principal.getId())) {
+ throw new RuntimeException("用户未登录");
+ }
+ WalletManageEntity wallet = walletService.getWallet(principal.getId(), WalletTypeEnum.EAT).getData();
+ if (ObjectUtil.isEmpty(wallet)) {
+ throw new RuntimeException("您还未开通饭卡钱包");
+ }
+ UserInfo user = remoteUserService.getInnerByUserId(principal.getId()).getData();
+ if (ObjectUtil.isEmpty(user) || ObjectUtil.isEmpty(user.getSysUser())) {
+ throw new RuntimeException("饭卡用户不存在");
+ }
+ return payWithWallet(coffeeOrder, order, wallet, user);
+ }
+
+ /**
+ * 校验订单存在且为未支付的咖啡机订单
+ */
+ private HardOrderEntity requireUnpaidCoffeeOrder(HardCoffeeOrderEntity coffeeOrder) {
+ HardOrderEntity order = hardOrderService.getById(coffeeOrder.getOrderId());
+ if (ObjectUtil.isEmpty(order) || !ObjectUtil.equals(HardTypeEnum.COFFEE.getType(), order.getSysType())) {
+ throw new RuntimeException("订单不存在");
+ }
+ if (!ObjectUtil.equals(HardOrderStatusEnum.CREATE.getType(), order.getOrderStatus())) {
+ throw new RuntimeException("订单已支付或已关闭");
+ }
+ return order;
+ }
+
+ /**
+ * 余额前置校验。钱包扣款使用force模式,余额是否充足由调用方负责校验
+ */
+ private void checkBalanceEnough(WalletManageEntity wallet, HardOrderEntity order) {
+ int balance = ObjectUtil.defaultIfNull(wallet.getBalance(), 0);
+ int payPrice = ObjectUtil.defaultIfNull(order.getPayPrice(), 0);
+ if (balance < payPrice) {
+ throw new RuntimeException("饭卡余额不足");
+ }
+ }
+
+ /**
+ * 绑定付款人并完成钱包扣款(事务),再通知厂商出餐。
+ * 通知在本地事务提交后执行,失败不回滚支付,标记后由补偿任务重试
+ */
+ private HardCoffeePayResultVo payWithWallet(HardCoffeeOrderEntity coffeeOrder, HardOrderEntity order, WalletManageEntity wallet, UserInfo user) {
+ Long orderId = coffeeOrder.getOrderId();
+ // force扣款前的余额充足性校验
+ checkBalanceEnough(wallet, order);
// 绑定付款人并扣款(事务),余额不足时抛异常回滚
HardOrderEntity paidOrder = SpringUtil.getBean(HardCoffeeService.class).bindAndDeduct(orderId, user, wallet.getId());
saveStageLog(coffeeOrder, HardCoffeeLogStageEnum.WALLET_PAY, coffeeOrder.getTenantId(),
- "卡号:" + dto.getCardNo() + ",金额(分):" + paidOrder.getPayPrice());
+ "卡号:" + wallet.getCardNumber() + ",金额(分):" + paidOrder.getPayPrice());
// 事务已提交,通知厂商出餐;失败不回滚,标记后由补偿任务重试
String phone = user.getSysUser().getPhone();
@@ -429,6 +496,23 @@ public class HardCoffeeServiceImpl implements HardCoffeeService {
}
}
+ /**
+ * 按本地订单id查询生命周期日志(订单售后详情页内嵌时间线用)
+ */
+ @Override
+ public List logListByOrderId(Long orderId) {
+ HardCoffeeOrderEntity coffeeOrder = coffeeOrderService.getByOrderId(orderId);
+ if (ObjectUtil.isEmpty(coffeeOrder)) {
+ return List.of();
+ }
+ return coffeeLogService.list(new LambdaQueryWrapper()
+ .eq(HardCoffeeLogEntity::getOrderId, orderId)
+ .orderByAsc(HardCoffeeLogEntity::getStartTime)
+ .orderByAsc(HardCoffeeLogEntity::getId))
+ .stream().map(entity -> BeanUtil.toBean(entity, HardCoffeeLogVo.class))
+ .collect(Collectors.toList());
+ }
+
/**
* 补偿任务: 重试通知失败的支付回调 + 补推退款同步缺口。
* 定时线程无租户上下文,列表查询天然跨租户,逐单切回订单租户执行
From b1b67b4dc7359837ffbef1981fc59cb6da1aed57 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Sun, 20 Sep 2026 15:18:06 +0800
Subject: [PATCH 4/6] =?UTF-8?q?feat:=E5=8D=A1=E7=89=87=E6=89=93=E5=8D=B0?=
=?UTF-8?q?=E6=9C=BA=E5=99=A8=E5=88=9D=E6=AD=A5=E5=AF=B9=E6=8E=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
db/20260920_add_wallet_card_template.sql | 48 ++++++++
db/20260920_饭卡卡面模板菜单权限.txt | 92 ++++++++++++++
.../api/entity/WalletCardTemplateEntity.java | 113 ++++++++++++++++++
.../WalletCardTemplateController.java | 111 +++++++++++++++++
.../mapper/WalletCardTemplateMapper.java | 10 ++
.../service/WalletCardTemplateService.java | 21 ++++
.../impl/WalletCardTemplateServiceImpl.java | 35 ++++++
7 files changed, 430 insertions(+)
create mode 100644 db/20260920_add_wallet_card_template.sql
create mode 100644 db/20260920_饭卡卡面模板菜单权限.txt
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletCardTemplateEntity.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCardTemplateController.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletCardTemplateMapper.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCardTemplateService.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCardTemplateServiceImpl.java
diff --git a/db/20260920_add_wallet_card_template.sql b/db/20260920_add_wallet_card_template.sql
new file mode 100644
index 000000000..20f3481da
--- /dev/null
+++ b/db/20260920_add_wallet_card_template.sql
@@ -0,0 +1,48 @@
+-- 饭卡卡面打印:卡面模板表 + 默认种子模板。
+-- 本脚本只新增表和种子数据,不修改既有表结构,可安全重跑。
+-- 菜单与按钮权限见同目录 20260920_饭卡卡面模板菜单权限.txt,由管理员手工添加。
+-- 模板 JSON 由前端可视化设计器生成与消费:background(背景) + elements(元素数组),
+-- 元素坐标基于 1014×642 @300dpi 卡面(HiTi CS-2xx 标准),文本 content 支持 {{field}} 动态字段插值。
+
+CREATE TABLE IF NOT EXISTS `pjcloudx_wallet`.`wallet_card_template`
+(
+ `id` bigint NOT NULL COMMENT '主键',
+ `name` varchar(64) NOT NULL COMMENT '模板名称',
+ `identity_type` varchar(16) DEFAULT NULL COMMENT '适用身份:STUDENT学生/TEACHER教师,空为通用',
+ `sides` char(1) NOT NULL DEFAULT '1' COMMENT '打印面数:1单面 2双面',
+ `front_json` mediumtext COMMENT '正面设计JSON(背景+元素数组)',
+ `back_json` mediumtext COMMENT '背面设计JSON(背景+元素数组),单面模板为空',
+ `remark` varchar(255) DEFAULT NULL COMMENT '备注',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime DEFAULT NULL COMMENT '修改时间',
+ `del_flag` char(1) DEFAULT '0' COMMENT '删除标记',
+ `tenant_id` bigint DEFAULT NULL COMMENT '租户ID',
+ PRIMARY KEY (`id`),
+ KEY `idx_wallet_card_template_tenant` (`tenant_id`, `identity_type`, `del_flag`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci
+ COMMENT = '饭卡卡面打印模板';
+
+-- 种子模板:为所有已初始化字典(即已开通)的租户各插一套学生/教师默认模板,按租户+名称幂等。
+INSERT INTO `pjcloudx_wallet`.`wallet_card_template`
+(`id`, `name`, `identity_type`, `sides`, `front_json`, `back_json`, `remark`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026092000000000000) FROM `pjcloudx_wallet`.`wallet_card_template`) + ROW_NUMBER() OVER (ORDER BY tenant.`tenant_id`, item.`sort`),
+ item.`name`, item.`identity_type`, item.`sides`, item.`front_json`, item.`back_json`, item.`remark`,
+ 'system', NOW(), 'system', NOW(), '0', tenant.`tenant_id`
+FROM (SELECT DISTINCT `tenant_id` FROM `pjcloudx`.`sys_dict` WHERE `del_flag` = '0') tenant
+JOIN (
+ SELECT '学生饭卡' AS `name`, 'STUDENT' AS `identity_type`, '2' AS `sides`, 1 AS `sort`,
+ '学生卡默认模板:姓名/学号/班级/头像' AS `remark`,
+ '{"background":{"type":"gradient","color":"#4e6ef2","color2":"#2d3a8c","angle":135},"elements":[{"id":"s1","type":"text","x":72,"y":64,"w":560,"h":96,"content":"{{name}}","fontSize":76,"bold":true,"color":"#ffffff","fontFamily":"PingFang SC","align":"left","lineHeight":1.2,"opacity":1,"rotate":0,"resin":false},{"id":"s2","type":"text","x":72,"y":208,"w":560,"h":56,"content":"学号:{{studentNo}}","fontSize":40,"bold":false,"color":"#ffffff","fontFamily":"PingFang SC","align":"left","lineHeight":1.2,"opacity":1,"rotate":0,"resin":false},{"id":"s3","type":"text","x":72,"y":284,"w":560,"h":56,"content":"班级:{{className}}","fontSize":40,"bold":false,"color":"#ffffff","fontFamily":"PingFang SC","align":"left","lineHeight":1.2,"opacity":1,"rotate":0,"resin":false},{"id":"s4","type":"image","x":724,"y":84,"w":216,"h":288,"src":"{{avatar}}","fit":"cover","radius":12,"opacity":1,"rotate":0},{"id":"s5","type":"rect","x":0,"y":492,"w":1014,"h":150,"fill":"rgba(255,255,255,0.14)","radius":0,"opacity":1,"rotate":0},{"id":"s6","type":"text","x":72,"y":545,"w":600,"h":48,"content":"智慧食堂 · 饭卡","fontSize":34,"bold":false,"color":"#ffffff","fontFamily":"PingFang SC","align":"left","lineHeight":1.2,"opacity":1,"rotate":0,"resin":false}]}' AS `front_json`,
+ '{"background":{"type":"gradient","color":"#2d3a8c","color2":"#4e6ef2","angle":135},"elements":[{"id":"b1","type":"text","x":72,"y":72,"w":400,"h":64,"content":"使用须知","fontSize":48,"bold":true,"color":"#ffffff","fontFamily":"PingFang SC","align":"left","lineHeight":1.2,"opacity":1,"rotate":0,"resin":false},{"id":"b2","type":"text","x":72,"y":170,"w":870,"h":250,"content":"1. 本卡仅限本人使用,请勿转借;\n2. 卡片遗失请及时到管理中心挂失补办;\n3. 请保持卡面清洁,避免弯折、靠近高温。","fontSize":30,"bold":false,"color":"rgba(255,255,255,0.92)","fontFamily":"PingFang SC","align":"left","lineHeight":1.5,"opacity":1,"rotate":0,"resin":false},{"id":"b3","type":"rect","x":0,"y":492,"w":1014,"h":150,"fill":"rgba(255,255,255,0.14)","radius":0,"opacity":1,"rotate":0},{"id":"b4","type":"text","x":72,"y":545,"w":600,"h":48,"content":"卡号:{{cardNumber}}","fontSize":34,"bold":false,"color":"#ffffff","fontFamily":"PingFang SC","align":"left","lineHeight":1.2,"opacity":1,"rotate":0,"resin":false}]}' AS `back_json`
+ UNION ALL
+ SELECT '教师饭卡', 'TEACHER', '1', 2,
+ '教师卡默认模板:校徽/标语/姓名/工号',
+ '{"background":{"type":"gradient","color":"#0f766e","color2":"#134e4a","angle":135},"elements":[{"id":"t1","type":"image","x":72,"y":64,"w":120,"h":120,"src":"","fit":"contain","radius":0,"opacity":1,"rotate":0},{"id":"t2","type":"text","x":216,"y":92,"w":640,"h":72,"content":"智慧食堂 · 教师卡","fontSize":52,"bold":true,"color":"#ffffff","fontFamily":"PingFang SC","align":"left","lineHeight":1.2,"opacity":1,"rotate":0,"resin":false},{"id":"t3","type":"text","x":72,"y":248,"w":560,"h":64,"content":"{{name}}","fontSize":48,"bold":true,"color":"#ffffff","fontFamily":"PingFang SC","align":"left","lineHeight":1.2,"opacity":1,"rotate":0,"resin":false},{"id":"t4","type":"text","x":72,"y":336,"w":560,"h":56,"content":"工号:{{teacherNo}}","fontSize":40,"bold":false,"color":"#ffffff","fontFamily":"PingFang SC","align":"left","lineHeight":1.2,"opacity":1,"rotate":0,"resin":false},{"id":"t5","type":"rect","x":0,"y":492,"w":1014,"h":150,"fill":"rgba(255,255,255,0.14)","radius":0,"opacity":1,"rotate":0},{"id":"t6","type":"text","x":72,"y":545,"w":640,"h":48,"content":"一餐一饭 皆是心意","fontSize":34,"bold":false,"color":"#ffffff","fontFamily":"PingFang SC","align":"left","lineHeight":1.2,"opacity":1,"rotate":0,"resin":false}]}' AS `front_json`,
+ NULL AS `back_json`
+) item ON item.`name` IS NOT NULL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `pjcloudx_wallet`.`wallet_card_template` t
+ WHERE t.`tenant_id` = tenant.`tenant_id` AND t.`name` = item.`name` AND t.`del_flag` = '0'
+);
diff --git a/db/20260920_饭卡卡面模板菜单权限.txt b/db/20260920_饭卡卡面模板菜单权限.txt
new file mode 100644
index 000000000..8a92d424d
--- /dev/null
+++ b/db/20260920_饭卡卡面模板菜单权限.txt
@@ -0,0 +1,92 @@
+========================================================================
+饭卡卡面打印 - 菜单/权限手工添加清单 (2026-09-20)
+========================================================================
+
+说明:以下内容需要管理员在对应环境手工添加(UPMS 管理端「菜单管理」或直接执行 SQL)。
+SQL 按租户幂等,风格与 db/20260918_hard_coffee_dict_menu.txt 一致。
+菜单 parent_id 依赖各环境实际菜单主键:模板页与「钱包开卡」菜单同级
+(参照 permission='wallet_walletActive_view' 的菜单取父级),打印按钮挂在「钱包开卡」页面菜单下。
+
+前端页面:
+ 卡面模板管理 src/views/wallet/card-template/index.vue(内嵌可视化设计器)
+ 打印入口 src/views/wallet/wallet-active/index.vue 操作列/工具栏(本机打印助手配合)
+
+------------------------------------------------------------------------
+一、页面菜单:卡面模板(与钱包开卡同级)
+------------------------------------------------------------------------
+
+INSERT INTO `pjcloudx`.`sys_menu`
+(`menu_id`, `name`, `permission`, `path`, `component`, `parent_id`, `icon`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026092000000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY sibling.`tenant_id`, sibling.`menu_id`),
+ '卡面模板', 'wallet_cardTemplate_view', '/wallet/card-template', 'wallet/card-template/index',
+ sibling.`parent_id`, NULL, '1', 50, '0', '0', '0',
+ 'system', NOW(), 'system', NOW(), '0', sibling.`tenant_id`
+FROM `pjcloudx`.`sys_menu` sibling
+WHERE sibling.`permission` = 'wallet_walletActive_view' AND sibling.`del_flag` = '0'
+ AND NOT EXISTS (
+ SELECT 1 FROM `pjcloudx`.`sys_menu` existing
+ WHERE existing.`tenant_id` = sibling.`tenant_id`
+ AND existing.`permission` = 'wallet_cardTemplate_view' AND existing.`del_flag` = '0'
+ );
+
+------------------------------------------------------------------------
+二、按钮权限:卡面模板 新增/编辑/删除(挂在卡面模板页面菜单下)
+------------------------------------------------------------------------
+
+INSERT INTO `pjcloudx`.`sys_menu`
+(`menu_id`, `name`, `permission`, `parent_id`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026092000000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY page_menu.`tenant_id`, page_menu.`menu_id`, item.`sort`),
+ item.`name`, item.`permission`, page_menu.`menu_id`,
+ '1', item.`sort`, '0', '0', '2', 'system', NOW(), '0', page_menu.`tenant_id`
+FROM `pjcloudx`.`sys_menu` page_menu
+JOIN (
+ SELECT '新增模板' AS `name`, 'wallet_cardTemplate_add' AS `permission`, 1 AS `sort`
+ UNION ALL SELECT '编辑模板', 'wallet_cardTemplate_edit', 2
+ UNION ALL SELECT '删除模板', 'wallet_cardTemplate_del', 3
+) item ON item.`name` IS NOT NULL
+WHERE page_menu.`permission` = 'wallet_cardTemplate_view' AND page_menu.`menu_type` = '0' AND page_menu.`del_flag` = '0'
+ AND NOT EXISTS (
+ SELECT 1 FROM `pjcloudx`.`sys_menu` existing
+ WHERE existing.`tenant_id` = page_menu.`tenant_id`
+ AND existing.`permission` = item.`permission` AND existing.`del_flag` = '0'
+ );
+
+------------------------------------------------------------------------
+三、按钮权限:钱包开卡页「打印卡面」(挂在钱包开卡页面菜单下)
+------------------------------------------------------------------------
+
+INSERT INTO `pjcloudx`.`sys_menu`
+(`menu_id`, `name`, `permission`, `parent_id`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026092000000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY page_menu.`tenant_id`, page_menu.`menu_id`),
+ '打印卡面', 'wallet_walletActive_print', page_menu.`menu_id`,
+ '1', 30, '0', '0', '2', 'system', NOW(), '0', page_menu.`tenant_id`
+FROM `pjcloudx`.`sys_menu` page_menu
+WHERE page_menu.`permission` = 'wallet_walletActive_view' AND page_menu.`menu_type` = '0' AND page_menu.`del_flag` = '0'
+ AND NOT EXISTS (
+ SELECT 1 FROM `pjcloudx`.`sys_menu` existing
+ WHERE existing.`tenant_id` = page_menu.`tenant_id`
+ AND existing.`permission` = 'wallet_walletActive_print' AND existing.`del_flag` = '0'
+ );
+
+------------------------------------------------------------------------
+四、后端权限字符串与接口对应关系(前端页面取用)
+------------------------------------------------------------------------
+
+wallet_cardTemplate_view:
+ GET /wallet/card-template/page 卡面模板分页(管理页)
+
+wallet_cardTemplate_add:
+ POST /wallet/card-template 新增卡面模板
+
+wallet_cardTemplate_edit:
+ PUT /wallet/card-template 修改卡面模板
+
+wallet_cardTemplate_del:
+ DELETE /wallet/card-template 删除卡面模板
+
+登录即可(无权限注解,打印抽屉取模板用):
+ GET /wallet/card-template/list 卡面模板全量列表
+ GET /wallet/card-template/{id} 卡面模板详情
+
+wallet_walletActive_print:
+ 前端按钮权限(v-auth),不对应后端接口;打印数据直接复用开卡列表行数据。
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletCardTemplateEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletCardTemplateEntity.java
new file mode 100644
index 000000000..5ade12e89
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletCardTemplateEntity.java
@@ -0,0 +1,113 @@
+package com.bsd.pjcloud.wallet.api.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.baomidou.mybatisplus.extension.activerecord.Model;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.*;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import java.time.LocalDateTime;
+
+/**
+ * 饭卡卡面打印模板
+ *
+ * 模板为前端可视化设计器产出的结构化 JSON(元素坐标基于 1014×642 @300dpi 卡面),
+ * 打印时由前端按开卡记录填充动态字段后渲染成图,后端只负责存取。
+ *
+ * @author dzc
+ * @date 2026-09-20
+ */
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+@Data
+@TenantTable
+@TableName("wallet_card_template")
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "饭卡卡面打印模板")
+public class WalletCardTemplateEntity extends Model {
+
+ /**
+ * 主键
+ */
+ @TableId(type = IdType.ASSIGN_ID)
+ @Schema(description = "主键")
+ private Long id;
+
+ /**
+ * 模板名称
+ */
+ @Schema(description = "模板名称")
+ private String name;
+
+ /**
+ * 适用身份:STUDENT 学生 / TEACHER 教师,空表示通用
+ */
+ @Schema(description = "适用身份:STUDENT学生/TEACHER教师,空为通用")
+ private String identityType;
+
+ /**
+ * 打印面数:1 单面 2 双面
+ */
+ @Schema(description = "打印面数:1单面 2双面")
+ private String sides;
+
+ /**
+ * 正面设计 JSON(背景 + 元素数组)
+ */
+ @Schema(description = "正面设计JSON")
+ private String frontJson;
+
+ /**
+ * 背面设计 JSON,双面模板才有
+ */
+ @Schema(description = "背面设计JSON")
+ private String backJson;
+
+ /**
+ * 备注
+ */
+ @Schema(description = "备注")
+ private String remark;
+
+ /**
+ * 创建人
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建人")
+ private String createBy;
+
+ /**
+ * 创建时间
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+
+ /**
+ * 修改人
+ */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改人")
+ private String updateBy;
+
+ /**
+ * 修改时间
+ */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改时间")
+ private LocalDateTime updateTime;
+
+ /**
+ * 删除标记
+ */
+ @TableLogic
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "删除标记")
+ private String delFlag;
+
+ /**
+ * 租户ID
+ */
+ @Schema(description = "租户ID")
+ private Long tenantId;
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCardTemplateController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCardTemplateController.java
new file mode 100644
index 000000000..823e26a6f
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCardTemplateController.java
@@ -0,0 +1,111 @@
+package com.bsd.pjcloud.wallet.controller;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.log.annotation.SysLog;
+import com.bsd.pjcloud.wallet.api.entity.WalletCardTemplateEntity;
+import com.bsd.pjcloud.wallet.service.WalletCardTemplateService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springdoc.core.annotations.ParameterObject;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 饭卡卡面打印模板
+ *
+ * 模板 JSON 由前端可视化设计器生成与消费;list/detail 登录即可访问,
+ * 供开卡打印抽屉在无模板管理权限时也能取模板。
+ *
+ * @author dzc
+ * @date 2026-09-20
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/card-template")
+@Tag(description = "card-template", name = "饭卡卡面打印模板")
+@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
+@Validated
+public class WalletCardTemplateController {
+
+ private final WalletCardTemplateService walletCardTemplateService;
+
+ /**
+ * 模板全量列表(按更新时间倒序),打印抽屉与设计器下拉使用,登录即可访问。
+ */
+ @GetMapping("/list")
+ @Operation(summary = "卡面模板列表", description = "登录即可访问,供打印抽屉选择模板")
+ public R> list() {
+ return R.ok(walletCardTemplateService.list(Wrappers.lambdaQuery()
+ .orderByDesc(WalletCardTemplateEntity::getUpdateTime)));
+ }
+
+ /**
+ * 模板分页,管理页使用。
+ */
+ @GetMapping("/page")
+ @Operation(summary = "卡面模板分页")
+ @PreAuthorize("@pms.hasPermission('wallet_cardTemplate_view')")
+ public R> page(@ParameterObject Page page, @ParameterObject WalletCardTemplateEntity entity) {
+ LambdaQueryWrapper wrapper = Wrappers.lambdaQuery();
+ if (ObjectUtil.isNotEmpty(entity)) {
+ wrapper.like(ObjectUtil.isNotEmpty(entity.getName()), WalletCardTemplateEntity::getName, entity.getName())
+ .eq(ObjectUtil.isNotEmpty(entity.getIdentityType()), WalletCardTemplateEntity::getIdentityType,
+ entity.getIdentityType());
+ }
+ wrapper.orderByDesc(WalletCardTemplateEntity::getUpdateTime);
+ return R.ok(walletCardTemplateService.page(page, wrapper));
+ }
+
+ /**
+ * 模板详情,设计器编辑回显,登录即可访问。
+ */
+ @GetMapping("/{id}")
+ @Operation(summary = "卡面模板详情")
+ public R getById(@PathVariable Long id) {
+ return R.ok(walletCardTemplateService.getById(id));
+ }
+
+ /**
+ * 新增模板
+ */
+ @PostMapping
+ @Operation(summary = "新增卡面模板")
+ @SysLog("新增饭卡卡面模板")
+ @PreAuthorize("@pms.hasPermission('wallet_cardTemplate_add')")
+ public R save(@Valid @RequestBody WalletCardTemplateEntity entity) {
+ return R.ok(walletCardTemplateService.save(entity));
+ }
+
+ /**
+ * 修改模板
+ */
+ @PutMapping
+ @Operation(summary = "修改卡面模板")
+ @SysLog("修改饭卡卡面模板")
+ @PreAuthorize("@pms.hasPermission('wallet_cardTemplate_edit')")
+ public R update(@Valid @RequestBody WalletCardTemplateEntity entity) {
+ return R.ok(walletCardTemplateService.updateById(entity));
+ }
+
+ /**
+ * 删除模板
+ */
+ @DeleteMapping
+ @Operation(summary = "删除卡面模板")
+ @SysLog("删除饭卡卡面模板")
+ @PreAuthorize("@pms.hasPermission('wallet_cardTemplate_del')")
+ public R remove(@RequestBody List ids) {
+ return R.ok(walletCardTemplateService.removeTemplateByIds(ids));
+ }
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletCardTemplateMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletCardTemplateMapper.java
new file mode 100644
index 000000000..38ac57855
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletCardTemplateMapper.java
@@ -0,0 +1,10 @@
+package com.bsd.pjcloud.wallet.mapper;
+
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.wallet.api.entity.WalletCardTemplateEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface WalletCardTemplateMapper extends PjcloudBaseMapper {
+
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCardTemplateService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCardTemplateService.java
new file mode 100644
index 000000000..4ddebe431
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCardTemplateService.java
@@ -0,0 +1,21 @@
+package com.bsd.pjcloud.wallet.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.bsd.pjcloud.wallet.api.entity.WalletCardTemplateEntity;
+
+/**
+ * 饭卡卡面打印模板
+ *
+ * @author dzc
+ * @date 2026-09-20
+ */
+public interface WalletCardTemplateService extends IService {
+
+ /**
+ * 删除前校验模板是否被引用,目前无引用关系,仅保留扩展点。
+ *
+ * @param ids 待删除的模板 ID 集合
+ * @return 是否删除成功
+ */
+ Boolean removeTemplateByIds(java.util.List ids);
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCardTemplateServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCardTemplateServiceImpl.java
new file mode 100644
index 000000000..ad51e3973
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCardTemplateServiceImpl.java
@@ -0,0 +1,35 @@
+package com.bsd.pjcloud.wallet.service.impl;
+
+import cn.hutool.core.collection.CollUtil;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.bsd.pjcloud.wallet.api.entity.WalletCardTemplateEntity;
+import com.bsd.pjcloud.wallet.mapper.WalletCardTemplateMapper;
+import com.bsd.pjcloud.wallet.service.WalletCardTemplateService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import java.util.List;
+
+/**
+ * 饭卡卡面打印模板
+ *
+ * @author dzc
+ * @date 2026-09-20
+ */
+@Validated
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class WalletCardTemplateServiceImpl extends ServiceImpl
+ implements WalletCardTemplateService {
+
+ @Override
+ public Boolean removeTemplateByIds(List ids) {
+ if (CollUtil.isEmpty(ids)) {
+ return Boolean.FALSE;
+ }
+ return removeByIds(ids);
+ }
+}
From ddf2af76a579eebb14872020861ebebdb94b1466 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Sun, 20 Sep 2026 18:24:46 +0800
Subject: [PATCH 5/6] =?UTF-8?q?feat:=E5=8F=96=E6=B6=88=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=E8=87=AA=E5=B8=A6=E8=BD=AF=E9=94=AE=E7=9B=98=E8=AE=BE=E8=AE=A1?=
=?UTF-8?q?=EF=BC=8C=E6=94=B9=E4=B8=BA=E7=B3=BB=E7=BB=9F=E8=87=AA=E7=94=A8?=
=?UTF-8?q?=E9=94=AE=E7=9B=98=E7=BB=84=E4=BB=B6-=E5=88=9D=E6=AD=A5=20feat:?=
=?UTF-8?q?=E5=B0=86=E6=89=93=E5=8D=B0=E5=88=86=E4=B8=BA=E5=9B=BE=E7=89=87?=
=?UTF-8?q?=E6=89=93=E5=8D=B0=E5=92=8C=E5=91=BD=E4=BB=A4=E5=BC=8F=E6=89=93?=
=?UTF-8?q?=E5=8D=B0-=E5=88=9D=E6=AD=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
db/20260920_add_hard_invoice_print_cmd.sql | 5 +
db/800pjcloudx_hardware.sql | 1 +
.../hard/api/entity/HardInvoiceEntity.java | 7 +
.../hard/api/utils/EscPosReceiptCommand.java | 372 ++++++++++++++++++
.../hard/api/utils/ReceiptRenderer.java | 134 +++++++
.../hard/api/utils/ThermalReceiptImage.java | 5 +-
.../service/impl/HardInvoiceServiceImpl.java | 242 +++++++++---
.../api/utils/EscPosReceiptCommandTest.java | 250 ++++++++++++
.../pjcloud/mqtt/dto/hard/HardPrintMsg.java | 6 +
9 files changed, 962 insertions(+), 60 deletions(-)
create mode 100644 db/20260920_add_hard_invoice_print_cmd.sql
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ReceiptRenderer.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java
diff --git a/db/20260920_add_hard_invoice_print_cmd.sql b/db/20260920_add_hard_invoice_print_cmd.sql
new file mode 100644
index 000000000..8ccb0cb08
--- /dev/null
+++ b/db/20260920_add_hard_invoice_print_cmd.sql
@@ -0,0 +1,5 @@
+-- 打印任务补充指令流列:后端在生成凭证 PNG 的同时拼装 ESC/POS 指令(Base64),
+-- 终端"打印机设置-打印方式"切换为指令打印时直接写入 USB,出纸快于图片栅格打印。
+-- 列可空且不回填:历史任务与旧后端无指令,终端指令模式下自动回退 pic 图片打印。
+ALTER TABLE `pjcloudx_hardware`.`hard_invoice`
+ ADD COLUMN `print_cmd` mediumtext DEFAULT NULL COMMENT '后端拼装的打印指令流Base64,终端指令打印直写USB' AFTER `pic`;
diff --git a/db/800pjcloudx_hardware.sql b/db/800pjcloudx_hardware.sql
index e5934cc37..33bf3ba06 100644
--- a/db/800pjcloudx_hardware.sql
+++ b/db/800pjcloudx_hardware.sql
@@ -182,6 +182,7 @@ CREATE TABLE hard_invoice
printed_at datetime DEFAULT NULL COMMENT '终端确认写入成功时间',
reprint_reason varchar(500) DEFAULT NULL COMMENT '人工重打或再次核销原因',
pic varchar(512) DEFAULT NULL COMMENT '后端生成的打印PNG地址',
+ print_cmd mediumtext DEFAULT NULL COMMENT '后端拼装的打印指令流Base64,终端指令打印直写USB',
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java
index 7b15c8dca..77a256969 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java
@@ -115,6 +115,13 @@ public class HardInvoiceEntity extends Model {
@Schema(description="打印图片")
private String pic;
+ /**
+ * 后端拼装的凭证打印指令流(Base64 的 ESC/POS 字节),供终端指令打印直写 USB。
+ * 历史任务和图片生成失败时为空,终端指令模式下回退 pic 图片打印。
+ */
+ @Schema(description="打印指令流Base64")
+ private String printCmd;
+
/**
* 创建人
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java
new file mode 100644
index 000000000..528431b94
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java
@@ -0,0 +1,372 @@
+package com.bsd.pjcloud.hard.api.utils;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 面向 80mm 热敏打印机的 ESC/POS 文本指令拼装器,实现 {@link ReceiptRenderer}。
+ *
+ * 与 {@link ThermalReceiptImage} 保持同一套版式语义:相同的调用顺序在图片模式产出
+ * PNG、在指令模式产出 ESC/POS 字节流,终端按本机设置二选一直写 USB。指令模式跳过
+ * 图片栅格化与传输,出纸速度显著快于图片打印。
+ *
+ * 排版约定:80mm 纸按 48 个 ANK 字符列计算(中文字符占 2 列);中文通过 ESC/POS
+ * 汉字模式(FS & / FS .)以 GBK 编码输出,每行打印后立即关闭汉字模式避免状态泄漏。
+ * 输出内容不含初始化以外的走纸与切刀命令,末尾走纸和切刀由终端按本机配置统一包装。
+ */
+public class EscPosReceiptCommand implements ReceiptRenderer {
+
+ /** 80mm 纸标准 ANK 字符列数,与终端 EscGenerator 的 80mm 每行列数一致。 */
+ public static final int CHARS_PER_LINE = 48;
+
+ /** ESC/POS 左对齐参数值。 */
+ private static final int ALIGN_LEFT = 0;
+
+ /** ESC/POS 居中对齐参数值。 */
+ private static final int ALIGN_CENTER = 1;
+
+ /** ESC/POS 右对齐参数值。 */
+ private static final int ALIGN_RIGHT = 2;
+
+ /** GBK 字符集;绝大多数国产热敏打印机默认按 GBK 解析中文。 */
+ private static final Charset GBK = Charset.forName("GBK");
+
+ /** 指令字节累积缓冲;toBase64 前不做任何 IO。 */
+ private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(2048);
+
+ /**
+ * 创建指令拼装器并输出 ESC @ 初始化,确保终端写入前打印机残留样式被复位。
+ */
+ public EscPosReceiptCommand() {
+ writeRaw(0x1B, 0x40);
+ }
+
+ /**
+ * 渲染居中加粗放大标题;超宽时自动降为单倍宽度保证一行放下。
+ */
+ @Override
+ public EscPosReceiptCommand title(String text) {
+ printCenteredScaled(text, true, 2);
+ return this;
+ }
+
+ /**
+ * 渲染上下横线包围的居中加粗提示,近似图片模式的描边横幅。
+ */
+ @Override
+ public EscPosReceiptCommand banner(String text) {
+ printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
+ printCenteredScaled(text, true, 2);
+ printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
+ return this;
+ }
+
+ /**
+ * 渲染居中三倍加粗短码;超宽时降为双倍再单倍。
+ */
+ @Override
+ public EscPosReceiptCommand verificationCode(String shortCode) {
+ printCenteredScaled(shortCode == null || shortCode.isBlank() ? "--" : shortCode, true, 3);
+ return this;
+ }
+
+ /**
+ * 渲染键值行;值放不下整行时标签独占一行,值从下一行起按整行宽度折行。
+ */
+ @Override
+ public EscPosReceiptCommand keyValue(String label, String value) {
+ String safeLabel = (label == null ? "" : label) + ":";
+ String safeValue = value == null || value.isBlank() ? "--" : value;
+ int valueCapacity = CHARS_PER_LINE - displayWidth(safeLabel);
+ if (displayWidth(safeValue) <= valueCapacity) {
+ printLine(safeLabel + safeValue, ALIGN_LEFT, false, 1);
+ return this;
+ }
+ printLine(safeLabel, ALIGN_LEFT, false, 1);
+ for (String line : wrapByWidth(safeValue, CHARS_PER_LINE)) {
+ printLine(line, ALIGN_LEFT, false, 1);
+ }
+ return this;
+ }
+
+ /**
+ * 渲染左对齐加粗区块小标题。
+ */
+ @Override
+ public EscPosReceiptCommand sectionTitle(String text) {
+ printLine(text == null ? "" : text, ALIGN_LEFT, true, 1);
+ return this;
+ }
+
+ /**
+ * 渲染合并批次中单笔订单摘要:标签与订单号同行加粗,日期餐段随行,订单间短线分隔。
+ */
+ @Override
+ public EscPosReceiptCommand voucherOrderSummary(int index, int total, String orderNo,
+ String orderDate, String mealName) {
+ int safeTotal = Math.max(1, total);
+ int safeIndex = Math.min(Math.max(1, index), safeTotal);
+ String tag = "订单 " + safeIndex + "/" + safeTotal;
+ String safeOrderNo = orderNo == null || orderNo.isBlank() ? "--" : orderNo;
+ int orderCapacity = CHARS_PER_LINE - displayWidth(tag) - 2;
+ List orderLines = wrapByWidth(safeOrderNo, Math.max(8, orderCapacity));
+ printTwoColumns(tag, orderLines.get(0), true);
+ for (int lineIndex = 1; lineIndex < orderLines.size(); lineIndex++) {
+ printLine(" ".repeat(displayWidth(tag) + 2) + orderLines.get(lineIndex), ALIGN_LEFT, true, 1);
+ }
+ String summary = "订餐日期:" + (orderDate == null || orderDate.isBlank() ? "--" : orderDate)
+ + " 餐段:" + (mealName == null || mealName.isBlank() ? "--" : mealName);
+ for (String line : wrapByWidth(summary, CHARS_PER_LINE)) {
+ printLine(line, ALIGN_LEFT, false, 1);
+ }
+ if (safeIndex < safeTotal) {
+ printLine("-".repeat(32), ALIGN_LEFT, false, 1);
+ }
+ return this;
+ }
+
+ /**
+ * 渲染商品或套餐组成项:名称左、数量右,组成项缩进两列并加“-”前缀。
+ */
+ @Override
+ public EscPosReceiptCommand item(String name, String quantity, boolean child, boolean emphasized) {
+ String prefix = child ? " - " : "";
+ String safeName = prefix + (name == null || name.isBlank() ? "未命名商品" : name);
+ String safeQuantity = quantity == null || quantity.isBlank() ? "x0" : quantity;
+ printNameTrailing(safeName, safeQuantity, 12, emphasized);
+ return this;
+ }
+
+ /**
+ * 渲染发票商品行:名称左、数量加金额摘要右。
+ */
+ @Override
+ public EscPosReceiptCommand invoiceItem(String name, String quantity, String amount) {
+ String trailing = String.format("%s %s", quantity, amount);
+ printNameTrailing(name == null || name.isBlank() ? "未命名商品" : name, trailing, 20, false);
+ return this;
+ }
+
+ /**
+ * 渲染兑换券商品卡片,数量为整数份数。
+ */
+ @Override
+ public EscPosReceiptCommand voucherProduct(String name, int quantity, String unitPrice, String subtotal,
+ boolean combo, List componentItems) {
+ return voucherProduct(name, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
+ }
+
+ /**
+ * 渲染兑换券商品卡片:类型标签与名称加粗同行,单价数量与小计两栏,组合内容缩进说明。
+ *
+ * 图片模式以描边卡片区分商品;指令模式用商品块后的空行近似同样的视觉间隔。
+ */
+ @Override
+ public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal,
+ boolean combo, List componentItems) {
+ String typeLabel = combo ? "[组合商品]" : "[单品]";
+ String safeName = name == null || name.isBlank() ? "未命名商品" : name;
+ String safeQuantity = quantity == null || quantity.isBlank() ? "x0" : quantity;
+ String priceText = "单价 ¥" + (unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice)
+ + " " + safeQuantity;
+ String subtotalText = "小计 ¥" + (subtotal == null || subtotal.isBlank() ? "--" : subtotal);
+
+ int nameCapacity = CHARS_PER_LINE - displayWidth(typeLabel) - 1;
+ List nameLines = wrapByWidth(safeName, nameCapacity);
+ printLine(typeLabel + " " + nameLines.get(0), ALIGN_LEFT, true, 1);
+ for (int lineIndex = 1; lineIndex < nameLines.size(); lineIndex++) {
+ printLine(" ".repeat(displayWidth(typeLabel) + 1) + nameLines.get(lineIndex), ALIGN_LEFT, true, 1);
+ }
+ printTwoColumns(priceText, subtotalText, false);
+
+ List safeComponents = componentItems == null ? List.of() : componentItems.stream()
+ .filter(item -> item != null && !item.isBlank()).toList();
+ if (combo && !safeComponents.isEmpty()) {
+ printLine("组合内容", ALIGN_LEFT, true, 1);
+ for (String component : safeComponents) {
+ printLine(" - " + component, ALIGN_LEFT, false, 1);
+ }
+ }
+ printLine("", ALIGN_LEFT, false, 1);
+ return this;
+ }
+
+ /**
+ * 渲染上下横线包围的商品合计;双倍宽度放不下时自动降为单倍。
+ */
+ @Override
+ public EscPosReceiptCommand voucherTotal(String totalAmount) {
+ String safeAmount = totalAmount == null || totalAmount.isBlank() ? "--" : totalAmount;
+ printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
+ String label = "商品合计";
+ String amount = "¥" + safeAmount;
+ if (displayWidth(label) + displayWidth(amount) <= CHARS_PER_LINE / 2) {
+ printTwoColumnsScaled(label, amount, 2);
+ } else {
+ printTwoColumns(label, amount, true);
+ }
+ printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
+ return this;
+ }
+
+ /**
+ * 渲染整行横线分隔。
+ */
+ @Override
+ public EscPosReceiptCommand divider() {
+ printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
+ return this;
+ }
+
+ /**
+ * 输出 Base64 编码的完整指令流,供 MQTT 与打印待办接口下发给终端直写 USB。
+ *
+ * @return Base64 指令字符串;调用方直接持久化或下发,不需要再编码
+ */
+ public String toBase64() {
+ return java.util.Base64.getEncoder().encodeToString(buffer.toByteArray());
+ }
+
+ /**
+ * 输出居中放大文本;放大倍数放不下时逐级降为 2 倍再 1 倍,保证单行完整展示。
+ */
+ private void printCenteredScaled(String text, boolean bold, int scale) {
+ String safeText = text == null ? "" : text;
+ int actualScale = scale;
+ while (actualScale > 1 && displayWidth(safeText) > CHARS_PER_LINE / actualScale) {
+ actualScale--;
+ }
+ printLine(safeText, ALIGN_CENTER, bold, actualScale);
+ }
+
+ /**
+ * 输出名称左、摘要右的两栏明细;名称按剩余宽度折行,摘要始终在首行右对齐。
+ *
+ * @param name 左侧名称文本
+ * @param trailing 首行右对齐摘要
+ * @param trailingWidth 右侧预留列数
+ * @param bold 是否加粗
+ */
+ private void printNameTrailing(String name, String trailing, int trailingWidth, boolean bold) {
+ List lines = wrapByWidth(name, CHARS_PER_LINE - trailingWidth);
+ printTwoColumns(lines.get(0), trailing, bold);
+ for (int index = 1; index < lines.size(); index++) {
+ printLine(lines.get(index), ALIGN_LEFT, bold, 1);
+ }
+ }
+
+ /**
+ * 输出单倍宽度两栏文本;左右间距不足 1 列时右侧文本自动折到下一行右对齐。
+ */
+ private void printTwoColumns(String left, String right, boolean bold) {
+ int gap = CHARS_PER_LINE - displayWidth(left) - displayWidth(right);
+ if (gap >= 1) {
+ printLine(left + " ".repeat(gap) + right, ALIGN_LEFT, bold, 1);
+ return;
+ }
+ printLine(left, ALIGN_LEFT, bold, 1);
+ for (String line : wrapByWidth(right, CHARS_PER_LINE)) {
+ printLine(line, ALIGN_RIGHT, bold, 1);
+ }
+ }
+
+ /**
+ * 输出双倍宽度两栏文本;倍宽后每行容量减半,放不下时右栏折行右对齐。
+ */
+ private void printTwoColumnsScaled(String left, String right, int scale) {
+ int capacity = CHARS_PER_LINE / scale;
+ int gap = capacity - displayWidth(left) - displayWidth(right);
+ if (gap >= 1) {
+ printLine(left + " ".repeat(gap) + right, ALIGN_LEFT, true, scale);
+ return;
+ }
+ printLine(left, ALIGN_LEFT, true, scale);
+ for (String line : wrapByWidth(right, capacity)) {
+ printLine(line, ALIGN_RIGHT, true, scale);
+ }
+ }
+
+ /**
+ * 输出单行文本:对齐、加粗、放大、汉字模式开、GBK 编码、换行、汉字模式关。
+ *
+ * 每个样式指令在行内声明,行后仅复位放大与加粗,对齐由下一行重新声明,
+ * 保证任意指令流截断或拼接都不会让打印机停留在未知样式状态。
+ *
+ * @param text 行文本
+ * @param align ESC a 对齐参数 0 左 1 中 2 右
+ * @param bold 是否加粗
+ * @param scale 放大倍数 1 单倍 2 双倍 3 三倍
+ */
+ private void printLine(String text, int align, boolean bold, int scale) {
+ writeRaw(0x1B, 0x61, align);
+ if (bold) {
+ writeRaw(0x1B, 0x45, 1);
+ }
+ if (scale > 1) {
+ // GS ! n:高 4 位为高度倍数、低 4 位为宽度倍数,宽度决定每行容量。
+ writeRaw(0x1D, 0x21, (scale << 4) | scale);
+ }
+ writeRaw(0x1C, 0x26);
+ writeText(text);
+ writeRaw('\n');
+ writeRaw(0x1C, 0x2E);
+ if (scale > 1) {
+ writeRaw(0x1D, 0x21, 0x01);
+ }
+ if (bold) {
+ writeRaw(0x1B, 0x45, 0);
+ }
+ }
+
+ /**
+ * 追加文本的 GBK 字节;GBK 为 JDK 内置字符集,不会抛出编码异常。
+ */
+ private void writeText(String text) {
+ buffer.writeBytes(text.getBytes(GBK));
+ }
+
+ /**
+ * 追加原始指令字节。
+ */
+ private void writeRaw(int... bytes) {
+ for (int value : bytes) {
+ buffer.write(value);
+ }
+ }
+
+ /**
+ * 计算文本的显示列数:中文字符按 2 列、其余按 1 列,与打印机汉字模式宽度一致。
+ */
+ static int displayWidth(String text) {
+ int width = 0;
+ for (int index = 0; index < text.length(); index++) {
+ width += text.charAt(index) > 0x7F ? 2 : 1;
+ }
+ return width;
+ }
+
+ /**
+ * 按显示列数折行;单个超宽字符仍至少独占一行,与图片绘制器的折行语义一致。
+ */
+ static List wrapByWidth(String text, int maxWidth) {
+ List lines = new ArrayList<>();
+ StringBuilder line = new StringBuilder();
+ int lineWidth = 0;
+ String safeText = text == null ? "" : text;
+ for (int index = 0; index < safeText.length(); index++) {
+ char character = safeText.charAt(index);
+ int charWidth = character > 0x7F ? 2 : 1;
+ if (lineWidth > 0 && lineWidth + charWidth > maxWidth) {
+ lines.add(line.toString());
+ line.setLength(0);
+ lineWidth = 0;
+ }
+ line.append(character);
+ lineWidth += charWidth;
+ }
+ if (line.length() > 0 || lines.isEmpty()) lines.add(line.toString());
+ return lines;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ReceiptRenderer.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ReceiptRenderer.java
new file mode 100644
index 000000000..c453d6581
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ReceiptRenderer.java
@@ -0,0 +1,134 @@
+package com.bsd.pjcloud.hard.api.utils;
+
+import java.util.List;
+
+/**
+ * 打印凭证版式渲染器契约,发票与兑换券的数据拼装只依赖本接口。
+ *
+ * 当前实现有两个:{@link ThermalReceiptImage} 渲染 576px PNG 图片供图片栅格打印;
+ * {@link EscPosReceiptCommand} 直接拼装 ESC/POS 文本指令供指令打印,出纸更快。
+ * 业务层用同一份调用序列驱动两个渲染器,保证两种输出方式的凭证内容始终一致。
+ *
+ * 实现类的方法返回类型允许返回自身具体类型以保留链式调用(协变返回),拼接顺序
+ * 由调用方保证,本接口不承载状态查询。
+ */
+public interface ReceiptRenderer {
+
+ /**
+ * 渲染居中大号标题,例如“智慧食堂发票”。
+ *
+ * @param text 标题文本
+ * @return 当前渲染器
+ */
+ ReceiptRenderer title(String text);
+
+ /**
+ * 渲染醒目的整行提示,例如“第 1 次打印 · 2 笔预约”。
+ *
+ * @param text 提示文本
+ * @return 当前渲染器
+ */
+ ReceiptRenderer banner(String text);
+
+ /**
+ * 渲染醒目的核销或取件短码。
+ *
+ * @param shortCode 短码文本;空值由实现决定占位展示
+ * @return 当前渲染器
+ */
+ ReceiptRenderer verificationCode(String shortCode);
+
+ /**
+ * 渲染一行“字段名:字段值”信息;值过长时由实现负责折行。
+ *
+ * @param label 字段名
+ * @param value 字段值;空白由实现决定占位展示
+ * @return 当前渲染器
+ */
+ ReceiptRenderer keyValue(String label, String value);
+
+ /**
+ * 渲染区块小标题,例如“用户信息”、“商品明细”。
+ *
+ * @param text 标题文本
+ * @return 当前渲染器
+ */
+ ReceiptRenderer sectionTitle(String text);
+
+ /**
+ * 渲染多订单合并兑换券中的单笔订单摘要。
+ *
+ * @param index 当前订单在合并批次中的一号起始序号
+ * @param total 合并批次订单总数
+ * @param orderNo 对外展示的完整订单号
+ * @param orderDate 预约订餐日期
+ * @param mealName 餐段名称
+ * @return 当前渲染器
+ */
+ ReceiptRenderer voucherOrderSummary(int index, int total, String orderNo,
+ String orderDate, String mealName);
+
+ /**
+ * 渲染商品或套餐组成项,数量在首行右对齐。
+ *
+ * @param name 商品名称
+ * @param quantity 数量文本
+ * @param child 是否为套餐组成项
+ * @param emphasized 是否加粗强调
+ * @return 当前渲染器
+ */
+ ReceiptRenderer item(String name, String quantity, boolean child, boolean emphasized);
+
+ /**
+ * 渲染带数量和金额摘要的发票商品行。
+ *
+ * @param name 商品名称
+ * @param quantity 数量或重量文本
+ * @param amount 含货币符号的商品总价
+ * @return 当前渲染器
+ */
+ ReceiptRenderer invoiceItem(String name, String quantity, String amount);
+
+ /**
+ * 渲染兑换券商品卡片,数量为整数份数。
+ *
+ * @param name 商品快照名称
+ * @param quantity 剩余可履约数量
+ * @param unitPrice 商品快照单价,单位元且不含货币符号
+ * @param subtotal 剩余可履约数量对应的小计,单位元且不含货币符号
+ * @param combo 是否为组合商品
+ * @param componentItems 组合商品组成说明,每项应已包含对应数量;可为空
+ * @return 当前渲染器
+ */
+ ReceiptRenderer voucherProduct(String name, int quantity, String unitPrice, String subtotal,
+ boolean combo, List componentItems);
+
+ /**
+ * 渲染兑换券商品卡片,数量为已格式化文本(份数“x2”或称重“0.5/kg”)。
+ *
+ * @param name 商品快照名称
+ * @param quantity 数量展示文本
+ * @param unitPrice 商品快照单价,单位元且不含货币符号
+ * @param subtotal 商品小计,单位元且不含货币符号
+ * @param combo 是否为组合商品
+ * @param componentItems 组合商品组成说明,每项应已包含对应数量;可为空
+ * @return 当前渲染器
+ */
+ ReceiptRenderer voucherProduct(String name, String quantity, String unitPrice, String subtotal,
+ boolean combo, List componentItems);
+
+ /**
+ * 渲染商品合计,形成票据金额层级。
+ *
+ * @param totalAmount 商品合计金额,单位元且不含货币符号
+ * @return 当前渲染器
+ */
+ ReceiptRenderer voucherTotal(String totalAmount);
+
+ /**
+ * 渲染一条横向分隔线。
+ *
+ * @return 当前渲染器
+ */
+ ReceiptRenderer divider();
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java
index e1650f771..d8d52ca14 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java
@@ -17,8 +17,11 @@ import java.util.List;
* 面向 80mm、576px ESC/POS 打印的动态高度 PNG 绘制器。
*
* 商品名称按真实像素宽度换行,数量列固定右对齐,保证长名称不会与数量错配。
+ *
+ * 实现 {@link ReceiptRenderer} 版式契约,方法返回自身具体类型保留链式调用;
+ * 指令打印由同包的 {@link EscPosReceiptCommand} 以相同调用序列产出 ESC/POS 字节。
*/
-public class ThermalReceiptImage {
+public class ThermalReceiptImage implements ReceiptRenderer {
/** 80mm 打印头常见有效像素宽度。 */
public static final int PAPER_WIDTH_PIXELS = 576;
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java
index 42eea3025..8830a9146 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java
@@ -39,6 +39,8 @@ import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity;
import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity;
import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.api.utils.EscPosReceiptCommand;
+import com.bsd.pjcloud.hard.api.utils.ReceiptRenderer;
import com.bsd.pjcloud.hard.api.utils.ThermalReceiptImage;
import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
@@ -260,6 +262,17 @@ public class HardInvoiceServiceImpl extends ServiceImpl carList = Optional.ofNullable(carService.getByOrderId(order.getId())).orElseGet(List::of);
@@ -268,58 +281,61 @@ public class HardInvoiceServiceImpl extends ServiceImpl发票是支付凭证,小计使用下单时的商品快照总额;套餐标签与组合内容沿用兑换券元数据和显示开关。
*
- * @param image 当前发票图片绘制器
+ * @param renderer 当前渲染器
* @param cars 订单商品快照
* @param metadata 按商品 ID 索引的套餐类型和组成项
* @param showComboItems 是否展示套餐组成内容
*/
- private static void appendInvoiceProducts(ThermalReceiptImage image, List cars,
+ private static void appendInvoiceProducts(ReceiptRenderer renderer, List cars,
Map metadata, boolean showComboItems) {
- image.divider().sectionTitle("商品明细");
+ renderer.divider().sectionTitle("商品明细");
boolean hasProduct = false;
for (HardCarVo car : cars) {
hasProduct = true;
@@ -328,10 +344,10 @@ public class HardInvoiceServiceImpl extends ServiceImpl comboItems = combo && showComboItems
? componentItemTexts(product.items(), invoiceComboQuantity(car)) : List.of();
- image.voucherProduct(car.getProductName(), invoiceQuantityText(car), money(voucherUnitPrice(car)),
+ renderer.voucherProduct(car.getProductName(), invoiceQuantityText(car), money(voucherUnitPrice(car)),
money(car.getTotalPrice()), combo, comboItems);
}
- if (!hasProduct) image.item("无商品明细", "x0", false, false);
+ if (!hasProduct) renderer.item("无商品明细", "x0", false, false);
}
/**
@@ -736,21 +752,22 @@ public class HardInvoiceServiceImpl extends ServiceImpllambdaUpdate()
.eq(HardInvoiceEntity::getId, task.getId())
.eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.PENDING_GENERATION.getType())
.eq(HardInvoiceEntity::getRetryVersion, task.getRetryVersion())
.set(HardInvoiceEntity::getStatus, HardPrintStatusEnum.GENERATE.getType())
- .set(HardInvoiceEntity::getPic, image)
+ .set(HardInvoiceEntity::getPic, receipt.pic())
+ .set(HardInvoiceEntity::getPrintCmd, receipt.printCmd())
.set(HardInvoiceEntity::getFailureStage, null)
.set(HardInvoiceEntity::getLastError, null));
return baseMapper.selectById(task.getId());
@@ -780,6 +797,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl result = mqttEventService.sendPrint(message);
if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) {
throw new RuntimeException(result == null ? "MQTT服务无响应"
@@ -797,36 +815,40 @@ public class HardInvoiceServiceImpl extends ServiceImpl身份资料查询失败时降级为不打印用户区,不能阻断已经完成核销的订单出票;商品金额按
* 部分退款后的剩余可履约数量重新计算,组合组成项只做说明而不重复计价。
*
* @param order 已完成核销的预约订单
* @param printNo 当前订单打印序号
- * @return 文件服务返回的兑换券 PNG 地址
+ * @return 图片地址与 Base64 指令流
*/
- private String generateVoucher(HardOrderEntity order, Integer printNo) {
+ private GeneratedReceipt renderVoucher(HardOrderEntity order, Integer printNo) {
List voucherOrders = loadVoucherBatchOrders(order);
MchManageVo merchant = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在");
UserVO user = loadVoucherUser(order.getUserId());
- ThermalReceiptImage image = new ThermalReceiptImage().compactFonts(true).title("预约核销兑换券")
+ ThermalReceiptImage image = new ThermalReceiptImage().compactFonts(true);
+ EscPosReceiptCommand command = new EscPosReceiptCommand();
+ ReceiptRenderer renderer = new CompositeRenderer(image, command);
+ renderer.title("预约核销兑换券")
.banner(String.format("第 %d 次打印 · %d 笔预约", ObjectUtil.defaultIfNull(printNo, 1), voucherOrders.size()))
.keyValue("商户", merchant.getName());
- appendVoucherUser(image, user);
+ appendVoucherUser(renderer, user);
boolean showComboItems = shouldShowVoucherComboItems();
List orderProducts = loadVoucherOrderProducts(voucherOrders, showComboItems);
boolean mergeMultiOrderProducts = voucherOrders.size() > 1 && shouldMergeMultiOrderVoucherProducts();
- if (mergeMultiOrderProducts) appendMergedVoucherLayout(image, orderProducts);
- else appendOrderGroupedVoucherLayout(image, orderProducts);
+ if (mergeMultiOrderProducts) appendMergedVoucherLayout(renderer, orderProducts);
+ else appendOrderGroupedVoucherLayout(renderer, orderProducts);
boolean hasProduct = orderProducts.stream().anyMatch(item -> !item.products().isEmpty());
long totalAmount = orderProducts.stream().flatMap(item -> item.products().stream())
.mapToLong(VoucherProductLine::subtotal).sum();
- if (!hasProduct) image.item("无可履约商品", "x0", false, false);
- image.voucherTotal(money(totalAmount)).keyValue("打印时间", formatDateTime(LocalDateTime.now()));
+ if (!hasProduct) renderer.item("无可履约商品", "x0", false, false);
+ renderer.voucherTotal(money(totalAmount)).keyValue("打印时间", formatDateTime(LocalDateTime.now()));
try {
- return uploadPng("voucher_" + order.getId() + "_" + printNo, image.toPngBytes());
+ return new GeneratedReceipt(uploadPng("voucher_" + order.getId() + "_" + printNo, image.toPngBytes()),
+ command.toBase64());
} catch (IOException exception) {
throw new RuntimeException("生成兑换券图片失败", exception);
}
@@ -912,51 +934,51 @@ public class HardInvoiceServiceImpl extends ServiceImpl orderProducts) {
for (VoucherOrderProducts group : orderProducts) {
HardOrderEntity order = group.order();
- image.divider().sectionTitle("订单 " + displayOrderNo(order))
+ renderer.divider().sectionTitle("订单 " + displayOrderNo(order))
.keyValue("预约日期", order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString())
.keyValue("餐段", resolveMealName(order.getHourType()))
.sectionTitle("剩余可履约商品");
- appendVoucherProducts(image, group.products());
+ appendVoucherProducts(renderer, group.products());
}
}
/**
- * 绘制多订单商品合并版式,先集中展示订单识别信息,再展示一份聚合商品明细。
+ * 渲染多订单商品合并版式,先集中展示订单识别信息,再展示一份聚合商品明细。
*
- * @param image 当前兑换券图片绘制器
+ * @param renderer 当前渲染器
* @param orderProducts 按订单分组的商品快照
*/
- private static void appendMergedVoucherLayout(ThermalReceiptImage image,
+ private static void appendMergedVoucherLayout(ReceiptRenderer renderer,
List orderProducts) {
- image.divider().sectionTitle("订单信息(" + orderProducts.size() + "笔)");
+ renderer.divider().sectionTitle("订单信息(" + orderProducts.size() + "笔)");
for (int index = 0; index < orderProducts.size(); index++) {
HardOrderEntity order = orderProducts.get(index).order();
- image.voucherOrderSummary(index + 1, orderProducts.size(), displayOrderNo(order),
+ renderer.voucherOrderSummary(index + 1, orderProducts.size(), displayOrderNo(order),
order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString(),
resolveMealName(order.getHourType()));
}
- image.divider().sectionTitle("合并商品明细");
- appendVoucherProducts(image, mergeVoucherProducts(orderProducts));
+ renderer.divider().sectionTitle("合并商品明细");
+ appendVoucherProducts(renderer, mergeVoucherProducts(orderProducts));
}
/**
- * 绘制一组结构化兑换券商品,套餐组成数量随商品合计数量同步计算。
+ * 渲染一组结构化兑换券商品,套餐组成数量随商品合计数量同步计算。
*
- * @param image 当前兑换券图片绘制器
- * @param products 待绘制商品;可以是单订单明细或多订单合并结果
+ * @param renderer 当前渲染器
+ * @param products 待渲染商品;可以是单订单明细或多订单合并结果
*/
- private static void appendVoucherProducts(ThermalReceiptImage image, List products) {
+ private static void appendVoucherProducts(ReceiptRenderer renderer, List products) {
for (VoucherProductLine product : products) {
- image.voucherProduct(product.productName(), product.quantity(), money(product.unitPrice()),
+ renderer.voucherProduct(product.productName(), product.quantity(), money(product.unitPrice()),
money(product.subtotal()), product.combo(), voucherComponentItems(product));
}
}
@@ -1078,14 +1100,14 @@ public class HardInvoiceServiceImpl extends ServiceImpl fields = voucherUserFields(user);
if (fields.isEmpty()) return;
- image.divider().sectionTitle("用户信息");
- fields.forEach(image::keyValue);
+ renderer.divider().sectionTitle("用户信息");
+ fields.forEach(renderer::keyValue);
}
/**
@@ -1401,4 +1423,106 @@ public class HardInvoiceServiceImpl extends ServiceImpl componentItems) {
+ image.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems);
+ command.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems);
+ return this;
+ }
+
+ @Override
+ public ReceiptRenderer voucherProduct(String name, String quantity, String unitPrice, String subtotal,
+ boolean combo, List componentItems) {
+ image.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems);
+ command.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems);
+ return this;
+ }
+
+ @Override
+ public ReceiptRenderer voucherTotal(String totalAmount) {
+ image.voucherTotal(totalAmount);
+ command.voucherTotal(totalAmount);
+ return this;
+ }
+
+ @Override
+ public ReceiptRenderer divider() {
+ image.divider();
+ command.divider();
+ return this;
+ }
+ }
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java
new file mode 100644
index 000000000..d583ef9f3
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java
@@ -0,0 +1,250 @@
+package com.bsd.pjcloud.hard.api.utils;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** 80mm 发票与兑换券 ESC/POS 指令流的结构、编码和内容完整性测试。 */
+class EscPosReceiptCommandTest {
+
+ /** GBK 解码字符集,与生产输出编码保持一致。 */
+ private static final Charset GBK = Charset.forName("GBK");
+
+ /** 输出必须以 ESC @ 初始化开头,保证打印机残留样式被复位。 */
+ @Test
+ void outputStartsWithEscAtInitAndBase64RoundTrip() {
+ EscPosReceiptCommand command = new EscPosReceiptCommand().title("智慧食堂发票");
+ byte[] bytes = java.util.Base64.getDecoder().decode(command.toBase64());
+
+ assertEquals(0x1B, bytes[0]);
+ assertEquals(0x40, bytes[1]);
+ assertTrue(bytes.length > 2, "指令流应包含初始化之后的内容指令");
+ }
+
+ /** 指令流不得包含切刀和走纸命令:末尾边界由终端按本机配置统一包装。 */
+ @Test
+ void outputNeverContainsCutOrFeedPaperCommand() {
+ String base64 = new EscPosReceiptCommand()
+ .title("预约核销兑换券")
+ .banner("第 1 次打印 · 2 笔预约")
+ .keyValue("商户", "青鸟同文")
+ .voucherProduct("A套餐-糖醋排骨", 2, "12.00", "24.00", true, List.of("糖醋排骨 x2"))
+ .voucherTotal("24.00")
+ .toBase64();
+ byte[] bytes = java.util.Base64.getDecoder().decode(base64);
+
+ for (int index = 0; index < bytes.length - 1; index++) {
+ assertFalse(bytes[index] == 0x1D && bytes[index + 1] == 0x56,
+ "指令流不应包含 GS V 切刀命令");
+ }
+ assertFalse(containsText(bytes, "\n\n\n"), "指令流不应含成段走纸");
+ }
+
+ /** 每行文本都被汉字模式开启与关闭包裹,截断在任意行都不会让打印机停留在汉字状态。 */
+ @Test
+ void everyLineIsWrappedByKanjiModeOnAndOff() {
+ byte[] bytes = decode(new EscPosReceiptCommand()
+ .title("智慧食堂发票")
+ .keyValue("姓名", "邓樟晨")
+ .voucherTotal("18.60"));
+
+ int kanjiOn = 0;
+ int kanjiOff = 0;
+ for (int index = 0; index < bytes.length - 1; index++) {
+ if (bytes[index] == 0x1C && bytes[index + 1] == 0x26) kanjiOn++;
+ if (bytes[index] == 0x1C && bytes[index + 1] == 0x2E) kanjiOff++;
+ }
+ assertTrue(kanjiOn >= 3, "每个文本行都应输出 FS & 开启汉字模式");
+ assertEquals(kanjiOn, kanjiOff, "汉字模式开启次数必须与关闭次数配对");
+ }
+
+ /** 完整发票样张按行解码后应包含图片版式的全部关键内容。 */
+ @Test
+ void invoiceSampleKeepsAllVisibleContentInDecodedLines() {
+ List lines = visibleLines(decode(invoiceSample()));
+
+ assertTrue(lines.stream().anyMatch(line -> line.contains("智慧食堂发票")), "标题行缺失");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("商户:青鸟同文")), "商户行缺失");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("姓名:邓樟晨")), "姓名行缺失");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("学号:1231312")), "学号行缺失");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("A套餐-糖醋排骨")), "商品名缺失");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("小计 ¥17.00")), "商品小计缺失");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("商品合计")), "商品合计缺失");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("¥18.60")), "合计金额缺失");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("打印时间")), "打印时间缺失");
+ }
+
+ /** 商品行的数量必须与名称同行且整行宽度恰好铺满 48 列,保证右列对齐稳定。 */
+ @Test
+ void itemLineFillsExactlyPaperWidthWithQuantityRightAligned() {
+ byte[] bytes = decode(new EscPosReceiptCommand().item("红糖发糕", "x3", false, false));
+ List lines = visibleLines(bytes);
+ String productLine = lines.stream().filter(line -> line.contains("红糖发糕")).findFirst().orElse("");
+
+ assertTrue(productLine.endsWith("x3"), "数量应位于商品行行尾");
+ assertEquals(EscPosReceiptCommand.CHARS_PER_LINE, EscPosReceiptCommand.displayWidth(productLine),
+ "商品行应以空格填充铺满整行宽度");
+ }
+
+ /** 超长商品名按剩余宽度折行,续行不丢字且数量仍保留在首行行尾。 */
+ @Test
+ void longProductNameWrapsAndKeepsAllCharacters() {
+ String longName = "超长套餐主商品名称".repeat(6);
+ List lines = visibleLines(decode(new EscPosReceiptCommand().item(longName, "x12", false, false)));
+
+ assertTrue(lines.stream().anyMatch(line -> line.endsWith("x12")), "数量应在首行行尾");
+ String rejoined = String.join("", lines).replace(" ", "").replace("x12", "");
+ assertTrue(rejoined.contains(longName), "折行后不得丢失商品名字符");
+ }
+
+ /** 商品合计常规金额使用双倍宽度输出;超长金额自动降为单倍保证不越界。 */
+ @Test
+ void voucherTotalScalesShortAmountAndDegradesLongAmount() {
+ byte[] shortAmount = decode(new EscPosReceiptCommand().voucherTotal("28.80"));
+ assertTrue(contains(shortAmount, 0x1D, 0x21, 0x22), "常规合计应使用双倍宽度 GS ! 0x22");
+
+ byte[] longAmount = decode(new EscPosReceiptCommand().voucherTotal("12345678901234.50"));
+ assertFalse(contains(longAmount, 0x1D, 0x21, 0x22), "超长合计应降级为单倍宽度");
+ List lines = visibleLines(longAmount);
+ assertTrue(lines.stream().anyMatch(line -> line.contains("¥12345678901234.50")), "降级后金额必须完整保留");
+ }
+
+ /** 长横幅文本自动从双倍降为单倍,保证提示完整单行展示。 */
+ @Test
+ void longBannerDegradesScaleInsteadOfLosingContent() {
+ List lines = visibleLines(decode(new EscPosReceiptCommand()
+ .banner("第 12 次打印 · 100 笔预约订单合并打印")));
+
+ assertTrue(lines.stream().anyMatch(line -> line.contains("第 12 次打印 · 100 笔预约订单合并打印")),
+ "降级后横幅文字必须完整");
+ }
+
+ /** 值放不下同一行的键值对应整段换行,与图片版的堆叠语义一致。 */
+ @Test
+ void keyValueStacksValueOnNextLinesWhenTooLong() {
+ List lines = visibleLines(decode(new EscPosReceiptCommand()
+ .keyValue("订单", "20967800000000000012096780000000000001209678")));
+
+ assertEquals("订单:", lines.get(0), "标签应独占首行");
+ assertTrue(lines.get(1).startsWith("2096780"), "值应从下一行起折行展示");
+ }
+
+ /** 多订单摘要完整保留订单号与日期餐段,订单间输出短线分隔。 */
+ @Test
+ void voucherOrderSummaryKeepsAllFieldsWithShortSeparator() {
+ List lines = visibleLines(decode(new EscPosReceiptCommand()
+ .voucherOrderSummary(1, 2, "2096780000000000001", "2026-09-07", "午餐")
+ .voucherOrderSummary(2, 2, "2096780000000000002", "2026-09-07", "午餐")));
+
+ assertTrue(lines.stream().anyMatch(line -> line.contains("订单 1/2")), "订单序号标签缺失");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("2096780000000000001")), "订单号缺失");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("订餐日期:2026-09-07")), "订餐日期缺失");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("餐段:午餐")), "餐段缺失");
+ assertTrue(lines.stream().anyMatch(line -> "-".repeat(32).equals(line)), "订单间应有短线分隔");
+ }
+
+ /** 显示宽度与折行:中文 2 列、ASCII 1 列,单个超宽字符仍独占一行。 */
+ @Test
+ void displayWidthAndWrapFollowChineseAwareColumns() {
+ assertEquals(6, EscPosReceiptCommand.displayWidth("红烧肉"));
+ assertEquals(4, EscPosReceiptCommand.displayWidth("x3ab"));
+
+ List lines = EscPosReceiptCommand.wrapByWidth("红糖发糕精品肋排", 8);
+ assertEquals(2, lines.size(), "8 列应把 12 列文本折成两行");
+ assertEquals("红糖发糕", lines.get(0));
+ assertEquals("精品肋排", lines.get(1));
+ }
+
+ /**
+ * 构造与发票装配顺序一致的完整指令样张。
+ *
+ * @return 已完成拼装的发票指令流
+ */
+ private static EscPosReceiptCommand invoiceSample() {
+ return new EscPosReceiptCommand()
+ .title("智慧食堂发票")
+ .keyValue("商户", "青鸟同文")
+ .keyValue("钱包类型", "就餐钱包")
+ .divider().sectionTitle("用户信息")
+ .keyValue("姓名", "邓樟晨")
+ .keyValue("学号", "1231312")
+ .divider().sectionTitle("订单 2096786045923254273")
+ .keyValue("订单类型", "线下购买")
+ .keyValue("下单时间", "2026-09-14 12:03:01")
+ .divider().sectionTitle("商品明细")
+ .voucherProduct("A套餐-糖醋排骨", "x1", "17.00", "17.00", true, List.of("糖醋排骨 x1", "米饭 x1"))
+ .voucherProduct("红糖发糕", "x1", "1.60", "1.60", false, List.of())
+ .keyValue("额外支付金额", "¥0.50")
+ .voucherTotal("18.60")
+ .keyValue("打印时间", "2026-09-14 12:03:05");
+ }
+
+ /**
+ * 解码 Base64 指令流为原始字节。
+ *
+ * @param command 已完成拼装的指令流
+ * @return ESC/POS 原始字节
+ */
+ private static byte[] decode(EscPosReceiptCommand command) {
+ return java.util.Base64.getDecoder().decode(command.toBase64());
+ }
+
+ /**
+ * 按换行符切分指令流并解码每行的可见文本。
+ *
+ * 每行结构固定为样式指令 + FS & + GBK 文本 + \n + FS .,取 FS & 之后到换行前的字节
+ * 即可还原文本;GBK 双字节编码不会出现 0x1C/0x0A,切分安全。
+ *
+ * @param bytes ESC/POS 原始字节
+ * @return 逐行可见文本
+ */
+ private static List visibleLines(byte[] bytes) {
+ List lines = new ArrayList<>();
+ int index = 0;
+ while (index < bytes.length) {
+ int start = index;
+ while (index < bytes.length && bytes[index] != 0x0A) index++;
+ int textStart = start;
+ for (int scan = start; scan < index - 1; scan++) {
+ if (bytes[scan] == 0x1C && bytes[scan + 1] == 0x26) {
+ textStart = scan + 2;
+ break;
+ }
+ }
+ lines.add(new String(Arrays.copyOfRange(bytes, textStart, index), GBK));
+ index++;
+ }
+ return lines;
+ }
+
+ /**
+ * 判断指令流是否包含指定字节序列。
+ */
+ private static boolean contains(byte[] bytes, int... pattern) {
+ for (int index = 0; index <= bytes.length - pattern.length; index++) {
+ boolean matched = true;
+ for (int offset = 0; offset < pattern.length; offset++) {
+ if (bytes[index + offset] != pattern[offset]) {
+ matched = false;
+ break;
+ }
+ }
+ if (matched) return true;
+ }
+ return false;
+ }
+
+ /**
+ * 判断解码后的指令流是否包含指定文本。
+ */
+ private static boolean containsText(byte[] bytes, String text) {
+ return String.join("", visibleLines(bytes)).contains(text);
+ }
+}
diff --git a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardPrintMsg.java b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardPrintMsg.java
index c6afa09cd..a7216aea9 100644
--- a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardPrintMsg.java
+++ b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardPrintMsg.java
@@ -56,4 +56,10 @@ public class HardPrintMsg extends MqttHardBaseMsg {
// 图片地址
private String pic;
+
+ /**
+ * 后端拼装的凭证打印指令流(Base64 的 ESC/POS 字节);终端指令打印直写 USB,可空。
+ */
+ @Schema(description = "打印指令流Base64")
+ private String printCmd;
}
From 5c47704e094fa47021ccb31c748ef6d8a6f939c1 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Mon, 21 Sep 2026 10:01:00 +0800
Subject: [PATCH 6/6] =?UTF-8?q?fix:=E5=91=BD=E4=BB=A4=E8=A1=8C=E6=89=93?=
=?UTF-8?q?=E5=8D=B0=E8=B0=83=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../hard/api/utils/EscPosReceiptCommand.java | 202 +++++++++++++++---
.../api/utils/EscPosReceiptCommandTest.java | 39 +++-
2 files changed, 206 insertions(+), 35 deletions(-)
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java
index 528431b94..0f351a294 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java
@@ -12,15 +12,32 @@ import java.util.List;
* PNG、在指令模式产出 ESC/POS 字节流,终端按本机设置二选一直写 USB。指令模式跳过
* 图片栅格化与传输,出纸速度显著快于图片打印。
*
- * 排版约定:80mm 纸按 48 个 ANK 字符列计算(中文字符占 2 列);中文通过 ESC/POS
+ *
排版约定:80mm 纸按 48 个 ANK 字符列计算(中文和制表符占 2 列);中文通过 ESC/POS
* 汉字模式(FS & / FS .)以 GBK 编码输出,每行打印后立即关闭汉字模式避免状态泄漏。
- * 输出内容不含初始化以外的走纸与切刀命令,末尾走纸和切刀由终端按本机配置统一包装。
+ * 图片版的矩形边框用 GBK 字库制表符(─ │ ┌ ┐ └ ┘)拼装,相邻字符字模相连成实线;
+ * 黑底白字标签用 ESC { 反色指令输出。输出内容不含初始化以外的走纸与切刀命令,
+ * 末尾走纸和切刀由终端按本机配置统一包装。
*/
public class EscPosReceiptCommand implements ReceiptRenderer {
/** 80mm 纸标准 ANK 字符列数,与终端 EscGenerator 的 80mm 每行列数一致。 */
public static final int CHARS_PER_LINE = 48;
+ /** 制表符矩形框内部内容列数:两侧竖线各占 2 列。 */
+ private static final int BOX_INNER_WIDTH = CHARS_PER_LINE - 4;
+
+ /** 矩形框横线字符。 */
+ private static final String BOX_HORIZONTAL = "─";
+
+ /** 矩形框竖线字符。 */
+ private static final String BOX_VERTICAL = "│";
+
+ /** 矩形框四角字符。 */
+ private static final String BOX_TOP_LEFT = "┌";
+ private static final String BOX_TOP_RIGHT = "┐";
+ private static final String BOX_BOTTOM_LEFT = "└";
+ private static final String BOX_BOTTOM_RIGHT = "┘";
+
/** ESC/POS 左对齐参数值。 */
private static final int ALIGN_LEFT = 0;
@@ -30,7 +47,7 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
/** ESC/POS 右对齐参数值。 */
private static final int ALIGN_RIGHT = 2;
- /** GBK 字符集;绝大多数国产热敏打印机默认按 GBK 解析中文。 */
+ /** GBK 字符集;绝大多数国产热敏打印机默认按 GBK 解析中文与制表符。 */
private static final Charset GBK = Charset.forName("GBK");
/** 指令字节累积缓冲;toBase64 前不做任何 IO。 */
@@ -53,13 +70,18 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
}
/**
- * 渲染上下横线包围的居中加粗提示,近似图片模式的描边横幅。
+ * 渲染制表符描边矩形框包围的居中加粗提示,还原图片模式的横幅边框。
*/
@Override
public EscPosReceiptCommand banner(String text) {
- printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
- printCenteredScaled(text, true, 2);
- printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
+ List lines = wrapByWidth(text == null ? "" : text, BOX_INNER_WIDTH);
+ printBoxTop();
+ for (String line : lines) {
+ int pad = BOX_INNER_WIDTH - displayWidth(line);
+ int left = pad / 2;
+ boxLine(" ".repeat(left) + line + " ".repeat(pad - left), true);
+ }
+ printBoxBottom();
return this;
}
@@ -101,18 +123,19 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
}
/**
- * 渲染合并批次中单笔订单摘要:标签与订单号同行加粗,日期餐段随行,订单间短线分隔。
+ * 渲染合并批次中单笔订单摘要:反色黑底标签与订单号同行加粗,日期餐段随行,订单间制表符短线分隔。
*/
@Override
public EscPosReceiptCommand voucherOrderSummary(int index, int total, String orderNo,
- String orderDate, String mealName) {
+ String orderDate, String mealName) {
int safeTotal = Math.max(1, total);
int safeIndex = Math.min(Math.max(1, index), safeTotal);
- String tag = "订单 " + safeIndex + "/" + safeTotal;
+ // 前后各留一个空格做黑底边距,与图片模式的黑底标签一致。
+ String tag = " 订单 " + safeIndex + "/" + safeTotal + " ";
String safeOrderNo = orderNo == null || orderNo.isBlank() ? "--" : orderNo;
int orderCapacity = CHARS_PER_LINE - displayWidth(tag) - 2;
List orderLines = wrapByWidth(safeOrderNo, Math.max(8, orderCapacity));
- printTwoColumns(tag, orderLines.get(0), true);
+ printTagTrailing(tag, orderLines.get(0));
for (int lineIndex = 1; lineIndex < orderLines.size(); lineIndex++) {
printLine(" ".repeat(displayWidth(tag) + 2) + orderLines.get(lineIndex), ALIGN_LEFT, true, 1);
}
@@ -122,7 +145,7 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
printLine(line, ALIGN_LEFT, false, 1);
}
if (safeIndex < safeTotal) {
- printLine("-".repeat(32), ALIGN_LEFT, false, 1);
+ printLine(BOX_HORIZONTAL.repeat(16), ALIGN_LEFT, false, 1);
}
return this;
}
@@ -154,52 +177,56 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
*/
@Override
public EscPosReceiptCommand voucherProduct(String name, int quantity, String unitPrice, String subtotal,
- boolean combo, List componentItems) {
+ boolean combo, List componentItems) {
return voucherProduct(name, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
}
/**
- * 渲染兑换券商品卡片:类型标签与名称加粗同行,单价数量与小计两栏,组合内容缩进说明。
+ * 渲染兑换券商品卡片:制表符矩形框包围,内部为反色类型标签、加粗名称、两栏价格和组合内容。
*
- * 图片模式以描边卡片区分商品;指令模式用商品块后的空行近似同样的视觉间隔。
+ * 与图片版卡片一一对应:框线对应描边矩形、反色标签对应黑底白字 tag、粗体名称对应大号商品名,
+ * 组合内容区保留内部短线与缩进组成项;卡片之间输出空行还原图片的卡片间距。
*/
@Override
public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal,
- boolean combo, List componentItems) {
- String typeLabel = combo ? "[组合商品]" : "[单品]";
+ boolean combo, List componentItems) {
+ // 前后各留一个空格做黑底边距,与图片模式的类型标签一致。
+ String typeLabel = combo ? " 组合商品 " : " 单品 ";
String safeName = name == null || name.isBlank() ? "未命名商品" : name;
- String safeQuantity = quantity == null || quantity.isBlank() ? "x0" : quantity;
String priceText = "单价 ¥" + (unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice)
- + " " + safeQuantity;
+ + " " + (quantity == null || quantity.isBlank() ? "x0" : quantity);
String subtotalText = "小计 ¥" + (subtotal == null || subtotal.isBlank() ? "--" : subtotal);
- int nameCapacity = CHARS_PER_LINE - displayWidth(typeLabel) - 1;
- List nameLines = wrapByWidth(safeName, nameCapacity);
- printLine(typeLabel + " " + nameLines.get(0), ALIGN_LEFT, true, 1);
- for (int lineIndex = 1; lineIndex < nameLines.size(); lineIndex++) {
- printLine(" ".repeat(displayWidth(typeLabel) + 1) + nameLines.get(lineIndex), ALIGN_LEFT, true, 1);
+ printBoxTop();
+ boxLineFragments(List.of(new Segment(typeLabel, true)), false);
+ for (String nameLine : wrapByWidth(safeName, BOX_INNER_WIDTH)) {
+ boxLine(nameLine, true);
}
- printTwoColumns(priceText, subtotalText, false);
+ boxLineTwoColumns(priceText, subtotalText, false);
List safeComponents = componentItems == null ? List.of() : componentItems.stream()
.filter(item -> item != null && !item.isBlank()).toList();
if (combo && !safeComponents.isEmpty()) {
- printLine("组合内容", ALIGN_LEFT, true, 1);
+ boxLine(" " + BOX_HORIZONTAL.repeat(14) + " ", false);
+ boxLine("组合内容", true);
for (String component : safeComponents) {
- printLine(" - " + component, ALIGN_LEFT, false, 1);
+ for (String componentLine : wrapByWidth(" - " + component, BOX_INNER_WIDTH)) {
+ boxLine(componentLine, false);
+ }
}
}
+ printBoxBottom();
printLine("", ALIGN_LEFT, false, 1);
return this;
}
/**
- * 渲染上下横线包围的商品合计;双倍宽度放不下时自动降为单倍。
+ * 渲染上下制表符实线包围的商品合计;双倍宽度放不下时自动降为单倍。
*/
@Override
public EscPosReceiptCommand voucherTotal(String totalAmount) {
String safeAmount = totalAmount == null || totalAmount.isBlank() ? "--" : totalAmount;
- printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
+ printLine(BOX_HORIZONTAL.repeat(CHARS_PER_LINE / 2), ALIGN_LEFT, false, 1);
String label = "商品合计";
String amount = "¥" + safeAmount;
if (displayWidth(label) + displayWidth(amount) <= CHARS_PER_LINE / 2) {
@@ -207,16 +234,16 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
} else {
printTwoColumns(label, amount, true);
}
- printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
+ printLine(BOX_HORIZONTAL.repeat(CHARS_PER_LINE / 2), ALIGN_LEFT, false, 1);
return this;
}
/**
- * 渲染整行横线分隔。
+ * 渲染整行制表符实线分隔。
*/
@Override
public EscPosReceiptCommand divider() {
- printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1);
+ printLine(BOX_HORIZONTAL.repeat(CHARS_PER_LINE / 2), ALIGN_LEFT, false, 1);
return this;
}
@@ -229,6 +256,83 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
return java.util.Base64.getEncoder().encodeToString(buffer.toByteArray());
}
+ /**
+ * 渲染矩形框顶边。
+ */
+ private void printBoxTop() {
+ printLine(BOX_TOP_LEFT + BOX_HORIZONTAL.repeat(BOX_INNER_WIDTH / 2) + BOX_TOP_RIGHT,
+ ALIGN_LEFT, false, 1);
+ }
+
+ /**
+ * 渲染矩形框底边。
+ */
+ private void printBoxBottom() {
+ printLine(BOX_BOTTOM_LEFT + BOX_HORIZONTAL.repeat(BOX_INNER_WIDTH / 2) + BOX_BOTTOM_RIGHT,
+ ALIGN_LEFT, false, 1);
+ }
+
+ /**
+ * 渲染框内单行内容:竖线 + 左对齐内容填充到框宽 + 竖线。
+ *
+ * @param content 已拼接的框内文本
+ * @param bold 是否加粗
+ */
+ private void boxLine(String content, boolean bold) {
+ boxLineFragments(List.of(new Segment(content, false)), bold);
+ }
+
+ /**
+ * 渲染框内含反色片段的单行:竖线 + 片段序列 + 空格填充到框宽 + 竖线。
+ *
+ * @param inner 框内片段序列
+ * @param bold 是否加粗
+ */
+ private void boxLineFragments(List inner, boolean bold) {
+ int innerWidth = 0;
+ List all = new ArrayList<>();
+ all.add(new Segment(BOX_VERTICAL, false));
+ all.addAll(inner);
+ for (Segment segment : inner) {
+ innerWidth += displayWidth(segment.text());
+ }
+ if (innerWidth < BOX_INNER_WIDTH) {
+ all.add(new Segment(" ".repeat(BOX_INNER_WIDTH - innerWidth), false));
+ }
+ all.add(new Segment(BOX_VERTICAL, false));
+ printFragments(all, ALIGN_LEFT, bold, 1);
+ }
+
+ /**
+ * 渲染框内两栏行:左栏左对齐、右栏右对齐;放不下时右栏折行右对齐。
+ */
+ private void boxLineTwoColumns(String left, String right, boolean bold) {
+ int gap = BOX_INNER_WIDTH - displayWidth(left) - displayWidth(right);
+ if (gap >= 1) {
+ boxLine(left + " ".repeat(gap) + right, bold);
+ return;
+ }
+ boxLine(left, bold);
+ for (String line : wrapByWidth(right, BOX_INNER_WIDTH)) {
+ int pad = BOX_INNER_WIDTH - displayWidth(line);
+ boxLine(" ".repeat(pad) + line, bold);
+ }
+ }
+
+ /**
+ * 渲染反色标签加同行右侧文本:标签黑底白字,右侧文本加粗右对齐。
+ */
+ private void printTagTrailing(String tag, String trailing) {
+ int gap = CHARS_PER_LINE - displayWidth(tag) - displayWidth(trailing);
+ List segments = new ArrayList<>();
+ segments.add(new Segment(tag, true));
+ if (gap >= 1) {
+ segments.add(new Segment(" ".repeat(gap), false));
+ }
+ segments.add(new Segment(trailing, false));
+ printFragments(segments, ALIGN_LEFT, true, 1);
+ }
+
/**
* 输出居中放大文本;放大倍数放不下时逐级降为 2 倍再 1 倍,保证单行完整展示。
*/
@@ -289,7 +393,8 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
}
/**
- * 输出单行文本:对齐、加粗、放大、汉字模式开、GBK 编码、换行、汉字模式关。
+ * 按片段序列输出单行文本:对齐、加粗、放大、汉字模式开、逐片段输出(反色片段用
+ * ESC { 包裹)、换行、汉字模式关。
*
* 每个样式指令在行内声明,行后仅复位放大与加粗,对齐由下一行重新声明,
* 保证任意指令流截断或拼接都不会让打印机停留在未知样式状态。
@@ -300,6 +405,18 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
* @param scale 放大倍数 1 单倍 2 双倍 3 三倍
*/
private void printLine(String text, int align, boolean bold, int scale) {
+ printFragments(List.of(new Segment(text, false)), align, bold, scale);
+ }
+
+ /**
+ * 按片段序列输出单行:对齐、加粗、放大、汉字模式开、GBK 输出、换行、汉字模式关。
+ *
+ * @param segments 行内片段序列;反色片段输出时被 ESC { 1 / ESC { 0 包裹
+ * @param align ESC a 对齐参数 0 左 1 中 2 右
+ * @param bold 是否加粗
+ * @param scale 放大倍数 1 单倍 2 双倍 3 三倍
+ */
+ private void printFragments(List segments, int align, boolean bold, int scale) {
writeRaw(0x1B, 0x61, align);
if (bold) {
writeRaw(0x1B, 0x45, 1);
@@ -309,7 +426,15 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
writeRaw(0x1D, 0x21, (scale << 4) | scale);
}
writeRaw(0x1C, 0x26);
- writeText(text);
+ for (Segment segment : segments) {
+ if (segment.reverse()) {
+ writeRaw(0x1B, 0x7B, 1);
+ }
+ writeText(segment.text());
+ if (segment.reverse()) {
+ writeRaw(0x1B, 0x7B, 0);
+ }
+ }
writeRaw('\n');
writeRaw(0x1C, 0x2E);
if (scale > 1) {
@@ -369,4 +494,13 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
if (line.length() > 0 || lines.isEmpty()) lines.add(line.toString());
return lines;
}
+
+ /**
+ * 行内文本片段;反色片段以黑底白字输出,用于还原图片模式的黑底标签。
+ *
+ * @param text 片段文本
+ * @param reverse 是否反色输出
+ */
+ private record Segment(String text, boolean reverse) {
+ }
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java
index d583ef9f3..ef0fcec18 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java
@@ -147,7 +147,44 @@ class EscPosReceiptCommandTest {
assertTrue(lines.stream().anyMatch(line -> line.contains("2096780000000000001")), "订单号缺失");
assertTrue(lines.stream().anyMatch(line -> line.contains("订餐日期:2026-09-07")), "订餐日期缺失");
assertTrue(lines.stream().anyMatch(line -> line.contains("餐段:午餐")), "餐段缺失");
- assertTrue(lines.stream().anyMatch(line -> "-".repeat(32).equals(line)), "订单间应有短线分隔");
+ assertTrue(lines.stream().anyMatch(line -> "─".repeat(16).equals(line)), "订单间应有制表符短线分隔");
+ }
+
+ /** 打印次数横幅应输出制表符描边矩形框,还原图片模式的横幅边框。 */
+ @Test
+ void bannerRendersBoxFrameAroundCenteredText() {
+ List lines = visibleLines(decode(new EscPosReceiptCommand().banner("第 1 次打印 · 2 笔预约")));
+
+ assertTrue(lines.stream().anyMatch(line -> line.startsWith("┌") && line.endsWith("┐")),
+ "横幅应有制表符顶边框");
+ assertTrue(lines.stream().anyMatch(line -> line.startsWith("└") && line.endsWith("┘")),
+ "横幅应有制表符底边框");
+ assertTrue(lines.stream().anyMatch(line -> line.startsWith("│") && line.endsWith("│")
+ && line.contains("第 1 次打印 · 2 笔预约")), "横幅文字应被竖线框包围且居中");
+ String contentLine = lines.stream()
+ .filter(line -> line.contains("第 1 次打印")).findFirst().orElse("");
+ assertEquals(EscPosReceiptCommand.CHARS_PER_LINE, EscPosReceiptCommand.displayWidth(contentLine),
+ "横幅内容行应铺满整行宽度保证竖线对齐");
+ }
+
+ /** 商品卡片应输出制表符矩形框与反色类型标签,还原图片模式的描边卡片和黑底白字 tag。 */
+ @Test
+ void voucherProductRendersBoxFrameWithReverseTypeTag() {
+ byte[] bytes = decode(new EscPosReceiptCommand()
+ .voucherProduct("A套餐-糖醋排骨", "x2", "12.00", "24.00", true, List.of("糖醋排骨 x2")));
+ List lines = visibleLines(bytes);
+
+ assertTrue(contains(bytes, 0x1B, 0x7B, 1), "类型标签应使用 ESC { 反色指令");
+ assertTrue(contains(bytes, 0x1B, 0x7B, 0), "反色片段必须闭合");
+ assertTrue(lines.stream().anyMatch(line -> line.startsWith("┌") && line.endsWith("┐")),
+ "商品卡片应有制表符顶边框");
+ assertTrue(lines.stream().anyMatch(line -> line.startsWith("└") && line.endsWith("┘")),
+ "商品卡片应有制表符底边框");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("组合商品") && line.startsWith("│")),
+ "反色类型标签应在卡片框内");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("A套餐-糖醋排骨")),
+ "商品名称应在卡片内加粗展示");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("组合内容")), "组合内容标题缺失");
}
/** 显示宽度与折行:中文 2 列、ASCII 1 列,单个超宽字符仍独占一行。 */