feat:app流水通知显示备注

feat:咖啡机初步
This commit is contained in:
dzc committed 2026-09-18 12:53:17 +08:00
1 parent c3e6efe5bc
commit 8aa2cfa104
52 files changed
+2887 -2

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`;
+199
View File
@@ -0,0 +1,199 @@
========================================================================
屋里咖啡咖啡机对接一期 - 字典/菜单/权限手工添加清单 (2026-09-18)
========================================================================
说明:以下内容需要管理员在对应环境手工添加(UPMS 管理端或直接执行 SQL)。
SQL 按租户幂等,风格与 db/20260904_add_er_yin_printer.sql 一致,可直接参考执行;
菜单 parent_id 依赖各环境实际菜单主键,请先确认父菜单再执行。
------------------------------------------------------------------------
一、字典项
------------------------------------------------------------------------
1. 设备类型字典 hard_type 增加咖啡机(所有已配置 hard_type 的租户):
INSERT INTO `pjcloudx`.`sys_dict_item`
(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `remarks`, `del_flag`, `tenant_id`)
SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, d.`id`),
d.`id`, '13', '咖啡机', 'hard_type', '屋里咖啡扫码支付咖啡机', 13,
'system', NOW(), '屋里咖啡咖啡机', '0', d.`tenant_id`
FROM `pjcloudx`.`sys_dict` d
WHERE d.`dict_type` = 'hard_type'
AND d.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_dict_item` i
WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_type'
AND i.`item_value` = '13' AND i.`del_flag` = '0'
);
2. 新字典 hard_coffee_stage(咖啡机生命周期阶段):
-- 先建字典类型(按租户)
INSERT INTO `pjcloudx`.`sys_dict`
(`id`, `dict_type`, `description`, `create_by`, `create_time`, `remarks`, `system_flag`, `del_flag`, `tenant_id`)
SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict`) + ROW_NUMBER() OVER (ORDER BY tenant.`tenant_id`),
'hard_coffee_stage', '咖啡机生命周期阶段', 'system', NOW(), '咖啡机生命周期阶段', '1', '0', tenant.`tenant_id`
FROM (SELECT DISTINCT `tenant_id` FROM `pjcloudx`.`sys_dict` WHERE `del_flag` = '0') tenant
WHERE NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_dict` d
WHERE d.`tenant_id` = tenant.`tenant_id` AND d.`dict_type` = 'hard_coffee_stage' AND d.`del_flag` = '0'
);
-- 再建字典项
INSERT INTO `pjcloudx`.`sys_dict_item`
(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, item.`sort_order`),
d.`id`, item.`item_value`, item.`label`, d.`dict_type`, item.`label`, item.`sort_order`,
'system', NOW(), '0', d.`tenant_id`
FROM `pjcloudx`.`sys_dict` d
JOIN (
SELECT 'GET_ORDER' AS `item_value`, '扫码拉取厂商订单' AS `label`, 1 AS `sort_order`
UNION ALL SELECT 'CREATE_ORDER', '创建本地订单', 2
UNION ALL SELECT 'WALLET_PAY', '饭卡扣款', 3
UNION ALL SELECT 'NOTIFY_PAY', '通知厂商支付结果', 4
UNION ALL SELECT 'QUERY', '厂商查单', 5
UNION ALL SELECT 'REFUND_NOTIFY','通知厂商退款', 6
) item ON item.`item_value` IS NOT NULL
WHERE d.`dict_type` = 'hard_coffee_stage'
AND d.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_dict_item` i
WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_coffee_stage'
AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0'
);
3. 新字典 hard_coffee_direction(交互方向):
INSERT INTO `pjcloudx`.`sys_dict`
(`id`, `dict_type`, `description`, `create_by`, `create_time`, `remarks`, `system_flag`, `del_flag`, `tenant_id`)
SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict`) + ROW_NUMBER() OVER (ORDER BY tenant.`tenant_id`),
'hard_coffee_direction', '咖啡机日志交互方向', 'system', NOW(), '咖啡机日志交互方向', '1', '0', tenant.`tenant_id`
FROM (SELECT DISTINCT `tenant_id` FROM `pjcloudx`.`sys_dict` WHERE `del_flag` = '0') tenant
WHERE NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_dict` d
WHERE d.`tenant_id` = tenant.`tenant_id` AND d.`dict_type` = 'hard_coffee_direction' AND d.`del_flag` = '0'
);
INSERT INTO `pjcloudx`.`sys_dict_item`
(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, item.`sort_order`),
d.`id`, item.`item_value`, item.`label`, d.`dict_type`, item.`label`, item.`sort_order`,
'system', NOW(), '0', d.`tenant_id`
FROM `pjcloudx`.`sys_dict` d
JOIN (
SELECT '1' AS `item_value`, '我方调厂商' AS `label`, 1 AS `sort_order`
UNION ALL SELECT '2', '厂商调我方', 2
) item ON item.`item_value` IS NOT NULL
WHERE d.`dict_type` = 'hard_coffee_direction'
AND d.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_dict_item` i
WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_coffee_direction'
AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0'
);
4. 新字典 hard_coffee_notify_status(通知厂商状态,用于订单列表筛选/展示):
INSERT INTO `pjcloudx`.`sys_dict`
(`id`, `dict_type`, `description`, `create_by`, `create_time`, `remarks`, `system_flag`, `del_flag`, `tenant_id`)
SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict`) + ROW_NUMBER() OVER (ORDER BY tenant.`tenant_id`),
'hard_coffee_notify_status', '咖啡机通知厂商状态', 'system', NOW(), '咖啡机通知厂商状态', '1', '0', tenant.`tenant_id`
FROM (SELECT DISTINCT `tenant_id` FROM `pjcloudx`.`sys_dict` WHERE `del_flag` = '0') tenant
WHERE NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_dict` d
WHERE d.`tenant_id` = tenant.`tenant_id` AND d.`dict_type` = 'hard_coffee_notify_status' AND d.`del_flag` = '0'
);
INSERT INTO `pjcloudx`.`sys_dict_item`
(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, item.`sort_order`),
d.`id`, item.`item_value`, item.`label`, d.`dict_type`, item.`label`, item.`sort_order`,
'system', NOW(), '0', d.`tenant_id`
FROM `pjcloudx`.`sys_dict` d
JOIN (
SELECT '0' AS `item_value`, '未通知' AS `label`, 1 AS `sort_order`
UNION ALL SELECT '1', '通知成功', 2
UNION ALL SELECT '2', '通知失败', 3
) item ON item.`item_value` IS NOT NULL
WHERE d.`dict_type` = 'hard_coffee_notify_status'
AND d.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_dict_item` i
WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_coffee_notify_status'
AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0'
);
------------------------------------------------------------------------
二、菜单与按钮权限
------------------------------------------------------------------------
说明:
- 「咖啡机订单」建议挂在硬件订单管理菜单下(父菜单 permission = hard_hardOrder_view 所在菜单);
- 「咖啡机日志」建议挂在设备管理菜单下(父菜单 permission = hard_hardManager_view 所在菜单);
- 如各环境父菜单定位不同,请先查 parent_id 再执行。
1. 咖啡机订单菜单(menu_type 1=菜单,含页面路由,路由地址建议 /hard/coffee-order):
INSERT INTO `pjcloudx`.`sys_menu`
(`menu_id`, `name`, `permission`, `parent_id`, `path`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY parent_menu.`tenant_id`, parent_menu.`menu_id`),
'咖啡机订单', 'hard_hardCoffeeOrder_view', parent_menu.`menu_id`, 'coffee-order',
'1', 30, '0', '0', '1', 'system', NOW(), '0', parent_menu.`tenant_id`
FROM `pjcloudx`.`sys_menu` parent_menu
WHERE parent_menu.`permission` = 'hard_hardOrder_view' AND parent_menu.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_menu` existing
WHERE existing.`tenant_id` = parent_menu.`tenant_id`
AND existing.`permission` = 'hard_hardCoffeeOrder_view' AND existing.`del_flag` = '0'
);
2. 咖啡机日志菜单:
INSERT INTO `pjcloudx`.`sys_menu`
(`menu_id`, `name`, `permission`, `parent_id`, `path`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY parent_menu.`tenant_id`, parent_menu.`menu_id`),
'咖啡机日志', 'hard_hardCoffeeLog_view', parent_menu.`menu_id`, 'coffee-log',
'1', 31, '0', '0', '1', 'system', NOW(), '0', parent_menu.`tenant_id`
FROM `pjcloudx`.`sys_menu` parent_menu
WHERE parent_menu.`permission` = 'hard_hardManager_view' AND parent_menu.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_menu` existing
WHERE existing.`tenant_id` = parent_menu.`tenant_id`
AND existing.`permission` = 'hard_hardCoffeeLog_view' AND existing.`del_flag` = '0'
);
------------------------------------------------------------------------
三、第三方密钥 third_key_manage(第三方库 pjcloudx_thirdparty)
------------------------------------------------------------------------
说明:屋里咖啡两对密钥通过 thirdparty 管理端「第三方密钥管理」录入,或按下面模板执行 SQL。
字段映射:app_key=屋里概念里的 appid,app_secret=屋里概念里的 appkey(SHA1签名密钥)。
密钥为平台级全局唯一(跨租户读取),建议维护在默认租户(tenant_id=1)下。
修改密钥后 hardware 侧最多延迟 5 分钟生效(本地缓存)。
1. 我方调厂商 getOrder/callback/refund 的鉴权对(bizType=11):
INSERT INTO `pjcloudx_thirdparty`.`third_key_manage`
(`id`, `name`, `biz_type`, `app_key`, `app_secret`, `status`, `remark`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
VALUES
(2026091800000000001, '屋里咖啡', '11', '<向屋里申请的16位appid>', '<向屋里申请的16位appkey>', '1',
'我方作为第三方支付服务商调厂商接口', 'system', NOW(), '0', 1);
2. 厂商调我方 queryOrder 查单的验签对(bizType=12,我方自行生成后告知屋里):
INSERT INTO `pjcloudx_thirdparty`.`third_key_manage`
(`id`, `name`, `biz_type`, `app_key`, `app_secret`, `status`, `remark`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
VALUES
(2026091800000000002, '屋里咖啡查单', '12', '<我方生成并告知屋里的16位appid>', '<我方生成的16位appkey>', '1',
'厂商调我方queryOrder验签', 'system', NOW(), '0', 1);
------------------------------------------------------------------------
四、后端权限字符串与接口对应关系(前端页面取用)
------------------------------------------------------------------------
hard_hardCoffeeOrder_view:
GET /hard-coffee-manager/order/page 咖啡机订单分页
GET /hard-coffee-manager/order/{id} 咖啡机订单详情(含生命周期时间线)
hard_hardCoffeeLog_view:
GET /hard-coffee-manager/log/page 生命周期日志分页
+72
View File
@@ -0,0 +1,72 @@
# 咖啡机(屋里咖啡)H5支付页对接契约 - 一期饭卡支付
> 交付时间: 2026-09-18。H5 页面在前端仓库(biz_zax_app_cloud)实现,本文档为后端接口契约。
## 1. 页面入口与二维码 URL
用户在咖啡机上选择饮品后,机器展示二维码,URL 由屋里咖啡后台配置,指向我方 H5:
```
https://www.pjzhhq.cn/h5/#/pages/pay/coffee-pay?order_no={厂商订单号}
```
- `order_no`: 厂商订单号,19 位数字(测试环境 18 位)。
- 屋里咖啡后台的「支付跳转 URL 模板」按上述地址配置,占位符以厂商后台实际支持为准。
## 2. 接口(匿名,无需登录态)
网关前缀: `https://www.pjzhhq.cn/api/hard` (StripPrefix 后到 hardware 服务)。
所有响应为统一 `R<T>` 结构 `{code:0, msg, data}`,`code != 0` 时展示 `msg`。
### 2.1 拉取订单信息
```
GET /api/hard/hard-coffee/order-info?orderNo={order_no}
```
响应 `data`:
| 字段 | 类型 | 说明 |
|---|---|---|
| orderId | long | 本地订单id,**支付时回传** |
| orderNo | string | 厂商订单号 |
| product | string | 产品名称 |
| img | string | 产品图片完整链接 |
| isCold | string | `1`冷饮 `0`热饮 |
| price | int | 价格(分) |
| priceYuan | string | 价格(元,两位小数),直接展示 |
| machineName | string | 机器号 |
| paid | boolean | 是否已支付。`true` 时展示"已支付"并禁止再次支付 |
| remainSeconds | long | 支付剩余秒数(下单起5分钟),用于倒计时,超时提示重新发起 |
页面逻辑: 进入页面即调用;`paid=true` 显示已支付结果;`remainSeconds<=0` 显示超时。
### 2.2 饭卡支付(卡号即扣款)
```
POST /api/hard/hard-coffee/card-pay
Content-Type: application/json
{ "orderId": 1234567890, "cardNo": "0123456789" }
```
- 卡号即身份,与消费机刷卡一致,无密码。失败原因见 `msg`(非本单位饭卡/余额不足/订单已超时/订单已支付等)。
响应 `data`:
| 字段 | 类型 | 说明 |
|---|---|---|
| orderId | long | 本地订单id |
| orderNo | string | 厂商订单号 |
| name | string | 支付人姓名(脱敏展示由前端决定) |
| cardNo | string | 饭卡卡号 |
| balanceYuan | string | 支付后余额(元) |
支付成功后展示"取咖啡"提示;出餐由后端回调厂商完成,前端无需轮询(如需兜底可再次调用 2.1 查看 `paid`)。
## 3. 注意事项
1. 匿名接口无租户头,后端通过机器号自动定位租户,前端无需传 TENANT-ID。
2. 同一订单支付窗口 5 分钟,过期后在咖啡机重新发起生成新二维码。
3. 防重复提交由后端并发锁保证,前端支付按钮加 loading 即可。
4. 一期仅饭卡支付;微信/支付宝支付为二期,接口预留扩展。
@@ -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。
@@ -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,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;
}
@@ -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,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;
}
@@ -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,50 @@
package com.bsd.pjcloud.hard.controller.coffee;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderInfoVo;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeePayResultVo;
import com.bsd.pjcloud.hard.service.HardCoffeeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.*;
/**
* 咖啡机H5匿名支付接口。
* 用户扫描咖啡机二维码打开我方H5支付页,页面依次调用拉单和饭卡支付接口;
* 匿名安全由厂商订单号随机性+5分钟支付窗口+并发锁保证。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/hard-coffee")
@Tag(description = "hard-coffee", name = "咖啡机H5支付接口")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class HardCoffeeController {
private final HardCoffeeService hardCoffeeService;
/**
* 拉取咖啡机订单信息(幂等,已支付订单返回paid=true)
*/
@Operation(summary = "拉取咖啡机订单信息", description = "根据厂商订单号拉单,H5进入页面时调用")
@GetMapping("/order-info")
@Inner(value = false)
public R<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));
}
}
@@ -0,0 +1,64 @@
package com.bsd.pjcloud.hard.controller.coffee;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeLogDto;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeOrderDto;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeLogVo;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderDetailVo;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderVo;
import com.bsd.pjcloud.hard.service.HardCoffeeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 咖啡机订单与生命周期日志管理端查询
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/hard-coffee-manager")
@Tag(description = "hard-coffee-manager", name = "咖啡机订单日志管理")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class HardCoffeeManagerController {
private final HardCoffeeService hardCoffeeService;
/**
* 咖啡机订单分页
*/
@Operation(summary = "咖啡机订单分页", description = "分页查询咖啡机厂商订单")
@GetMapping("/order/page")
@PreAuthorize("@pms.hasPermission('hard_hardCoffeeOrder_view')")
public R<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));
}
/**
* 生命周期日志分页
*/
@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);
}
}
@@ -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,83 @@
package com.bsd.pjcloud.hard.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeLogDto;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeOrderDto;
import com.bsd.pjcloud.hard.api.dto.coffee.WuliQueryOrderDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderDetailVo;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderInfoVo;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderVo;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeLogVo;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeePayResultVo;
import com.bsd.pjcloud.hard.api.vo.coffee.WuliQueryOrderVo;
/**
* 咖啡机(屋里咖啡)对接主流程
*/
public interface HardCoffeeService {
/**
* H5匿名拉单: 根据厂商订单号调 getOrder 并落本地订单(幂等)
*
* @param orderNo 厂商订单号
* @return 订单展示信息
*/
HardCoffeeOrderInfoVo orderInfo(String orderNo);
/**
* H5匿名饭卡支付: 卡号即身份,扣款成功后通知厂商出餐
*
* @param dto 订单id+饭卡卡号
* @return 支付结果(含余额展示)
*/
HardCoffeePayResultVo cardPay(HardCoffeeCardPayDto dto);
/**
* 厂商主动查单(文档3.4 queryOrder,验签)
*
* @param dto 厂商查单参数
* @return 查单响应
*/
WuliQueryOrderVo wuliQueryOrder(WuliQueryOrderDto dto);
/**
* 本地退款完成后向厂商同步退款(文档3.3 refund,支持分批)。
* 非咖啡机订单直接返回,零影响
*
* @param orderId 本地订单id
*/
void syncRefundIfNeeded(Long orderId);
/**
* 管理端咖啡机订单分页
*/
Page<HardCoffeeOrderVo> orderPage(Page page, HardCoffeeOrderDto dto);
/**
* 管理端生命周期日志分页
*/
Page<HardCoffeeLogVo> logPage(Page page, HardCoffeeLogDto dto);
/**
* 管理端订单详情(含生命周期时间线)
*/
HardCoffeeOrderDetailVo orderDetail(Long id);
/**
* 绑定付款人并完成钱包扣款(独立事务,内部通过Spring代理调用保证事务生效)
*
* @param orderId 本地订单id
* @param user 付款人信息
* @param walletId 钱包id
* @return 扣款完成后的订单
*/
HardOrderEntity bindAndDeduct(Long orderId, UserInfo user, Long walletId);
/**
* 补偿: 重试通知失败的支付结果回调 + 补推退款同步缺口
*/
void retryNotifyFailures();
}
@@ -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,778 @@
package com.bsd.pjcloud.hard.service.impl.coffee;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogDirectionEnum;
import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogStageEnum;
import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeNotifyStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeLogDto;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeOrderDto;
import com.bsd.pjcloud.hard.api.dto.coffee.WuliQueryOrderDto;
import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity;
import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity;
import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeLogVo;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderDetailVo;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderInfoVo;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderVo;
import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeePayResultVo;
import com.bsd.pjcloud.hard.api.vo.coffee.WuliQueryOrderVo;
import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
import com.bsd.pjcloud.hard.service.HardCoffeeLogService;
import com.bsd.pjcloud.hard.service.HardCoffeeOrderService;
import com.bsd.pjcloud.hard.service.HardCoffeeService;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.hard.service.impl.coffee.WuliCoffeeClient.WuliCallContext;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 咖啡机(屋里咖啡)对接主流程。
* 角色说明: 我方平台是厂商文档中的"第三方支付服务商",
* 用户扫咖啡机二维码打开我方H5,由我方拉单、饭卡扣款并回调厂商出餐。
*/
@Validated
@Slf4j
@RequiredArgsConstructor
@Service
public class HardCoffeeServiceImpl implements HardCoffeeService {
private static final String TAG = "咖啡机";
/**
* 支付窗口Redis Key,拉单时写入、支付时校验,值存厂商订单号
*/
private static final String PAY_WINDOW_KEY = "hard:coffee:order:non:pay:";
/**
* 支付窗口时长(秒),与商户扫码订单保持一致
*/
private static final long PAY_WINDOW_SECONDS = 300;
/**
* 拉单并发锁Key前缀
*/
private static final String CREATE_LOCK_KEY = "hard:coffee:order:create:lock:";
/**
* 支付并发锁Key前缀
*/
private static final String PAY_LOCK_KEY = "hard:coffee:order:pay:lock:";
/**
* 并发锁时长(秒)
*/
private static final long LOCK_SECONDS = 10;
/**
* 厂商订单号格式: 文档为19位数字,测试环境18位,放宽到17-20位兼容
*/
private static final Pattern ORDER_NO_PATTERN = Pattern.compile("^\\d{17,20}$");
/**
* 支付结果通知厂商的最大重试次数,超过后需人工介入
*/
private static final int NOTIFY_MAX_RETRY = 10;
/**
* 补偿任务单轮处理的订单量
*/
private static final int RETRY_BATCH_SIZE = 50;
/**
* 退款同步缺口回看的订单天数
*/
private static final int REFUND_SCAN_DAYS = 7;
private final HardCoffeeOrderService coffeeOrderService;
private final HardCoffeeLogService coffeeLogService;
private final HardManagerService hardManagerService;
private final HardOrderService hardOrderService;
private final WuliCoffeeClient wuliCoffeeClient;
private final RemoteMchService remoteMchService;
private final RemoteWalletService walletService;
private final RemoteUserService remoteUserService;
private final StringRedisTemplate redisTemplate;
/**
* H5匿名拉单: 幂等查询本地扩展订单,不存在时调厂商getOrder并落本地订单。
* 匿名请求无租户头,机器号定位设备后用 TenantBroker 切换到设备租户落单。
*/
@Override
public HardCoffeeOrderInfoVo orderInfo(String orderNo) {
if (StrUtil.isBlank(orderNo) || !ORDER_NO_PATTERN.matcher(orderNo).matches()) {
throw new RuntimeException("订单号格式错误");
}
// 幂等: 已落单直接返回
HardCoffeeOrderEntity exist = queryAcrossTenants(() -> coffeeOrderService.getByOrderNo(orderNo));
if (ObjectUtil.isNotEmpty(exist)) {
return buildOrderInfoVo(exist);
}
// 并发锁防止同一厂商订单号重复落单
Boolean locked = redisTemplate.opsForValue().setIfAbsent(CREATE_LOCK_KEY + orderNo, "1", Duration.ofSeconds(LOCK_SECONDS));
if (!Boolean.TRUE.equals(locked)) {
throw new RuntimeException("订单处理中,请稍后重试");
}
try {
exist = queryAcrossTenants(() -> coffeeOrderService.getByOrderNo(orderNo));
if (ObjectUtil.isNotEmpty(exist)) {
return buildOrderInfoVo(exist);
}
// 拉取厂商订单详情(日志随结果带回,租户解析后落库)
WuliCallContext context = WuliCallContext.builder().orderNo(orderNo).build();
WuliCoffeeClient.WuliGetOrderResult getOrderResult = wuliCoffeeClient.getOrderWithLog(context);
WuliCoffeeClient.WuliOrderDetail detail = getOrderResult.getDetail();
if (ObjectUtil.isEmpty(detail.getPrice()) || detail.getPrice() <= 0) {
throw new RuntimeException("厂商订单金额异常");
}
// 机器号定位咖啡机设备
HardManagerVo machine = queryAcrossTenants(() -> hardManagerService.getByUniqueCode(detail.getMachineName()));
if (ObjectUtil.isEmpty(machine)) {
throw new RuntimeException("咖啡机未接入平台,机器号:" + detail.getMachineName());
}
if (!ObjectUtil.equals(HardTypeEnum.COFFEE.getType(), machine.getType())) {
throw new RuntimeException("设备类型不是咖啡机,机器号:" + detail.getMachineName());
}
Long tenantId = machine.getTenantId();
// 拉单日志补全租户后落库,保证租户内生命周期时间线完整
HardCoffeeLogEntity getOrderLog = getOrderResult.getLog();
if (ObjectUtil.isNotEmpty(getOrderLog)) {
getOrderLog.setTenantId(tenantId);
wuliCoffeeClient.saveLog(getOrderLog);
}
// 设备租户上下文内创建本地订单
Long orderId = TenantBroker.applyAs(tenantId, tenant -> createLocalOrder(machine, detail));
// 支付窗口写在匿名默认上下文,与cardPay读取保持一致
redisTemplate.opsForValue().set(PAY_WINDOW_KEY + orderId, orderNo, Duration.ofSeconds(PAY_WINDOW_SECONDS));
// 落单阶段日志
exist = queryAcrossTenants(() -> coffeeOrderService.getByOrderNo(orderNo));
saveStageLog(exist, HardCoffeeLogStageEnum.CREATE_ORDER, tenantId, null);
return buildOrderInfoVo(exist);
} finally {
redisTemplate.delete(CREATE_LOCK_KEY + orderNo);
}
}
/**
* 在设备租户上下文内创建 hard_order + hard_coffee_order
*/
private Long createLocalOrder(HardManagerVo machine, WuliCoffeeClient.WuliOrderDetail detail) {
MchManageVo mch = remoteMchService.getById(machine.getMerchantId()).getData();
if (ObjectUtil.isEmpty(mch)) {
throw new RuntimeException("咖啡机绑定的商户不存在");
}
HardOrderEntity order = HardOrderEntity.builder()
.userId(-1L)
.orderNo(detail.getOrderNo())
.merchantId(mch.getId())
.merchantType(mch.getTypes())
.merchantDeptId(mch.getDeptId())
.hardId(machine.getId())
.sysType(HardTypeEnum.COFFEE.getType())
.orderStatus(HardOrderStatusEnum.CREATE.getType())
.orderType(HardOrderTypeEnum.COFFEE.getType())
.payType(HardPayTypeEnum.WALLET_CARD.getType())
.totalPrice(detail.getPrice())
.extraPrice(0)
.payPrice(detail.getPrice())
.isOfflineOrder(YesNoEnum.NO.getCode())
.apptUseDate(LocalDate.now())
.productDesc(detail.getProduct())
.build();
hardOrderService.save(order);
HardCoffeeOrderEntity coffeeOrder = new HardCoffeeOrderEntity();
coffeeOrder.setOrderId(order.getId());
coffeeOrder.setOrderNo(detail.getOrderNo());
coffeeOrder.setHardId(machine.getId());
coffeeOrder.setMachineName(detail.getMachineName());
coffeeOrder.setProduct(detail.getProduct());
coffeeOrder.setImg(detail.getImg());
coffeeOrder.setIsCold(detail.getIsCold());
coffeeOrder.setPrice(detail.getPrice());
coffeeOrder.setWuliNotifyStatus(HardCoffeeNotifyStatusEnum.NONE.getStatus());
coffeeOrder.setWuliNotifyCount(0);
coffeeOrder.setRefundSyncAmount(0);
coffeeOrder.setTenantId(machine.getTenantId());
coffeeOrderService.save(coffeeOrder);
return order.getId();
}
/**
* H5匿名饭卡支付: 卡号即身份(与消费机刷卡一致)。
* 扣款在设备租户的本地事务内完成,厂商回调在事务外执行,通知失败不回滚支付,由补偿任务重试。
*/
@Override
public HardCoffeePayResultVo cardPay(HardCoffeeCardPayDto dto) {
Long orderId = dto.getOrderId();
String redisKey = PAY_WINDOW_KEY + orderId;
// 支付窗口校验(匿名上下文)
String orderNoInWindow = redisTemplate.opsForValue().get(redisKey);
if (StrUtil.isBlank(orderNoInWindow)) {
throw new RuntimeException("订单已超时,请在咖啡机上重新发起");
}
// 支付并发锁,防止同一订单重复提交
Boolean locked = redisTemplate.opsForValue().setIfAbsent(PAY_LOCK_KEY + orderId, "1", Duration.ofSeconds(LOCK_SECONDS));
if (!Boolean.TRUE.equals(locked)) {
throw new RuntimeException("支付处理中,请勿重复提交");
}
try {
HardCoffeeOrderEntity coffeeOrder = queryAcrossTenants(() -> coffeeOrderService.getByOrderId(orderId));
if (ObjectUtil.isEmpty(coffeeOrder)) {
throw new RuntimeException("订单不存在");
}
Long tenantId = coffeeOrder.getTenantId();
HardCoffeePayResultVo result = TenantBroker.applyAs(tenantId, tenant -> doCardPay(coffeeOrder, dto));
// 窗口Key删除必须在匿名上下文执行,与写入时前缀一致
redisTemplate.delete(redisKey);
return result;
} finally {
redisTemplate.delete(PAY_LOCK_KEY + orderId);
}
}
/**
* 设备租户上下文内的支付主逻辑
*/
private HardCoffeePayResultVo doCardPay(HardCoffeeOrderEntity coffeeOrder, HardCoffeeCardPayDto dto) {
Long orderId = coffeeOrder.getOrderId();
HardOrderEntity order = hardOrderService.getById(orderId);
if (ObjectUtil.isEmpty(order) || !ObjectUtil.equals(HardTypeEnum.COFFEE.getType(), order.getSysType())) {
throw new RuntimeException("订单不存在");
}
if (!ObjectUtil.equals(HardOrderStatusEnum.CREATE.getType(), order.getOrderStatus())) {
throw new RuntimeException("订单已支付或已关闭");
}
// 卡号定位钱包(Feign携带当前租户头)
WalletManageEntity wallet = walletService.getWalletByCardNumber(dto.getCardNo()).getData();
if (ObjectUtil.isEmpty(wallet)) {
throw new RuntimeException("非本单位饭卡");
}
UserInfo user = remoteUserService.getInnerByUserId(wallet.getUserId()).getData();
if (ObjectUtil.isEmpty(user) || ObjectUtil.isEmpty(user.getSysUser())) {
throw new RuntimeException("饭卡用户不存在");
}
// 绑定付款人并扣款(事务),余额不足时抛异常回滚
HardOrderEntity paidOrder = SpringUtil.getBean(HardCoffeeService.class).bindAndDeduct(orderId, user, wallet.getId());
saveStageLog(coffeeOrder, HardCoffeeLogStageEnum.WALLET_PAY, coffeeOrder.getTenantId(),
"卡号:" + dto.getCardNo() + ",金额(分):" + paidOrder.getPayPrice());
// 事务已提交,通知厂商出餐;失败不回滚,标记后由补偿任务重试
String phone = user.getSysUser().getPhone();
boolean notified;
try {
WuliCallContext context = buildCallContext(coffeeOrder);
notified = wuliCoffeeClient.callback(context, "SUCCESS", phone);
} catch (Exception e) {
log.error("|{}|通知厂商支付结果异常,orderId:{},orderNo:{}", TAG, orderId, coffeeOrder.getOrderNo(), e);
notified = false;
}
updateNotifyStatus(coffeeOrder, notified);
// 回填支付者手机号,供厂商查单展示
HardCoffeeOrderEntity phoneUpdate = new HardCoffeeOrderEntity();
phoneUpdate.setId(coffeeOrder.getId());
phoneUpdate.setPhoneNo(phone);
coffeeOrderService.updateById(phoneUpdate);
// 支付后余额重新查询展示
WalletManageEntity latestWallet = walletService.getWalletById(wallet.getId()).getData();
String balanceYuan = fenToYuan(ObjectUtil.isNotEmpty(latestWallet) ? latestWallet.getBalance() : null);
return HardCoffeePayResultVo.builder()
.orderId(orderId)
.orderNo(coffeeOrder.getOrderNo())
.name(user.getSysUser().getName())
.cardNo(wallet.getCardNumber())
.balanceYuan(balanceYuan)
.build();
}
/**
* 绑定付款人并完成钱包扣款。独立事务方法,通过Spring代理调用保证事务生效
*/
@Override
@Transactional(rollbackFor = Exception.class)
public HardOrderEntity bindAndDeduct(Long orderId, UserInfo user, Long walletId) {
HardOrderEntity order = hardOrderService.getById(orderId);
if (ObjectUtil.isEmpty(order)) {
throw new RuntimeException("订单不存在");
}
if (!ObjectUtil.equals(HardOrderStatusEnum.CREATE.getType(), order.getOrderStatus())) {
throw new RuntimeException("订单状态已变化,请刷新重试");
}
HardOrderEntity bind = HardOrderEntity.builder()
.id(orderId)
.userId(user.getSysUser().getUserId())
.userDeptId(user.getSysUser().getDeptId())
.walletId(walletId)
.build();
hardOrderService.updateById(bind);
order.setUserId(user.getSysUser().getUserId());
order.setWalletId(walletId);
// 钱包扣款+订单置为已支付(余额不足抛异常整体回滚)
hardOrderService.fastReduceBalance(order);
return hardOrderService.getById(orderId);
}
/**
* 厂商主动查单(文档3.4): 验签通过后返回本地订单支付状态映射
*/
@Override
public WuliQueryOrderVo wuliQueryOrder(WuliQueryOrderDto dto) {
WuliQueryOrderVo.WuliQueryOrderVoBuilder builder = WuliQueryOrderVo.builder();
if (!wuliCoffeeClient.verifyQuerySign(dto.getAppId(), dto.getRandstr(), dto.getTimestamp(), dto.getSign())) {
saveInboundQueryLog(dto, null, "验签失败");
return builder.returnCode("FAIL").returnMsg("验签失败").build();
}
HardCoffeeOrderEntity coffeeOrder = queryAcrossTenants(() -> coffeeOrderService.getByOrderNo(dto.getOrderNo()));
if (ObjectUtil.isEmpty(coffeeOrder)) {
saveInboundQueryLog(dto, null, "订单不存在");
return builder.returnCode("FAIL").returnMsg("订单不存在").build();
}
HardOrderEntity order = queryAcrossTenants(() -> hardOrderService.getById(coffeeOrder.getOrderId()));
String status = mapQueryStatus(ObjectUtil.isEmpty(order) ? null : order.getOrderStatus());
saveInboundQueryLog(dto, coffeeOrder, status);
return builder.returnCode("SUCCESS").status(status).phoneNo(coffeeOrder.getPhoneNo()).build();
}
/**
* 本地订单状态映射厂商查单状态: 已支付/已核销->SUCCESS,创建中->USERPAYING,退款/取消->FAIL
*/
private String mapQueryStatus(String orderStatus) {
if (ObjectUtil.isEmpty(orderStatus)) {
return "FAIL";
}
if (List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.CANCEL.getType()).contains(orderStatus)) {
return "SUCCESS";
}
if (List.of(HardOrderStatusEnum.CREATE.getType(), HardOrderStatusEnum.OFFLINE_CREATE.getType()).contains(orderStatus)) {
return "USERPAYING";
}
return "FAIL";
}
/**
* 本地退款完成后同步通知厂商(文档3.3)。amount为本次差额,累计退款以refund_sync_amount记账
*/
@Override
public void syncRefundIfNeeded(Long orderId) {
// 当前租户上下文查询;非咖啡机订单直接返回
HardCoffeeOrderEntity coffeeOrder = coffeeOrderService.getByOrderId(orderId);
if (ObjectUtil.isEmpty(coffeeOrder)) {
return;
}
HardOrderEntity order = hardOrderService.getById(orderId);
if (ObjectUtil.isEmpty(order)) {
return;
}
int refunded = ObjectUtil.defaultIfNull(order.getRefundPrice(), 0);
int synced = ObjectUtil.defaultIfNull(coffeeOrder.getRefundSyncAmount(), 0);
int delta = refunded - synced;
if (delta <= 0) {
return;
}
try {
WuliCallContext context = buildCallContext(coffeeOrder);
boolean ok = wuliCoffeeClient.refund(context, delta);
if (ok) {
HardCoffeeOrderEntity update = new HardCoffeeOrderEntity();
update.setId(coffeeOrder.getId());
update.setRefundSyncAmount(refunded);
update.setRefundSyncTime(LocalDateTime.now());
coffeeOrderService.updateById(update);
} else {
log.warn("|{}|退款同步厂商返回失败,等待补偿重试,orderId:{},差额(分):{}", TAG, orderId, delta);
}
} catch (Exception e) {
log.error("|{}|退款同步厂商异常,等待补偿重试,orderId:{},差额(分):{}", TAG, orderId, delta, e);
}
}
/**
* 补偿任务: 重试通知失败的支付回调 + 补推退款同步缺口。
* 定时线程无租户上下文,列表查询天然跨租户,逐单切回订单租户执行
*/
@Override
public void retryNotifyFailures() {
// 1.支付结果通知失败重试
List<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);
}
}
/**
@@ -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
@@ -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());
}
@@ -63,6 +63,9 @@ public class WalletMessageOutboxEntity implements Serializable {
/** 本次变动完成后的余额,单位分。 */
private Integer balance;
/** 流水备注快照,由后台操作人员补充业务说明;历史流水和未填写场景允许为空,最多256个字符。 */
private String remark;
/** 钱包流水实际创建时间。 */
private LocalDateTime occurredTime;
@@ -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);