diff --git a/db/20260918_add_hard_coffee.sql b/db/20260918_add_hard_coffee.sql new file mode 100644 index 00000000..28d720f5 --- /dev/null +++ b/db/20260918_add_hard_coffee.sql @@ -0,0 +1,64 @@ +-- 屋里咖啡咖啡机对接一期(饭卡支付):厂商订单扩展表 + 生命周期日志表。 +-- 本脚本只新增表,不修改既有表结构,可安全重跑。 +-- 字典与菜单权限见同目录 20260918_hard_coffee_dict_menu.txt,由管理员手工添加。 + +CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_coffee_order` +( + `id` bigint NOT NULL COMMENT '编号', + `order_id` bigint NOT NULL COMMENT '本地硬件订单id(hard_order.id)', + `order_no` varchar(32) NOT NULL COMMENT '屋里咖啡订单号(19位数字)', + `hard_id` bigint DEFAULT NULL COMMENT '咖啡机设备id(hard_manager.id)', + `machine_name` varchar(64) DEFAULT NULL COMMENT '厂商机器号快照', + `product` varchar(255) DEFAULT NULL COMMENT '产品名称', + `img` varchar(512) DEFAULT NULL COMMENT '产品图片链接', + `is_cold` char(1) DEFAULT '0' COMMENT '1冷饮 0热饮', + `price` int NOT NULL DEFAULT 0 COMMENT '厂商订单价格(分)', + `phone_no` varchar(32) DEFAULT NULL COMMENT '支付者手机号', + `wuli_notify_status` char(1) NOT NULL DEFAULT '0' COMMENT '支付结果通知厂商状态 0未通知 1成功 2失败', + `wuli_notify_time` datetime DEFAULT NULL COMMENT '最近一次通知厂商时间', + `wuli_notify_count` int NOT NULL DEFAULT 0 COMMENT '通知厂商累计尝试次数', + `refund_sync_amount` int NOT NULL DEFAULT 0 COMMENT '累计已同步厂商退款金额(分)', + `refund_sync_time` datetime DEFAULT NULL COMMENT '最近一次退款同步时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `del_flag` char(1) DEFAULT '0' COMMENT '删除标记', + `tenant_id` bigint DEFAULT NULL COMMENT '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_hard_coffee_order_no` (`tenant_id`, `order_no`), + KEY `idx_hard_coffee_order_hard` (`tenant_id`, `hard_id`, `create_time`), + KEY `idx_hard_coffee_order_order` (`tenant_id`, `order_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci + COMMENT = '咖啡机厂商订单扩展(屋里咖啡)'; + +CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_coffee_log` +( + `id` bigint NOT NULL COMMENT '编号', + `order_no` varchar(32) DEFAULT NULL COMMENT '厂商订单号', + `order_id` bigint DEFAULT NULL COMMENT '本地订单id(hard_order.id)', + `hard_id` bigint DEFAULT NULL COMMENT '设备id(hard_manager.id)', + `unique_code` varchar(64) DEFAULT NULL COMMENT '设备唯一编号(厂商机器号)', + `stage` varchar(32) NOT NULL COMMENT '生命周期阶段编码', + `direction` char(1) NOT NULL DEFAULT '1' COMMENT '交互方向 1我方调厂商 2厂商调我方', + `title` varchar(64) NOT NULL COMMENT '阶段中文说明', + `headers` varchar(2048) DEFAULT NULL COMMENT '请求头', + `req` varchar(2048) DEFAULT NULL COMMENT '请求报文', + `res` varchar(2048) DEFAULT NULL COMMENT '响应报文', + `error` varchar(2048) DEFAULT NULL COMMENT '错误信息', + `start_time` datetime DEFAULT NULL COMMENT '开始时间', + `end_time` datetime DEFAULT NULL COMMENT '结束时间', + `cost` int DEFAULT NULL COMMENT '耗时(毫秒)', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `del_flag` char(1) DEFAULT '0' COMMENT '删除标记', + `tenant_id` bigint DEFAULT NULL COMMENT '租户ID', + PRIMARY KEY (`id`), + KEY `idx_hard_coffee_log_order_no` (`tenant_id`, `order_no`), + KEY `idx_hard_coffee_log_order` (`tenant_id`, `order_id`), + KEY `idx_hard_coffee_log_hard` (`tenant_id`, `hard_id`, `create_time`), + KEY `idx_hard_coffee_log_stage` (`tenant_id`, `stage`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci + COMMENT = '咖啡机使用生命周期日志(屋里咖啡)'; diff --git a/db/20260918_add_wallet_outbox_remark.sql b/db/20260918_add_wallet_outbox_remark.sql new file mode 100644 index 00000000..98e4e9ef --- /dev/null +++ b/db/20260918_add_wallet_outbox_remark.sql @@ -0,0 +1,4 @@ +-- 钱包消息Outbox补充流水备注快照:remark 随钱包流水同事务落库,供消息正文与推送通知展示后台补充的业务说明。 +-- 历史流水和未填写场景允许为空,最多保存256个字符,与 wallet_transaction.remark 保持同长度约束。 +ALTER TABLE `pjcloudx_wallet`.`wallet_message_outbox` + ADD COLUMN `remark` varchar(256) DEFAULT NULL COMMENT '流水备注快照,由后台操作人员补充业务说明,最多256字符' AFTER `balance`; diff --git a/db/20260918_hard_coffee_dict_menu.txt b/db/20260918_hard_coffee_dict_menu.txt new file mode 100644 index 00000000..41f72255 --- /dev/null +++ b/db/20260918_hard_coffee_dict_menu.txt @@ -0,0 +1,199 @@ +======================================================================== +屋里咖啡咖啡机对接一期 - 字典/菜单/权限手工添加清单 (2026-09-18) +======================================================================== + +说明:以下内容需要管理员在对应环境手工添加(UPMS 管理端或直接执行 SQL)。 +SQL 按租户幂等,风格与 db/20260904_add_er_yin_printer.sql 一致,可直接参考执行; +菜单 parent_id 依赖各环境实际菜单主键,请先确认父菜单再执行。 + +------------------------------------------------------------------------ +一、字典项 +------------------------------------------------------------------------ + +1. 设备类型字典 hard_type 增加咖啡机(所有已配置 hard_type 的租户): + +INSERT INTO `pjcloudx`.`sys_dict_item` +(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `remarks`, `del_flag`, `tenant_id`) +SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, d.`id`), + d.`id`, '13', '咖啡机', 'hard_type', '屋里咖啡扫码支付咖啡机', 13, + 'system', NOW(), '屋里咖啡咖啡机', '0', d.`tenant_id` +FROM `pjcloudx`.`sys_dict` d +WHERE d.`dict_type` = 'hard_type' + AND d.`del_flag` = '0' + AND NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_dict_item` i + WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_type' + AND i.`item_value` = '13' AND i.`del_flag` = '0' + ); + +2. 新字典 hard_coffee_stage(咖啡机生命周期阶段): + +-- 先建字典类型(按租户) +INSERT INTO `pjcloudx`.`sys_dict` +(`id`, `dict_type`, `description`, `create_by`, `create_time`, `remarks`, `system_flag`, `del_flag`, `tenant_id`) +SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict`) + ROW_NUMBER() OVER (ORDER BY tenant.`tenant_id`), + 'hard_coffee_stage', '咖啡机生命周期阶段', 'system', NOW(), '咖啡机生命周期阶段', '1', '0', tenant.`tenant_id` +FROM (SELECT DISTINCT `tenant_id` FROM `pjcloudx`.`sys_dict` WHERE `del_flag` = '0') tenant +WHERE NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_dict` d + WHERE d.`tenant_id` = tenant.`tenant_id` AND d.`dict_type` = 'hard_coffee_stage' AND d.`del_flag` = '0' +); + +-- 再建字典项 +INSERT INTO `pjcloudx`.`sys_dict_item` +(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `del_flag`, `tenant_id`) +SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, item.`sort_order`), + d.`id`, item.`item_value`, item.`label`, d.`dict_type`, item.`label`, item.`sort_order`, + 'system', NOW(), '0', d.`tenant_id` +FROM `pjcloudx`.`sys_dict` d +JOIN ( + SELECT 'GET_ORDER' AS `item_value`, '扫码拉取厂商订单' AS `label`, 1 AS `sort_order` + UNION ALL SELECT 'CREATE_ORDER', '创建本地订单', 2 + UNION ALL SELECT 'WALLET_PAY', '饭卡扣款', 3 + UNION ALL SELECT 'NOTIFY_PAY', '通知厂商支付结果', 4 + UNION ALL SELECT 'QUERY', '厂商查单', 5 + UNION ALL SELECT 'REFUND_NOTIFY','通知厂商退款', 6 +) item ON item.`item_value` IS NOT NULL +WHERE d.`dict_type` = 'hard_coffee_stage' + AND d.`del_flag` = '0' + AND NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_dict_item` i + WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_coffee_stage' + AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0' + ); + +3. 新字典 hard_coffee_direction(交互方向): + +INSERT INTO `pjcloudx`.`sys_dict` +(`id`, `dict_type`, `description`, `create_by`, `create_time`, `remarks`, `system_flag`, `del_flag`, `tenant_id`) +SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict`) + ROW_NUMBER() OVER (ORDER BY tenant.`tenant_id`), + 'hard_coffee_direction', '咖啡机日志交互方向', 'system', NOW(), '咖啡机日志交互方向', '1', '0', tenant.`tenant_id` +FROM (SELECT DISTINCT `tenant_id` FROM `pjcloudx`.`sys_dict` WHERE `del_flag` = '0') tenant +WHERE NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_dict` d + WHERE d.`tenant_id` = tenant.`tenant_id` AND d.`dict_type` = 'hard_coffee_direction' AND d.`del_flag` = '0' +); + +INSERT INTO `pjcloudx`.`sys_dict_item` +(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `del_flag`, `tenant_id`) +SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, item.`sort_order`), + d.`id`, item.`item_value`, item.`label`, d.`dict_type`, item.`label`, item.`sort_order`, + 'system', NOW(), '0', d.`tenant_id` +FROM `pjcloudx`.`sys_dict` d +JOIN ( + SELECT '1' AS `item_value`, '我方调厂商' AS `label`, 1 AS `sort_order` + UNION ALL SELECT '2', '厂商调我方', 2 +) item ON item.`item_value` IS NOT NULL +WHERE d.`dict_type` = 'hard_coffee_direction' + AND d.`del_flag` = '0' + AND NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_dict_item` i + WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_coffee_direction' + AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0' + ); + +4. 新字典 hard_coffee_notify_status(通知厂商状态,用于订单列表筛选/展示): + +INSERT INTO `pjcloudx`.`sys_dict` +(`id`, `dict_type`, `description`, `create_by`, `create_time`, `remarks`, `system_flag`, `del_flag`, `tenant_id`) +SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict`) + ROW_NUMBER() OVER (ORDER BY tenant.`tenant_id`), + 'hard_coffee_notify_status', '咖啡机通知厂商状态', 'system', NOW(), '咖啡机通知厂商状态', '1', '0', tenant.`tenant_id` +FROM (SELECT DISTINCT `tenant_id` FROM `pjcloudx`.`sys_dict` WHERE `del_flag` = '0') tenant +WHERE NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_dict` d + WHERE d.`tenant_id` = tenant.`tenant_id` AND d.`dict_type` = 'hard_coffee_notify_status' AND d.`del_flag` = '0' +); + +INSERT INTO `pjcloudx`.`sys_dict_item` +(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `del_flag`, `tenant_id`) +SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, item.`sort_order`), + d.`id`, item.`item_value`, item.`label`, d.`dict_type`, item.`label`, item.`sort_order`, + 'system', NOW(), '0', d.`tenant_id` +FROM `pjcloudx`.`sys_dict` d +JOIN ( + SELECT '0' AS `item_value`, '未通知' AS `label`, 1 AS `sort_order` + UNION ALL SELECT '1', '通知成功', 2 + UNION ALL SELECT '2', '通知失败', 3 +) item ON item.`item_value` IS NOT NULL +WHERE d.`dict_type` = 'hard_coffee_notify_status' + AND d.`del_flag` = '0' + AND NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_dict_item` i + WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_coffee_notify_status' + AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0' + ); + +------------------------------------------------------------------------ +二、菜单与按钮权限 +------------------------------------------------------------------------ + +说明: +- 「咖啡机订单」建议挂在硬件订单管理菜单下(父菜单 permission = hard_hardOrder_view 所在菜单); +- 「咖啡机日志」建议挂在设备管理菜单下(父菜单 permission = hard_hardManager_view 所在菜单); +- 如各环境父菜单定位不同,请先查 parent_id 再执行。 + +1. 咖啡机订单菜单(menu_type 1=菜单,含页面路由,路由地址建议 /hard/coffee-order): + +INSERT INTO `pjcloudx`.`sys_menu` +(`menu_id`, `name`, `permission`, `parent_id`, `path`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`) +SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY parent_menu.`tenant_id`, parent_menu.`menu_id`), + '咖啡机订单', 'hard_hardCoffeeOrder_view', parent_menu.`menu_id`, 'coffee-order', + '1', 30, '0', '0', '1', 'system', NOW(), '0', parent_menu.`tenant_id` +FROM `pjcloudx`.`sys_menu` parent_menu +WHERE parent_menu.`permission` = 'hard_hardOrder_view' AND parent_menu.`del_flag` = '0' + AND NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_menu` existing + WHERE existing.`tenant_id` = parent_menu.`tenant_id` + AND existing.`permission` = 'hard_hardCoffeeOrder_view' AND existing.`del_flag` = '0' + ); + +2. 咖啡机日志菜单: + +INSERT INTO `pjcloudx`.`sys_menu` +(`menu_id`, `name`, `permission`, `parent_id`, `path`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`) +SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY parent_menu.`tenant_id`, parent_menu.`menu_id`), + '咖啡机日志', 'hard_hardCoffeeLog_view', parent_menu.`menu_id`, 'coffee-log', + '1', 31, '0', '0', '1', 'system', NOW(), '0', parent_menu.`tenant_id` +FROM `pjcloudx`.`sys_menu` parent_menu +WHERE parent_menu.`permission` = 'hard_hardManager_view' AND parent_menu.`del_flag` = '0' + AND NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_menu` existing + WHERE existing.`tenant_id` = parent_menu.`tenant_id` + AND existing.`permission` = 'hard_hardCoffeeLog_view' AND existing.`del_flag` = '0' + ); + +------------------------------------------------------------------------ +三、第三方密钥 third_key_manage(第三方库 pjcloudx_thirdparty) +------------------------------------------------------------------------ + +说明:屋里咖啡两对密钥通过 thirdparty 管理端「第三方密钥管理」录入,或按下面模板执行 SQL。 +字段映射:app_key=屋里概念里的 appid,app_secret=屋里概念里的 appkey(SHA1签名密钥)。 +密钥为平台级全局唯一(跨租户读取),建议维护在默认租户(tenant_id=1)下。 +修改密钥后 hardware 侧最多延迟 5 分钟生效(本地缓存)。 + +1. 我方调厂商 getOrder/callback/refund 的鉴权对(bizType=11): + +INSERT INTO `pjcloudx_thirdparty`.`third_key_manage` +(`id`, `name`, `biz_type`, `app_key`, `app_secret`, `status`, `remark`, `create_by`, `create_time`, `del_flag`, `tenant_id`) +VALUES +(2026091800000000001, '屋里咖啡', '11', '<向屋里申请的16位appid>', '<向屋里申请的16位appkey>', '1', + '我方作为第三方支付服务商调厂商接口', 'system', NOW(), '0', 1); + +2. 厂商调我方 queryOrder 查单的验签对(bizType=12,我方自行生成后告知屋里): + +INSERT INTO `pjcloudx_thirdparty`.`third_key_manage` +(`id`, `name`, `biz_type`, `app_key`, `app_secret`, `status`, `remark`, `create_by`, `create_time`, `del_flag`, `tenant_id`) +VALUES +(2026091800000000002, '屋里咖啡查单', '12', '<我方生成并告知屋里的16位appid>', '<我方生成的16位appkey>', '1', + '厂商调我方queryOrder验签', 'system', NOW(), '0', 1); + +------------------------------------------------------------------------ +四、后端权限字符串与接口对应关系(前端页面取用) +------------------------------------------------------------------------ + +hard_hardCoffeeOrder_view: + GET /hard-coffee-manager/order/page 咖啡机订单分页 + GET /hard-coffee-manager/order/{id} 咖啡机订单详情(含生命周期时间线) + +hard_hardCoffeeLog_view: + GET /hard-coffee-manager/log/page 生命周期日志分页 diff --git a/db/20260918_hard_coffee_h5_api.md b/db/20260918_hard_coffee_h5_api.md new file mode 100644 index 00000000..9c1a1d42 --- /dev/null +++ b/db/20260918_hard_coffee_h5_api.md @@ -0,0 +1,72 @@ +# 咖啡机(屋里咖啡)H5支付页对接契约 - 一期饭卡支付 + +> 交付时间: 2026-09-18。H5 页面在前端仓库(biz_zax_app_cloud)实现,本文档为后端接口契约。 + +## 1. 页面入口与二维码 URL + +用户在咖啡机上选择饮品后,机器展示二维码,URL 由屋里咖啡后台配置,指向我方 H5: + +``` +https://www.pjzhhq.cn/h5/#/pages/pay/coffee-pay?order_no={厂商订单号} +``` + +- `order_no`: 厂商订单号,19 位数字(测试环境 18 位)。 +- 屋里咖啡后台的「支付跳转 URL 模板」按上述地址配置,占位符以厂商后台实际支持为准。 + +## 2. 接口(匿名,无需登录态) + +网关前缀: `https://www.pjzhhq.cn/api/hard` (StripPrefix 后到 hardware 服务)。 +所有响应为统一 `R` 结构 `{code:0, msg, data}`,`code != 0` 时展示 `msg`。 + +### 2.1 拉取订单信息 + +``` +GET /api/hard/hard-coffee/order-info?orderNo={order_no} +``` + +响应 `data`: + +| 字段 | 类型 | 说明 | +|---|---|---| +| orderId | long | 本地订单id,**支付时回传** | +| orderNo | string | 厂商订单号 | +| product | string | 产品名称 | +| img | string | 产品图片完整链接 | +| isCold | string | `1`冷饮 `0`热饮 | +| price | int | 价格(分) | +| priceYuan | string | 价格(元,两位小数),直接展示 | +| machineName | string | 机器号 | +| paid | boolean | 是否已支付。`true` 时展示"已支付"并禁止再次支付 | +| remainSeconds | long | 支付剩余秒数(下单起5分钟),用于倒计时,超时提示重新发起 | + +页面逻辑: 进入页面即调用;`paid=true` 显示已支付结果;`remainSeconds<=0` 显示超时。 + +### 2.2 饭卡支付(卡号即扣款) + +``` +POST /api/hard/hard-coffee/card-pay +Content-Type: application/json + +{ "orderId": 1234567890, "cardNo": "0123456789" } +``` + +- 卡号即身份,与消费机刷卡一致,无密码。失败原因见 `msg`(非本单位饭卡/余额不足/订单已超时/订单已支付等)。 + +响应 `data`: + +| 字段 | 类型 | 说明 | +|---|---|---| +| orderId | long | 本地订单id | +| orderNo | string | 厂商订单号 | +| name | string | 支付人姓名(脱敏展示由前端决定) | +| cardNo | string | 饭卡卡号 | +| balanceYuan | string | 支付后余额(元) | + +支付成功后展示"取咖啡"提示;出餐由后端回调厂商完成,前端无需轮询(如需兜底可再次调用 2.1 查看 `paid`)。 + +## 3. 注意事项 + +1. 匿名接口无租户头,后端通过机器号自动定位租户,前端无需传 TENANT-ID。 +2. 同一订单支付窗口 5 分钟,过期后在咖啡机重新发起生成新二维码。 +3. 防重复提交由后端并发锁保证,前端支付按钮加 loading 即可。 +4. 一期仅饭卡支付;微信/支付宝支付为二期,接口预留扩展。 diff --git a/db/20260918_hard_coffee_integration_notes.md b/db/20260918_hard_coffee_integration_notes.md new file mode 100644 index 00000000..4a923992 --- /dev/null +++ b/db/20260918_hard_coffee_integration_notes.md @@ -0,0 +1,52 @@ +# 屋里咖啡咖啡机对接一期 - 联调与部署说明 (2026-09-18) + +## 1. 角色与外部配置 + +我方平台 = 厂商文档中的"第三方支付服务商"。需要在屋里咖啡侧完成配置: + +| 配置项 | 值 | 说明 | +|---|---|---| +| 支付跳转URL模板 | `https://www.pjzhhq.cn/h5/#/pages/pay/coffee-pay?order_no=厂商单号参数` | 用户扫码打开我方H5(前端页面由前端仓库实现) | +| 我方提供的查单地址 | `https://www.pjzhhq.cn/api/hard/hard-coffee/wuli/query-order` | 文档3.4 queryOrder,POST form表单 | +| 向屋里申请 | 我方调 getOrder/callback/refund 的 appid/appkey(16位) | 录入 third_key_manage bizType=11 | +| 向屋里分配 | 厂商调我方 queryOrder 的 appid/appkey(16位) | 录入 third_key_manage bizType=12 | + +## 2. 密钥与配置 + +密钥统一在 **thirdparty 服务 third_key_manage 表**维护(管理端「第三方密钥管理」录入或 SQL,模板见 `20260918_hard_coffee_dict_menu.txt` 第三节): + +| biz_type | 用途 | app_key | app_secret | +|---|---|---|---| +| 11 屋里咖啡 | 我方调 getOrder/callback/refund | 向屋里申请的 appid | 向屋里申请的 appkey(SHA1签名) | +| 12 屋里咖啡查单 | 厂商调我方 queryOrder 验签 | 我方生成并告知屋里的 appid | 我方生成的 appkey | + +密钥为平台级全局唯一(跨租户读取),建议 tenant_id=1;修改后 hardware 侧最多延迟 5 分钟生效(本地缓存)。 + +Nacos 的 pjcloud-hardware-biz 只保留环境参数: + +```yaml +coffee: + base-url: https://test.wulicoffee.com # 生产切 https://www.wulicoffee.com + timeout-millis: 10000 +``` + +## 3. 设备登记(管理端 hard-manager) + +1. 先执行 `db/20260918_add_hard_coffee.sql`(两张新表)。 +2. 管理端添加字典 hard_type=13 咖啡机(见 dict_menu txt 文件)。 +3. 设备管理新增设备: 类型=咖啡机(13), 设备唯一编号=屋里机器号(machine_name), 绑定商户。 +4. 未登记的机器扫码会返回"咖啡机未接入平台"。 + +## 4. 部署顺序 + +1. 执行 SQL: `db/20260918_add_hard_coffee.sql` → 字典/菜单/third_key_manage 密钥(手工,`20260918_hard_coffee_dict_menu.txt`)。 +2. 部署 **pjcloud-thirdparty-biz 先于 pjcloud-hardware-biz**(hardware 通过新增 Feign /third-key-manage/inner/get-by-biz-type 读密钥)。 +3. 前端 H5 加 coffee-pay 页面(契约见 `20260918_hard_coffee_h5_api.md`)。 +4. 在屋里测试环境发起一笔扫码支付联调,验证: 拉单 → 饭卡支付 → callback → 出餐 → 管理端订单/日志。 + +## 5. 运维要点 + +- 支付结果通知厂商失败: hard_coffee_order.wuli_notify_status=2,补偿任务每60秒重试,上限10次(超过需人工介入,管理端订单列表可见)。 +- 退款同步: 管理端售后退款成功后自动按差额通知厂商(refund_sync_amount 记账),补偿任务回看近7天补推缺口。 +- 生命周期日志: hard_coffee_log 按订单号/本地订单/设备关联,管理端"咖啡机订单详情"含完整时间线。 +- 查单验签: 时间戳窗口±5分钟,appid 必须等于 coffee.query-app-id。 diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java index b8889a50..7039cb63 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java @@ -1339,6 +1339,7 @@ public interface ErrorCodes { String APP_MSG_WALLET_DETAIL_INC = "app.msg.wallet.detail.inc"; String APP_MSG_WALLET_DETAIL_DEC = "app.msg.wallet.detail.dec"; String APP_MSG_WALLET_DETAIL_SEP = "app.msg.wallet.detail.sep"; + String APP_MSG_WALLET_DETAIL_REMARK = "app.msg.wallet.detail.remark"; String APP_MSG_WALLET_CONTENT = "app.msg.wallet.content"; String APP_MSG_MEAL_DEFAULT_NAME = "app.msg.meal.default.name"; diff --git a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties index 9f07d6bf..6dae7b3d 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties +++ b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties @@ -443,6 +443,7 @@ app.msg.wallet.default.name=Wallet app.msg.wallet.detail.inc={0} increased by {1}, balance {2} app.msg.wallet.detail.dec={0} decreased by {1}, balance {2} app.msg.wallet.detail.sep=; +app.msg.wallet.detail.remark= (Note: {0}) app.msg.wallet.content={0}: {1} app.msg.meal.default.name=This reserved meal app.msg.meal.content={0} has been verified. Enjoy your meal. diff --git a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties index f6b8a84c..4b59d67c 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties +++ b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties @@ -443,6 +443,7 @@ app.msg.wallet.default.name=钱包 app.msg.wallet.detail.inc={0}增加{1}元,余额{2}元 app.msg.wallet.detail.dec={0}减少{1}元,余额{2}元 app.msg.wallet.detail.sep=; +app.msg.wallet.detail.remark=(备注:{0}) app.msg.wallet.content={0}:{1} app.msg.meal.default.name=本次预约用餐 app.msg.meal.content={0}已核销,祝您用餐愉快。 diff --git a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties index 0ff97006..a41bdfca 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties +++ b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties @@ -443,6 +443,7 @@ app.msg.wallet.default.name=錢包 app.msg.wallet.detail.inc={0}增加{1}元,餘額{2}元 app.msg.wallet.detail.dec={0}減少{1}元,餘額{2}元 app.msg.wallet.detail.sep=; +app.msg.wallet.detail.remark=(備註:{0}) app.msg.wallet.content={0}:{1} app.msg.meal.default.name=本次預約用餐 app.msg.meal.content={0}已核銷,祝您用餐愉快。 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java index d842d863..8de7e697 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java @@ -28,6 +28,8 @@ public enum HardTypeEnum { YYX_WIFI("11", "wifi-云音响"), /** 由绑定的超市或食堂 Android 终端代理驱动的无屏尔印 USB 打印机。 */ EY_PRINT("12", "尔印打印机器"), + /** 屋里咖啡扫码支付咖啡机,机器号写入 uniqueCode。 */ + COFFEE("13", "咖啡机"), ; private final String type; private final String description; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogDirectionEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogDirectionEnum.java new file mode 100644 index 00000000..6ba9e165 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogDirectionEnum.java @@ -0,0 +1,19 @@ +package com.bsd.pjcloud.hard.api.constant.coffee; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 咖啡机日志交互方向 + */ +@AllArgsConstructor +@Getter +public enum HardCoffeeLogDirectionEnum { + + OUT("1", "我方调厂商"), + IN("2", "厂商调我方"), + ; + + private final String direction; + private final String description; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogStageEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogStageEnum.java new file mode 100644 index 00000000..cfb976e0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeLogStageEnum.java @@ -0,0 +1,33 @@ +package com.bsd.pjcloud.hard.api.constant.coffee; + +import cn.hutool.core.util.ObjectUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 咖啡机生命周期日志阶段 + */ +@AllArgsConstructor +@Getter +public enum HardCoffeeLogStageEnum { + + GET_ORDER("GET_ORDER", "扫码拉取厂商订单"), + CREATE_ORDER("CREATE_ORDER", "创建本地订单"), + WALLET_PAY("WALLET_PAY", "饭卡扣款"), + NOTIFY_PAY("NOTIFY_PAY", "通知厂商支付结果"), + QUERY("QUERY", "厂商查单"), + REFUND_NOTIFY("REFUND_NOTIFY", "通知厂商退款"), + ; + + private final String stage; + private final String title; + + public static HardCoffeeLogStageEnum valueOfByStage(String stage) { + if (ObjectUtil.isEmpty(stage)) { + return null; + } + return Arrays.stream(values()).filter(v -> ObjectUtil.equals(stage, v.getStage())).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeNotifyStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeNotifyStatusEnum.java new file mode 100644 index 00000000..762e2f4b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/coffee/HardCoffeeNotifyStatusEnum.java @@ -0,0 +1,20 @@ +package com.bsd.pjcloud.hard.api.constant.coffee; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 支付结果通知厂商状态 + */ +@AllArgsConstructor +@Getter +public enum HardCoffeeNotifyStatusEnum { + + NONE("0", "未通知"), + SUCCESS("1", "通知成功"), + FAIL("2", "通知失败"), + ; + + private final String status; + private final String description; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java index 08295e99..035e10e9 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java @@ -23,6 +23,7 @@ public enum HardOrderTypeEnum { TOC_VISITOR("8", "toc访客购买(不记名用户)"), XFJ("9", "消费机支付"), TAKE_OUT("10", "外卖"), + COFFEE("13", "咖啡机支付"), ; private final String type; private final String description; @@ -34,10 +35,11 @@ public enum HardOrderTypeEnum { // 简单支付(没有购物车) public static final List isSimplePayList = List.of( - XFJ + XFJ, + COFFEE ); - public static final List refundList = List.of(BELOW, RESERVE, FOREIGN_TICKET, ZY, LB, FARMER, XFJ, TAKE_OUT); + public static final List refundList = List.of(BELOW, RESERVE, FOREIGN_TICKET, ZY, LB, FARMER, XFJ, TAKE_OUT, COFFEE); public static final List payList = List.of(BELOW, RESERVE); diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeCardPayDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeCardPayDto.java new file mode 100644 index 00000000..ec4dfd58 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeCardPayDto.java @@ -0,0 +1,30 @@ +package com.bsd.pjcloud.hard.api.dto.coffee; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +/** + * 咖啡机饭卡支付请求(H5匿名) + */ +@Data +@Schema(description = "咖啡机饭卡支付请求") +public class HardCoffeeCardPayDto { + + /** + * 本地订单id(拉单接口返回) + */ + @NotNull(message = "订单id不能为空") + @Schema(description = "本地订单id") + private Long orderId; + + /** + * 饭卡卡号 + */ + @NotBlank(message = "卡号不能为空") + @Pattern(regexp = "^\\d{1,32}$", message = "卡号格式错误") + @Schema(description = "饭卡卡号") + private String cardNo; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeLogDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeLogDto.java new file mode 100644 index 00000000..71f05b77 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeLogDto.java @@ -0,0 +1,20 @@ +package com.bsd.pjcloud.hard.api.dto.coffee; + +import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 咖啡机生命周期日志管理端分页查询 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class HardCoffeeLogDto extends HardCoffeeLogEntity { + + /** + * 开始时间范围 + */ + private LocalDateTime[] startTimeList; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeOrderDto.java new file mode 100644 index 00000000..31d334d8 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeOrderDto.java @@ -0,0 +1,30 @@ +package com.bsd.pjcloud.hard.api.dto.coffee; + +import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 咖啡机订单管理端分页查询 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class HardCoffeeOrderDto extends HardCoffeeOrderEntity { + + /** + * 创建时间范围 + */ + private LocalDateTime[] createTimeList; + + /** + * 本地订单状态(hard_order.order_status) + */ + private String orderStatus; + + /** + * 本地订单支付人id + */ + private Long userId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/WuliQueryOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/WuliQueryOrderDto.java new file mode 100644 index 00000000..2e06c7e1 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/WuliQueryOrderDto.java @@ -0,0 +1,42 @@ +package com.bsd.pjcloud.hard.api.dto.coffee; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 屋里咖啡查单请求(文档3.4 queryOrder,厂商调我方) + */ +@Data +@Schema(description = "屋里咖啡查单请求") +public class WuliQueryOrderDto { + + /** + * 订单号(19位数字) + */ + @Schema(description = "订单号") + private String orderNo; + + /** + * 应用id(我方分配给厂商) + */ + @Schema(description = "应用id") + private String appId; + + /** + * 随机字符串 + */ + @Schema(description = "随机字符串") + private String randstr; + + /** + * Unix时间戳(秒) + */ + @Schema(description = "Unix时间戳") + private String timestamp; + + /** + * 签名 + */ + @Schema(description = "签名") + private String sign; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeLogEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeLogEntity.java new file mode 100644 index 00000000..f293f4bb --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeLogEntity.java @@ -0,0 +1,154 @@ +package com.bsd.pjcloud.hard.api.entity.coffee; + +import com.baomidou.mybatisplus.annotation.*; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 咖啡机使用生命周期日志 + */ +@Data +@TenantTable +@TableName("hard_coffee_log") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "咖啡机生命周期日志") +public class HardCoffeeLogEntity extends Model { + + /** + * 编号 + */ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "编号") + private Long id; + + /** + * 厂商订单号 + */ + @Schema(description = "厂商订单号") + private String orderNo; + + /** + * 本地订单id + */ + @Schema(description = "本地订单id") + private Long orderId; + + /** + * 设备id + */ + @Schema(description = "设备id") + private Long hardId; + + /** + * 设备唯一编号(厂商机器号) + */ + @Schema(description = "设备唯一编号") + private String uniqueCode; + + /** + * 生命周期阶段编码 + */ + @Schema(description = "生命周期阶段编码") + private String stage; + + /** + * 交互方向 1我方调厂商 2厂商调我方 + */ + @Schema(description = "交互方向") + private String direction; + + /** + * 阶段中文说明 + */ + @Schema(description = "阶段说明") + private String title; + + /** + * 请求头 + */ + @Schema(description = "请求头") + private String headers; + + /** + * 请求报文 + */ + @Schema(description = "请求报文") + private String req; + + /** + * 响应报文 + */ + @Schema(description = "响应报文") + private String res; + + /** + * 错误信息 + */ + @Schema(description = "错误信息") + private String error; + + /** + * 开始时间 + */ + @Schema(description = "开始时间") + private LocalDateTime startTime; + + /** + * 结束时间 + */ + @Schema(description = "结束时间") + private LocalDateTime endTime; + + /** + * 耗时(毫秒) + */ + @Schema(description = "耗时(毫秒)") + private Long cost; + + /** + * 创建人 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** + * 修改人 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** + * 修改时间 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** + * 删除标记 + */ + @TableLogic + @TableField(fill = FieldFill.INSERT) + @Schema(description = "删除标记") + private String delFlag; + + /** + * 租户ID + */ + @Schema(description = "租户ID") + private Long tenantId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeOrderEntity.java new file mode 100644 index 00000000..155c4526 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/coffee/HardCoffeeOrderEntity.java @@ -0,0 +1,154 @@ +package com.bsd.pjcloud.hard.api.entity.coffee; + +import com.baomidou.mybatisplus.annotation.*; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 咖啡机厂商订单扩展(屋里咖啡) + */ +@Data +@TenantTable +@TableName("hard_coffee_order") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "咖啡机厂商订单扩展") +public class HardCoffeeOrderEntity extends Model { + + /** + * 编号 + */ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "编号") + private Long id; + + /** + * 本地硬件订单id(hard_order.id) + */ + @Schema(description = "本地订单id") + private Long orderId; + + /** + * 屋里咖啡订单号(19位数字) + */ + @Schema(description = "厂商订单号") + private String orderNo; + + /** + * 咖啡机设备id + */ + @Schema(description = "设备id") + private Long hardId; + + /** + * 厂商机器号快照 + */ + @Schema(description = "厂商机器号") + private String machineName; + + /** + * 产品名称 + */ + @Schema(description = "产品名称") + private String product; + + /** + * 产品图片链接 + */ + @Schema(description = "产品图片链接") + private String img; + + /** + * 1冷饮 0热饮 + */ + @Schema(description = "1冷饮 0热饮") + private String isCold; + + /** + * 厂商订单价格(分) + */ + @Schema(description = "厂商订单价格(分)") + private Integer price; + + /** + * 支付者手机号 + */ + @Schema(description = "支付者手机号") + private String phoneNo; + + /** + * 支付结果通知厂商状态 0未通知 1成功 2失败 + */ + @Schema(description = "通知厂商状态") + private String wuliNotifyStatus; + + /** + * 最近一次通知厂商时间 + */ + @Schema(description = "最近通知时间") + private LocalDateTime wuliNotifyTime; + + /** + * 通知厂商累计尝试次数 + */ + @Schema(description = "通知尝试次数") + private Integer wuliNotifyCount; + + /** + * 累计已同步厂商退款金额(分) + */ + @Schema(description = "累计已同步退款金额(分)") + private Integer refundSyncAmount; + + /** + * 最近一次退款同步时间 + */ + @Schema(description = "最近退款同步时间") + private LocalDateTime refundSyncTime; + + /** + * 创建人 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** + * 修改人 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** + * 修改时间 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** + * 删除标记 + */ + @TableLogic + @TableField(fill = FieldFill.INSERT) + @Schema(description = "删除标记") + private String delFlag; + + /** + * 租户ID + */ + @Schema(description = "租户ID") + private Long tenantId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeLogVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeLogVo.java new file mode 100644 index 00000000..b1dcafe6 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeLogVo.java @@ -0,0 +1,18 @@ +package com.bsd.pjcloud.hard.api.vo.coffee; + +import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 咖啡机生命周期日志 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class HardCoffeeLogVo extends HardCoffeeLogEntity { + + /** + * 设备名称 + */ + private String hardName; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderDetailVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderDetailVo.java new file mode 100644 index 00000000..b9a791e0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderDetailVo.java @@ -0,0 +1,26 @@ +package com.bsd.pjcloud.hard.api.vo.coffee; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 咖啡机订单详情(含生命周期时间线) + */ +@Data +@Schema(description = "咖啡机订单详情") +public class HardCoffeeOrderDetailVo { + + /** + * 订单信息 + */ + @Schema(description = "订单信息") + private HardCoffeeOrderVo order; + + /** + * 生命周期日志(按时间正序) + */ + @Schema(description = "生命周期日志") + private List logs; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderInfoVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderInfoVo.java new file mode 100644 index 00000000..359bc387 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderInfoVo.java @@ -0,0 +1,74 @@ +package com.bsd.pjcloud.hard.api.vo.coffee; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * 咖啡机订单信息(H5匿名拉单返回) + */ +@Data +@Builder +@Schema(description = "咖啡机订单信息") +public class HardCoffeeOrderInfoVo { + + /** + * 本地订单id(支付时回传) + */ + @Schema(description = "本地订单id") + private Long orderId; + + /** + * 厂商订单号 + */ + @Schema(description = "厂商订单号") + private String orderNo; + + /** + * 产品名称 + */ + @Schema(description = "产品名称") + private String product; + + /** + * 产品图片链接 + */ + @Schema(description = "产品图片链接") + private String img; + + /** + * 1冷饮 0热饮 + */ + @Schema(description = "1冷饮 0热饮") + private String isCold; + + /** + * 价格(分) + */ + @Schema(description = "价格(分)") + private Integer price; + + /** + * 价格(元,两位小数) + */ + @Schema(description = "价格(元)") + private String priceYuan; + + /** + * 厂商机器号 + */ + @Schema(description = "厂商机器号") + private String machineName; + + /** + * 是否已支付(已支付时禁止重复支付) + */ + @Schema(description = "是否已支付") + private Boolean paid; + + /** + * 支付剩余秒数(超时后不可支付) + */ + @Schema(description = "支付剩余秒数") + private Long remainSeconds; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderVo.java new file mode 100644 index 00000000..2db48eb0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeeOrderVo.java @@ -0,0 +1,50 @@ +package com.bsd.pjcloud.hard.api.vo.coffee; + +import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 咖啡机订单管理端分页/详情 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class HardCoffeeOrderVo extends HardCoffeeOrderEntity { + + /** + * 设备名称 + */ + private String hardName; + + /** + * 支付人姓名 + */ + private String userName; + + /** + * 本地订单状态 + */ + private String orderStatus; + + /** + * 本地订单支付方式 + */ + private String payType; + + /** + * 支付时间 + */ + private LocalDateTime payTime; + + /** + * 本地订单退款金额(分) + */ + private Integer refundPrice; + + /** + * 本地订单退款状态 + */ + private String refundStatus; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeePayResultVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeePayResultVo.java new file mode 100644 index 00000000..102f09bf --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/HardCoffeePayResultVo.java @@ -0,0 +1,44 @@ +package com.bsd.pjcloud.hard.api.vo.coffee; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * 咖啡机饭卡支付结果(H5匿名) + */ +@Data +@Builder +@Schema(description = "咖啡机饭卡支付结果") +public class HardCoffeePayResultVo { + + /** + * 本地订单id + */ + @Schema(description = "本地订单id") + private Long orderId; + + /** + * 厂商订单号 + */ + @Schema(description = "厂商订单号") + private String orderNo; + + /** + * 支付人姓名 + */ + @Schema(description = "支付人姓名") + private String name; + + /** + * 饭卡卡号 + */ + @Schema(description = "饭卡卡号") + private String cardNo; + + /** + * 支付后余额(元,两位小数) + */ + @Schema(description = "支付后余额(元)") + private String balanceYuan; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/WuliQueryOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/WuliQueryOrderVo.java new file mode 100644 index 00000000..574ee466 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/coffee/WuliQueryOrderVo.java @@ -0,0 +1,42 @@ +package com.bsd.pjcloud.hard.api.vo.coffee; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * 屋里咖啡查单响应(文档3.4 queryOrder)。 + * 厂商协议字段为蛇形命名(return_code/return_msg/phone_no),序列化必须保持一致 + */ +@Data +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@Schema(description = "屋里咖啡查单响应") +public class WuliQueryOrderVo { + + /** + * SUCCESS/FAIL + */ + @Schema(description = "SUCCESS/FAIL") + private String returnCode; + + /** + * 失败原因 + */ + @Schema(description = "失败原因") + private String returnMsg; + + /** + * 支付状态 SUCCESS支付成功/USERPAYING支付中/FAIL失败或关闭 + */ + @Schema(description = "支付状态") + private String status; + + /** + * 支付者手机号 + */ + @Schema(description = "支付者手机号") + private String phoneNo; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/CoffeeConf.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/CoffeeConf.java new file mode 100644 index 00000000..77f8556b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/CoffeeConf.java @@ -0,0 +1,25 @@ +package com.bsd.pjcloud.hard.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 屋里咖啡对接配置。密钥统一在 thirdparty 服务 third_key_manage 表维护 + * (bizType=11 我方调厂商,bizType=12 厂商调我方查单),此处只保留环境相关参数。 + */ +@Component +@ConfigurationProperties(prefix = "coffee") +@Data +public class CoffeeConf { + + /** + * 厂商接口域名,测试环境 https://test.wulicoffee.com,生产环境 https://www.wulicoffee.com + */ + private String baseUrl = "https://test.wulicoffee.com"; + + /** + * HTTP 超时时间(毫秒) + */ + private Integer timeoutMillis = 10000; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java new file mode 100644 index 00000000..1b19092f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java @@ -0,0 +1,50 @@ +package com.bsd.pjcloud.hard.controller.coffee; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderInfoVo; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeePayResultVo; +import com.bsd.pjcloud.hard.service.HardCoffeeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.*; + +/** + * 咖啡机H5匿名支付接口。 + * 用户扫描咖啡机二维码打开我方H5支付页,页面依次调用拉单和饭卡支付接口; + * 匿名安全由厂商订单号随机性+5分钟支付窗口+并发锁保证。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/hard-coffee") +@Tag(description = "hard-coffee", name = "咖啡机H5支付接口") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class HardCoffeeController { + + private final HardCoffeeService hardCoffeeService; + + /** + * 拉取咖啡机订单信息(幂等,已支付订单返回paid=true) + */ + @Operation(summary = "拉取咖啡机订单信息", description = "根据厂商订单号拉单,H5进入页面时调用") + @GetMapping("/order-info") + @Inner(value = false) + public R orderInfo(@RequestParam("orderNo") String orderNo) { + return R.ok(hardCoffeeService.orderInfo(orderNo)); + } + + /** + * 饭卡支付(卡号即身份,与消费机刷卡一致) + */ + @Operation(summary = "咖啡机饭卡支付", description = "卡号即扣款,支付窗口5分钟") + @PostMapping("/card-pay") + @Inner(value = false) + public R cardPay(@Valid @RequestBody HardCoffeeCardPayDto dto) { + return R.ok(hardCoffeeService.cardPay(dto)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java new file mode 100644 index 00000000..f019769a --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java @@ -0,0 +1,64 @@ +package com.bsd.pjcloud.hard.controller.coffee; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeLogDto; +import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeOrderDto; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeLogVo; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderDetailVo; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderVo; +import com.bsd.pjcloud.hard.service.HardCoffeeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 咖啡机订单与生命周期日志管理端查询 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/hard-coffee-manager") +@Tag(description = "hard-coffee-manager", name = "咖啡机订单日志管理") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class HardCoffeeManagerController { + + private final HardCoffeeService hardCoffeeService; + + /** + * 咖啡机订单分页 + */ + @Operation(summary = "咖啡机订单分页", description = "分页查询咖啡机厂商订单") + @GetMapping("/order/page") + @PreAuthorize("@pms.hasPermission('hard_hardCoffeeOrder_view')") + public R> getOrderPage(@ParameterObject Page page, @ParameterObject HardCoffeeOrderDto dto) { + return R.ok(hardCoffeeService.orderPage(page, dto)); + } + + /** + * 咖啡机订单详情(含生命周期时间线) + */ + @Operation(summary = "咖啡机订单详情", description = "订单详情与生命周期日志时间线") + @GetMapping("/order/{id}") + @PreAuthorize("@pms.hasPermission('hard_hardCoffeeOrder_view')") + public R getOrderDetail(@PathVariable("id") Long id) { + return R.ok(hardCoffeeService.orderDetail(id)); + } + + /** + * 生命周期日志分页 + */ + @Operation(summary = "生命周期日志分页", description = "按订单号/设备/阶段/方向筛选") + @GetMapping("/log/page") + @PreAuthorize("@pms.hasPermission('hard_hardCoffeeLog_view')") + public R> getLogPage(@ParameterObject Page page, @ParameterObject HardCoffeeLogDto dto) { + return R.ok(hardCoffeeService.logPage(page, dto)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeWuliController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeWuliController.java new file mode 100644 index 00000000..1ece2369 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeWuliController.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.hard.controller.coffee; + +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.hard.api.dto.coffee.WuliQueryOrderDto; +import com.bsd.pjcloud.hard.api.vo.coffee.WuliQueryOrderVo; +import com.bsd.pjcloud.hard.service.HardCoffeeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 屋里咖啡服务器调我方接口(文档3.4 queryOrder)。 + * 匿名访问+签名校验,响应体字段为厂商协议蛇形命名。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/hard-coffee/wuli") +@Tag(description = "hard-coffee-wuli", name = "屋里咖啡查单接口") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class HardCoffeeWuliController { + + private final HardCoffeeService hardCoffeeService; + + /** + * 厂商主动查单: 校验我方分配的appid/appkey签名后返回订单支付状态 + */ + @Operation(summary = "厂商查单", description = "屋里咖啡服务器调用,form表单提交蛇形参数") + @PostMapping("/query-order") + @Inner(value = false) + public WuliQueryOrderVo queryOrder(HttpServletRequest request) { + // 厂商以form表单提交order_no/appid/randstr/timestamp/sign,手动绑定避免驼峰映射丢失 + WuliQueryOrderDto dto = new WuliQueryOrderDto(); + dto.setOrderNo(request.getParameter("order_no")); + dto.setAppId(request.getParameter("appid")); + dto.setRandstr(request.getParameter("randstr")); + dto.setTimestamp(request.getParameter("timestamp")); + dto.setSign(request.getParameter("sign")); + return hardCoffeeService.wuliQueryOrder(dto); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java new file mode 100644 index 00000000..decc9fac --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java @@ -0,0 +1,36 @@ +package com.bsd.pjcloud.hard.job; + +import com.bsd.pjcloud.hard.service.HardCoffeeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 咖啡机补偿调度器: 重试通知失败的支付结果回调(callback)和退款同步缺口(refund)。 + * 单轮批量受限,失败订单按 wuli_notify_count 上限熔断,超过后需人工在管理端介入。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HardCoffeeNotifyJob { + + /** + * 调度间隔,单位毫秒,固定为 60 秒 + */ + private static final long SCAN_INTERVAL_MILLIS = 60_000L; + + private final HardCoffeeService hardCoffeeService; + + /** + * 定时扫描通知失败与退款同步缺口的咖啡机订单 + */ + @Scheduled(fixedDelay = SCAN_INTERVAL_MILLIS, initialDelay = SCAN_INTERVAL_MILLIS) + public void retryNotifyFailures() { + try { + hardCoffeeService.retryNotifyFailures(); + } catch (Exception e) { + log.error("咖啡机补偿调度异常", e); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeLogMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeLogMapper.java new file mode 100644 index 00000000..39f80bd7 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeLogMapper.java @@ -0,0 +1,10 @@ +package com.bsd.pjcloud.hard.mapper.coffee; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface HardCoffeeLogMapper extends PjcloudBaseMapper { + +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeOrderMapper.java new file mode 100644 index 00000000..b4ab5f62 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/coffee/HardCoffeeOrderMapper.java @@ -0,0 +1,10 @@ +package com.bsd.pjcloud.hard.mapper.coffee; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface HardCoffeeOrderMapper extends PjcloudBaseMapper { + +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeLogService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeLogService.java new file mode 100644 index 00000000..de99a1c0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeLogService.java @@ -0,0 +1,11 @@ +package com.bsd.pjcloud.hard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity; + +/** + * 咖啡机生命周期日志 + */ +public interface HardCoffeeLogService extends IService { + +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeOrderService.java new file mode 100644 index 00000000..5cb31303 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeOrderService.java @@ -0,0 +1,26 @@ +package com.bsd.pjcloud.hard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity; + +/** + * 咖啡机厂商订单扩展 + */ +public interface HardCoffeeOrderService extends IService { + + /** + * 按厂商订单号查询(当前租户上下文) + * + * @param orderNo 厂商订单号 + * @return 扩展订单;不存在返回 null + */ + HardCoffeeOrderEntity getByOrderNo(String orderNo); + + /** + * 按本地订单id查询(当前租户上下文) + * + * @param orderId 本地订单id + * @return 扩展订单;不存在返回 null + */ + HardCoffeeOrderEntity getByOrderId(Long orderId); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java new file mode 100644 index 00000000..581233ef --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java @@ -0,0 +1,83 @@ +package com.bsd.pjcloud.hard.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.dto.UserInfo; +import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto; +import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeLogDto; +import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeOrderDto; +import com.bsd.pjcloud.hard.api.dto.coffee.WuliQueryOrderDto; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderDetailVo; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderInfoVo; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderVo; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeLogVo; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeePayResultVo; +import com.bsd.pjcloud.hard.api.vo.coffee.WuliQueryOrderVo; + +/** + * 咖啡机(屋里咖啡)对接主流程 + */ +public interface HardCoffeeService { + + /** + * H5匿名拉单: 根据厂商订单号调 getOrder 并落本地订单(幂等) + * + * @param orderNo 厂商订单号 + * @return 订单展示信息 + */ + HardCoffeeOrderInfoVo orderInfo(String orderNo); + + /** + * H5匿名饭卡支付: 卡号即身份,扣款成功后通知厂商出餐 + * + * @param dto 订单id+饭卡卡号 + * @return 支付结果(含余额展示) + */ + HardCoffeePayResultVo cardPay(HardCoffeeCardPayDto dto); + + /** + * 厂商主动查单(文档3.4 queryOrder,验签) + * + * @param dto 厂商查单参数 + * @return 查单响应 + */ + WuliQueryOrderVo wuliQueryOrder(WuliQueryOrderDto dto); + + /** + * 本地退款完成后向厂商同步退款(文档3.3 refund,支持分批)。 + * 非咖啡机订单直接返回,零影响 + * + * @param orderId 本地订单id + */ + void syncRefundIfNeeded(Long orderId); + + /** + * 管理端咖啡机订单分页 + */ + Page orderPage(Page page, HardCoffeeOrderDto dto); + + /** + * 管理端生命周期日志分页 + */ + Page logPage(Page page, HardCoffeeLogDto dto); + + /** + * 管理端订单详情(含生命周期时间线) + */ + HardCoffeeOrderDetailVo orderDetail(Long id); + + /** + * 绑定付款人并完成钱包扣款(独立事务,内部通过Spring代理调用保证事务生效) + * + * @param orderId 本地订单id + * @param user 付款人信息 + * @param walletId 钱包id + * @return 扣款完成后的订单 + */ + HardOrderEntity bindAndDeduct(Long orderId, UserInfo user, Long walletId); + + /** + * 补偿: 重试通知失败的支付结果回调 + 补推退款同步缺口 + */ + void retryNotifyFailures(); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeLogServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeLogServiceImpl.java new file mode 100644 index 00000000..2d1b5586 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeLogServiceImpl.java @@ -0,0 +1,21 @@ +package com.bsd.pjcloud.hard.service.impl.coffee; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity; +import com.bsd.pjcloud.hard.mapper.coffee.HardCoffeeLogMapper; +import com.bsd.pjcloud.hard.service.HardCoffeeLogService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +/** + * 咖啡机生命周期日志 + */ +@Validated +@Slf4j +@RequiredArgsConstructor +@Service +public class HardCoffeeLogServiceImpl extends ServiceImpl implements HardCoffeeLogService { + +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeOrderServiceImpl.java new file mode 100644 index 00000000..420cf6ba --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeOrderServiceImpl.java @@ -0,0 +1,35 @@ +package com.bsd.pjcloud.hard.service.impl.coffee; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity; +import com.bsd.pjcloud.hard.mapper.coffee.HardCoffeeOrderMapper; +import com.bsd.pjcloud.hard.service.HardCoffeeOrderService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +/** + * 咖啡机厂商订单扩展 + */ +@Validated +@Slf4j +@RequiredArgsConstructor +@Service +public class HardCoffeeOrderServiceImpl extends ServiceImpl implements HardCoffeeOrderService { + + @Override + public HardCoffeeOrderEntity getByOrderNo(String orderNo) { + return getOne(new LambdaQueryWrapper() + .eq(HardCoffeeOrderEntity::getOrderNo, orderNo) + .last("limit 1")); + } + + @Override + public HardCoffeeOrderEntity getByOrderId(Long orderId) { + return getOne(new LambdaQueryWrapper() + .eq(HardCoffeeOrderEntity::getOrderId, orderId) + .last("limit 1")); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java new file mode 100644 index 00000000..52afc716 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java @@ -0,0 +1,778 @@ +package com.bsd.pjcloud.hard.service.impl.coffee; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.dto.UserInfo; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; +import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogDirectionEnum; +import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogStageEnum; +import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeNotifyStatusEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; +import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto; +import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeLogDto; +import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeOrderDto; +import com.bsd.pjcloud.hard.api.dto.coffee.WuliQueryOrderDto; +import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity; +import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity; +import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeLogVo; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderDetailVo; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderInfoVo; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderVo; +import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeePayResultVo; +import com.bsd.pjcloud.hard.api.vo.coffee.WuliQueryOrderVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; +import com.bsd.pjcloud.hard.service.HardCoffeeLogService; +import com.bsd.pjcloud.hard.service.HardCoffeeOrderService; +import com.bsd.pjcloud.hard.service.HardCoffeeService; +import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardOrderService; +import com.bsd.pjcloud.hard.service.impl.coffee.WuliCoffeeClient.WuliCallContext; +import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 咖啡机(屋里咖啡)对接主流程。 + * 角色说明: 我方平台是厂商文档中的"第三方支付服务商", + * 用户扫咖啡机二维码打开我方H5,由我方拉单、饭卡扣款并回调厂商出餐。 + */ +@Validated +@Slf4j +@RequiredArgsConstructor +@Service +public class HardCoffeeServiceImpl implements HardCoffeeService { + + private static final String TAG = "咖啡机"; + + /** + * 支付窗口Redis Key,拉单时写入、支付时校验,值存厂商订单号 + */ + private static final String PAY_WINDOW_KEY = "hard:coffee:order:non:pay:"; + + /** + * 支付窗口时长(秒),与商户扫码订单保持一致 + */ + private static final long PAY_WINDOW_SECONDS = 300; + + /** + * 拉单并发锁Key前缀 + */ + private static final String CREATE_LOCK_KEY = "hard:coffee:order:create:lock:"; + + /** + * 支付并发锁Key前缀 + */ + private static final String PAY_LOCK_KEY = "hard:coffee:order:pay:lock:"; + + /** + * 并发锁时长(秒) + */ + private static final long LOCK_SECONDS = 10; + + /** + * 厂商订单号格式: 文档为19位数字,测试环境18位,放宽到17-20位兼容 + */ + private static final Pattern ORDER_NO_PATTERN = Pattern.compile("^\\d{17,20}$"); + + /** + * 支付结果通知厂商的最大重试次数,超过后需人工介入 + */ + private static final int NOTIFY_MAX_RETRY = 10; + + /** + * 补偿任务单轮处理的订单量 + */ + private static final int RETRY_BATCH_SIZE = 50; + + /** + * 退款同步缺口回看的订单天数 + */ + private static final int REFUND_SCAN_DAYS = 7; + + private final HardCoffeeOrderService coffeeOrderService; + private final HardCoffeeLogService coffeeLogService; + private final HardManagerService hardManagerService; + private final HardOrderService hardOrderService; + private final WuliCoffeeClient wuliCoffeeClient; + + private final RemoteMchService remoteMchService; + private final RemoteWalletService walletService; + private final RemoteUserService remoteUserService; + + private final StringRedisTemplate redisTemplate; + + /** + * H5匿名拉单: 幂等查询本地扩展订单,不存在时调厂商getOrder并落本地订单。 + * 匿名请求无租户头,机器号定位设备后用 TenantBroker 切换到设备租户落单。 + */ + @Override + public HardCoffeeOrderInfoVo orderInfo(String orderNo) { + if (StrUtil.isBlank(orderNo) || !ORDER_NO_PATTERN.matcher(orderNo).matches()) { + throw new RuntimeException("订单号格式错误"); + } + // 幂等: 已落单直接返回 + HardCoffeeOrderEntity exist = queryAcrossTenants(() -> coffeeOrderService.getByOrderNo(orderNo)); + if (ObjectUtil.isNotEmpty(exist)) { + return buildOrderInfoVo(exist); + } + // 并发锁防止同一厂商订单号重复落单 + Boolean locked = redisTemplate.opsForValue().setIfAbsent(CREATE_LOCK_KEY + orderNo, "1", Duration.ofSeconds(LOCK_SECONDS)); + if (!Boolean.TRUE.equals(locked)) { + throw new RuntimeException("订单处理中,请稍后重试"); + } + try { + exist = queryAcrossTenants(() -> coffeeOrderService.getByOrderNo(orderNo)); + if (ObjectUtil.isNotEmpty(exist)) { + return buildOrderInfoVo(exist); + } + // 拉取厂商订单详情(日志随结果带回,租户解析后落库) + WuliCallContext context = WuliCallContext.builder().orderNo(orderNo).build(); + WuliCoffeeClient.WuliGetOrderResult getOrderResult = wuliCoffeeClient.getOrderWithLog(context); + WuliCoffeeClient.WuliOrderDetail detail = getOrderResult.getDetail(); + if (ObjectUtil.isEmpty(detail.getPrice()) || detail.getPrice() <= 0) { + throw new RuntimeException("厂商订单金额异常"); + } + // 机器号定位咖啡机设备 + HardManagerVo machine = queryAcrossTenants(() -> hardManagerService.getByUniqueCode(detail.getMachineName())); + if (ObjectUtil.isEmpty(machine)) { + throw new RuntimeException("咖啡机未接入平台,机器号:" + detail.getMachineName()); + } + if (!ObjectUtil.equals(HardTypeEnum.COFFEE.getType(), machine.getType())) { + throw new RuntimeException("设备类型不是咖啡机,机器号:" + detail.getMachineName()); + } + Long tenantId = machine.getTenantId(); + // 拉单日志补全租户后落库,保证租户内生命周期时间线完整 + HardCoffeeLogEntity getOrderLog = getOrderResult.getLog(); + if (ObjectUtil.isNotEmpty(getOrderLog)) { + getOrderLog.setTenantId(tenantId); + wuliCoffeeClient.saveLog(getOrderLog); + } + // 设备租户上下文内创建本地订单 + Long orderId = TenantBroker.applyAs(tenantId, tenant -> createLocalOrder(machine, detail)); + // 支付窗口写在匿名默认上下文,与cardPay读取保持一致 + redisTemplate.opsForValue().set(PAY_WINDOW_KEY + orderId, orderNo, Duration.ofSeconds(PAY_WINDOW_SECONDS)); + // 落单阶段日志 + exist = queryAcrossTenants(() -> coffeeOrderService.getByOrderNo(orderNo)); + saveStageLog(exist, HardCoffeeLogStageEnum.CREATE_ORDER, tenantId, null); + return buildOrderInfoVo(exist); + } finally { + redisTemplate.delete(CREATE_LOCK_KEY + orderNo); + } + } + + /** + * 在设备租户上下文内创建 hard_order + hard_coffee_order + */ + private Long createLocalOrder(HardManagerVo machine, WuliCoffeeClient.WuliOrderDetail detail) { + MchManageVo mch = remoteMchService.getById(machine.getMerchantId()).getData(); + if (ObjectUtil.isEmpty(mch)) { + throw new RuntimeException("咖啡机绑定的商户不存在"); + } + HardOrderEntity order = HardOrderEntity.builder() + .userId(-1L) + .orderNo(detail.getOrderNo()) + .merchantId(mch.getId()) + .merchantType(mch.getTypes()) + .merchantDeptId(mch.getDeptId()) + .hardId(machine.getId()) + .sysType(HardTypeEnum.COFFEE.getType()) + .orderStatus(HardOrderStatusEnum.CREATE.getType()) + .orderType(HardOrderTypeEnum.COFFEE.getType()) + .payType(HardPayTypeEnum.WALLET_CARD.getType()) + .totalPrice(detail.getPrice()) + .extraPrice(0) + .payPrice(detail.getPrice()) + .isOfflineOrder(YesNoEnum.NO.getCode()) + .apptUseDate(LocalDate.now()) + .productDesc(detail.getProduct()) + .build(); + hardOrderService.save(order); + + HardCoffeeOrderEntity coffeeOrder = new HardCoffeeOrderEntity(); + coffeeOrder.setOrderId(order.getId()); + coffeeOrder.setOrderNo(detail.getOrderNo()); + coffeeOrder.setHardId(machine.getId()); + coffeeOrder.setMachineName(detail.getMachineName()); + coffeeOrder.setProduct(detail.getProduct()); + coffeeOrder.setImg(detail.getImg()); + coffeeOrder.setIsCold(detail.getIsCold()); + coffeeOrder.setPrice(detail.getPrice()); + coffeeOrder.setWuliNotifyStatus(HardCoffeeNotifyStatusEnum.NONE.getStatus()); + coffeeOrder.setWuliNotifyCount(0); + coffeeOrder.setRefundSyncAmount(0); + coffeeOrder.setTenantId(machine.getTenantId()); + coffeeOrderService.save(coffeeOrder); + return order.getId(); + } + + /** + * H5匿名饭卡支付: 卡号即身份(与消费机刷卡一致)。 + * 扣款在设备租户的本地事务内完成,厂商回调在事务外执行,通知失败不回滚支付,由补偿任务重试。 + */ + @Override + public HardCoffeePayResultVo cardPay(HardCoffeeCardPayDto dto) { + Long orderId = dto.getOrderId(); + String redisKey = PAY_WINDOW_KEY + orderId; + // 支付窗口校验(匿名上下文) + String orderNoInWindow = redisTemplate.opsForValue().get(redisKey); + if (StrUtil.isBlank(orderNoInWindow)) { + throw new RuntimeException("订单已超时,请在咖啡机上重新发起"); + } + // 支付并发锁,防止同一订单重复提交 + Boolean locked = redisTemplate.opsForValue().setIfAbsent(PAY_LOCK_KEY + orderId, "1", Duration.ofSeconds(LOCK_SECONDS)); + if (!Boolean.TRUE.equals(locked)) { + throw new RuntimeException("支付处理中,请勿重复提交"); + } + try { + HardCoffeeOrderEntity coffeeOrder = queryAcrossTenants(() -> coffeeOrderService.getByOrderId(orderId)); + if (ObjectUtil.isEmpty(coffeeOrder)) { + throw new RuntimeException("订单不存在"); + } + Long tenantId = coffeeOrder.getTenantId(); + HardCoffeePayResultVo result = TenantBroker.applyAs(tenantId, tenant -> doCardPay(coffeeOrder, dto)); + // 窗口Key删除必须在匿名上下文执行,与写入时前缀一致 + redisTemplate.delete(redisKey); + return result; + } finally { + redisTemplate.delete(PAY_LOCK_KEY + orderId); + } + } + + /** + * 设备租户上下文内的支付主逻辑 + */ + private HardCoffeePayResultVo doCardPay(HardCoffeeOrderEntity coffeeOrder, HardCoffeeCardPayDto dto) { + Long orderId = coffeeOrder.getOrderId(); + HardOrderEntity order = hardOrderService.getById(orderId); + if (ObjectUtil.isEmpty(order) || !ObjectUtil.equals(HardTypeEnum.COFFEE.getType(), order.getSysType())) { + throw new RuntimeException("订单不存在"); + } + if (!ObjectUtil.equals(HardOrderStatusEnum.CREATE.getType(), order.getOrderStatus())) { + throw new RuntimeException("订单已支付或已关闭"); + } + // 卡号定位钱包(Feign携带当前租户头) + WalletManageEntity wallet = walletService.getWalletByCardNumber(dto.getCardNo()).getData(); + if (ObjectUtil.isEmpty(wallet)) { + throw new RuntimeException("非本单位饭卡"); + } + UserInfo user = remoteUserService.getInnerByUserId(wallet.getUserId()).getData(); + if (ObjectUtil.isEmpty(user) || ObjectUtil.isEmpty(user.getSysUser())) { + throw new RuntimeException("饭卡用户不存在"); + } + // 绑定付款人并扣款(事务),余额不足时抛异常回滚 + HardOrderEntity paidOrder = SpringUtil.getBean(HardCoffeeService.class).bindAndDeduct(orderId, user, wallet.getId()); + saveStageLog(coffeeOrder, HardCoffeeLogStageEnum.WALLET_PAY, coffeeOrder.getTenantId(), + "卡号:" + dto.getCardNo() + ",金额(分):" + paidOrder.getPayPrice()); + + // 事务已提交,通知厂商出餐;失败不回滚,标记后由补偿任务重试 + String phone = user.getSysUser().getPhone(); + boolean notified; + try { + WuliCallContext context = buildCallContext(coffeeOrder); + notified = wuliCoffeeClient.callback(context, "SUCCESS", phone); + } catch (Exception e) { + log.error("|{}|通知厂商支付结果异常,orderId:{},orderNo:{}", TAG, orderId, coffeeOrder.getOrderNo(), e); + notified = false; + } + updateNotifyStatus(coffeeOrder, notified); + + // 回填支付者手机号,供厂商查单展示 + HardCoffeeOrderEntity phoneUpdate = new HardCoffeeOrderEntity(); + phoneUpdate.setId(coffeeOrder.getId()); + phoneUpdate.setPhoneNo(phone); + coffeeOrderService.updateById(phoneUpdate); + + // 支付后余额重新查询展示 + WalletManageEntity latestWallet = walletService.getWalletById(wallet.getId()).getData(); + String balanceYuan = fenToYuan(ObjectUtil.isNotEmpty(latestWallet) ? latestWallet.getBalance() : null); + return HardCoffeePayResultVo.builder() + .orderId(orderId) + .orderNo(coffeeOrder.getOrderNo()) + .name(user.getSysUser().getName()) + .cardNo(wallet.getCardNumber()) + .balanceYuan(balanceYuan) + .build(); + } + + /** + * 绑定付款人并完成钱包扣款。独立事务方法,通过Spring代理调用保证事务生效 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardOrderEntity bindAndDeduct(Long orderId, UserInfo user, Long walletId) { + HardOrderEntity order = hardOrderService.getById(orderId); + if (ObjectUtil.isEmpty(order)) { + throw new RuntimeException("订单不存在"); + } + if (!ObjectUtil.equals(HardOrderStatusEnum.CREATE.getType(), order.getOrderStatus())) { + throw new RuntimeException("订单状态已变化,请刷新重试"); + } + HardOrderEntity bind = HardOrderEntity.builder() + .id(orderId) + .userId(user.getSysUser().getUserId()) + .userDeptId(user.getSysUser().getDeptId()) + .walletId(walletId) + .build(); + hardOrderService.updateById(bind); + order.setUserId(user.getSysUser().getUserId()); + order.setWalletId(walletId); + // 钱包扣款+订单置为已支付(余额不足抛异常整体回滚) + hardOrderService.fastReduceBalance(order); + return hardOrderService.getById(orderId); + } + + /** + * 厂商主动查单(文档3.4): 验签通过后返回本地订单支付状态映射 + */ + @Override + public WuliQueryOrderVo wuliQueryOrder(WuliQueryOrderDto dto) { + WuliQueryOrderVo.WuliQueryOrderVoBuilder builder = WuliQueryOrderVo.builder(); + if (!wuliCoffeeClient.verifyQuerySign(dto.getAppId(), dto.getRandstr(), dto.getTimestamp(), dto.getSign())) { + saveInboundQueryLog(dto, null, "验签失败"); + return builder.returnCode("FAIL").returnMsg("验签失败").build(); + } + HardCoffeeOrderEntity coffeeOrder = queryAcrossTenants(() -> coffeeOrderService.getByOrderNo(dto.getOrderNo())); + if (ObjectUtil.isEmpty(coffeeOrder)) { + saveInboundQueryLog(dto, null, "订单不存在"); + return builder.returnCode("FAIL").returnMsg("订单不存在").build(); + } + HardOrderEntity order = queryAcrossTenants(() -> hardOrderService.getById(coffeeOrder.getOrderId())); + String status = mapQueryStatus(ObjectUtil.isEmpty(order) ? null : order.getOrderStatus()); + saveInboundQueryLog(dto, coffeeOrder, status); + return builder.returnCode("SUCCESS").status(status).phoneNo(coffeeOrder.getPhoneNo()).build(); + } + + /** + * 本地订单状态映射厂商查单状态: 已支付/已核销->SUCCESS,创建中->USERPAYING,退款/取消->FAIL + */ + private String mapQueryStatus(String orderStatus) { + if (ObjectUtil.isEmpty(orderStatus)) { + return "FAIL"; + } + if (List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.CANCEL.getType()).contains(orderStatus)) { + return "SUCCESS"; + } + if (List.of(HardOrderStatusEnum.CREATE.getType(), HardOrderStatusEnum.OFFLINE_CREATE.getType()).contains(orderStatus)) { + return "USERPAYING"; + } + return "FAIL"; + } + + /** + * 本地退款完成后同步通知厂商(文档3.3)。amount为本次差额,累计退款以refund_sync_amount记账 + */ + @Override + public void syncRefundIfNeeded(Long orderId) { + // 当前租户上下文查询;非咖啡机订单直接返回 + HardCoffeeOrderEntity coffeeOrder = coffeeOrderService.getByOrderId(orderId); + if (ObjectUtil.isEmpty(coffeeOrder)) { + return; + } + HardOrderEntity order = hardOrderService.getById(orderId); + if (ObjectUtil.isEmpty(order)) { + return; + } + int refunded = ObjectUtil.defaultIfNull(order.getRefundPrice(), 0); + int synced = ObjectUtil.defaultIfNull(coffeeOrder.getRefundSyncAmount(), 0); + int delta = refunded - synced; + if (delta <= 0) { + return; + } + try { + WuliCallContext context = buildCallContext(coffeeOrder); + boolean ok = wuliCoffeeClient.refund(context, delta); + if (ok) { + HardCoffeeOrderEntity update = new HardCoffeeOrderEntity(); + update.setId(coffeeOrder.getId()); + update.setRefundSyncAmount(refunded); + update.setRefundSyncTime(LocalDateTime.now()); + coffeeOrderService.updateById(update); + } else { + log.warn("|{}|退款同步厂商返回失败,等待补偿重试,orderId:{},差额(分):{}", TAG, orderId, delta); + } + } catch (Exception e) { + log.error("|{}|退款同步厂商异常,等待补偿重试,orderId:{},差额(分):{}", TAG, orderId, delta, e); + } + } + + /** + * 补偿任务: 重试通知失败的支付回调 + 补推退款同步缺口。 + * 定时线程无租户上下文,列表查询天然跨租户,逐单切回订单租户执行 + */ + @Override + public void retryNotifyFailures() { + // 1.支付结果通知失败重试 + List notifyFails = coffeeOrderService.list(new LambdaQueryWrapper() + .eq(HardCoffeeOrderEntity::getWuliNotifyStatus, HardCoffeeNotifyStatusEnum.FAIL.getStatus()) + .lt(HardCoffeeOrderEntity::getWuliNotifyCount, NOTIFY_MAX_RETRY) + .orderByAsc(HardCoffeeOrderEntity::getUpdateTime) + .last("limit " + RETRY_BATCH_SIZE)); + for (HardCoffeeOrderEntity coffeeOrder : notifyFails) { + if (coffeeOrder.getTenantId() == null) { + continue; + } + try { + TenantBroker.runAs(coffeeOrder.getTenantId(), tenant -> retryOneNotify(coffeeOrder)); + } catch (Exception e) { + log.warn("|{}|补偿通知异常,orderId:{}", TAG, coffeeOrder.getOrderId()); + } + } + // 2.退款同步缺口补推(回看近N天订单) + List recent = coffeeOrderService.list(new LambdaQueryWrapper() + .ge(HardCoffeeOrderEntity::getCreateTime, LocalDateTime.now().minusDays(REFUND_SCAN_DAYS)) + .last("limit " + RETRY_BATCH_SIZE)); + for (HardCoffeeOrderEntity coffeeOrder : recent) { + if (coffeeOrder.getTenantId() == null) { + continue; + } + try { + TenantBroker.runAs(coffeeOrder.getTenantId(), tenant -> syncRefundIfNeeded(coffeeOrder.getOrderId())); + } catch (Exception e) { + log.warn("|{}|补偿退款同步异常,orderId:{}", TAG, coffeeOrder.getOrderId()); + } + } + } + + /** + * 重试单笔支付结果通知 + */ + private void retryOneNotify(HardCoffeeOrderEntity coffeeOrder) { + HardOrderEntity order = hardOrderService.getById(coffeeOrder.getOrderId()); + // 只有已支付订单才需要补发通知 + if (ObjectUtil.isEmpty(order) || !ObjectUtil.equals(HardOrderStatusEnum.PAY.getType(), order.getOrderStatus())) { + return; + } + boolean notified; + try { + WuliCallContext context = buildCallContext(coffeeOrder); + notified = wuliCoffeeClient.callback(context, "SUCCESS", coffeeOrder.getPhoneNo()); + } catch (Exception e) { + log.error("|{}|补偿通知厂商异常,orderId:{}", TAG, coffeeOrder.getOrderId(), e); + notified = false; + } + updateNotifyStatus(coffeeOrder, notified); + } + + /** + * 更新通知厂商状态与次数 + */ + private void updateNotifyStatus(HardCoffeeOrderEntity coffeeOrder, boolean notified) { + HardCoffeeOrderEntity update = new HardCoffeeOrderEntity(); + update.setId(coffeeOrder.getId()); + update.setWuliNotifyStatus(notified + ? HardCoffeeNotifyStatusEnum.SUCCESS.getStatus() + : HardCoffeeNotifyStatusEnum.FAIL.getStatus()); + update.setWuliNotifyTime(LocalDateTime.now()); + update.setWuliNotifyCount(ObjectUtil.defaultIfNull(coffeeOrder.getWuliNotifyCount(), 0) + 1); + coffeeOrderService.updateById(update); + } + + /** + * 管理端咖啡机订单分页(当前租户),补齐订单状态/支付人/设备名 + */ + @Override + public Page orderPage(Page page, HardCoffeeOrderDto dto) { + Page entityPage = coffeeOrderService.page(page, buildOrderWrapper(dto)); + Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); + List records = entityPage.getRecords(); + if (ObjectUtil.isEmpty(records)) { + return voPage; + } + Map orderMap = listByIdsMap( + records.stream().map(HardCoffeeOrderEntity::getOrderId).collect(Collectors.toList()), + ids -> hardOrderService.listByIds(ids).stream() + .collect(Collectors.toMap(HardOrderEntity::getId, Function.identity(), (a, b) -> a))); + Map hardNameMap = listByIdsMap( + records.stream().map(HardCoffeeOrderEntity::getHardId).filter(Objects::nonNull).collect(Collectors.toList()), + ids -> hardManagerService.listByIds(ids).stream() + .collect(Collectors.toMap(HardManagerEntity::getId, HardManagerEntity::getName, (a, b) -> a))); + Map userNameMap = listByIdsMap( + orderMap.values().stream().map(HardOrderEntity::getUserId).filter(id -> id != null && id > 0).collect(Collectors.toList()), + ids -> { + R> userResult = remoteUserService.getInnerUserById(ids); + List users = userResult != null && userResult.isOk() && ObjectUtil.isNotEmpty(userResult.getData()) + ? userResult.getData() : List.of(); + return users.stream().collect(Collectors.toMap(SysUser::getUserId, SysUser::getName, (a, b) -> a)); + }); + voPage.setRecords(records.stream().map(entity -> { + HardCoffeeOrderVo vo = BeanUtil.toBean(entity, HardCoffeeOrderVo.class); + HardOrderEntity order = orderMap.get(entity.getOrderId()); + if (ObjectUtil.isNotEmpty(order)) { + vo.setOrderStatus(order.getOrderStatus()); + vo.setPayType(order.getPayType()); + vo.setPayTime(order.getPayTime()); + vo.setRefundPrice(order.getRefundPrice()); + vo.setRefundStatus(order.getRefundStatus()); + if (order.getUserId() != null && order.getUserId() > 0) { + vo.setUserName(userNameMap.get(order.getUserId())); + } + } + if (entity.getHardId() != null) { + vo.setHardName(hardNameMap.get(entity.getHardId())); + } + return vo; + }).collect(Collectors.toList())); + return voPage; + } + + private LambdaQueryWrapper buildOrderWrapper(HardCoffeeOrderDto dto) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (dto == null) { + return wrapper.orderByDesc(HardCoffeeOrderEntity::getId); + } + LocalDateTime createTimeStart = dto.getCreateTimeList() != null && dto.getCreateTimeList().length > 0 ? dto.getCreateTimeList()[0] : null; + LocalDateTime createTimeEnd = dto.getCreateTimeList() != null && dto.getCreateTimeList().length > 1 ? dto.getCreateTimeList()[1] : null; + wrapper.like(StrUtil.isNotBlank(dto.getOrderNo()), HardCoffeeOrderEntity::getOrderNo, dto.getOrderNo()) + .eq(ObjectUtil.isNotEmpty(dto.getHardId()), HardCoffeeOrderEntity::getHardId, dto.getHardId()) + .like(StrUtil.isNotBlank(dto.getMachineName()), HardCoffeeOrderEntity::getMachineName, dto.getMachineName()) + .like(StrUtil.isNotBlank(dto.getPhoneNo()), HardCoffeeOrderEntity::getPhoneNo, dto.getPhoneNo()) + .eq(StrUtil.isNotBlank(dto.getWuliNotifyStatus()), HardCoffeeOrderEntity::getWuliNotifyStatus, dto.getWuliNotifyStatus()) + .ge(createTimeStart != null, HardCoffeeOrderEntity::getCreateTime, createTimeStart) + .le(createTimeEnd != null, HardCoffeeOrderEntity::getCreateTime, createTimeEnd) + .orderByDesc(HardCoffeeOrderEntity::getId); + return wrapper; + } + + /** + * 管理端生命周期日志分页(当前租户) + */ + @Override + public Page logPage(Page page, HardCoffeeLogDto dto) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (dto != null) { + wrapper.like(StrUtil.isNotBlank(dto.getOrderNo()), HardCoffeeLogEntity::getOrderNo, dto.getOrderNo()) + .eq(ObjectUtil.isNotEmpty(dto.getOrderId()), HardCoffeeLogEntity::getOrderId, dto.getOrderId()) + .eq(ObjectUtil.isNotEmpty(dto.getHardId()), HardCoffeeLogEntity::getHardId, dto.getHardId()) + .like(StrUtil.isNotBlank(dto.getUniqueCode()), HardCoffeeLogEntity::getUniqueCode, dto.getUniqueCode()) + .eq(StrUtil.isNotBlank(dto.getStage()), HardCoffeeLogEntity::getStage, dto.getStage()) + .eq(StrUtil.isNotBlank(dto.getDirection()), HardCoffeeLogEntity::getDirection, dto.getDirection()) + .ge(dto.getStartTimeList() != null && dto.getStartTimeList().length > 0, + HardCoffeeLogEntity::getStartTime, dto.getStartTimeList() != null ? dto.getStartTimeList()[0] : null) + .le(dto.getStartTimeList() != null && dto.getStartTimeList().length > 1, + HardCoffeeLogEntity::getStartTime, dto.getStartTimeList() != null ? dto.getStartTimeList()[1] : null); + } + wrapper.orderByDesc(HardCoffeeLogEntity::getId); + Page entityPage = coffeeLogService.page(page, wrapper); + Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); + List records = entityPage.getRecords(); + if (ObjectUtil.isEmpty(records)) { + return voPage; + } + Map hardNameMap = listByIdsMap( + records.stream().map(HardCoffeeLogEntity::getHardId).filter(Objects::nonNull).collect(Collectors.toList()), + ids -> hardManagerService.listByIds(ids).stream() + .collect(Collectors.toMap(HardManagerEntity::getId, HardManagerEntity::getName, (a, b) -> a))); + voPage.setRecords(records.stream().map(entity -> { + HardCoffeeLogVo vo = BeanUtil.toBean(entity, HardCoffeeLogVo.class); + if (entity.getHardId() != null) { + vo.setHardName(hardNameMap.get(entity.getHardId())); + } + return vo; + }).collect(Collectors.toList())); + return voPage; + } + + /** + * 管理端订单详情: 订单信息+生命周期时间线(核心展示:日志与订单紧紧关联) + */ + @Override + public HardCoffeeOrderDetailVo orderDetail(Long id) { + HardCoffeeOrderEntity coffeeOrder = coffeeOrderService.getById(id); + if (ObjectUtil.isEmpty(coffeeOrder)) { + throw new RuntimeException("咖啡机订单不存在"); + } + // 直接复用VO组装逻辑 + HardCoffeeOrderVo vo = BeanUtil.toBean(coffeeOrder, HardCoffeeOrderVo.class); + HardOrderEntity order = hardOrderService.getById(coffeeOrder.getOrderId()); + if (ObjectUtil.isNotEmpty(order)) { + vo.setOrderStatus(order.getOrderStatus()); + vo.setPayType(order.getPayType()); + vo.setPayTime(order.getPayTime()); + vo.setRefundPrice(order.getRefundPrice()); + vo.setRefundStatus(order.getRefundStatus()); + if (order.getUserId() != null && order.getUserId() > 0) { + R> userResult = remoteUserService.getInnerUserById(List.of(order.getUserId())); + if (userResult != null && userResult.isOk() && ObjectUtil.isNotEmpty(userResult.getData())) { + vo.setUserName(userResult.getData().get(0).getName()); + } + } + } + if (coffeeOrder.getHardId() != null) { + HardManagerEntity hard = hardManagerService.getById(coffeeOrder.getHardId()); + if (ObjectUtil.isNotEmpty(hard)) { + vo.setHardName(hard.getName()); + } + } + HardCoffeeOrderDetailVo detail = new HardCoffeeOrderDetailVo(); + detail.setOrder(vo); + detail.setLogs(coffeeLogService.list(new LambdaQueryWrapper() + .eq(HardCoffeeLogEntity::getOrderId, coffeeOrder.getOrderId()) + .orderByAsc(HardCoffeeLogEntity::getStartTime) + .orderByAsc(HardCoffeeLogEntity::getId)) + .stream().map(entity -> BeanUtil.toBean(entity, HardCoffeeLogVo.class)) + .collect(Collectors.toList())); + return detail; + } + + /** + * 构建H5拉单展示VO(已支付订单只返回状态禁止重复支付) + */ + private HardCoffeeOrderInfoVo buildOrderInfoVo(HardCoffeeOrderEntity coffeeOrder) { + HardOrderEntity order = queryAcrossTenants(() -> hardOrderService.getById(coffeeOrder.getOrderId())); + boolean paid = ObjectUtil.isNotEmpty(order) + && !ObjectUtil.equals(HardOrderStatusEnum.CREATE.getType(), order.getOrderStatus()) + && !ObjectUtil.equals(HardOrderStatusEnum.OFFLINE_CREATE.getType(), order.getOrderStatus()); + Long remainSeconds = 0L; + if (!paid) { + String windowKey = PAY_WINDOW_KEY + coffeeOrder.getOrderId(); + Long expire = redisTemplate.getExpire(windowKey); + remainSeconds = expire != null && expire > 0 ? expire : 0L; + } + return HardCoffeeOrderInfoVo.builder() + .orderId(coffeeOrder.getOrderId()) + .orderNo(coffeeOrder.getOrderNo()) + .product(coffeeOrder.getProduct()) + .img(coffeeOrder.getImg()) + .isCold(coffeeOrder.getIsCold()) + .price(coffeeOrder.getPrice()) + .priceYuan(fenToYuan(coffeeOrder.getPrice())) + .machineName(coffeeOrder.getMachineName()) + .paid(paid) + .remainSeconds(remainSeconds) + .build(); + } + + /** + * 厂商查单入站日志(方向=厂商调我方) + */ + private void saveInboundQueryLog(WuliQueryOrderDto dto, HardCoffeeOrderEntity coffeeOrder, String result) { + try { + HardCoffeeLogEntity logger = new HardCoffeeLogEntity(); + logger.setOrderNo(dto.getOrderNo()); + logger.setStage(HardCoffeeLogStageEnum.QUERY.getStage()); + logger.setTitle(HardCoffeeLogStageEnum.QUERY.getTitle()); + logger.setDirection(HardCoffeeLogDirectionEnum.IN.getDirection()); + logger.setReq("appid:" + dto.getAppId() + ",randstr:" + dto.getRandstr() + ",timestamp:" + dto.getTimestamp()); + logger.setRes(result); + logger.setStartTime(LocalDateTime.now()); + if (ObjectUtil.isNotEmpty(coffeeOrder)) { + logger.setOrderId(coffeeOrder.getOrderId()); + logger.setHardId(coffeeOrder.getHardId()); + logger.setUniqueCode(coffeeOrder.getMachineName()); + logger.setTenantId(coffeeOrder.getTenantId()); + } + if (logger.getTenantId() != null) { + TenantBroker.runAs(logger.getTenantId(), tenantId -> coffeeLogService.save(logger)); + } else { + coffeeLogService.save(logger); + } + } catch (Exception e) { + log.error("|{}|保存厂商查单日志失败,orderNo:{}", TAG, dto.getOrderNo(), e); + } + } + + /** + * 保存业务阶段生命周期日志(无HTTP报文的本地阶段) + */ + private void saveStageLog(HardCoffeeOrderEntity coffeeOrder, HardCoffeeLogStageEnum stage, Long tenantId, String extra) { + if (ObjectUtil.isEmpty(coffeeOrder)) { + return; + } + try { + HardCoffeeLogEntity logger = new HardCoffeeLogEntity(); + logger.setOrderNo(coffeeOrder.getOrderNo()); + logger.setOrderId(coffeeOrder.getOrderId()); + logger.setHardId(coffeeOrder.getHardId()); + logger.setUniqueCode(coffeeOrder.getMachineName()); + logger.setStage(stage.getStage()); + logger.setTitle(stage.getTitle()); + logger.setDirection(HardCoffeeLogDirectionEnum.OUT.getDirection()); + logger.setReq(extra); + logger.setRes("OK"); + logger.setStartTime(LocalDateTime.now()); + logger.setTenantId(tenantId); + if (tenantId != null) { + TenantBroker.runAs(tenantId, tenantId2 -> coffeeLogService.save(logger)); + } else { + coffeeLogService.save(logger); + } + } catch (Exception e) { + log.error("|{}|保存阶段日志失败,stage:{},orderNo:{}", TAG, stage, coffeeOrder.getOrderNo(), e); + } + } + + private WuliCallContext buildCallContext(HardCoffeeOrderEntity coffeeOrder) { + return WuliCallContext.builder() + .orderNo(coffeeOrder.getOrderNo()) + .orderId(coffeeOrder.getOrderId()) + .hardId(coffeeOrder.getHardId()) + .uniqueCode(coffeeOrder.getMachineName()) + .tenantId(coffeeOrder.getTenantId()) + .build(); + } + + /** + * 匿名请求默认租户为1,跨租户定位设备/订单时临时跳过租户过滤,结束后恢复原上下文 + */ + private T queryAcrossTenants(Supplier supplier) { + Long origin = TenantContextHolder.getTenantId(); + TenantContextHolder.setTenantSkip(); + try { + return supplier.get(); + } finally { + TenantContextHolder.clear(); + TenantContextHolder.setTenantId(origin); + } + } + + /** + * 批量补齐关联数据,空集合直接返回空Map,避免空IN查询 + */ + private Map listByIdsMap(List ids, Function, Map> loader) { + if (ObjectUtil.isEmpty(ids)) { + return Map.of(); + } + return loader.apply(ids); + } + + private String fenToYuan(Integer fen) { + if (ObjectUtil.isEmpty(fen)) { + return "0"; + } + return new BigDecimal(fen).divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).toPlainString(); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/WuliCoffeeClient.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/WuliCoffeeClient.java new file mode 100644 index 00000000..d2dd6a8b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/WuliCoffeeClient.java @@ -0,0 +1,406 @@ +package com.bsd.pjcloud.hard.service.impl.coffee; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogDirectionEnum; +import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogStageEnum; +import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity; +import com.bsd.pjcloud.hard.config.CoffeeConf; +import com.bsd.pjcloud.hard.service.HardCoffeeLogService; +import com.bsd.pjcloud.thirdparty.api.feign.RemoteThirdKeyManageService; +import com.bsd.pjcloud.thirdparty.constant.ThirdKeyTypeEnum; +import com.bsd.pjcloud.thirdparty.entity.ThirdKeyManageEntity; +import lombok.Builder; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 屋里咖啡开放接口客户端(文档《第三方支付接口说明文档V1.1.1》)。 + * 我方作为第三方支付服务商调用厂商 getOrder/callback/refund; + * 厂商调我方 queryOrder 的验签也在此统一实现。 + * 密钥从 thirdparty 服务 third_key_manage 表读取(本地缓存5分钟),字段映射: + * app_key=屋里appid,app_secret=屋里appkey(SHA1签名密钥)。 + * 每次调用均写入 hard_coffee_log 生命周期日志(方向=我方调厂商)。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WuliCoffeeClient { + + private static final String TAG = "屋里咖啡客户端"; + + /** + * randstr 长度,文档要求 16 位大小写字母或数字 + */ + private static final int RAND_STR_LENGTH = 16; + + /** + * randstr 字符集,大小写字母加数字 + */ + private static final String RAND_STR_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + /** + * 日志报文字段截断长度,对应表字段 varchar(2048) + */ + private static final int LOG_TEXT_MAX_LENGTH = 2000; + + /** + * 厂商查单时间戳允许误差(秒),文档建议 2 分钟,放宽到 5 分钟容忍时钟漂移 + */ + private static final long QUERY_TIMESTAMP_WINDOW_SECONDS = 300; + + /** + * 密钥本地缓存时长(毫秒),修改 third_key_manage 后最多延迟5分钟生效 + */ + private static final long KEY_CACHE_MILLIS = 5 * 60 * 1000L; + + private final CoffeeConf coffeeConf; + private final HardCoffeeLogService hardCoffeeLogService; + private final RemoteThirdKeyManageService thirdKeyManageService; + + private final SecureRandom secureRandom = new SecureRandom(); + + /** + * postWithoutLog 与调用方之间传递日志实体的线程变量,用后即清 + */ + private final ThreadLocal currentLog = new ThreadLocal<>(); + + /** + * 密钥本地缓存: bizType -> 缓存条目(含空值缓存,避免未配置时反复打Feign) + */ + private final Map keyCache = new ConcurrentHashMap<>(); + + /** + * 密钥缓存条目 + */ + private static class KeyCacheEntry { + + private final ThirdKeyManageEntity entity; + private final long expireAt; + + private KeyCacheEntry(ThirdKeyManageEntity entity, long expireAt) { + this.entity = entity; + this.expireAt = expireAt; + } + } + + /** + * 厂商调用上下文,用于补全生命周期日志的订单与设备关联 + */ + @Data + @Builder + public static class WuliCallContext { + + private String orderNo; + private Long orderId; + private Long hardId; + private String uniqueCode; + private Long tenantId; + } + + /** + * 厂商订单详情(getOrder 返回的 order 对象) + */ + @Data + public static class WuliOrderDetail { + + private String orderNo; + private String product; + private Integer price; + private String img; + private String isCold; + private String machineName; + } + + /** + * getOrder 调用结果: 详情 + 已填好的生命周期日志(不自动落库,由调用方在租户解析后落库) + */ + @Data + public static class WuliGetOrderResult { + + private WuliOrderDetail detail; + private HardCoffeeLogEntity log; + } + + /** + * 根据订单号获得订单详情(文档3.1 getOrder)。 + * 调用时机器租户尚未解析,日志随结果返回,由调用方确定租户后调用 {@link #saveLog} 落库 + * + * @param context 调用上下文(此时本地订单尚未建立,只需 orderNo) + * @return 厂商订单详情与日志实体 + */ + public WuliGetOrderResult getOrderWithLog(WuliCallContext context) { + Map params = new HashMap<>(); + params.put("order_no", context.getOrderNo()); + WuliGetOrderResult result = new WuliGetOrderResult(); + try { + JSONObject resJson = postWithoutLog("getOrder", params, context, HardCoffeeLogStageEnum.GET_ORDER); + JSONObject order = resJson.getJSONObject("order"); + if (ObjectUtil.isEmpty(order)) { + throw new RuntimeException("厂商订单信息为空"); + } + WuliOrderDetail detail = new WuliOrderDetail(); + detail.setOrderNo(order.getStr("order_no")); + detail.setProduct(order.getStr("product")); + detail.setPrice(order.getInt("price")); + detail.setImg(order.getStr("img")); + detail.setIsCold(order.getStr("is_cold")); + detail.setMachineName(order.getStr("machine_name")); + result.setDetail(detail); + return result; + } catch (RuntimeException e) { + // 拉单失败时租户仍未知,日志落默认上下文保留失败痕迹 + HardCoffeeLogEntity failLog = currentLog.get(); + if (ObjectUtil.isNotEmpty(failLog)) { + saveLog(failLog); + } + throw e; + } finally { + result.setLog(currentLog.get()); + currentLog.remove(); + } + } + + /** + * 通知厂商支付结果(文档3.2 callback) + * + * @param context 调用上下文 + * @param status 支付状态 SUCCESS/FAIL + * @param phoneNo 支付者手机号,可为空 + * @return 厂商是否确认接收成功 + */ + public boolean callback(WuliCallContext context, String status, String phoneNo) { + Map params = new HashMap<>(); + params.put("order_no", context.getOrderNo()); + params.put("status", status); + if (StrUtil.isNotBlank(phoneNo)) { + params.put("phone_no", phoneNo); + } + JSONObject resJson = post("callback", params, context, HardCoffeeLogStageEnum.NOTIFY_PAY); + return ObjectUtil.equals(resJson.getStr("return_code"), "SUCCESS"); + } + + /** + * 通知厂商退款(文档3.3 refund)。amount 为本次退款金额而非累计金额,允许分批多次调用 + * + * @param context 调用上下文 + * @param amountFen 本次退款金额(分) + * @return 厂商是否确认接收成功 + */ + public boolean refund(WuliCallContext context, int amountFen) { + Map params = new HashMap<>(); + params.put("order_no", context.getOrderNo()); + params.put("amount", String.valueOf(amountFen)); + JSONObject resJson = post("refund", params, context, HardCoffeeLogStageEnum.REFUND_NOTIFY); + return ObjectUtil.equals(resJson.getStr("return_code"), "SUCCESS"); + } + + /** + * 校验厂商调我方 queryOrder 的签名(文档3.4 + 鉴权方法)。 + * appid 必须为 third_key_manage(bizType=12) 配置的 app_key,时间戳误差不超过窗口值, + * sign = sha1(字典序排序(app_secret, timestamp, randstr) 后拼接) + */ + public boolean verifyQuerySign(String appId, String randstr, String timestamp, String sign) { + if (StrUtil.hasBlank(appId, randstr, timestamp, sign)) { + return false; + } + ThirdKeyManageEntity keyConfig = getKeyConfig(ThirdKeyTypeEnum.WULI_COFFEE_QUERY); + if (ObjectUtil.isEmpty(keyConfig) || StrUtil.hasBlank(keyConfig.getAppKey(), keyConfig.getAppSecret())) { + log.error("|{}|查单密钥未配置,请在third_key_manage维护bizType={}", TAG, ThirdKeyTypeEnum.WULI_COFFEE_QUERY.getType()); + return false; + } + if (!ObjectUtil.equals(appId, keyConfig.getAppKey())) { + return false; + } + long ts; + try { + ts = Long.parseLong(timestamp); + } catch (NumberFormatException e) { + return false; + } + long now = System.currentTimeMillis() / 1000; + if (Math.abs(now - ts) > QUERY_TIMESTAMP_WINDOW_SECONDS) { + return false; + } + return ObjectUtil.equals(buildSign(keyConfig.getAppSecret(), timestamp, randstr), sign); + } + + /** + * 带自动落库的统一POST: callback/refund 等租户已知的调用使用 + */ + private JSONObject post(String action, Map bizParams, WuliCallContext context, HardCoffeeLogStageEnum stage) { + HardCoffeeLogEntity logger = initLog(context, stage); + try { + return doPost(action, bizParams, context, stage, logger); + } finally { + saveLog(logger); + } + } + + /** + * 不自动落库的统一POST: getOrder 场景租户未知,日志通过 {@link #currentLog} 带回由调用方落库 + */ + private JSONObject postWithoutLog(String action, Map bizParams, WuliCallContext context, HardCoffeeLogStageEnum stage) { + HardCoffeeLogEntity logger = initLog(context, stage); + try { + JSONObject resJson = doPost(action, bizParams, context, stage, logger); + currentLog.set(logger); + return resJson; + } catch (Exception e) { + currentLog.set(logger); + throw e; + } + } + + /** + * 统一POST: 追加鉴权四参数、发起请求、解析JSON并把请求响应写入生命周期日志。 + * 厂商返回 return_code=FAIL 时抛出异常,由调用方决定补偿策略。 + */ + private JSONObject doPost(String action, Map bizParams, WuliCallContext context, HardCoffeeLogStageEnum stage, HardCoffeeLogEntity logger) { + ThirdKeyManageEntity keyConfig = requireKeyConfig(ThirdKeyTypeEnum.WULI_COFFEE); + Map form = new HashMap<>(bizParams); + String randstr = randomStr(); + String timestamp = String.valueOf(System.currentTimeMillis() / 1000); + form.put("appid", keyConfig.getAppKey()); + form.put("randstr", randstr); + form.put("timestamp", timestamp); + form.put("sign", buildSign(keyConfig.getAppSecret(), timestamp, randstr)); + + logger.setReq(truncate(JSONUtil.toJsonStr(form))); + try { + String url = StrUtil.removeSuffix(coffeeConf.getBaseUrl(), "/") + "/api/order/" + action; + String res = cn.hutool.http.HttpUtil.createPost(url) + .form(form) + .timeout(coffeeConf.getTimeoutMillis()) + .execute() + .body(); + logger.setRes(truncate(res)); + if (StrUtil.isBlank(res)) { + throw new RuntimeException("厂商接口返回为空:" + action); + } + JSONObject resJson = JSONUtil.parseObj(res); + if (!ObjectUtil.equals(resJson.getStr("return_code"), "SUCCESS")) { + throw new RuntimeException("厂商接口返回失败:" + action + "," + resJson.getStr("return_msg")); + } + return resJson; + } catch (Exception e) { + logger.setError(truncate(e.getMessage())); + throw e instanceof RuntimeException re ? re : new RuntimeException(e.getMessage(), e); + } + } + + /** + * 读取屋里咖啡密钥配置,本地缓存5分钟(空值同样缓存)。 + * 匿名扫码场景无租户上下文,密钥按平台级全局唯一维护,由thirdparty跨租户查询 + */ + private ThirdKeyManageEntity getKeyConfig(ThirdKeyTypeEnum type) { + KeyCacheEntry cached = keyCache.get(type.getType()); + long now = System.currentTimeMillis(); + if (cached != null && cached.expireAt > now) { + return cached.entity; + } + ThirdKeyManageEntity entity = null; + try { + R result = thirdKeyManageService.getByBizType(type.getType()); + if (result != null && result.isOk()) { + entity = result.getData(); + } + } catch (Exception e) { + log.error("|{}|读取third_key_manage失败,bizType:{}", TAG, type.getType(), e); + } + keyCache.put(type.getType(), new KeyCacheEntry(entity, now + KEY_CACHE_MILLIS)); + return entity; + } + + /** + * 读取必填密钥配置,未配置时直接抛错阻断调用 + */ + private ThirdKeyManageEntity requireKeyConfig(ThirdKeyTypeEnum type) { + ThirdKeyManageEntity keyConfig = getKeyConfig(type); + if (ObjectUtil.isEmpty(keyConfig) || StrUtil.hasBlank(keyConfig.getAppKey(), keyConfig.getAppSecret())) { + throw new RuntimeException("屋里咖啡密钥未配置,请在third_key_manage维护bizType=" + type.getType()); + } + return keyConfig; + } + + /** + * 签名: 将 appkey、timestamp、randstr 字典序排序后拼接做 sha1。 + * 注意: SHA1 是屋里咖啡接口文档第4章强制规定的签名算法,属于厂商协议约束, + * 此处仅用于参数防篡改,不承担机密性保护,不可单方面更换为 SHA-256。 + */ + private String buildSign(String appKey, String timestamp, String randstr) { + String joined = Stream.of(appKey, timestamp, randstr).sorted().collect(Collectors.joining()); + return SecureUtil.sha1(joined); + } + + /** + * 生成 16 位随机字符串,使用 SecureRandom 保证不可预测 + */ + private String randomStr() { + StringBuilder sb = new StringBuilder(RAND_STR_LENGTH); + for (int i = 0; i < RAND_STR_LENGTH; i++) { + sb.append(RAND_STR_CHARS.charAt(secureRandom.nextInt(RAND_STR_CHARS.length()))); + } + return sb.toString(); + } + + private HardCoffeeLogEntity initLog(WuliCallContext context, HardCoffeeLogStageEnum stage) { + HardCoffeeLogEntity logger = new HardCoffeeLogEntity(); + logger.setOrderNo(context.getOrderNo()); + logger.setOrderId(context.getOrderId()); + logger.setHardId(context.getHardId()); + logger.setUniqueCode(context.getUniqueCode()); + logger.setStage(stage.getStage()); + logger.setTitle(stage.getTitle()); + logger.setDirection(HardCoffeeLogDirectionEnum.OUT.getDirection()); + logger.setTenantId(context.getTenantId()); + logger.setHeaders("url:" + StrUtil.removeSuffix(coffeeConf.getBaseUrl(), "/")); + logger.setStartTime(LocalDateTime.now()); + return logger; + } + + /** + * 日志落库。携带租户id时切换租户上下文写入,保证匿名请求下 tenant_id 正确。 + * getOrder 场景由服务层在机器租户解析后调用 + */ + public void saveLog(HardCoffeeLogEntity logger) { + LocalDateTime now = LocalDateTime.now(); + logger.setEndTime(now); + if (logger.getStartTime() != null) { + logger.setCost(Duration.between(logger.getStartTime(), now).toMillis()); + } + try { + if (logger.getTenantId() != null) { + TenantBroker.runAs(logger.getTenantId(), tenantId -> hardCoffeeLogService.save(logger)); + } else { + hardCoffeeLogService.save(logger); + } + } catch (Exception e) { + // 日志失败不影响主流程 + log.error("|{}|保存生命周期日志失败,stage:{},orderNo:{}", TAG, logger.getStage(), logger.getOrderNo(), e); + } + } + + private String truncate(String text) { + if (text == null) { + return null; + } + return text.length() <= LOG_TEXT_MAX_LENGTH ? text : text.substring(0, LOG_TEXT_MAX_LENGTH); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java index fffd5cbe..f9330745 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java @@ -66,6 +66,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; @@ -143,6 +145,8 @@ public class HardAfterSaleServiceImpl extends ServiceImpl pageJoin(Page page, HardAfterSaleDto dto) { @@ -1664,6 +1668,41 @@ public class HardAfterSaleServiceImpl extends ServiceImpl getByBizType(@RequestParam("bizType") String bizType); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdKeyTypeEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdKeyTypeEnum.java index a58fbcbc..bf070ff7 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdKeyTypeEnum.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdKeyTypeEnum.java @@ -15,6 +15,10 @@ import java.util.Arrays; public enum ThirdKeyTypeEnum { LB("10", "宁波银行"), + /** 屋里咖啡咖啡机: 我方作为第三方支付服务商调厂商 getOrder/callback/refund 的鉴权对。 */ + WULI_COFFEE("11", "屋里咖啡"), + /** 屋里咖啡咖啡机: 厂商调我方 queryOrder 查单的验签对(app_key=我方分配的appid)。 */ + WULI_COFFEE_QUERY("12", "屋里咖啡查单"), ; private final String type; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageDetailDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageDetailDto.java index bd1a6716..426b1d1e 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageDetailDto.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/WalletMessageDetailDto.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.thirdparty.dto.appmessage; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.Data; import java.io.Serializable; @@ -31,6 +32,10 @@ public class WalletMessageDetailDto implements Serializable { @NotNull(message = "钱包结果余额不能为空") private Integer balance; + /** 流水备注快照,由后台操作人员补充业务说明;历史流水和未填写场景允许为空,最多256个字符。 */ + @Size(max = 256, message = "流水备注不能超过256个字符") + private String remark; + /** WalletBizEnum持久化编码,用于服务端再次校验聚合明细一致性。 */ @NotBlank(message = "钱包业务类型不能为空") private String walletBizType; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/ThirdKeyManageController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/ThirdKeyManageController.java index c12a3907..9d394aaf 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/ThirdKeyManageController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/ThirdKeyManageController.java @@ -7,7 +7,9 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.feign.annotation.NoToken; import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.common.security.annotation.Inner; import com.bsd.pjcloud.thirdparty.entity.ThirdKeyManageEntity; import com.bsd.pjcloud.thirdparty.service.ThirdKeyManageService; import org.springframework.security.access.prepost.PreAuthorize; @@ -77,4 +79,14 @@ public class ThirdKeyManageController { return R.ok(thirdKeyManageService.removeBatchByIds(CollUtil.toList(ids))); } + /** + * 内部服务按bizType跨租户读取平台级密钥(咖啡机等匿名设备场景) + */ + @Inner + @NoToken + @GetMapping("/inner/get-by-biz-type") + public R getByBizType(@RequestParam("bizType") String bizType) { + return R.ok(thirdKeyManageService.getGlobalByBizType(bizType)); + } + } \ No newline at end of file diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdKeyManageService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdKeyManageService.java index 25b9ea4d..f549dcb3 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdKeyManageService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdKeyManageService.java @@ -23,6 +23,12 @@ public interface ThirdKeyManageService extends IService { */ ThirdKeyManageEntity get(ThirdKeyTypeEnum type); + /** + * 跨租户按bizType获取平台级key配置(咖啡机等匿名设备场景无租户上下文)。 + * 该类密钥要求全平台唯一,建议统一维护在默认租户下 + */ + ThirdKeyManageEntity getGlobalByBizType(String bizType); + /** * 验签并且返回配置 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/ThirdKeyManageServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/ThirdKeyManageServiceImpl.java index d18ebc97..be0b8c74 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/ThirdKeyManageServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/ThirdKeyManageServiceImpl.java @@ -4,6 +4,7 @@ import cn.hutool.core.util.ObjectUtil; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; import com.bsd.pjcloud.thirdparty.constant.ThirdKeyTypeEnum; import com.bsd.pjcloud.thirdparty.entity.ThirdKeyManageEntity; import com.bsd.pjcloud.thirdparty.mapper.ThirdKeyManageMapper; @@ -58,6 +59,28 @@ public class ThirdKeyManageServiceImpl extends ServiceImpl().eq(ThirdKeyManageEntity::getBizType, type.getType())); } + /** + * 跨租户按bizType获取平台级key配置。咖啡机扫码支付等匿名场景无租户头, + * 该类密钥按平台唯一维护,取id最小的一条保证多副本数据时结果稳定 + */ + @Override + public ThirdKeyManageEntity getGlobalByBizType(String bizType) { + if (ObjectUtil.isEmpty(bizType)) { + return null; + } + Long originTenant = TenantContextHolder.getTenantId(); + TenantContextHolder.setTenantSkip(); + try { + return baseMapper.selectOne(new LambdaQueryWrapper() + .eq(ThirdKeyManageEntity::getBizType, bizType) + .orderByAsc(ThirdKeyManageEntity::getId) + .last("limit 1")); + } finally { + TenantContextHolder.clear(); + TenantContextHolder.setTenantId(originTenant); + } + } + /** * 验签并且返回配置 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java index 7d405b22..6e401b09 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java @@ -440,6 +440,9 @@ public class ThirdAppMessageServiceImpl implements ThirdAppMessageService { ? ErrorCodes.APP_MSG_WALLET_DETAIL_INC : ErrorCodes.APP_MSG_WALLET_DETAIL_DEC; detailsText.append(MsgUtils.getMessage(detailCode, walletName, amount, balance)); + if (StrUtil.isNotBlank(detail.getRemark())) { + detailsText.append(MsgUtils.getMessage(ErrorCodes.APP_MSG_WALLET_DETAIL_REMARK, detail.getRemark().trim())); + } } return MsgUtils.getMessage(ErrorCodes.APP_MSG_WALLET_CONTENT, eventLabel(event), detailsText.toString()); } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletMessageOutboxEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletMessageOutboxEntity.java index 9d09615c..81092719 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletMessageOutboxEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletMessageOutboxEntity.java @@ -63,6 +63,9 @@ public class WalletMessageOutboxEntity implements Serializable { /** 本次变动完成后的余额,单位分。 */ private Integer balance; + /** 流水备注快照,由后台操作人员补充业务说明;历史流水和未填写场景允许为空,最多256个字符。 */ + private String remark; + /** 钱包流水实际创建时间。 */ private LocalDateTime occurredTime; diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletMessageOutboxServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletMessageOutboxServiceImpl.java index 3abebb90..2676a1fa 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletMessageOutboxServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletMessageOutboxServiceImpl.java @@ -263,6 +263,7 @@ public class WalletMessageOutboxServiceImpl implements WalletMessageOutboxServic detail.setWalletName(outbox.getWalletName()); detail.setAmount(outbox.getAmount()); detail.setBalance(outbox.getBalance()); + detail.setRemark(outbox.getRemark()); detail.setWalletBizType(outbox.getSourceBizType()); return detail; } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java index 01e8bc85..68366d4f 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java @@ -243,6 +243,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl