Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-09-21 14:59:40 +08:00
commit 7a75aa28ce
78 files changed
+5068 -93

No files matched your search

+64
View File
@@ -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 = '咖啡机使用生命周期日志(屋里咖啡)';
+4
View File
@@ -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`;
+230
View File
@@ -0,0 +1,230 @@
========================================================================
屋里咖啡咖啡机对接一期 - 字典/菜单/权限手工添加清单 (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'
);
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'
);
------------------------------------------------------------------------
二、菜单与页面路由
------------------------------------------------------------------------
说明(重要):
- 前端页面已实现:咖啡机订单页 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. 咖啡机订单页面菜单:
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), 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` = sibling.`tenant_id`
AND existing.`permission` = 'hard_hardCoffeeOrder_view' AND existing.`del_flag` = '0'
);
2. 咖啡机日志页面菜单:
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), 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` = 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 分钟生效(本地缓存)。
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 生命周期日志分页
+85
View File
@@ -0,0 +1,85 @@
# 咖啡机(屋里咖啡)H5支付页对接契约 - 一期饭卡支付
> 更新: 2026-09-18。**页面已实现**(biz_zax_app_cloud `src/pages/pay/coffee-pay.vue`),本文档保留为接口契约与行为说明。
## 1. 页面入口与二维码 URL
用户在咖啡机上选择饮品后,机器展示二维码,URL 由屋里咖啡后台配置,指向我方 H5:
```
https://www.pjzhhq.cn/h5/#/pages/pay/coffee-pay?order_no={厂商订单号}
```
- `order_no`: 厂商订单号,19 位数字(测试环境 18 位)。
- 屋里咖啡后台的「支付跳转 URL 模板」按上述地址配置,占位符拼接方式以屋里后台实际支持为准(联调时确认,若模板系统不支持 hash 路由带参需调整为 query 形式)。
## 2. 页面行为(biz_zax_app_cloud 已实现)
页面路由: `/pages/pay/coffee-pay`,pages.json 已注册(pages/pay 分包)。
**必须登录**: 页面 onLoad 检查登录态,未登录 toast 提示后跳转 `/pages/root/login`(登录后重新扫码进入)。所有支付走登录用户自己的饭卡钱包,无需输卡号。
页面流程:
1. 进入即调拉单接口,展示商品图片/名称/冷热/机器号/金额与支付倒计时(5分钟)。
2. 展示登录用户饭卡余额(钱包类型编码 `1`)。
3. 点击支付 → 确认弹窗 → 调支付接口 → 页内展示成功结果(支付人/支付后余额/取咖啡提示)。
4. 订单已支付(paid=true)展示已支付态;倒计时归零展示超时态,禁用支付。
## 3. 接口契约
网关前缀: `https://www.pjzhhq.cn/api/hard`。响应为统一 `R<T>` 结构,`code != 0` 时展示 `msg`。
### 3.1 拉取订单信息(匿名可用)
```
GET /api/hard/hard-coffee/order-info?orderNo={order_no}
```
| 字段 | 类型 | 说明 |
|---|---|---|
| orderId | long | 本地订单id,**支付时回传** |
| orderNo | string | 厂商订单号 |
| product / img / isCold | string | 产品名/图片链接/`1`冷`0`热 |
| price | int | 价格(分) |
| priceYuan | string | 价格(元,两位小数),直接展示 |
| machineName | string | 机器号 |
| paid | boolean | 是否已支付,`true` 时禁止再次支付 |
| remainSeconds | long | 支付剩余秒数(下单起5分钟),超时提示重新发起 |
### 3.2 饭卡支付(登录态,扣本人饭卡)
```
POST /api/hard/hard-coffee/wallet-pay
Content-Type: application/json
Authorization: Bearer {token}
{ "orderId": 1234567890 }
```
- 需登录(OAuth2),未登录返回 401/424 由移动端拦截器跳登录。
- 扣登录用户饭卡钱包(WalletTypeEnum.EAT),前置校验余额;失败原因见 `msg`(未开通饭卡/余额不足/订单已超时/订单已支付等)。
响应 `data`:
| 字段 | 类型 | 说明 |
|---|---|---|
| orderId | long | 本地订单id |
| orderNo | string | 厂商订单号 |
| name | string | 支付人姓名 |
| cardNo | string | 饭卡卡号 |
| balanceYuan | string | 支付后余额(元) |
### 3.3 卡号支付(匿名备用通道,默认不使用)
```
POST /api/hard/hard-coffee/card-pay { "orderId": ..., "cardNo": ... }
```
保留作运维备用(卡号即扣,与消费机刷卡一致),正常流程走 3.2。
## 4. 注意事项
1. 前端固定 TENANT-ID:1,后端通过机器号自动定位设备租户,多租户无需前端处理。
2. 同一订单支付窗口 5 分钟,过期后在咖啡机重新发起生成新二维码。
3. 防重复提交由后端并发锁保证,前端支付按钮加 loading 即可。
4. 支付出餐由后端回调厂商完成,前端无需轮询(如需兜底可再调 3.1 查看 `paid`)。
@@ -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。
+154
View File
@@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="1150" viewBox="0 0 1280 1150" font-family="'PingFang SC','Microsoft YaHei','Helvetica Neue',sans-serif">
<defs>
<marker id="aBlue" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#409EFF"/></marker>
<marker id="aGreen" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#67C23A"/></marker>
<marker id="aOrange" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#E6A23C"/></marker>
<marker id="aRed" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#F56C6C"/></marker>
<marker id="aGray" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#909399"/></marker>
</defs>
<rect x="0" y="0" width="1280" height="1150" fill="#F5F7FA"/>
<!-- 标题 -->
<text x="640" y="38" text-anchor="middle" font-size="22" font-weight="700" fill="#303133">咖啡机(屋里咖啡)饭卡支付流程 · 一期</text>
<text x="640" y="60" text-anchor="middle" font-size="12" fill="#909399">我方平台 = 厂商文档中的「第三方支付服务商」 · 图中 ①~⑥ 为生命周期日志阶段编码</text>
<!-- 泳道 -->
<g font-size="14" font-weight="600" text-anchor="middle">
<rect x="20" y="80" width="170" height="790" rx="8" fill="#ECF5FF" stroke="#B3D8FF"/>
<rect x="200" y="80" width="250" height="790" rx="8" fill="#FDF6EC" stroke="#F5DAB6"/>
<rect x="460" y="80" width="200" height="790" rx="8" fill="#F0F9EB" stroke="#C2E7B0"/>
<rect x="670" y="80" width="360" height="790" rx="8" fill="#F4F4F5" stroke="#D3D4D6"/>
<rect x="1040" y="80" width="220" height="790" rx="8" fill="#F4F4F5" stroke="#D3D4D6"/>
<rect x="20" y="80" width="170" height="34" rx="8" fill="#409EFF"/><text x="105" y="102" fill="#fff">用户(手机)</text>
<rect x="200" y="80" width="250" height="34" rx="8" fill="#E6A23C"/><text x="325" y="102" fill="#fff">咖啡机 / 屋里咖啡平台</text>
<rect x="460" y="80" width="200" height="34" rx="8" fill="#67C23A"/><text x="560" y="102" fill="#fff">H5 支付页(前端)</text>
<rect x="670" y="80" width="360" height="34" rx="8" fill="#303133"/><text x="850" y="102" fill="#fff">hardware 后端 /hard-coffee</text>
<rect x="1040" y="80" width="220" height="34" rx="8" fill="#303133"/><text x="1150" y="102" fill="#fff">wallet 钱包服务</text>
</g>
<!-- 生命线 -->
<g stroke="#C0C4CC" stroke-dasharray="4 4" stroke-width="1">
<line x1="105" y1="114" x2="105" y2="870"/>
<line x1="325" y1="114" x2="325" y2="870"/>
<line x1="560" y1="114" x2="560" y2="870"/>
<line x1="850" y1="114" x2="850" y2="870"/>
<line x1="1150" y1="114" x2="1150" y2="870"/>
</g>
<!-- ① 用户选饮品 -->
<circle cx="105" cy="150" r="11" fill="#409EFF"/><text x="105" y="154" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">1</text>
<line x1="116" y1="150" x2="324" y2="150" stroke="#409EFF" stroke-width="1.6" marker-end="url(#aBlue)"/>
<text x="220" y="142" text-anchor="middle" font-size="12" fill="#409EFF">在咖啡机上选择饮品</text>
<!-- ② 咖啡机生成订单+二维码 -->
<circle cx="325" cy="200" r="11" fill="#E6A23C"/><text x="325" y="204" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">2</text>
<rect x="240" y="212" width="170" height="46" rx="6" fill="#fff" stroke="#E6A23C"/>
<text x="325" y="230" text-anchor="middle" font-size="11" fill="#606266">生成订单 order_no(19位)</text>
<text x="325" y="248" text-anchor="middle" font-size="11" fill="#606266">屏幕展示支付二维码</text>
<rect x="420" y="186" width="330" height="34" rx="6" fill="#FEF0F0" stroke="#FBC4C4"/>
<text x="585" y="201" text-anchor="middle" font-size="10.5" fill="#F56C6C">二维码 URL(屋里后台配置指向我方):</text>
<text x="585" y="215" text-anchor="middle" font-size="10" fill="#F56C6C">pjzhhq.cn/h5/#/pages/pay/coffee-pay?order_no=…</text>
<!-- ③ 扫码 -->
<circle cx="105" cy="290" r="11" fill="#409EFF"/><text x="105" y="294" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">3</text>
<line x1="116" y1="290" x2="559" y2="290" stroke="#409EFF" stroke-width="1.6" marker-end="url(#aBlue)"/>
<text x="338" y="282" text-anchor="middle" font-size="12" fill="#409EFF">扫码打开我方 H5 支付页</text>
<!-- ④ order-info -->
<circle cx="560" cy="338" r="11" fill="#67C23A"/><text x="560" y="342" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">4</text>
<line x1="571" y1="338" x2="849" y2="338" stroke="#67C23A" stroke-width="1.6" marker-end="url(#aGreen)"/>
<text x="710" y="330" text-anchor="middle" font-size="12" fill="#67C23A">GET /hard-coffee/order-info?orderNo</text>
<text x="710" y="353" text-anchor="middle" font-size="10" fill="#909399">匿名接口 @Inner(false) · 幂等</text>
<!-- ⑤ getOrder -->
<circle cx="850" cy="398" r="11" fill="#409EFF"/><text x="850" y="402" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">5</text>
<line x1="839" y1="398" x2="326" y2="398" stroke="#409EFF" stroke-width="1.6" marker-end="url(#aBlue)"/>
<text x="583" y="390" text-anchor="middle" font-size="12" fill="#409EFF">POST /api/order/getOrder(SHA1 签名:字典序拼接 appkey+timestamp+randstr)</text>
<line x1="326" y1="418" x2="839" y2="418" stroke="#909399" stroke-width="1.2" stroke-dasharray="6 4" marker-end="url(#aGray)"/>
<text x="583" y="412" text-anchor="middle" font-size="11" fill="#909399">返回 order:product · price(分) · img · is_cold · machine_name</text>
<!-- ⑥ 落单活动框 -->
<rect x="700" y="438" width="300" height="72" rx="8" fill="#fff" stroke="#409EFF"/>
<circle cx="714" cy="452" r="11" fill="#409EFF"/><text x="714" y="456" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">6</text>
<text x="858" y="456" text-anchor="middle" font-size="11.5" fill="#303133" font-weight="600">本地落单(并发锁 + 幂等)</text>
<text x="858" y="474" text-anchor="middle" font-size="10.5" fill="#606266">机器号定位设备(type=13) → TenantBroker 切设备租户</text>
<text x="858" y="490" text-anchor="middle" font-size="10.5" fill="#606266">建 hard_order(CREATE)+扩展表 · Redis 支付窗口 5min</text>
<text x="858" y="505" text-anchor="middle" font-size="10.5" fill="#909399">生命周期日志 ①GET_ORDER ②CREATE_ORDER</text>
<!-- ⑦ 返回订单信息 -->
<circle cx="560" cy="532" r="11" fill="#67C23A"/><text x="560" y="536" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">7</text>
<line x1="839" y1="532" x2="571" y2="532" stroke="#67C23A" stroke-width="1.6" marker-end="url(#aGreen)"/>
<text x="705" y="524" text-anchor="middle" font-size="12" fill="#67C23A">订单信息:商品 · 金额(元) · 冷热 · 机器号 · 倒计时</text>
<!-- ⑧ 确认支付 -->
<circle cx="105" cy="572" r="11" fill="#409EFF"/><text x="105" y="576" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">8</text>
<line x1="116" y1="572" x2="559" y2="572" stroke="#409EFF" stroke-width="1.6" marker-end="url(#aBlue)"/>
<text x="338" y="564" text-anchor="middle" font-size="12" fill="#409EFF">确认支付(页面需登录,未登录先跳登录页;扣本人饭卡)</text>
<!-- ⑨ wallet-pay -->
<circle cx="560" cy="612" r="11" fill="#67C23A"/><text x="560" y="616" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">9</text>
<line x1="571" y1="612" x2="849" y2="612" stroke="#67C23A" stroke-width="1.6" marker-end="url(#aGreen)"/>
<text x="710" y="604" text-anchor="middle" font-size="12" fill="#67C23A">POST /hard-coffee/wallet-pay {orderId}(登录态)</text>
<text x="710" y="627" text-anchor="middle" font-size="10" fill="#909399">校验 5 分钟窗口 · 支付并发锁</text>
<!-- ⑩ 查钱包 -->
<circle cx="850" cy="662" r="11" fill="#409EFF"/><text x="850" y="666" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">10</text>
<line x1="861" y1="662" x2="1149" y2="662" stroke="#409EFF" stroke-width="1.6" marker-end="url(#aBlue)"/>
<text x="1000" y="654" text-anchor="middle" font-size="12" fill="#409EFF">getWallet(登录用户, 饭卡)(租户上下文)</text>
<line x1="1149" y1="680" x2="861" y2="680" stroke="#909399" stroke-width="1.2" stroke-dasharray="6 4" marker-end="url(#aGray)"/>
<text x="1000" y="675" text-anchor="middle" font-size="11" fill="#909399">饭卡钱包 · 余额前置校验(未开通/余额不足报错)</text>
<!-- ⑪ 扣款事务 -->
<rect x="700" y="698" width="300" height="70" rx="8" fill="#fff" stroke="#F56C6C"/>
<circle cx="714" cy="712" r="11" fill="#F56C6C"/><text x="714" y="716" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">11</text>
<text x="858" y="716" text-anchor="middle" font-size="11.5" fill="#303133" font-weight="600">bindAndDeduct 本地事务扣款</text>
<text x="858" y="734" text-anchor="middle" font-size="10.5" fill="#606266">绑定付款人 → fastReduceBalance 钱包扣款(force 模式,余额前置校验)</text>
<text x="858" y="750" text-anchor="middle" font-size="10.5" fill="#606266">订单置已支付 PAY(余额不足整体回滚)· 日志 ③WALLET_PAY</text>
<!-- ⑫ callback -->
<circle cx="850" cy="796" r="11" fill="#E6A23C"/><text x="850" y="800" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">12</text>
<line x1="839" y1="796" x2="326" y2="796" stroke="#E6A23C" stroke-width="1.6" marker-end="url(#aOrange)"/>
<text x="583" y="788" text-anchor="middle" font-size="12" fill="#E6A23C">POST /api/order/callback(order_no, SUCCESS, phone_no)</text>
<text x="583" y="810" text-anchor="middle" font-size="10" fill="#909399">事务提交后通知(失败不回滚)· 日志 ④NOTIFY_PAY</text>
<rect x="670" y="820" width="360" height="30" rx="6" fill="#FDF6EC" stroke="#F5DAB6"/>
<text x="850" y="840" text-anchor="middle" font-size="10.5" fill="#E6A23C">通知失败 → 状态=2,补偿 Job 每 60s 重试(上限 10 次后人工介入)</text>
<!-- ⑬ 出餐 -->
<circle cx="325" cy="848" r="11" fill="#67C23A"/><text x="325" y="852" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">13</text>
<line x1="314" y1="848" x2="106" y2="848" stroke="#67C23A" stroke-width="1.6" marker-end="url(#aGreen)"/>
<text x="210" y="840" text-anchor="middle" font-size="12" fill="#67C23A">咖啡机出餐 ☕ 用户取咖啡</text>
<!-- ⑭ query-order 异步 -->
<g>
<rect x="460" y="878" width="800" height="44" rx="8" fill="#fff" stroke="#909399" stroke-dasharray="5 4"/>
<circle cx="482" cy="892" r="11" fill="#909399"/><text x="482" y="896" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">14</text>
<text x="500" y="896" font-size="11.5" fill="#606266" font-weight="600">(异步可选)屋里主动查单:POST /hard-coffee/wuli/query-order(另一对 appid/appkey 验签 + 蛇形参数)</text>
<text x="500" y="912" font-size="10.5" fill="#909399">状态映射:CREATE→USERPAYING · PAY→SUCCESS · 退款/关闭→FAIL · 日志 ⑤QUERY</text>
</g>
<!-- 退款分支 -->
<rect x="20" y="940" width="1240" height="180" rx="10" fill="#FEF0F0" stroke="#F56C6C"/>
<rect x="20" y="940" width="1240" height="34" rx="10" fill="#F56C6C"/>
<text x="640" y="962" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">退款分支(管理端售后触发 · 文档 3.3 refund · 支持分批多次)</text>
<g font-size="11.5" fill="#606266">
<rect x="45" y="990" width="360" height="56" rx="8" fill="#fff" stroke="#FBC4C4"/>
<text x="225" y="1012" text-anchor="middle" font-weight="600" fill="#303133">① 管理端售后退款(复用 hard_after_sale)</text>
<text x="225" y="1030" text-anchor="middle">钱包/渠道退款成功 → 订单退款状态重算</text>
<line x1="405" y1="1018" x2="440" y2="1018" stroke="#F56C6C" stroke-width="1.6" marker-end="url(#aRed)"/>
<rect x="445" y="990" width="380" height="56" rx="8" fill="#fff" stroke="#FBC4C4"/>
<text x="635" y="1012" text-anchor="middle" font-weight="600" fill="#303133">② 事务提交后 syncRefundIfNeeded(差额)</text>
<text x="635" y="1030" text-anchor="middle">POST /api/order/refund(order_no, 本次退款金额分)</text>
<text x="635" y="1076" text-anchor="middle" font-size="10.5" fill="#909399">累计 refund_sync_amount 记账 · 总退款 ≤ 订单支付额校验</text>
<line x1="825" y1="1018" x2="860" y2="1018" stroke="#F56C6C" stroke-width="1.6" marker-end="url(#aRed)"/>
<rect x="865" y="990" width="370" height="56" rx="8" fill="#fff" stroke="#FBC4C4"/>
<text x="1050" y="1012" text-anchor="middle" font-weight="600" fill="#303133">③ 厂商确认 + 生命周期日志</text>
<text x="1050" y="1030" text-anchor="middle">⑥REFUND_NOTIFY · 厂商侧同步退款状态</text>
<text x="1050" y="1076" text-anchor="middle" font-size="10.5" fill="#909399">补偿 Job 每 60s 回看近 7 天订单补推退款缺口</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

