feat:app流水通知显示备注
feat:咖啡机初步
This commit is contained in:
1 parent
c3e6efe5bc
commit
8aa2cfa104
52 files changed
+2887
-2
No files matched your search
@@ -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 = '咖啡机使用生命周期日志(屋里咖啡)';
|
||||
@@ -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`;
|
||||
@@ -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 生命周期日志分页
|
||||
@@ -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。
|
||||
+1
@@ -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}已核銷,祝您用餐愉快。
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+19
@@ -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;
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+4
-2
@@ -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);
|
||||
|
||||
|
||||
+30
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
+154
@@ -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;
|
||||
}
|
||||
+154
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+74
@@ -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;
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+50
@@ -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));
|
||||
}
|
||||
}
|
||||
+64
@@ -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));
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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> {
|
||||
|
||||
}
|
||||
+10
@@ -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> {
|
||||
|
||||
}
|
||||
+11
@@ -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> {
|
||||
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
+83
@@ -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();
|
||||
}
|
||||
+21
@@ -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 {
|
||||
|
||||
}
|
||||
+35
@@ -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"));
|
||||
}
|
||||
}
|
||||
+778
@@ -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();
|
||||
}
|
||||
}
|
||||
+406
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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
|
||||
|
||||
+26
@@ -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);
|
||||
}
|
||||
+4
@@ -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;
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
+12
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+6
@@ -23,6 +23,12 @@ public interface ThirdKeyManageService extends IService<ThirdKeyManageEntity> {
|
||||
*/
|
||||
ThirdKeyManageEntity get(ThirdKeyTypeEnum type);
|
||||
|
||||
/**
|
||||
* 跨租户按bizType获取平台级key配置(咖啡机等匿名设备场景无租户上下文)。
|
||||
* 该类密钥要求全平台唯一,建议统一维护在默认租户下
|
||||
*/
|
||||
ThirdKeyManageEntity getGlobalByBizType(String bizType);
|
||||
|
||||
/**
|
||||
* 验签并且返回配置
|
||||
*/
|
||||
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验签并且返回配置
|
||||
*/
|
||||
|
||||
+3
@@ -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());
|
||||
}
|
||||
|
||||
+3
@@ -63,6 +63,9 @@ public class WalletMessageOutboxEntity implements Serializable {
|
||||
/** 本次变动完成后的余额,单位分。 */
|
||||
private Integer balance;
|
||||
|
||||
/** 流水备注快照,由后台操作人员补充业务说明;历史流水和未填写场景允许为空,最多256个字符。 */
|
||||
private String remark;
|
||||
|
||||
/** 钱包流水实际创建时间。 */
|
||||
private LocalDateTime occurredTime;
|
||||
|
||||
|
||||
+1
@@ -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;
|
||||
}
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user