diff --git a/db/20260918_hard_coffee_dict_menu.txt b/db/20260918_hard_coffee_dict_menu.txt index 41f72255..cb2fa7c1 100644 --- a/db/20260918_hard_coffee_dict_menu.txt +++ b/db/20260918_hard_coffee_dict_menu.txt @@ -123,50 +123,81 @@ WHERE d.`dict_type` = 'hard_coffee_notify_status' AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0' ); +5. 第三方密钥类型字典 third_key_type 增加屋里咖啡两项(所有已配置该字典的租户)。 + third_key_manage 表单的「类型」下拉即读此字典,不加则无法在管理端选择咖啡机类型录入密钥。 + item_value 必须与后端 ThirdKeyTypeEnum 一致(11 调用对、12 查单对): + +INSERT INTO `pjcloudx`.`sys_dict_item` +(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `remarks`, `del_flag`, `tenant_id`) +SELECT (SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_dict_item`) + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, item.`sort_order`), + d.`id`, item.`item_value`, item.`label`, d.`dict_type`, item.`label`, item.`sort_order`, + 'system', NOW(), item.`label`, '0', d.`tenant_id` +FROM `pjcloudx`.`sys_dict` d +JOIN ( + SELECT '11' AS `item_value`, '屋里咖啡' AS `label`, 11 AS `sort_order` + UNION ALL SELECT '12', '屋里咖啡查单', 12 +) item ON item.`item_value` IS NOT NULL +WHERE d.`dict_type` = 'third_key_type' + AND d.`del_flag` = '0' + AND NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_dict_item` i + WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'third_key_type' + AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0' + ); + ------------------------------------------------------------------------ -二、菜单与按钮权限 +二、菜单与页面路由 ------------------------------------------------------------------------ -说明: -- 「咖啡机订单」建议挂在硬件订单管理菜单下(父菜单 permission = hard_hardOrder_view 所在菜单); -- 「咖啡机日志」建议挂在设备管理菜单下(父菜单 permission = hard_hardManager_view 所在菜单); -- 如各环境父菜单定位不同,请先查 parent_id 再执行。 +说明(重要): +- 前端页面已实现:咖啡机订单页 views/hard/hard-coffee-order/index.vue、 + 生命周期日志页 views/hard/hard-coffee-log/index.vue; +- 「咖啡机订单」与现有订单管理菜单同级(参照 permission=hard_hardOrder_view 的菜单取父级); +- 「咖啡机日志」与设备管理菜单同级(参照 permission=hard_hardManager_view 的菜单取父级); +- menu_type='0' 为页面菜单,path/component 与前端路由约定一致; +- 页面菜单自身的 permission 即查看权限,无需再单独加按钮。 -1. 咖啡机订单菜单(menu_type 1=菜单,含页面路由,路由地址建议 /hard/coffee-order): +1. 咖啡机订单页面菜单: INSERT INTO `pjcloudx`.`sys_menu` -(`menu_id`, `name`, `permission`, `parent_id`, `path`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`) -SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY parent_menu.`tenant_id`, parent_menu.`menu_id`), - '咖啡机订单', 'hard_hardCoffeeOrder_view', parent_menu.`menu_id`, 'coffee-order', - '1', 30, '0', '0', '1', 'system', NOW(), '0', parent_menu.`tenant_id` -FROM `pjcloudx`.`sys_menu` parent_menu -WHERE parent_menu.`permission` = 'hard_hardOrder_view' AND parent_menu.`del_flag` = '0' +(`menu_id`, `name`, `permission`, `path`, `component`, `parent_id`, `icon`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `tenant_id`) +SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY sibling.`tenant_id`, sibling.`menu_id`), + '咖啡机订单', 'hard_hardCoffeeOrder_view', '/hard/coffee-order', 'hard/hard-coffee-order/index', + sibling.`parent_id`, NULL, '1', 40, '0', '0', '0', + 'system', NOW(), 'system', NOW(), '0', sibling.`tenant_id` +FROM `pjcloudx`.`sys_menu` sibling +WHERE sibling.`permission` = 'hard_hardOrder_view' AND sibling.`del_flag` = '0' AND NOT EXISTS ( SELECT 1 FROM `pjcloudx`.`sys_menu` existing - WHERE existing.`tenant_id` = parent_menu.`tenant_id` + WHERE existing.`tenant_id` = sibling.`tenant_id` AND existing.`permission` = 'hard_hardCoffeeOrder_view' AND existing.`del_flag` = '0' ); -2. 咖啡机日志菜单: +2. 咖啡机日志页面菜单: INSERT INTO `pjcloudx`.`sys_menu` -(`menu_id`, `name`, `permission`, `parent_id`, `path`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`) -SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY parent_menu.`tenant_id`, parent_menu.`menu_id`), - '咖啡机日志', 'hard_hardCoffeeLog_view', parent_menu.`menu_id`, 'coffee-log', - '1', 31, '0', '0', '1', 'system', NOW(), '0', parent_menu.`tenant_id` -FROM `pjcloudx`.`sys_menu` parent_menu -WHERE parent_menu.`permission` = 'hard_hardManager_view' AND parent_menu.`del_flag` = '0' +(`menu_id`, `name`, `permission`, `path`, `component`, `parent_id`, `icon`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `tenant_id`) +SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026091800000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY sibling.`tenant_id`, sibling.`menu_id`), + '咖啡机日志', 'hard_hardCoffeeLog_view', '/hard/coffee-log', 'hard/hard-coffee-log/index', + sibling.`parent_id`, NULL, '1', 41, '0', '0', '0', + 'system', NOW(), 'system', NOW(), '0', sibling.`tenant_id` +FROM `pjcloudx`.`sys_menu` sibling +WHERE sibling.`permission` = 'hard_hardManager_view' AND sibling.`del_flag` = '0' AND NOT EXISTS ( SELECT 1 FROM `pjcloudx`.`sys_menu` existing - WHERE existing.`tenant_id` = parent_menu.`tenant_id` + WHERE existing.`tenant_id` = sibling.`tenant_id` AND existing.`permission` = 'hard_hardCoffeeLog_view' AND existing.`del_flag` = '0' ); +注意:菜单配置更推荐在管理端「菜单管理」页面按上述参数手工添加(组件路径 +hard/hard-coffee-order/index 与 hard/hard-coffee-log/index),SQL 仅为模板参考。 + ------------------------------------------------------------------------ 三、第三方密钥 third_key_manage(第三方库 pjcloudx_thirdparty) ------------------------------------------------------------------------ 说明:屋里咖啡两对密钥通过 thirdparty 管理端「第三方密钥管理」录入,或按下面模板执行 SQL。 +**通过表单录入前必须先完成上面第 5 项 third_key_type 字典项的添加,否则「类型」下拉选不到屋里咖啡。** 字段映射:app_key=屋里概念里的 appid,app_secret=屋里概念里的 appkey(SHA1签名密钥)。 密钥为平台级全局唯一(跨租户读取),建议维护在默认租户(tenant_id=1)下。 修改密钥后 hardware 侧最多延迟 5 分钟生效(本地缓存)。 diff --git a/db/20260918_hard_coffee_h5_api.md b/db/20260918_hard_coffee_h5_api.md index 9c1a1d42..f1b730ee 100644 --- a/db/20260918_hard_coffee_h5_api.md +++ b/db/20260918_hard_coffee_h5_api.md @@ -1,6 +1,6 @@ # 咖啡机(屋里咖啡)H5支付页对接契约 - 一期饭卡支付 -> 交付时间: 2026-09-18。H5 页面在前端仓库(biz_zax_app_cloud)实现,本文档为后端接口契约。 +> 更新: 2026-09-18。**页面已实现**(biz_zax_app_cloud `src/pages/pay/coffee-pay.vue`),本文档保留为接口契约与行为说明。 ## 1. 页面入口与二维码 URL @@ -11,46 +11,53 @@ https://www.pjzhhq.cn/h5/#/pages/pay/coffee-pay?order_no={厂商订单号} ``` - `order_no`: 厂商订单号,19 位数字(测试环境 18 位)。 -- 屋里咖啡后台的「支付跳转 URL 模板」按上述地址配置,占位符以厂商后台实际支持为准。 +- 屋里咖啡后台的「支付跳转 URL 模板」按上述地址配置,占位符拼接方式以屋里后台实际支持为准(联调时确认,若模板系统不支持 hash 路由带参需调整为 query 形式)。 -## 2. 接口(匿名,无需登录态) +## 2. 页面行为(biz_zax_app_cloud 已实现) -网关前缀: `https://www.pjzhhq.cn/api/hard` (StripPrefix 后到 hardware 服务)。 -所有响应为统一 `R` 结构 `{code:0, msg, data}`,`code != 0` 时展示 `msg`。 +页面路由: `/pages/pay/coffee-pay`,pages.json 已注册(pages/pay 分包)。 -### 2.1 拉取订单信息 +**必须登录**: 页面 onLoad 检查登录态,未登录 toast 提示后跳转 `/pages/root/login`(登录后重新扫码进入)。所有支付走登录用户自己的饭卡钱包,无需输卡号。 + +页面流程: +1. 进入即调拉单接口,展示商品图片/名称/冷热/机器号/金额与支付倒计时(5分钟)。 +2. 展示登录用户饭卡余额(钱包类型编码 `1`)。 +3. 点击支付 → 确认弹窗 → 调支付接口 → 页内展示成功结果(支付人/支付后余额/取咖啡提示)。 +4. 订单已支付(paid=true)展示已支付态;倒计时归零展示超时态,禁用支付。 + +## 3. 接口契约 + +网关前缀: `https://www.pjzhhq.cn/api/hard`。响应为统一 `R` 结构,`code != 0` 时展示 `msg`。 + +### 3.1 拉取订单信息(匿名可用) ``` GET /api/hard/hard-coffee/order-info?orderNo={order_no} ``` -响应 `data`: - | 字段 | 类型 | 说明 | |---|---|---| | orderId | long | 本地订单id,**支付时回传** | | orderNo | string | 厂商订单号 | -| product | string | 产品名称 | -| img | string | 产品图片完整链接 | -| isCold | string | `1`冷饮 `0`热饮 | +| product / img / isCold | string | 产品名/图片链接/`1`冷`0`热 | | price | int | 价格(分) | | priceYuan | string | 价格(元,两位小数),直接展示 | | machineName | string | 机器号 | -| paid | boolean | 是否已支付。`true` 时展示"已支付"并禁止再次支付 | -| remainSeconds | long | 支付剩余秒数(下单起5分钟),用于倒计时,超时提示重新发起 | +| paid | boolean | 是否已支付,`true` 时禁止再次支付 | +| remainSeconds | long | 支付剩余秒数(下单起5分钟),超时提示重新发起 | -页面逻辑: 进入页面即调用;`paid=true` 显示已支付结果;`remainSeconds<=0` 显示超时。 - -### 2.2 饭卡支付(卡号即扣款) +### 3.2 饭卡支付(登录态,扣本人饭卡) ``` -POST /api/hard/hard-coffee/card-pay +POST /api/hard/hard-coffee/wallet-pay Content-Type: application/json +Authorization: Bearer {token} -{ "orderId": 1234567890, "cardNo": "0123456789" } +{ "orderId": 1234567890 } ``` -- 卡号即身份,与消费机刷卡一致,无密码。失败原因见 `msg`(非本单位饭卡/余额不足/订单已超时/订单已支付等)。 +- 需登录(OAuth2),未登录返回 401/424 由移动端拦截器跳登录。 +- 扣登录用户饭卡钱包(WalletTypeEnum.EAT),前置校验余额;失败原因见 `msg`(未开通饭卡/余额不足/订单已超时/订单已支付等)。 响应 `data`: @@ -58,15 +65,21 @@ Content-Type: application/json |---|---|---| | orderId | long | 本地订单id | | orderNo | string | 厂商订单号 | -| name | string | 支付人姓名(脱敏展示由前端决定) | +| name | string | 支付人姓名 | | cardNo | string | 饭卡卡号 | | balanceYuan | string | 支付后余额(元) | -支付成功后展示"取咖啡"提示;出餐由后端回调厂商完成,前端无需轮询(如需兜底可再次调用 2.1 查看 `paid`)。 +### 3.3 卡号支付(匿名备用通道,默认不使用) -## 3. 注意事项 +``` +POST /api/hard/hard-coffee/card-pay { "orderId": ..., "cardNo": ... } +``` -1. 匿名接口无租户头,后端通过机器号自动定位租户,前端无需传 TENANT-ID。 +保留作运维备用(卡号即扣,与消费机刷卡一致),正常流程走 3.2。 + +## 4. 注意事项 + +1. 前端固定 TENANT-ID:1,后端通过机器号自动定位设备租户,多租户无需前端处理。 2. 同一订单支付窗口 5 分钟,过期后在咖啡机重新发起生成新二维码。 3. 防重复提交由后端并发锁保证,前端支付按钮加 loading 即可。 -4. 一期仅饭卡支付;微信/支付宝支付为二期,接口预留扩展。 +4. 支付出餐由后端回调厂商完成,前端无需轮询(如需兜底可再调 3.1 查看 `paid`)。 diff --git a/db/20260918_hard_coffee_pay_flow.svg b/db/20260918_hard_coffee_pay_flow.svg new file mode 100644 index 00000000..d13e69fd --- /dev/null +++ b/db/20260918_hard_coffee_pay_flow.svg @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + 咖啡机(屋里咖啡)饭卡支付流程 · 一期 + 我方平台 = 厂商文档中的「第三方支付服务商」 · 图中 ①~⑥ 为生命周期日志阶段编码 + + + + + + + + + 用户(手机) + 咖啡机 / 屋里咖啡平台 + H5 支付页(前端) + hardware 后端 /hard-coffee + wallet 钱包服务 + + + + + + + + + + + + + 1 + + 在咖啡机上选择饮品 + + + 2 + + 生成订单 order_no(19位) + 屏幕展示支付二维码 + + 二维码 URL(屋里后台配置指向我方): + pjzhhq.cn/h5/#/pages/pay/coffee-pay?order_no=… + + + 3 + + 扫码打开我方 H5 支付页 + + + 4 + + GET /hard-coffee/order-info?orderNo + 匿名接口 @Inner(false) · 幂等 + + + 5 + + POST /api/order/getOrder(SHA1 签名:字典序拼接 appkey+timestamp+randstr) + + 返回 order:product · price(分) · img · is_cold · machine_name + + + + 6 + 本地落单(并发锁 + 幂等) + 机器号定位设备(type=13) → TenantBroker 切设备租户 + 建 hard_order(CREATE)+扩展表 · Redis 支付窗口 5min + 生命周期日志 ①GET_ORDER ②CREATE_ORDER + + + 7 + + 订单信息:商品 · 金额(元) · 冷热 · 机器号 · 倒计时 + + + 8 + + 确认支付(页面需登录,未登录先跳登录页;扣本人饭卡) + + + 9 + + POST /hard-coffee/wallet-pay {orderId}(登录态) + 校验 5 分钟窗口 · 支付并发锁 + + + 10 + + getWallet(登录用户, 饭卡)(租户上下文) + + 饭卡钱包 · 余额前置校验(未开通/余额不足报错) + + + + 11 + bindAndDeduct 本地事务扣款 + 绑定付款人 → fastReduceBalance 钱包扣款(force 模式,余额前置校验) + 订单置已支付 PAY(余额不足整体回滚)· 日志 ③WALLET_PAY + + + 12 + + POST /api/order/callback(order_no, SUCCESS, phone_no) + 事务提交后通知(失败不回滚)· 日志 ④NOTIFY_PAY + + 通知失败 → 状态=2,补偿 Job 每 60s 重试(上限 10 次后人工介入) + + + 13 + + 咖啡机出餐 ☕ 用户取咖啡 + + + + + 14 + (异步可选)屋里主动查单:POST /hard-coffee/wuli/query-order(另一对 appid/appkey 验签 + 蛇形参数) + 状态映射:CREATE→USERPAYING · PAY→SUCCESS · 退款/关闭→FAIL · 日志 ⑤QUERY + + + + + + 退款分支(管理端售后触发 · 文档 3.3 refund · 支持分批多次) + + + ① 管理端售后退款(复用 hard_after_sale) + 钱包/渠道退款成功 → 订单退款状态重算 + + + + + ② 事务提交后 syncRefundIfNeeded(差额) + POST /api/order/refund(order_no, 本次退款金额分) + 累计 refund_sync_amount 记账 · 总退款 ≤ 订单支付额校验 + + + + + ③ 厂商确认 + 生命周期日志 + ⑥REFUND_NOTIFY · 厂商侧同步退款状态 + 补偿 Job 每 60s 回看近 7 天订单补推退款缺口 + + diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeWalletPayDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeWalletPayDto.java new file mode 100644 index 00000000..486dc68d --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/coffee/HardCoffeeWalletPayDto.java @@ -0,0 +1,20 @@ +package com.bsd.pjcloud.hard.api.dto.coffee; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 咖啡机饭卡支付请求(H5登录用户) + */ +@Data +@Schema(description = "咖啡机登录用户支付请求") +public class HardCoffeeWalletPayDto { + + /** + * 本地订单id(拉单接口返回) + */ + @NotNull(message = "订单id不能为空") + @Schema(description = "本地订单id") + private Long orderId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java index 1b19092f..7d33512b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java @@ -3,6 +3,7 @@ package com.bsd.pjcloud.hard.controller.coffee; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.security.annotation.Inner; import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto; +import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeWalletPayDto; import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeOrderInfoVo; import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeePayResultVo; import com.bsd.pjcloud.hard.service.HardCoffeeService; @@ -39,7 +40,7 @@ public class HardCoffeeController { } /** - * 饭卡支付(卡号即身份,与消费机刷卡一致) + * 饭卡支付(卡号即身份,与消费机刷卡一致,匿名备用通道) */ @Operation(summary = "咖啡机饭卡支付", description = "卡号即扣款,支付窗口5分钟") @PostMapping("/card-pay") @@ -47,4 +48,14 @@ public class HardCoffeeController { public R cardPay(@Valid @RequestBody HardCoffeeCardPayDto dto) { return R.ok(hardCoffeeService.cardPay(dto)); } + + /** + * 登录用户饭卡支付(默认走此通道): 扣当前登录用户自己的饭卡钱包,无需输卡号。 + * 不加 @Inner(false),走 OAuth2 认证,未登录返回 401 + */ + @Operation(summary = "咖啡机登录用户支付", description = "扣登录用户饭卡钱包,支付窗口5分钟") + @PostMapping("/wallet-pay") + public R walletPay(@Valid @RequestBody HardCoffeeWalletPayDto dto) { + return R.ok(hardCoffeeService.walletPay(dto)); + } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java index f019769a..09a4955e 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeManagerController.java @@ -20,6 +20,8 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + /** * 咖啡机订单与生命周期日志管理端查询 */ @@ -52,6 +54,16 @@ public class HardCoffeeManagerController { return R.ok(hardCoffeeService.orderDetail(id)); } + /** + * 按本地订单id查询生命周期日志(订单售后详情页内嵌时间线用) + */ + @Operation(summary = "按订单查询生命周期日志", description = "咖啡机订单的厂商交互时间线") + @GetMapping("/log/list-by-order/{orderId}") + @PreAuthorize("@pms.hasPermission('hard_hardCoffeeLog_view')") + public R> getLogListByOrderId(@PathVariable("orderId") Long orderId) { + return R.ok(hardCoffeeService.logListByOrderId(orderId)); + } + /** * 生命周期日志分页 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java index 581233ef..10e7f956 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCoffeeService.java @@ -3,6 +3,7 @@ package com.bsd.pjcloud.hard.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.admin.api.dto.UserInfo; import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto; +import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeWalletPayDto; import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeLogDto; import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeOrderDto; import com.bsd.pjcloud.hard.api.dto.coffee.WuliQueryOrderDto; @@ -14,6 +15,8 @@ import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeeLogVo; import com.bsd.pjcloud.hard.api.vo.coffee.HardCoffeePayResultVo; import com.bsd.pjcloud.hard.api.vo.coffee.WuliQueryOrderVo; +import java.util.List; + /** * 咖啡机(屋里咖啡)对接主流程 */ @@ -35,6 +38,14 @@ public interface HardCoffeeService { */ HardCoffeePayResultVo cardPay(HardCoffeeCardPayDto dto); + /** + * H5登录用户饭卡支付: 扣登录用户自己的饭卡钱包,扣款成功后通知厂商出餐 + * + * @param dto 订单id + * @return 支付结果(含余额展示) + */ + HardCoffeePayResultVo walletPay(HardCoffeeWalletPayDto dto); + /** * 厂商主动查单(文档3.4 queryOrder,验签) * @@ -66,6 +77,15 @@ public interface HardCoffeeService { */ HardCoffeeOrderDetailVo orderDetail(Long id); + /** + * 按本地订单id查询生命周期日志(订单售后详情页内嵌时间线用)。 + * 非咖啡机订单返回空集合 + * + * @param orderId 本地订单id(hard_order.id) + * @return 生命周期日志(按时间正序) + */ + List logListByOrderId(Long orderId); + /** * 绑定付款人并完成钱包扣款(独立事务,内部通过Spring代理调用保证事务生效) * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java index 52afc716..49d5ae8c 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java @@ -13,6 +13,8 @@ import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.tenant.TenantBroker; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogDirectionEnum; import com.bsd.pjcloud.hard.api.constant.coffee.HardCoffeeLogStageEnum; @@ -23,6 +25,7 @@ import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto; import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeLogDto; import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeOrderDto; +import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeWalletPayDto; import com.bsd.pjcloud.hard.api.dto.coffee.WuliQueryOrderDto; import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeLogEntity; import com.bsd.pjcloud.hard.api.entity.coffee.HardCoffeeOrderEntity; @@ -41,6 +44,7 @@ import com.bsd.pjcloud.hard.service.HardCoffeeService; import com.bsd.pjcloud.hard.service.HardManagerService; import com.bsd.pjcloud.hard.service.HardOrderService; import com.bsd.pjcloud.hard.service.impl.coffee.WuliCoffeeClient.WuliCallContext; +import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; @@ -240,14 +244,29 @@ public class HardCoffeeServiceImpl implements HardCoffeeService { } /** - * H5匿名饭卡支付: 卡号即身份(与消费机刷卡一致)。 - * 扣款在设备租户的本地事务内完成,厂商回调在事务外执行,通知失败不回滚支付,由补偿任务重试。 + * H5匿名饭卡支付: 卡号即身份(与消费机刷卡一致),保留作备用通道。 + * 扣款在设备租户的本地事务内完成,厂商回调在事务外执行,通知失败不回滚支付,由补偿任务重试 */ @Override public HardCoffeePayResultVo cardPay(HardCoffeeCardPayDto dto) { - Long orderId = dto.getOrderId(); + return payInWindow(dto.getOrderId(), coffeeOrder -> doCardPay(coffeeOrder, dto)); + } + + /** + * H5登录用户饭卡支付: 直接扣登录用户自己的饭卡钱包,无需输卡号 + */ + @Override + public HardCoffeePayResultVo walletPay(HardCoffeeWalletPayDto dto) { + return payInWindow(dto.getOrderId(), this::doWalletPay); + } + + /** + * 支付公共外壳: 支付窗口校验、并发锁、跨租户定位、窗口Key清理。 + * 窗口Key的读写固定在匿名默认上下文,避免租户前缀漂移 + */ + private HardCoffeePayResultVo payInWindow(Long orderId, Function tenantPayer) { String redisKey = PAY_WINDOW_KEY + orderId; - // 支付窗口校验(匿名上下文) + // 支付窗口校验 String orderNoInWindow = redisTemplate.opsForValue().get(redisKey); if (StrUtil.isBlank(orderNoInWindow)) { throw new RuntimeException("订单已超时,请在咖啡机上重新发起"); @@ -262,8 +281,7 @@ public class HardCoffeeServiceImpl implements HardCoffeeService { if (ObjectUtil.isEmpty(coffeeOrder)) { throw new RuntimeException("订单不存在"); } - Long tenantId = coffeeOrder.getTenantId(); - HardCoffeePayResultVo result = TenantBroker.applyAs(tenantId, tenant -> doCardPay(coffeeOrder, dto)); + HardCoffeePayResultVo result = TenantBroker.applyAs(coffeeOrder.getTenantId(), tenant -> tenantPayer.apply(coffeeOrder)); // 窗口Key删除必须在匿名上下文执行,与写入时前缀一致 redisTemplate.delete(redisKey); return result; @@ -273,17 +291,10 @@ public class HardCoffeeServiceImpl implements HardCoffeeService { } /** - * 设备租户上下文内的支付主逻辑 + * 设备租户上下文内的卡号支付主逻辑 */ private HardCoffeePayResultVo doCardPay(HardCoffeeOrderEntity coffeeOrder, HardCoffeeCardPayDto dto) { - Long orderId = coffeeOrder.getOrderId(); - HardOrderEntity order = hardOrderService.getById(orderId); - if (ObjectUtil.isEmpty(order) || !ObjectUtil.equals(HardTypeEnum.COFFEE.getType(), order.getSysType())) { - throw new RuntimeException("订单不存在"); - } - if (!ObjectUtil.equals(HardOrderStatusEnum.CREATE.getType(), order.getOrderStatus())) { - throw new RuntimeException("订单已支付或已关闭"); - } + HardOrderEntity order = requireUnpaidCoffeeOrder(coffeeOrder); // 卡号定位钱包(Feign携带当前租户头) WalletManageEntity wallet = walletService.getWalletByCardNumber(dto.getCardNo()).getData(); if (ObjectUtil.isEmpty(wallet)) { @@ -293,10 +304,66 @@ public class HardCoffeeServiceImpl implements HardCoffeeService { if (ObjectUtil.isEmpty(user) || ObjectUtil.isEmpty(user.getSysUser())) { throw new RuntimeException("饭卡用户不存在"); } + return payWithWallet(coffeeOrder, order, wallet, user); + } + + /** + * 设备租户上下文内的登录用户支付主逻辑: 扣当前登录用户自己的饭卡钱包 + */ + private HardCoffeePayResultVo doWalletPay(HardCoffeeOrderEntity coffeeOrder) { + HardOrderEntity order = requireUnpaidCoffeeOrder(coffeeOrder); + PjcloudUser principal = SecurityUtils.getUser(); + if (ObjectUtil.isEmpty(principal) || ObjectUtil.isEmpty(principal.getId())) { + throw new RuntimeException("用户未登录"); + } + WalletManageEntity wallet = walletService.getWallet(principal.getId(), WalletTypeEnum.EAT).getData(); + if (ObjectUtil.isEmpty(wallet)) { + throw new RuntimeException("您还未开通饭卡钱包"); + } + UserInfo user = remoteUserService.getInnerByUserId(principal.getId()).getData(); + if (ObjectUtil.isEmpty(user) || ObjectUtil.isEmpty(user.getSysUser())) { + throw new RuntimeException("饭卡用户不存在"); + } + return payWithWallet(coffeeOrder, order, wallet, user); + } + + /** + * 校验订单存在且为未支付的咖啡机订单 + */ + private HardOrderEntity requireUnpaidCoffeeOrder(HardCoffeeOrderEntity coffeeOrder) { + HardOrderEntity order = hardOrderService.getById(coffeeOrder.getOrderId()); + if (ObjectUtil.isEmpty(order) || !ObjectUtil.equals(HardTypeEnum.COFFEE.getType(), order.getSysType())) { + throw new RuntimeException("订单不存在"); + } + if (!ObjectUtil.equals(HardOrderStatusEnum.CREATE.getType(), order.getOrderStatus())) { + throw new RuntimeException("订单已支付或已关闭"); + } + return order; + } + + /** + * 余额前置校验。钱包扣款使用force模式,余额是否充足由调用方负责校验 + */ + private void checkBalanceEnough(WalletManageEntity wallet, HardOrderEntity order) { + int balance = ObjectUtil.defaultIfNull(wallet.getBalance(), 0); + int payPrice = ObjectUtil.defaultIfNull(order.getPayPrice(), 0); + if (balance < payPrice) { + throw new RuntimeException("饭卡余额不足"); + } + } + + /** + * 绑定付款人并完成钱包扣款(事务),再通知厂商出餐。 + * 通知在本地事务提交后执行,失败不回滚支付,标记后由补偿任务重试 + */ + private HardCoffeePayResultVo payWithWallet(HardCoffeeOrderEntity coffeeOrder, HardOrderEntity order, WalletManageEntity wallet, UserInfo user) { + Long orderId = coffeeOrder.getOrderId(); + // force扣款前的余额充足性校验 + checkBalanceEnough(wallet, order); // 绑定付款人并扣款(事务),余额不足时抛异常回滚 HardOrderEntity paidOrder = SpringUtil.getBean(HardCoffeeService.class).bindAndDeduct(orderId, user, wallet.getId()); saveStageLog(coffeeOrder, HardCoffeeLogStageEnum.WALLET_PAY, coffeeOrder.getTenantId(), - "卡号:" + dto.getCardNo() + ",金额(分):" + paidOrder.getPayPrice()); + "卡号:" + wallet.getCardNumber() + ",金额(分):" + paidOrder.getPayPrice()); // 事务已提交,通知厂商出餐;失败不回滚,标记后由补偿任务重试 String phone = user.getSysUser().getPhone(); @@ -429,6 +496,23 @@ public class HardCoffeeServiceImpl implements HardCoffeeService { } } + /** + * 按本地订单id查询生命周期日志(订单售后详情页内嵌时间线用) + */ + @Override + public List logListByOrderId(Long orderId) { + HardCoffeeOrderEntity coffeeOrder = coffeeOrderService.getByOrderId(orderId); + if (ObjectUtil.isEmpty(coffeeOrder)) { + return List.of(); + } + return coffeeLogService.list(new LambdaQueryWrapper() + .eq(HardCoffeeLogEntity::getOrderId, orderId) + .orderByAsc(HardCoffeeLogEntity::getStartTime) + .orderByAsc(HardCoffeeLogEntity::getId)) + .stream().map(entity -> BeanUtil.toBean(entity, HardCoffeeLogVo.class)) + .collect(Collectors.toList()); + } + /** * 补偿任务: 重试通知失败的支付回调 + 补推退款同步缺口。 * 定时线程无租户上下文,列表查询天然跨租户,逐单切回订单租户执行