feat:咖啡机

This commit is contained in:
dzc committed 2026-09-18 14:38:29 +08:00
1 parent 8aa2cfa104
commit 035ae7fbd7
8 files changed
+407 -62

No files matched your search

+52 -21
View File
@@ -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 分钟生效(本地缓存)。
+37 -24
View File
@@ -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<T>` 结构 `{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<T>` 结构,`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`)。
+154
View File
@@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="1150" viewBox="0 0 1280 1150" font-family="'PingFang SC','Microsoft YaHei','Helvetica Neue',sans-serif">
<defs>
<marker id="aBlue" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#409EFF"/></marker>
<marker id="aGreen" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#67C23A"/></marker>
<marker id="aOrange" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#E6A23C"/></marker>
<marker id="aRed" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#F56C6C"/></marker>
<marker id="aGray" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#909399"/></marker>
</defs>
<rect x="0" y="0" width="1280" height="1150" fill="#F5F7FA"/>
<!-- 标题 -->
<text x="640" y="38" text-anchor="middle" font-size="22" font-weight="700" fill="#303133">咖啡机(屋里咖啡)饭卡支付流程 · 一期</text>
<text x="640" y="60" text-anchor="middle" font-size="12" fill="#909399">我方平台 = 厂商文档中的「第三方支付服务商」 · 图中 ①~⑥ 为生命周期日志阶段编码</text>
<!-- 泳道 -->
<g font-size="14" font-weight="600" text-anchor="middle">
<rect x="20" y="80" width="170" height="790" rx="8" fill="#ECF5FF" stroke="#B3D8FF"/>
<rect x="200" y="80" width="250" height="790" rx="8" fill="#FDF6EC" stroke="#F5DAB6"/>
<rect x="460" y="80" width="200" height="790" rx="8" fill="#F0F9EB" stroke="#C2E7B0"/>
<rect x="670" y="80" width="360" height="790" rx="8" fill="#F4F4F5" stroke="#D3D4D6"/>
<rect x="1040" y="80" width="220" height="790" rx="8" fill="#F4F4F5" stroke="#D3D4D6"/>
<rect x="20" y="80" width="170" height="34" rx="8" fill="#409EFF"/><text x="105" y="102" fill="#fff">用户(手机)</text>
<rect x="200" y="80" width="250" height="34" rx="8" fill="#E6A23C"/><text x="325" y="102" fill="#fff">咖啡机 / 屋里咖啡平台</text>
<rect x="460" y="80" width="200" height="34" rx="8" fill="#67C23A"/><text x="560" y="102" fill="#fff">H5 支付页(前端)</text>
<rect x="670" y="80" width="360" height="34" rx="8" fill="#303133"/><text x="850" y="102" fill="#fff">hardware 后端 /hard-coffee</text>
<rect x="1040" y="80" width="220" height="34" rx="8" fill="#303133"/><text x="1150" y="102" fill="#fff">wallet 钱包服务</text>
</g>
<!-- 生命线 -->
<g stroke="#C0C4CC" stroke-dasharray="4 4" stroke-width="1">
<line x1="105" y1="114" x2="105" y2="870"/>
<line x1="325" y1="114" x2="325" y2="870"/>
<line x1="560" y1="114" x2="560" y2="870"/>
<line x1="850" y1="114" x2="850" y2="870"/>
<line x1="1150" y1="114" x2="1150" y2="870"/>
</g>
<!-- ① 用户选饮品 -->
<circle cx="105" cy="150" r="11" fill="#409EFF"/><text x="105" y="154" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">1</text>
<line x1="116" y1="150" x2="324" y2="150" stroke="#409EFF" stroke-width="1.6" marker-end="url(#aBlue)"/>
<text x="220" y="142" text-anchor="middle" font-size="12" fill="#409EFF">在咖啡机上选择饮品</text>
<!-- ② 咖啡机生成订单+二维码 -->
<circle cx="325" cy="200" r="11" fill="#E6A23C"/><text x="325" y="204" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">2</text>
<rect x="240" y="212" width="170" height="46" rx="6" fill="#fff" stroke="#E6A23C"/>
<text x="325" y="230" text-anchor="middle" font-size="11" fill="#606266">生成订单 order_no(19位)</text>
<text x="325" y="248" text-anchor="middle" font-size="11" fill="#606266">屏幕展示支付二维码</text>
<rect x="420" y="186" width="330" height="34" rx="6" fill="#FEF0F0" stroke="#FBC4C4"/>
<text x="585" y="201" text-anchor="middle" font-size="10.5" fill="#F56C6C">二维码 URL(屋里后台配置指向我方):</text>
<text x="585" y="215" text-anchor="middle" font-size="10" fill="#F56C6C">pjzhhq.cn/h5/#/pages/pay/coffee-pay?order_no=…</text>
<!-- ③ 扫码 -->
<circle cx="105" cy="290" r="11" fill="#409EFF"/><text x="105" y="294" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">3</text>
<line x1="116" y1="290" x2="559" y2="290" stroke="#409EFF" stroke-width="1.6" marker-end="url(#aBlue)"/>
<text x="338" y="282" text-anchor="middle" font-size="12" fill="#409EFF">扫码打开我方 H5 支付页</text>
<!-- ④ order-info -->
<circle cx="560" cy="338" r="11" fill="#67C23A"/><text x="560" y="342" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">4</text>
<line x1="571" y1="338" x2="849" y2="338" stroke="#67C23A" stroke-width="1.6" marker-end="url(#aGreen)"/>
<text x="710" y="330" text-anchor="middle" font-size="12" fill="#67C23A">GET /hard-coffee/order-info?orderNo</text>
<text x="710" y="353" text-anchor="middle" font-size="10" fill="#909399">匿名接口 @Inner(false) · 幂等</text>
<!-- ⑤ getOrder -->
<circle cx="850" cy="398" r="11" fill="#409EFF"/><text x="850" y="402" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">5</text>
<line x1="839" y1="398" x2="326" y2="398" stroke="#409EFF" stroke-width="1.6" marker-end="url(#aBlue)"/>
<text x="583" y="390" text-anchor="middle" font-size="12" fill="#409EFF">POST /api/order/getOrder(SHA1 签名:字典序拼接 appkey+timestamp+randstr)</text>
<line x1="326" y1="418" x2="839" y2="418" stroke="#909399" stroke-width="1.2" stroke-dasharray="6 4" marker-end="url(#aGray)"/>
<text x="583" y="412" text-anchor="middle" font-size="11" fill="#909399">返回 order:product · price(分) · img · is_cold · machine_name</text>
<!-- ⑥ 落单活动框 -->
<rect x="700" y="438" width="300" height="72" rx="8" fill="#fff" stroke="#409EFF"/>
<circle cx="714" cy="452" r="11" fill="#409EFF"/><text x="714" y="456" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">6</text>
<text x="858" y="456" text-anchor="middle" font-size="11.5" fill="#303133" font-weight="600">本地落单(并发锁 + 幂等)</text>
<text x="858" y="474" text-anchor="middle" font-size="10.5" fill="#606266">机器号定位设备(type=13) → TenantBroker 切设备租户</text>
<text x="858" y="490" text-anchor="middle" font-size="10.5" fill="#606266">建 hard_order(CREATE)+扩展表 · Redis 支付窗口 5min</text>
<text x="858" y="505" text-anchor="middle" font-size="10.5" fill="#909399">生命周期日志 ①GET_ORDER ②CREATE_ORDER</text>
<!-- ⑦ 返回订单信息 -->
<circle cx="560" cy="532" r="11" fill="#67C23A"/><text x="560" y="536" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">7</text>
<line x1="839" y1="532" x2="571" y2="532" stroke="#67C23A" stroke-width="1.6" marker-end="url(#aGreen)"/>
<text x="705" y="524" text-anchor="middle" font-size="12" fill="#67C23A">订单信息:商品 · 金额(元) · 冷热 · 机器号 · 倒计时</text>
<!-- ⑧ 确认支付 -->
<circle cx="105" cy="572" r="11" fill="#409EFF"/><text x="105" y="576" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">8</text>
<line x1="116" y1="572" x2="559" y2="572" stroke="#409EFF" stroke-width="1.6" marker-end="url(#aBlue)"/>
<text x="338" y="564" text-anchor="middle" font-size="12" fill="#409EFF">确认支付(页面需登录,未登录先跳登录页;扣本人饭卡)</text>
<!-- ⑨ wallet-pay -->
<circle cx="560" cy="612" r="11" fill="#67C23A"/><text x="560" y="616" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">9</text>
<line x1="571" y1="612" x2="849" y2="612" stroke="#67C23A" stroke-width="1.6" marker-end="url(#aGreen)"/>
<text x="710" y="604" text-anchor="middle" font-size="12" fill="#67C23A">POST /hard-coffee/wallet-pay {orderId}(登录态)</text>
<text x="710" y="627" text-anchor="middle" font-size="10" fill="#909399">校验 5 分钟窗口 · 支付并发锁</text>
<!-- ⑩ 查钱包 -->
<circle cx="850" cy="662" r="11" fill="#409EFF"/><text x="850" y="666" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">10</text>
<line x1="861" y1="662" x2="1149" y2="662" stroke="#409EFF" stroke-width="1.6" marker-end="url(#aBlue)"/>
<text x="1000" y="654" text-anchor="middle" font-size="12" fill="#409EFF">getWallet(登录用户, 饭卡)(租户上下文)</text>
<line x1="1149" y1="680" x2="861" y2="680" stroke="#909399" stroke-width="1.2" stroke-dasharray="6 4" marker-end="url(#aGray)"/>
<text x="1000" y="675" text-anchor="middle" font-size="11" fill="#909399">饭卡钱包 · 余额前置校验(未开通/余额不足报错)</text>
<!-- ⑪ 扣款事务 -->
<rect x="700" y="698" width="300" height="70" rx="8" fill="#fff" stroke="#F56C6C"/>
<circle cx="714" cy="712" r="11" fill="#F56C6C"/><text x="714" y="716" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">11</text>
<text x="858" y="716" text-anchor="middle" font-size="11.5" fill="#303133" font-weight="600">bindAndDeduct 本地事务扣款</text>
<text x="858" y="734" text-anchor="middle" font-size="10.5" fill="#606266">绑定付款人 → fastReduceBalance 钱包扣款(force 模式,余额前置校验)</text>
<text x="858" y="750" text-anchor="middle" font-size="10.5" fill="#606266">订单置已支付 PAY(余额不足整体回滚)· 日志 ③WALLET_PAY</text>
<!-- ⑫ callback -->
<circle cx="850" cy="796" r="11" fill="#E6A23C"/><text x="850" y="800" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">12</text>
<line x1="839" y1="796" x2="326" y2="796" stroke="#E6A23C" stroke-width="1.6" marker-end="url(#aOrange)"/>
<text x="583" y="788" text-anchor="middle" font-size="12" fill="#E6A23C">POST /api/order/callback(order_no, SUCCESS, phone_no)</text>
<text x="583" y="810" text-anchor="middle" font-size="10" fill="#909399">事务提交后通知(失败不回滚)· 日志 ④NOTIFY_PAY</text>
<rect x="670" y="820" width="360" height="30" rx="6" fill="#FDF6EC" stroke="#F5DAB6"/>
<text x="850" y="840" text-anchor="middle" font-size="10.5" fill="#E6A23C">通知失败 → 状态=2,补偿 Job 每 60s 重试(上限 10 次后人工介入)</text>
<!-- ⑬ 出餐 -->
<circle cx="325" cy="848" r="11" fill="#67C23A"/><text x="325" y="852" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">13</text>
<line x1="314" y1="848" x2="106" y2="848" stroke="#67C23A" stroke-width="1.6" marker-end="url(#aGreen)"/>
<text x="210" y="840" text-anchor="middle" font-size="12" fill="#67C23A">咖啡机出餐 ☕ 用户取咖啡</text>
<!-- ⑭ query-order 异步 -->
<g>
<rect x="460" y="878" width="800" height="44" rx="8" fill="#fff" stroke="#909399" stroke-dasharray="5 4"/>
<circle cx="482" cy="892" r="11" fill="#909399"/><text x="482" y="896" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">14</text>
<text x="500" y="896" font-size="11.5" fill="#606266" font-weight="600">(异步可选)屋里主动查单:POST /hard-coffee/wuli/query-order(另一对 appid/appkey 验签 + 蛇形参数)</text>
<text x="500" y="912" font-size="10.5" fill="#909399">状态映射:CREATE→USERPAYING · PAY→SUCCESS · 退款/关闭→FAIL · 日志 ⑤QUERY</text>
</g>
<!-- 退款分支 -->
<rect x="20" y="940" width="1240" height="180" rx="10" fill="#FEF0F0" stroke="#F56C6C"/>
<rect x="20" y="940" width="1240" height="34" rx="10" fill="#F56C6C"/>
<text x="640" y="962" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">退款分支(管理端售后触发 · 文档 3.3 refund · 支持分批多次)</text>
<g font-size="11.5" fill="#606266">
<rect x="45" y="990" width="360" height="56" rx="8" fill="#fff" stroke="#FBC4C4"/>
<text x="225" y="1012" text-anchor="middle" font-weight="600" fill="#303133">① 管理端售后退款(复用 hard_after_sale)</text>
<text x="225" y="1030" text-anchor="middle">钱包/渠道退款成功 → 订单退款状态重算</text>
<line x1="405" y1="1018" x2="440" y2="1018" stroke="#F56C6C" stroke-width="1.6" marker-end="url(#aRed)"/>
<rect x="445" y="990" width="380" height="56" rx="8" fill="#fff" stroke="#FBC4C4"/>
<text x="635" y="1012" text-anchor="middle" font-weight="600" fill="#303133">② 事务提交后 syncRefundIfNeeded(差额)</text>
<text x="635" y="1030" text-anchor="middle">POST /api/order/refund(order_no, 本次退款金额分)</text>
<text x="635" y="1076" text-anchor="middle" font-size="10.5" fill="#909399">累计 refund_sync_amount 记账 · 总退款 ≤ 订单支付额校验</text>
<line x1="825" y1="1018" x2="860" y2="1018" stroke="#F56C6C" stroke-width="1.6" marker-end="url(#aRed)"/>
<rect x="865" y="990" width="370" height="56" rx="8" fill="#fff" stroke="#FBC4C4"/>
<text x="1050" y="1012" text-anchor="middle" font-weight="600" fill="#303133">③ 厂商确认 + 生命周期日志</text>
<text x="1050" y="1030" text-anchor="middle">⑥REFUND_NOTIFY · 厂商侧同步退款状态</text>
<text x="1050" y="1076" text-anchor="middle" font-size="10.5" fill="#909399">补偿 Job 每 60s 回看近 7 天订单补推退款缺口</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,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;
}
@@ -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<HardCoffeePayResultVo> cardPay(@Valid @RequestBody HardCoffeeCardPayDto dto) {
return R.ok(hardCoffeeService.cardPay(dto));
}
/**
* 登录用户饭卡支付(默认走此通道): 扣当前登录用户自己的饭卡钱包,无需输卡号。
* 不加 @Inner(false),走 OAuth2 认证,未登录返回 401
*/
@Operation(summary = "咖啡机登录用户支付", description = "扣登录用户饭卡钱包,支付窗口5分钟")
@PostMapping("/wallet-pay")
public R<HardCoffeePayResultVo> walletPay(@Valid @RequestBody HardCoffeeWalletPayDto dto) {
return R.ok(hardCoffeeService.walletPay(dto));
}
}
@@ -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<List<HardCoffeeLogVo>> getLogListByOrderId(@PathVariable("orderId") Long orderId) {
return R.ok(hardCoffeeService.logListByOrderId(orderId));
}
/**
* 生命周期日志分页
*/
@@ -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<HardCoffeeLogVo> logListByOrderId(Long orderId);
/**
* 绑定付款人并完成钱包扣款(独立事务,内部通过Spring代理调用保证事务生效)
*
@@ -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<HardCoffeeOrderEntity, HardCoffeePayResultVo> 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<HardCoffeeLogVo> logListByOrderId(Long orderId) {
HardCoffeeOrderEntity coffeeOrder = coffeeOrderService.getByOrderId(orderId);
if (ObjectUtil.isEmpty(coffeeOrder)) {
return List.of();
}
return coffeeLogService.list(new LambdaQueryWrapper<HardCoffeeLogEntity>()
.eq(HardCoffeeLogEntity::getOrderId, orderId)
.orderByAsc(HardCoffeeLogEntity::getStartTime)
.orderByAsc(HardCoffeeLogEntity::getId))
.stream().map(entity -> BeanUtil.toBean(entity, HardCoffeeLogVo.class))
.collect(Collectors.toList());
}
/**
* 补偿任务: 重试通知失败的支付回调 + 补推退款同步缺口。
* 定时线程无租户上下文,列表查询天然跨租户,逐单切回订单租户执行