@@ -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`;
+48
View File
@@ -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'
);
@@ -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),不对应后端接口;打印数据直接复用开卡列表行数据。
+1
View File
@@ -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 '修改人',
@@ -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";
@@ -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.
@@ -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}已核销,祝您用餐愉快。
@@ -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}已核銷,祝您用餐愉快。
@@ -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;
@@ -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;
}
@@ -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);
}
}
@@ -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;
}
@@ -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<HardOrderTypeEnum> isSimplePayList = List.of(
XFJ
XFJ,
COFFEE
);
public static final List<HardOrderTypeEnum> refundList = List.of(BELOW, RESERVE, FOREIGN_TICKET, ZY, LB, FARMER, XFJ, TAKE_OUT);
public static final List<HardOrderTypeEnum> refundList = List.of(BELOW, RESERVE, FOREIGN_TICKET, ZY, LB, FARMER, XFJ, TAKE_OUT, COFFEE);
public static final List<HardOrderTypeEnum> payList = List.of(BELOW, RESERVE);
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -81,6 +81,12 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
/** 预约用餐日期下限,用于查询今天及之后的未核销预约。 */
private LocalDateTime apptUseDateStart;
/**
* 统一用餐日期范围:预约单按预约使用日匹配,线下等其他类型按支付时间匹配;
* 仅 ward 系列用餐接口使用。
*/
private LocalDateTime[] mealDateRange;
private List<String> orderTypeCodes;
private List<String> orderPayTypeCodes;
@@ -115,6 +115,13 @@ public class HardInvoiceEntity extends Model<HardInvoiceEntity> {
@Schema(description="打印图片")
private String pic;
/**
* 后端拼装的凭证打印指令流(Base64 的 ESC/POS 字节),供终端指令打印直写 USB。
* 历史任务和图片生成失败时为空,终端指令模式下回退 pic 图片打印。
*/
@Schema(description="打印指令流Base64")
private String printCmd;
/**
* 创建人
*/
@@ -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<HardCoffeeLogEntity> {
/**
* 编号
*/
@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;
}
@@ -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<HardCoffeeOrderEntity> {
/**
* 编号
*/
@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;
}
@@ -0,0 +1,506 @@
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}。
*
* <p>与 {@link ThermalReceiptImage} 保持同一套版式语义:相同的调用顺序在图片模式产出
* PNG、在指令模式产出 ESC/POS 字节流,终端按本机设置二选一直写 USB。指令模式跳过
* 图片栅格化与传输,出纸速度显著快于图片打印。</p>
*
* <p>排版约定:80mm 纸按 48 个 ANK 字符列计算(中文和制表符占 2 列);中文通过 ESC/POS
* 汉字模式(FS &amp; / FS .)以 GBK 编码输出,每行打印后立即关闭汉字模式避免状态泄漏。
* 图片版的矩形边框用 GBK 字库制表符(─ │ ┌ ┐ └ ┘)拼装,相邻字符字模相连成实线;
* 黑底白字标签用 ESC { 反色指令输出。输出内容不含初始化以外的走纸与切刀命令,
* 末尾走纸和切刀由终端按本机配置统一包装。</p>
*/
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;
/** 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) {
List<String> 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;
}
/**
* 渲染居中三倍加粗短码;超宽时降为双倍再单倍。
*/
@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<String> orderLines = wrapByWidth(safeOrderNo, Math.max(8, orderCapacity));
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);
}
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(BOX_HORIZONTAL.repeat(16), 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<String> componentItems) {
return voucherProduct(name, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
}
/**
* 渲染兑换券商品卡片:制表符矩形框包围,内部为反色类型标签、加粗名称、两栏价格和组合内容。
*
* <p>与图片版卡片一一对应:框线对应描边矩形、反色标签对应黑底白字 tag、粗体名称对应大号商品名,
* 组合内容区保留内部短线与缩进组成项;卡片之间输出空行还原图片的卡片间距。</p>
*/
@Override
public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal,
boolean combo, List<String> componentItems) {
// 前后各留一个空格做黑底边距,与图片模式的类型标签一致。
String typeLabel = combo ? " 组合商品 " : " 单品 ";
String safeName = name == null || name.isBlank() ? "未命名商品" : name;
String priceText = "单价 ¥" + (unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice)
+ " " + (quantity == null || quantity.isBlank() ? "x0" : quantity);
String subtotalText = "小计 ¥" + (subtotal == null || subtotal.isBlank() ? "--" : subtotal);
printBoxTop();
boxLineFragments(List.of(new Segment(typeLabel, true)), false);
for (String nameLine : wrapByWidth(safeName, BOX_INNER_WIDTH)) {
boxLine(nameLine, true);
}
boxLineTwoColumns(priceText, subtotalText, false);
List<String> safeComponents = componentItems == null ? List.of() : componentItems.stream()
.filter(item -> item != null && !item.isBlank()).toList();
if (combo && !safeComponents.isEmpty()) {
boxLine(" " + BOX_HORIZONTAL.repeat(14) + " ", false);
boxLine("组合内容", true);
for (String component : safeComponents) {
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(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) {
printTwoColumnsScaled(label, amount, 2);
} else {
printTwoColumns(label, amount, true);
}
printLine(BOX_HORIZONTAL.repeat(CHARS_PER_LINE / 2), ALIGN_LEFT, false, 1);
return this;
}
/**
* 渲染整行制表符实线分隔。
*/
@Override
public EscPosReceiptCommand divider() {
printLine(BOX_HORIZONTAL.repeat(CHARS_PER_LINE / 2), ALIGN_LEFT, false, 1);
return this;
}
/**
* 输出 Base64 编码的完整指令流,供 MQTT 与打印待办接口下发给终端直写 USB。
*
* @return Base64 指令字符串;调用方直接持久化或下发,不需要再编码
*/
public String toBase64() {
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<Segment> inner, boolean bold) {
int innerWidth = 0;
List<Segment> 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<Segment> 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 倍,保证单行完整展示。
*/
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<String> 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);
}
}
/**
* 按片段序列输出单行文本:对齐、加粗、放大、汉字模式开、逐片段输出(反色片段用
* ESC { 包裹)、换行、汉字模式关。
*
* <p>每个样式指令在行内声明,行后仅复位放大与加粗,对齐由下一行重新声明,
* 保证任意指令流截断或拼接都不会让打印机停留在未知样式状态。</p>
*
* @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) {
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<Segment> segments, 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);
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) {
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<String> wrapByWidth(String text, int maxWidth) {
List<String> 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;
}
/**
* 行内文本片段;反色片段以黑底白字输出,用于还原图片模式的黑底标签。
*
* @param text 片段文本
* @param reverse 是否反色输出
*/
private record Segment(String text, boolean reverse) {
}
}
@@ -0,0 +1,134 @@
package com.bsd.pjcloud.hard.api.utils;
import java.util.List;
/**
* 打印凭证版式渲染器契约,发票与兑换券的数据拼装只依赖本接口。
*
* <p>当前实现有两个:{@link ThermalReceiptImage} 渲染 576px PNG 图片供图片栅格打印;
* {@link EscPosReceiptCommand} 直接拼装 ESC/POS 文本指令供指令打印,出纸更快。
* 业务层用同一份调用序列驱动两个渲染器,保证两种输出方式的凭证内容始终一致。</p>
*
* <p>实现类的方法返回类型允许返回自身具体类型以保留链式调用(协变返回),拼接顺序
* 由调用方保证,本接口不承载状态查询。</p>
*/
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<String> 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<String> componentItems);
/**
* 渲染商品合计,形成票据金额层级。
*
* @param totalAmount 商品合计金额,单位元且不含货币符号
* @return 当前渲染器
*/
ReceiptRenderer voucherTotal(String totalAmount);
/**
* 渲染一条横向分隔线。
*
* @return 当前渲染器
*/
ReceiptRenderer divider();
}
@@ -17,8 +17,11 @@ import java.util.List;
* 面向 80mm、576px ESC/POS 打印的动态高度 PNG 绘制器。
*
* <p>商品名称按真实像素宽度换行,数量列固定右对齐,保证长名称不会与数量错配。</p>
*
* <p>实现 {@link ReceiptRenderer} 版式契约,方法返回自身具体类型保留链式调用;
* 指令打印由同包的 {@link EscPosReceiptCommand} 以相同调用序列产出 ESC/POS 字节。</p>
*/
public class ThermalReceiptImage {
public class ThermalReceiptImage implements ReceiptRenderer {
/** 80mm 打印头常见有效像素宽度。 */
public static final int PAPER_WIDTH_PIXELS = 576;
@@ -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;
}
@@ -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<HardCoffeeLogVo> logs;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -133,6 +133,12 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac
*/
private Boolean canViewReserveCode;
/**
* 统一用餐日期(yyyy-MM-dd):预约单取预约使用日,线下等其他类型取支付日;
* 仅 ward 系列用餐接口回填,不对应 hard_order 表持久化字段。
*/
private String mealDate;
/**
* 订单详情中的长期识别证据列表,按证据类型、角色和序号排序。
*/
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,61 @@
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;
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<HardCoffeeOrderInfoVo> orderInfo(@RequestParam("orderNo") String orderNo) {
return R.ok(hardCoffeeService.orderInfo(orderNo));
}
/**
* 饭卡支付(卡号即身份,与消费机刷卡一致,匿名备用通道)
*/
@Operation(summary = "咖啡机饭卡支付", description = "卡号即扣款,支付窗口5分钟")
@PostMapping("/card-pay")
@Inner(value = false)
public R<HardCoffeePayResultVo> cardPay(@Valid @RequestBody HardCoffeeCardPayDto dto) {
return R.ok(hardCoffeeService.cardPay(dto));
}
/**
* 登录用户饭卡支付(默认走此通道): 扣当前登录用户自己的饭卡钱包,无需输卡号。
* 不加 @Inner(false),走 OAuth2 认证,未登录返回 401
*/
@Operation(summary = "咖啡机登录用户支付", description = "扣登录用户饭卡钱包,支付窗口5分钟")
@PostMapping("/wallet-pay")
public R<HardCoffeePayResultVo> walletPay(@Valid @RequestBody HardCoffeeWalletPayDto dto) {
return R.ok(hardCoffeeService.walletPay(dto));
}
}
@@ -0,0 +1,76 @@
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;
import java.util.List;
/**
* 咖啡机订单与生命周期日志管理端查询
*/
@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<Page<HardCoffeeOrderVo>> 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<HardCoffeeOrderDetailVo> getOrderDetail(@PathVariable("id") Long id) {
return R.ok(hardCoffeeService.orderDetail(id));
}
/**
* 按本地订单id查询生命周期日志(订单售后详情页内嵌时间线用)
*/
@Operation(summary = "按订单查询生命周期日志", description = "咖啡机订单的厂商交互时间线")
@GetMapping("/log/list-by-order/{orderId}")
@PreAuthorize("@pms.hasPermission('hard_hardCoffeeLog_view')")
public R<List<HardCoffeeLogVo>> getLogListByOrderId(@PathVariable("orderId") Long orderId) {
return R.ok(hardCoffeeService.logListByOrderId(orderId));
}
/**
* 生命周期日志分页
*/
@Operation(summary = "生命周期日志分页", description = "按订单号/设备/阶段/方向筛选")
@GetMapping("/log/page")
@PreAuthorize("@pms.hasPermission('hard_hardCoffeeLog_view')")
public R<Page<HardCoffeeLogVo>> getLogPage(@ParameterObject Page page, @ParameterObject HardCoffeeLogDto dto) {
return R.ok(hardCoffeeService.logPage(page, dto));
}
}
@@ -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);
}
}
@@ -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<List<WardMenuMapDayVo>> wardMenuMap(@RequestParam(required = false) List<Long> 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<List<HardOrderVo>> wardMenuDayOrders(@RequestParam(required = false) List<Long> dinerUserIds,
@RequestParam String date) {
return R.ok(wardMenuOrdersResponse(dinerUserIds, date, date));
}
@Operation(summary = "app查询用餐订单窗口列表", description = "按日期窗口查询本人或被监护人的全类型用餐订单明细,整单退款的订单不返回")
@ApiCryptoAes
@GetMapping("/app/ward-menu-orders")
public R<List<HardOrderVo>> wardMenuOrders(@RequestParam(required = false) List<Long> 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<HardOrderVo> wardMenuOrdersResponse(List<Long> 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<HardOrderVo> orders = hardwareOrderService.wardMenuDayOrders(dto);
List<HardOrderVo> orders = hardwareOrderService.wardMenuOrders(dto);
hardwareOrderService.applyAppPermissions(orders, user.getId());
return R.ok(orders);
return orders;
}
@@ -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);
}
}
}
@@ -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<HardCoffeeLogEntity> {
}
@@ -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<HardCoffeeOrderEntity> {
}
@@ -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<HardCoffeeLogEntity> {
}
@@ -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<HardCoffeeOrderEntity> {
/**
* 按厂商订单号查询(当前租户上下文)
*
* @param orderNo 厂商订单号
* @return 扩展订单;不存在返回 null
*/
HardCoffeeOrderEntity getByOrderNo(String orderNo);
/**
* 按本地订单id查询(当前租户上下文)
*
* @param orderId 本地订单id
* @return 扩展订单;不存在返回 null
*/
HardCoffeeOrderEntity getByOrderId(Long orderId);
}
@@ -0,0 +1,103 @@
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;
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;
import java.util.List;
/**
* 咖啡机(屋里咖啡)对接主流程
*/
public interface HardCoffeeService {
/**
* H5匿名拉单: 根据厂商订单号调 getOrder 并落本地订单(幂等)
*
* @param orderNo 厂商订单号
* @return 订单展示信息
*/
HardCoffeeOrderInfoVo orderInfo(String orderNo);
/**
* H5匿名饭卡支付: 卡号即身份,扣款成功后通知厂商出餐
*
* @param dto 订单id+饭卡卡号
* @return 支付结果(含余额展示)
*/
HardCoffeePayResultVo cardPay(HardCoffeeCardPayDto dto);
/**
* H5登录用户饭卡支付: 扣登录用户自己的饭卡钱包,扣款成功后通知厂商出餐
*
* @param dto 订单id
* @return 支付结果(含余额展示)
*/
HardCoffeePayResultVo walletPay(HardCoffeeWalletPayDto dto);
/**
* 厂商主动查单(文档3.4 queryOrder,验签)
*
* @param dto 厂商查单参数
* @return 查单响应
*/
WuliQueryOrderVo wuliQueryOrder(WuliQueryOrderDto dto);
/**
* 本地退款完成后向厂商同步退款(文档3.3 refund,支持分批)。
* 非咖啡机订单直接返回,零影响
*
* @param orderId 本地订单id
*/
void syncRefundIfNeeded(Long orderId);
/**
* 管理端咖啡机订单分页
*/
Page<HardCoffeeOrderVo> orderPage(Page page, HardCoffeeOrderDto dto);
/**
* 管理端生命周期日志分页
*/
Page<HardCoffeeLogVo> logPage(Page page, HardCoffeeLogDto dto);
/**
* 管理端订单详情(含生命周期时间线)
*/
HardCoffeeOrderDetailVo orderDetail(Long id);
/**
* 按本地订单id查询生命周期日志(订单售后详情页内嵌时间线用)。
* 非咖啡机订单返回空集合
*
* @param orderId 本地订单id(hard_order.id)
* @return 生命周期日志(按时间正序)
*/
List<HardCoffeeLogVo> logListByOrderId(Long orderId);
/**
* 绑定付款人并完成钱包扣款(独立事务,内部通过Spring代理调用保证事务生效)
*
* @param orderId 本地订单id
* @param user 付款人信息
* @param walletId 钱包id
* @return 扣款完成后的订单
*/
HardOrderEntity bindAndDeduct(Long orderId, UserInfo user, Long walletId);
/**
* 补偿: 重试通知失败的支付结果回调 + 补推退款同步缺口
*/
void retryNotifyFailures();
}
@@ -198,15 +198,20 @@ public interface HardOrderService extends IService<HardOrderEntity> {
List<Long> validateReserveDinerUserIds(Long payerUserId, List<Long> dinerUserIds);
/**
* app 预约菜谱日历地图:按用餐日聚合未退款预约订单的待核销/已核销数量。
* app 用餐日历地图:按统一用餐日聚合未退款订单,预约单分待核销/已核销,其余类型计线下消费。
*/
List<WardMenuMapDayVo> wardMenuMap(HardOrderDto dto);
/**
* app 预约菜谱某日明细:返回当天未整单退款的待核销/已核销预约订单。
* app 用餐某日明细:返回当天未整单退款的全类型用餐订单,并回填统一用餐日期。
*/
List<HardOrderVo> wardMenuDayOrders(HardOrderDto dto);
/**
* app 用餐订单窗口列表:返回日期窗口内未整单退款的全类型用餐订单,并回填统一用餐日期。
*/
List<HardOrderVo> wardMenuOrders(HardOrderDto dto);
/**
* app创建食堂外卖订单
*/
@@ -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<HardInvoiceMapper, HardI
*/
@Override
public String generateInvoice(HardOrderEntity order, String code) {
return renderInvoice(order, code).pic();
}
/**
* 渲染发票并产出图片地址与打印指令流双通道内容。
*
* @param order 已支付订单
* @param code 取件码;空时不渲染取件码区
* @return 图片地址与 Base64 指令流
*/
private GeneratedReceipt renderInvoice(HardOrderEntity order, String code) {
MchManageVo mch = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在");
UserVO user = loadVoucherUser(order.getUserId());
List<HardCarVo> carList = Optional.ofNullable(carService.getByOrderId(order.getId())).orElseGet(List::of);
@@ -268,58 +281,61 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
String hardwareName = Optional.ofNullable(HardTypeEnum.valueOfByType(order.getSysType()))
.map(HardTypeEnum::getDescription).orElse("设备");
ThermalReceiptImage image = new ThermalReceiptImage().compactFonts(true)
.title(String.format("智慧%s发票", hardwareName))
ThermalReceiptImage image = new ThermalReceiptImage().compactFonts(true);
EscPosReceiptCommand command = new EscPosReceiptCommand();
ReceiptRenderer renderer = new CompositeRenderer(image, command);
renderer.title(String.format("智慧%s发票", hardwareName))
.keyValue("商户", mch.getName());
String walletTypeName = resolveWalletTypeName(order.getWalletId());
if (StrUtil.isNotBlank(walletTypeName)) image.keyValue("钱包类型", walletTypeName);
appendVoucherUser(image, user);
appendInvoiceOrderBlock(image, order);
appendInvoiceProducts(image, carList, metadata, showComboItems);
if (StrUtil.isNotBlank(walletTypeName)) renderer.keyValue("钱包类型", walletTypeName);
appendVoucherUser(renderer, user);
appendInvoiceOrderBlock(renderer, order);
appendInvoiceProducts(renderer, carList, metadata, showComboItems);
if (order.getExtraPrice() != null && order.getExtraPrice() != 0) {
image.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue()));
renderer.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue()));
}
image.voucherTotal(money(order.getTotalPrice() == null ? null : order.getTotalPrice().longValue()))
renderer.voucherTotal(money(order.getTotalPrice() == null ? null : order.getTotalPrice().longValue()))
.keyValue("打印时间", formatDateTime(LocalDateTime.now()));
if (StrUtil.isNotBlank(code)) image.divider().sectionTitle("取件码").verificationCode(code);
if (StrUtil.isNotBlank(code)) renderer.divider().sectionTitle("取件码").verificationCode(code);
try {
return uploadPng("invoice_" + order.getId(), image.toPngBytes());
return new GeneratedReceipt(uploadPng("invoice_" + order.getId(), image.toPngBytes()),
command.toBase64());
} catch (IOException exception) {
throw new RuntimeException("生成发票图片失败", exception);
}
}
/**
* 按兑换券订单区版式绘制发票订单识别信息;预约单补齐预约日期与餐段。
* 按兑换券订单区版式渲染发票订单识别信息;预约单补齐预约日期与餐段。
*
* @param image 当前发票图片绘制器
* @param renderer 当前渲染器
* @param order 订单快照
*/
private static void appendInvoiceOrderBlock(ThermalReceiptImage image, HardOrderEntity order) {
image.divider().sectionTitle("订单 " + displayOrderNo(order));
private static void appendInvoiceOrderBlock(ReceiptRenderer renderer, HardOrderEntity order) {
renderer.divider().sectionTitle("订单 " + displayOrderNo(order));
if (ObjectUtil.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())) {
image.keyValue("预约日期", order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString())
renderer.keyValue("预约日期", order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString())
.keyValue("餐段", resolveMealName(order.getHourType()));
}
String orderType = Optional.ofNullable(HardOrderTypeEnum.valueOfByType(order.getOrderType()))
.map(HardOrderTypeEnum::getDescription).orElse("--");
image.keyValue("订单类型", orderType)
renderer.keyValue("订单类型", orderType)
.keyValue("下单时间", formatDateTime(order.getCreateTime()));
}
/**
* 按兑换券商品卡片版式绘制发票商品明细,数量支持份数与称重重量。
* 按兑换券商品卡片版式渲染发票商品明细,数量支持份数与称重重量。
*
* <p>发票是支付凭证,小计使用下单时的商品快照总额;套餐标签与组合内容沿用兑换券元数据和显示开关。</p>
*
* @param image 当前发票图片绘制器
* @param renderer 当前渲染器
* @param cars 订单商品快照
* @param metadata 按商品 ID 索引的套餐类型和组成项
* @param showComboItems 是否展示套餐组成内容
*/
private static void appendInvoiceProducts(ThermalReceiptImage image, List<HardCarVo> cars,
private static void appendInvoiceProducts(ReceiptRenderer renderer, List<HardCarVo> cars,
Map<Long, ProductMetadata> 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<HardInvoiceMapper, HardI
boolean combo = product != null && product.combo();
List<String> 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 ServiceImpl<HardInvoiceMapper, HardI
}
/**
* 生成并上传任务图片,失败时将原任务标记为图片生成失败。
* 生成并上传任务图片,同时拼装指令流;失败时将原任务标记为图片生成失败。
*/
private HardInvoiceEntity generateTaskImage(HardInvoiceEntity task) {
try {
HardOrderEntity order = orderService.getById(task.getOrderId());
if (order == null) throw new RuntimeException("订单不存在");
String image = Objects.equals(task.getPrintType(), HardPrintTypeEnum.REDEMPTION_VOUCHER.getType())
? generateVoucher(order, task.getPrintNo()) : generateInvoice(order);
if (StrUtil.isBlank(image)) throw new RuntimeException("图片上传地址为空");
GeneratedReceipt receipt = Objects.equals(task.getPrintType(), HardPrintTypeEnum.REDEMPTION_VOUCHER.getType())
? renderVoucher(order, task.getPrintNo()) : renderInvoice(order, null);
if (StrUtil.isBlank(receipt.pic())) throw new RuntimeException("图片上传地址为空");
baseMapper.update(null, Wrappers.<HardInvoiceEntity>lambdaUpdate()
.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<HardInvoiceMapper, HardI
message.setRetryVersion(ObjectUtil.defaultIfNull(task.getRetryVersion(), 0));
message.setAndroidOrderCode(order.getAndroidOrderCode());
message.setPic(task.getPic());
message.setPrintCmd(task.getPrintCmd());
R<Boolean> 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<HardInvoiceMapper, HardI
}
/**
* 生成不含核销短码的兑换券 PNG 并上传文件服务。
* 生成不含核销短码的兑换券内容并产出图片与指令流双通道输出。
*
* <p>身份资料查询失败时降级为不打印用户区,不能阻断已经完成核销的订单出票;商品金额按
* 部分退款后的剩余可履约数量重新计算,组合组成项只做说明而不重复计价。</p>
*
* @param order 已完成核销的预约订单
* @param printNo 当前订单打印序号
* @return 文件服务返回的兑换券 PNG 地址
* @return 图片地址与 Base64 指令流
*/
private String generateVoucher(HardOrderEntity order, Integer printNo) {
private GeneratedReceipt renderVoucher(HardOrderEntity order, Integer printNo) {
List<HardOrderEntity> 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<VoucherOrderProducts> 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<HardInvoiceMapper, HardI
}
/**
* 绘制原有按订单分组的兑换券版式。
* 渲染原有按订单分组的兑换券版式。
*
* @param image 当前兑换券图片绘制器
* @param renderer 当前渲染器
* @param orderProducts 按订单分组的商品快照
*/
private static void appendOrderGroupedVoucherLayout(ThermalReceiptImage image,
private static void appendOrderGroupedVoucherLayout(ReceiptRenderer renderer,
List<VoucherOrderProducts> 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<VoucherOrderProducts> 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<VoucherProductLine> products) {
private static void appendVoucherProducts(ReceiptRenderer renderer, List<VoucherProductLine> 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<HardInvoiceMapper, HardI
/**
* 按非空字段绘制兑换券用户身份资料,学生学号优先于老师学号。
*
* @param image 当前兑换券图片绘制器
* @param user UPMS 返回的用户及身份扩展;为空时不绘制用户区
* @param renderer 当前渲染器
* @param user UPMS 返回的用户及身份扩展;为空时不渲染用户区
*/
private static void appendVoucherUser(ThermalReceiptImage image, UserVO user) {
private static void appendVoucherUser(ReceiptRenderer renderer, UserVO user) {
Map<String, String> 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<HardInvoiceMapper, HardI
*/
private record VoucherProductKey(Long productId, String productName, long unitPrice, boolean combo) {
}
/**
* 一次凭证渲染的双通道输出。
*
* @param pic 上传后的凭证 PNG 地址;图片打印与指令回退均使用
* @param printCmd ESC/POS 指令流 Base64;指令打印直写 USB,历史任务允许为空
*/
private record GeneratedReceipt(String pic, String printCmd) {
}
/**
* 双通道渲染器:同一份版式调用序列同时驱动图片绘制与 ESC/POS 指令拼装,
* 保证图片模式与指令模式的凭证内容始终一致,业务拼装代码只写一份。
*/
private record CompositeRenderer(ReceiptRenderer image, ReceiptRenderer command) implements ReceiptRenderer {
@Override
public ReceiptRenderer title(String text) {
image.title(text);
command.title(text);
return this;
}
@Override
public ReceiptRenderer banner(String text) {
image.banner(text);
command.banner(text);
return this;
}
@Override
public ReceiptRenderer verificationCode(String shortCode) {
image.verificationCode(shortCode);
command.verificationCode(shortCode);
return this;
}
@Override
public ReceiptRenderer keyValue(String label, String value) {
image.keyValue(label, value);
command.keyValue(label, value);
return this;
}
@Override
public ReceiptRenderer sectionTitle(String text) {
image.sectionTitle(text);
command.sectionTitle(text);
return this;
}
@Override
public ReceiptRenderer voucherOrderSummary(int index, int total, String orderNo,
String orderDate, String mealName) {
image.voucherOrderSummary(index, total, orderNo, orderDate, mealName);
command.voucherOrderSummary(index, total, orderNo, orderDate, mealName);
return this;
}
@Override
public ReceiptRenderer item(String name, String quantity, boolean child, boolean emphasized) {
image.item(name, quantity, child, emphasized);
command.item(name, quantity, child, emphasized);
return this;
}
@Override
public ReceiptRenderer invoiceItem(String name, String quantity, String amount) {
image.invoiceItem(name, quantity, amount);
command.invoiceItem(name, quantity, amount);
return this;
}
@Override
public ReceiptRenderer voucherProduct(String name, int quantity, String unitPrice, String subtotal,
boolean combo, List<String> 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<String> 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;
}
}
}
@@ -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<HardCoffeeLogMapper, HardCoffeeLogEntity> implements HardCoffeeLogService {
}
@@ -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<HardCoffeeOrderMapper, HardCoffeeOrderEntity> implements HardCoffeeOrderService {
@Override
public HardCoffeeOrderEntity getByOrderNo(String orderNo) {
return getOne(new LambdaQueryWrapper<HardCoffeeOrderEntity>()
.eq(HardCoffeeOrderEntity::getOrderNo, orderNo)
.last("limit 1"));
}
@Override
public HardCoffeeOrderEntity getByOrderId(Long orderId) {
return getOne(new LambdaQueryWrapper<HardCoffeeOrderEntity>()
.eq(HardCoffeeOrderEntity::getOrderId, orderId)
.last("limit 1"));
}
}
@@ -0,0 +1,862 @@
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.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;
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.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;
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.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;
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) {
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<HardCoffeeOrderEntity, HardCoffeePayResultVo> tenantPayer) {
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("订单不存在");
}
HardCoffeePayResultVo result = TenantBroker.applyAs(coffeeOrder.getTenantId(), tenant -> tenantPayer.apply(coffeeOrder));
// 窗口Key删除必须在匿名上下文执行,与写入时前缀一致
redisTemplate.delete(redisKey);
return result;
} finally {
redisTemplate.delete(PAY_LOCK_KEY + orderId);
}
}
/**
* 设备租户上下文内的卡号支付主逻辑
*/
private HardCoffeePayResultVo doCardPay(HardCoffeeOrderEntity coffeeOrder, HardCoffeeCardPayDto dto) {
HardOrderEntity order = requireUnpaidCoffeeOrder(coffeeOrder);
// 卡号定位钱包(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("饭卡用户不存在");
}
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(),
"卡号:" + wallet.getCardNumber() + ",金额(分):" + 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);
}
}
/**
* 按本地订单id查询生命周期日志(订单售后详情页内嵌时间线用)
*/
@Override
public List<HardCoffeeLogVo> logListByOrderId(Long orderId) {
HardCoffeeOrderEntity coffeeOrder = coffeeOrderService.getByOrderId(orderId);
if (ObjectUtil.isEmpty(coffeeOrder)) {
return List.of();
}
return coffeeLogService.list(new LambdaQueryWrapper<HardCoffeeLogEntity>()
.eq(HardCoffeeLogEntity::getOrderId, orderId)
.orderByAsc(HardCoffeeLogEntity::getStartTime)
.orderByAsc(HardCoffeeLogEntity::getId))
.stream().map(entity -> BeanUtil.toBean(entity, HardCoffeeLogVo.class))
.collect(Collectors.toList());
}
/**
* 补偿任务: 重试通知失败的支付回调 + 补推退款同步缺口。
* 定时线程无租户上下文,列表查询天然跨租户,逐单切回订单租户执行
*/
@Override
public void retryNotifyFailures() {
// 1.支付结果通知失败重试
List<HardCoffeeOrderEntity> notifyFails = coffeeOrderService.list(new LambdaQueryWrapper<HardCoffeeOrderEntity>()
.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<HardCoffeeOrderEntity> recent = coffeeOrderService.list(new LambdaQueryWrapper<HardCoffeeOrderEntity>()
.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<HardCoffeeOrderVo> orderPage(Page page, HardCoffeeOrderDto dto) {
Page<HardCoffeeOrderEntity> entityPage = coffeeOrderService.page(page, buildOrderWrapper(dto));
Page<HardCoffeeOrderVo> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
List<HardCoffeeOrderEntity> records = entityPage.getRecords();
if (ObjectUtil.isEmpty(records)) {
return voPage;
}
Map<Long, HardOrderEntity> 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<Long, String> 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<Long, String> userNameMap = listByIdsMap(
orderMap.values().stream().map(HardOrderEntity::getUserId).filter(id -> id != null && id > 0).collect(Collectors.toList()),
ids -> {
R<List<SysUser>> userResult = remoteUserService.getInnerUserById(ids);
List<SysUser> 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<HardCoffeeOrderEntity> buildOrderWrapper(HardCoffeeOrderDto dto) {
LambdaQueryWrapper<HardCoffeeOrderEntity> 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<HardCoffeeLogVo> logPage(Page page, HardCoffeeLogDto dto) {
LambdaQueryWrapper<HardCoffeeLogEntity> 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<HardCoffeeLogEntity> entityPage = coffeeLogService.page(page, wrapper);
Page<HardCoffeeLogVo> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
List<HardCoffeeLogEntity> records = entityPage.getRecords();
if (ObjectUtil.isEmpty(records)) {
return voPage;
}
Map<Long, String> 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<List<SysUser>> 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<HardCoffeeLogEntity>()
.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> T queryAcrossTenants(Supplier<T> supplier) {
Long origin = TenantContextHolder.getTenantId();
TenantContextHolder.setTenantSkip();
try {
return supplier.get();
} finally {
TenantContextHolder.clear();
TenantContextHolder.setTenantId(origin);
}
}
/**
* 批量补齐关联数据,空集合直接返回空Map,避免空IN查询
*/
private <K, V> Map<K, V> listByIdsMap(List<K> ids, Function<List<K>, Map<K, V>> 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();
}
}
@@ -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<HardCoffeeLogEntity> currentLog = new ThreadLocal<>();
/**
* 密钥本地缓存: bizType -> 缓存条目(含空值缓存,避免未配置时反复打Feign)
*/
private final Map<String, KeyCacheEntry> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> bizParams, WuliCallContext context, HardCoffeeLogStageEnum stage, HardCoffeeLogEntity logger) {
ThirdKeyManageEntity keyConfig = requireKeyConfig(ThirdKeyTypeEnum.WULI_COFFEE);
Map<String, Object> 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<ThirdKeyManageEntity> 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);
}
}
@@ -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<HardAfterSaleMapper, H
/** 售后申请用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
private final HardCoffeeService hardCoffeeService;
@Override
public Page<HardAfterSaleVo> pageJoin(Page page, HardAfterSaleDto dto) {
@@ -1664,6 +1668,41 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
latest != null && ObjectUtil.equals(latest.getStatus(), HardSaleStatusEnum.BUYER_CANCEL.getStatus())
? LocalDateTime.now() : null);
hardOrderService.update(wrapper);
// 咖啡机订单才需要向屋里咖啡同步退款,其他订单类型零额外开销
if (ObjectUtil.equals(order.getSysType(), HardTypeEnum.COFFEE.getType())) {
registerCoffeeRefundSync(orderId);
}
}
/**
* 咖啡机订单退款状态刷新后,把退款差额同步通知屋里咖啡(文档3.3 refund)。
* 远程通知延后到本地事务提交后执行,通知失败不影响本地退款,由补偿任务重试;
* 非咖啡机订单在服务内部直接返回,零影响。
*
* @param orderId 原订单 ID
*/
private void registerCoffeeRefundSync(Long orderId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
doCoffeeRefundSync(orderId);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
doCoffeeRefundSync(orderId);
}
});
}
/**
* 执行咖啡机退款同步,异常只记录日志不向外抛出
*/
private void doCoffeeRefundSync(Long orderId) {
try {
hardCoffeeService.syncRefundIfNeeded(orderId);
} catch (Exception e) {
log.error("|{}|咖啡机退款同步通知异常,orderId:{}", TAG, orderId, e);
}
}
/**
@@ -2466,41 +2466,71 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
/**
* app 预约菜谱日历地图:按用餐日聚合未退款预约订单的待核销/已核销数量。
* 整单退完(退款金额为正且不低于实付金额)的订单不再计入。
* app 用餐日历地图:按统一用餐日聚合未退款订单,预约单分待核销/已核销计数,
* 线下消费、外卖等其他类型统一计入线下消费数。整单退完(退款金额为正且不低于实付金额)的订单不再计入。
*/
@Override
public List<WardMenuMapDayVo> wardMenuMap(HardOrderDto dto) {
List<HardOrderVo> orders = scopeList(dto, null);
Map<LocalDate, WardMenuMapDayVo> 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<HardOrderVo> wardMenuDayOrders(HardOrderDto dto) {
return scopeList(dto, null).stream()
.filter(order -> !isFullyRefundedOrder(order))
return wardMenuOrders(dto);
}
/**
* app 用餐订单窗口列表:返回统一用餐日期窗口内未整单退款的全类型用餐订单,并回填统一用餐日期。
* 无法解析用餐日期的订单(预约缺使用日且无支付时间)不返回,避免前端落入脏数据兜底日。
*/
@Override
public List<HardOrderVo> wardMenuOrders(HardOrderDto dto) {
List<HardOrderVo> 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();
}
/**
@@ -59,6 +59,12 @@ yyx:
secret: 67ed0e41427f754f6c7bc572b60f4162
template: 购买成功总金额${price}元
# 屋里咖啡咖啡机对接:密钥在 thirdparty 服务 third_key_manage 表维护(bizType=11 我方调厂商、12 厂商调我方查单)。
# 测试环境 base-url https://test.wulicoffee.com,生产环境 https://www.wulicoffee.com。
coffee:
base-url: https://test.wulicoffee.com
timeout-millis: 10000
#yyx:
# base-url: https://open.sw-aiot.com
# username: ahuang
@@ -333,6 +333,19 @@
</if>
</sql>
<!--
统一用餐日期口径:预约单(order_type='2')按预约使用日匹配,
线下消费、外卖等其他类型按支付时间匹配;仅 ward 系列用餐接口设置该范围。
-->
<sql id="mealDateRangeWhere">
<if test="dto.mealDateRange != null and dto.mealDateRange.length == 2">
and (
(a.order_type = '2' and a.appt_use_date between #{dto.mealDateRange[0]} and #{dto.mealDateRange[1]})
or (a.order_type &lt;&gt; '2' and a.pay_time between #{dto.mealDateRange[0]} and #{dto.mealDateRange[1]})
)
</if>
</sql>
<!-- App 主键分页和总数统计共用相同的关联筛选,确保页记录与 total 语义一致。 -->
<sql id="appRelationFilterWhere">
<if test="dto.hardTypeCode != null and dto.hardTypeCode != ''">
@@ -431,6 +444,7 @@
<where>
<include refid="whereA"/>
<include refid="appVisibilityWhere"/>
<include refid="mealDateRangeWhere"/>
<if test="dto.hardTypeCode != null and dto.hardTypeCode != '' ">
and m.type = #{dto.hardTypeCode}
</if>
@@ -0,0 +1,287 @@
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<String> 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<String> 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<String> 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<String> lines = visibleLines(longAmount);
assertTrue(lines.stream().anyMatch(line -> line.contains("¥12345678901234.50")), "降级后金额必须完整保留");
}
/** 长横幅文本自动从双倍降为单倍,保证提示完整单行展示。 */
@Test
void longBannerDegradesScaleInsteadOfLosingContent() {
List<String> lines = visibleLines(decode(new EscPosReceiptCommand()
.banner("第 12 次打印 · 100 笔预约订单合并打印")));
assertTrue(lines.stream().anyMatch(line -> line.contains("第 12 次打印 · 100 笔预约订单合并打印")),
"降级后横幅文字必须完整");
}
/** 值放不下同一行的键值对应整段换行,与图片版的堆叠语义一致。 */
@Test
void keyValueStacksValueOnNextLinesWhenTooLong() {
List<String> lines = visibleLines(decode(new EscPosReceiptCommand()
.keyValue("订单", "20967800000000000012096780000000000001209678")));
assertEquals("订单:", lines.get(0), "标签应独占首行");
assertTrue(lines.get(1).startsWith("2096780"), "值应从下一行起折行展示");
}
/** 多订单摘要完整保留订单号与日期餐段,订单间输出短线分隔。 */
@Test
void voucherOrderSummaryKeepsAllFieldsWithShortSeparator() {
List<String> 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(16).equals(line)), "订单间应有制表符短线分隔");
}
/** 打印次数横幅应输出制表符描边矩形框,还原图片模式的横幅边框。 */
@Test
void bannerRendersBoxFrameAroundCenteredText() {
List<String> 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<String> 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 列,单个超宽字符仍独占一行。 */
@Test
void displayWidthAndWrapFollowChineseAwareColumns() {
assertEquals(6, EscPosReceiptCommand.displayWidth("红烧肉"));
assertEquals(4, EscPosReceiptCommand.displayWidth("x3ab"));
List<String> 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());
}
/**
* 按换行符切分指令流并解码每行的可见文本。
*
* <p>每行结构固定为样式指令 + FS &amp; + GBK 文本 + \n + FS .,取 FS &amp; 之后到换行前的字节
* 即可还原文本;GBK 双字节编码不会出现 0x1C/0x0A,切分安全。</p>
*
* @param bytes ESC/POS 原始字节
* @return 逐行可见文本
*/
private static List<String> visibleLines(byte[] bytes) {
List<String> 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);
}
}
@@ -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<HardOrderVo> 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<WardMenuMapDayVo> 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<HardOrderVo> 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<WardMenuMapDayVo> 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<HardOrderVo> 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<HardOrderVo> 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;
}
}
@@ -56,4 +56,10 @@ public class HardPrintMsg extends MqttHardBaseMsg {
// 图片地址
private String pic;
/**
* 后端拼装的凭证打印指令流(Base64 的 ESC/POS 字节);终端指令打印直写 USB,可空。
*/
@Schema(description = "打印指令流Base64")
private String printCmd;
}
@@ -0,0 +1,26 @@
package com.bsd.pjcloud.thirdparty.api.feign;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.feign.annotation.NoToken;
import com.bsd.pjcloud.thirdparty.entity.ThirdKeyManageEntity;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
/**
* 第三方密钥管理远程调用。密钥为平台级配置,跨租户按 bizType 全局取唯一记录
*/
@FeignClient(contextId = "remoteThirdKeyManageService", value = ServiceNameConstants.THIRDPARTY_SERVICE)
public interface RemoteThirdKeyManageService {
/**
* 按 bizType 跨租户读取平台级密钥配置(咖啡机扫码支付场景匿名请求无租户头)
*
* @param bizType ThirdKeyTypeEnum 的 type 值
* @return 密钥配置;未配置时 data 为空
*/
@NoToken
@GetMapping("/third-key-manage/inner/get-by-biz-type")
R<ThirdKeyManageEntity> getByBizType(@RequestParam("bizType") String bizType);
}
@@ -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;
@@ -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;
@@ -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<ThirdKeyManageEntity> getByBizType(@RequestParam("bizType") String bizType) {
return R.ok(thirdKeyManageService.getGlobalByBizType(bizType));
}
}
@@ -23,6 +23,12 @@ public interface ThirdKeyManageService extends IService<ThirdKeyManageEntity> {
*/
ThirdKeyManageEntity get(ThirdKeyTypeEnum type);
/**
* 跨租户按bizType获取平台级key配置(咖啡机等匿名设备场景无租户上下文)。
* 该类密钥要求全平台唯一,建议统一维护在默认租户下
*/
ThirdKeyManageEntity getGlobalByBizType(String bizType);
/**
* 验签并且返回配置
*/
@@ -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<ThirdKeyManageMapper,
return baseMapper.selectOne(new LambdaQueryWrapper<ThirdKeyManageEntity>().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<ThirdKeyManageEntity>()
.eq(ThirdKeyManageEntity::getBizType, bizType)
.orderByAsc(ThirdKeyManageEntity::getId)
.last("limit 1"));
} finally {
TenantContextHolder.clear();
TenantContextHolder.setTenantId(originTenant);
}
}
/**
* 验签并且返回配置
*/
@@ -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());
}
@@ -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;
/**
* 饭卡卡面打印模板
*
* <p>模板为前端可视化设计器产出的结构化 JSON(元素坐标基于 1014×642 @300dpi 卡面),
* 打印时由前端按开卡记录填充动态字段后渲染成图,后端只负责存取。</p>
*
* @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<WalletCardTemplateEntity> {
/**
* 主键
*/
@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;
}
@@ -63,6 +63,9 @@ public class WalletMessageOutboxEntity implements Serializable {
/** 本次变动完成后的余额,单位分。 */
private Integer balance;
/** 流水备注快照,由后台操作人员补充业务说明;历史流水和未填写场景允许为空,最多256个字符。 */
private String remark;
/** 钱包流水实际创建时间。 */
private LocalDateTime occurredTime;
@@ -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;
/**
* 饭卡卡面打印模板
*
* <p>模板 JSON 由前端可视化设计器生成与消费;list/detail 登录即可访问,
* 供开卡打印抽屉在无模板管理权限时也能取模板。</p>
*
* @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<WalletCardTemplateEntity>> list() {
return R.ok(walletCardTemplateService.list(Wrappers.<WalletCardTemplateEntity>lambdaQuery()
.orderByDesc(WalletCardTemplateEntity::getUpdateTime)));
}
/**
* 模板分页,管理页使用。
*/
@GetMapping("/page")
@Operation(summary = "卡面模板分页")
@PreAuthorize("@pms.hasPermission('wallet_cardTemplate_view')")
public R<Page<WalletCardTemplateEntity>> page(@ParameterObject Page page, @ParameterObject WalletCardTemplateEntity entity) {
LambdaQueryWrapper<WalletCardTemplateEntity> 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<WalletCardTemplateEntity> getById(@PathVariable Long id) {
return R.ok(walletCardTemplateService.getById(id));
}
/**
* 新增模板
*/
@PostMapping
@Operation(summary = "新增卡面模板")
@SysLog("新增饭卡卡面模板")
@PreAuthorize("@pms.hasPermission('wallet_cardTemplate_add')")
public R<Boolean> save(@Valid @RequestBody WalletCardTemplateEntity entity) {
return R.ok(walletCardTemplateService.save(entity));
}
/**
* 修改模板
*/
@PutMapping
@Operation(summary = "修改卡面模板")
@SysLog("修改饭卡卡面模板")
@PreAuthorize("@pms.hasPermission('wallet_cardTemplate_edit')")
public R<Boolean> update(@Valid @RequestBody WalletCardTemplateEntity entity) {
return R.ok(walletCardTemplateService.updateById(entity));
}
/**
* 删除模板
*/
@DeleteMapping
@Operation(summary = "删除卡面模板")
@SysLog("删除饭卡卡面模板")
@PreAuthorize("@pms.hasPermission('wallet_cardTemplate_del')")
public R<Boolean> remove(@RequestBody List<Long> ids) {
return R.ok(walletCardTemplateService.removeTemplateByIds(ids));
}
}
@@ -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<WalletCardTemplateEntity> {
}
@@ -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<WalletCardTemplateEntity> {
/**
* 删除前校验模板是否被引用,目前无引用关系,仅保留扩展点。
*
* @param ids 待删除的模板 ID 集合
* @return 是否删除成功
*/
Boolean removeTemplateByIds(java.util.List<Long> ids);
}
@@ -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<WalletCardTemplateMapper, WalletCardTemplateEntity>
implements WalletCardTemplateService {
@Override
public Boolean removeTemplateByIds(List<Long> ids) {
if (CollUtil.isEmpty(ids)) {
return Boolean.FALSE;
}
return removeByIds(ids);
}
}
@@ -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;
}
@@ -243,6 +243,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
outbox.setWalletName(walletType == null ? "钱包" : StrUtil.blankToDefault(walletType.getName(), "钱包"));
outbox.setAmount(transaction.getPrice());
outbox.setBalance(transaction.getBalance());
outbox.setRemark(transaction.getRemark());
outbox.setOccurredTime(transaction.getCreateTime() == null ? java.time.LocalDateTime.now() : transaction.getCreateTime());
outbox.setStatus("PENDING");
outbox.setAttemptCount(0);