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 @@ + + + + + + + + + + + + + + 咖啡机(屋里咖啡)饭卡支付流程 · 一期 + 我方平台 = 厂商文档中的「第三方支付服务商」 · 图中 ①~⑥ 为生命周期日志阶段编码 + + + + + + + + + 用户(手机) + 咖啡机 / 屋里咖啡平台 + H5 支付页(前端) + hardware 后端 /hard-coffee + wallet 钱包服务 + + + + + + + + + + + + + 1 + + 在咖啡机上选择饮品 + + + 2 + + 生成订单 order_no(19位) + 屏幕展示支付二维码 + + 二维码 URL(屋里后台配置指向我方): + pjzhhq.cn/h5/#/pages/pay/coffee-pay?order_no=… + + + 3 + + 扫码打开我方 H5 支付页 + + + 4 + + GET /hard-coffee/order-info?orderNo + 匿名接口 @Inner(false) · 幂等 + + + 5 + + POST /api/order/getOrder(SHA1 签名:字典序拼接 appkey+timestamp+randstr) + + 返回 order:product · price(分) · img · is_cold · machine_name + + + + 6 + 本地落单(并发锁 + 幂等) + 机器号定位设备(type=13) → TenantBroker 切设备租户 + 建 hard_order(CREATE)+扩展表 · Redis 支付窗口 5min + 生命周期日志 ①GET_ORDER ②CREATE_ORDER + + + 7 + + 订单信息:商品 · 金额(元) · 冷热 · 机器号 · 倒计时 + + + 8 + + 确认支付(页面需登录,未登录先跳登录页;扣本人饭卡) + + + 9 + + POST /hard-coffee/wallet-pay {orderId}(登录态) + 校验 5 分钟窗口 · 支付并发锁 + + + 10 + + getWallet(登录用户, 饭卡)(租户上下文) + + 饭卡钱包 · 余额前置校验(未开通/余额不足报错) + + + + 11 + bindAndDeduct 本地事务扣款 + 绑定付款人 → fastReduceBalance 钱包扣款(force 模式,余额前置校验) + 订单置已支付 PAY(余额不足整体回滚)· 日志 ③WALLET_PAY + + + 12 + + POST /api/order/callback(order_no, SUCCESS, phone_no) + 事务提交后通知(失败不回滚)· 日志 ④NOTIFY_PAY + + 通知失败 → 状态=2,补偿 Job 每 60s 重试(上限 10 次后人工介入) + + + 13 + + 咖啡机出餐 ☕ 用户取咖啡 + + + + + 14 + (异步可选)屋里主动查单:POST /hard-coffee/wuli/query-order(另一对 appid/appkey 验签 + 蛇形参数) + 状态映射:CREATE→USERPAYING · PAY→SUCCESS · 退款/关闭→FAIL · 日志 ⑤QUERY + + + + + + 退款分支(管理端售后触发 · 文档 3.3 refund · 支持分批多次) + + + ① 管理端售后退款(复用 hard_after_sale) + 钱包/渠道退款成功 → 订单退款状态重算 + + + + + ② 事务提交后 syncRefundIfNeeded(差额) + POST /api/order/refund(order_no, 本次退款金额分) + 累计 refund_sync_amount 记账 · 总退款 ≤ 订单支付额校验 + + + + + ③ 厂商确认 + 生命周期日志 + ⑥REFUND_NOTIFY · 厂商侧同步退款状态 + 补偿 Job 每 60s 回看近 7 天订单补推退款缺口 + + 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 列,单个超宽字符仍独占一行。 */