From 667b87fc00833ebd0cd27823c98c7d60d6ff8d0f Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Tue, 20 Oct 2026 13:40:25 +0800
Subject: [PATCH] =?UTF-8?q?feat:=E9=80=82=E9=85=8D=E8=81=9A=E5=90=88?=
=?UTF-8?q?=E6=94=AF=E4=BB=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 6 +
db/20260825_third_payment_schema.sql | 2 +-
db/20261020_add_aggregate_pay_scene.sql | 23 +++
pjcloud-thirdparty/README.md | 4 +
pjcloud-thirdparty/docs/aggregate-h5.md | 59 ++++++
.../feign/RemoterThirdAppPlusPayService.java | 5 +
.../pay/ThirdAggregatePaySceneEnum.java | 18 ++
.../pay/ThirdPayOrderBizTypeEnum.java | 10 +
.../ThirdAggregateMerchantCreateDto.java | 19 ++
.../ThirdAggregateMerchantEntryDto.java | 23 +++
.../pay/ThirdAggregatePaySessionEntity.java | 8 +-
.../entity/pay/ThirdPayOrderEntity.java | 4 +
.../pay/ThirdAggregateH5ContextVo.java | 28 +++
.../pay/ThirdAggregateMerchantCreateVo.java | 15 ++
.../appplus/pay/ThirdAggregateMerchantVo.java | 17 ++
.../thirdparty/vo/pay/ThirdPayOrderVo.java | 3 +
.../config/AggregateH5CorsFilter.java | 57 ++++++
.../config/AggregatePayProperties.java | 36 ++++
.../ThirdAggregateMerchantPayController.java | 59 ++++++
.../ThirdAggregateOrderPayController.java | 181 +++++++----------
.../appplus/ThirdAppPlusController.java | 11 ++
.../pay/ThirdPayOrderController.java | 7 +-
.../mapper/pay/ThirdPayOrderMapper.java | 5 +
.../pay/ThirdAggregateCashierPageData.java | 14 ++
.../pay/ThirdAggregatePayCreateContext.java | 7 +-
.../service/ThirdAggregateH5Support.java | 124 ++++++++++++
.../ThirdAggregateMerchantPayService.java | 17 ++
.../ThirdAggregateOrderPayService.java | 10 +
.../service/ThirdPayOrderService.java | 4 +
.../ThirdAggregateAliPayServiceImpl.java | 1 +
.../ThirdAggregateOrderPayServiceImpl.java | 183 +++++++++++++++---
.../ThirdAggregateWechatPayServiceImpl.java | 1 +
.../ThirdAggregateMerchantPayServiceImpl.java | 126 ++++++++++++
.../impl/pay/ThirdPayOrderServiceImpl.java | 29 +++
.../resources/mapper/ThirdPayOrderMapper.xml | 16 ++
.../ThirdAggregateOrderPayControllerTest.java | 41 ++++
.../service/ThirdAggregateH5SupportTest.java | 93 +++++++++
...ThirdAggregateOrderPayServiceImplTest.java | 100 +++++++++-
...rdAggregateMerchantPayServiceImplTest.java | 117 +++++++++++
.../service/impl/SysUserServiceImpl.java | 40 ++--
.../impl/mch/MchManageServiceImpl.java | 65 +++++--
41 files changed, 1391 insertions(+), 197 deletions(-)
create mode 100644 db/20261020_add_aggregate_pay_scene.sql
create mode 100644 pjcloud-thirdparty/docs/aggregate-h5.md
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdAggregatePaySceneEnum.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantCreateDto.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantEntryDto.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateH5ContextVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantCreateVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantVo.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregateH5CorsFilter.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregatePayProperties.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateMerchantPayController.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5Support.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateMerchantPayService.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImpl.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayControllerTest.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5SupportTest.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImplTest.java
diff --git a/AGENTS.md b/AGENTS.md
index 893e0b0d..735a8483 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -60,6 +60,12 @@
- Feign 返回 `R` 时必须检查业务状态与空数据;不得只判断 HTTP 成功。
- 接口加解密已有 `pjcloud-common-encrypt-api`,前端也已有对应拦截器。不得在单个接口另造不兼容的加密协议。
+## 数据库迁移交付规则
+
+- 用户授权数据库调整后,**必须在本项目 `db/` 下新建独立的 `YYYYMMDD_变更说明.sql` 迁移文件**,不能仅修改历史迁移或初始化 SQL。
+- 迁移保持简单、可重复执行;新增表或列时先检查是否存在,避免重复执行报错,不混入无关业务数据改写。
+- 编写迁移不代表获准执行;未经用户额外授权,不得对现有环境数据库执行脚本。
+
## 配置、安全与接口约定
- 各服务 `application.yml` 通常只保存启动端口、服务名和 Nacos 引导配置,并导入 `application-{profile}.yml` 与服务级配置。修改配置前同时检查本地文件、环境变量占位符和 Nacos 配置来源。
diff --git a/db/20260825_third_payment_schema.sql b/db/20260825_third_payment_schema.sql
index 9841ed27..701249a0 100644
--- a/db/20260825_third_payment_schema.sql
+++ b/db/20260825_third_payment_schema.sql
@@ -1,5 +1,5 @@
-- thirdparty 支付、聚合支付、任务补偿和商户分账关系统一迁移脚本。
--- 线上只执行本文件;脚本可重复执行,兼容全新数据库和已经执行部分旧迁移的数据库。
+-- 本文件为统一支付基线迁移;后续功能增量在 db 中独立提供,不通过重跑本文件交付。
-- 本脚本不删除支付任务、订单、分账关系或历史配置数据;仅在迁移核对通过后清理旧配置字段。
USE `pjcloudx_thirdparty`;
diff --git a/db/20261020_add_aggregate_pay_scene.sql b/db/20261020_add_aggregate_pay_scene.sql
new file mode 100644
index 00000000..9953ecf9
--- /dev/null
+++ b/db/20261020_add_aggregate_pay_scene.sql
@@ -0,0 +1,23 @@
+-- 双场景聚合支付:仅新增场景字段,前置为统一支付基线表已存在。
+-- 可重复执行:字段已存在则跳过,不修改已有字段定义。
+USE `pjcloudx_thirdparty`;
+
+-- 聚合会话默认 ORDER,兼容原订单聚合支付。
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `third_aggregate_pay_session` ADD COLUMN `aggregate_scene` varchar(32) NOT NULL DEFAULT ''ORDER'' COMMENT ''聚合场景:ORDER订单聚合,OFFLINE线下收款'' AFTER `aggregate_no`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND COLUMN_NAME = 'aggregate_scene');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 支付流水默认 NULL,避免把普通充值等非聚合支付标为订单聚合。
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `third_pay_order` ADD COLUMN `aggregate_scene` varchar(32) DEFAULT NULL COMMENT ''聚合场景:ORDER订单聚合,OFFLINE线下收款,NULL非聚合'' AFTER `biz_type`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME = 'aggregate_scene');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/pjcloud-thirdparty/README.md b/pjcloud-thirdparty/README.md
index 77b54353..22e0b27e 100644
--- a/pjcloud-thirdparty/README.md
+++ b/pjcloud-thirdparty/README.md
@@ -20,3 +20,7 @@
```
+
+## 聚合 H5 收银
+
+双场景配置、迁移与联调见 [部署说明](docs/aggregate-h5.md)。
diff --git a/pjcloud-thirdparty/docs/aggregate-h5.md b/pjcloud-thirdparty/docs/aggregate-h5.md
new file mode 100644
index 00000000..2ee5d017
--- /dev/null
+++ b/pjcloud-thirdparty/docs/aggregate-h5.md
@@ -0,0 +1,59 @@
+# 双场景聚合支付部署与联调
+
+## 配置
+
+在 third 服务的 Nacos 配置中增加以下配置。H5 域名由配置提供,不编入 Java 源码;签名密钥需要由部署环境注入至少 32 字节的随机值,不要使用本文或测试中的字符串。
+
+```yaml
+app-plus:
+ pay:
+ aggregate:
+ h5-base-url: https://baac9a029ea84f36ba28c023fed4fc5f.hd.takin.cc
+ public-route-prefix: /third # 直接使用 Boot 服务时设为 ""
+ merchant-signing-key: ${MERCHANT_COLLECTION_SIGNING_KEY}
+ offline-max-amount: 999999 # 整数分,9,999.99 元;提高上限时同时调整 H5 的输入限制
+ query-limit-per-minute: 120
+ create-limit-per-minute: 30
+ trusted-proxy-addresses: []
+```
+
+`h5-base-url` 只接受 HTTPS 基础地址,不能带查询参数、Hash 或用户信息。可以包含实际部署子路径;H5 的路由 base 需对应配置。
+
+仅在网关/反向代理**覆盖客户端传入的 X-Real-IP**时,将其直连地址加入 `trusted-proxy-addresses`。默认使用直连 IP 限流。Redis 业务键继续由项目基础设施附加租户前缀,不手工拼接租户编号。
+
+H5 只使用项目根目录一个 `.env`,变量命名沿用参考 App:开发请求走 `VITE_API_URL`(默认 `/api`),由 Vite 代理到 `VITE_APP_DEV_SERVICE`;生产读取 `VITE_APP_PROD_SERVICE`。这些后端地址与 H5 页面地址分别配置,不重复附加 `/third`。`VITE_IS_MICRO=true` 保留 `/third`;`false` 移除前缀,此时后端 `public-route-prefix` 同时设为 `""`,确保二维码、OAuth 回调与状态地址也使用 Boot 路径。若线上部署已提供同源接口,生产后端地址可留空。本次未修改实际 Nacos、网关部署或平台控制台。
+
+后端仅为配置的 H5 精确 Origin 开放匿名商户、创建、上下文和状态 JSON 接口的跨域访问,不开放凭据跨域;其他接口保持既有安全规则。
+
+## 平台配置
+
+- 微信沿用服务商统一公众号 OAuth 与 JSAPI 下单配置,确认公众号网页授权域名对应现有后端 OAuth 回调域名。
+- 将新 H5 的实际页面目录核对加入微信支付 JSAPI 支付授权目录;Hash 部分不作为目录填写。当前根目录部署对应 `https://baac9a029ea84f36ba28c023fed4fc5f.hd.takin.cc/`。
+- 支付宝沿用现有应用和固定 OAuth 回调 `/third/aggregate-order-pay/oauth/alipay`,租户通过一次性 state 恢复。不要把独立 H5 地址替换为后端回调地址。
+- 两种渠道继续使用商户既有支付配置、子商户和默认分佣关系快照,配置需已具备相应 OAuth/JSAPI 权限。仅域名能访问不代表上述平台配置已完成。
+
+## 发布顺序与兼容
+
+1. 先确认统一支付基线迁移 `db/20260825_third_payment_schema.sql` 已完成;已完成的环境无需重跑。双场景升级单独执行新文件 [`db/20261020_add_aggregate_pay_scene.sql`](../../db/20261020_add_aggregate_pay_scene.sql),不得只靠修改历史迁移升级现有库。
+2. 新迁移仅新增两个 `aggregate_scene` 字段,先查字段是否存在,可重复执行;不包含存储过程、字典写入或历史流水回填。业务类型字典 `70 = 商户线下收款` 可通过管理端配置。
+3. 配置 Nacos 的 H5 地址与随机签名密钥,配套发布 third、wallet 与 H5,再发布后台页面。完整测试与构建由集中验证指令执行。
+4. 商户至少启用并配置完成微信或支付宝其中一个渠道后,即可通过原刷新二维码操作生成新的 URL 收款码。允许仅微信、仅支付宝或双渠道;只校验已启用渠道。新增商户仍先生成原 App Base64 码;没有可用渠道时不生成 H5 码。手动刷新失败会明确反馈并保留原图。
+5. 消费机原 `AGGREGATE_ORDER_PAY` 类型、二维码路径及内部创建/状态接口保持兼容,OAuth 出口改为 302 到新 H5。原 HTML 渲染器文件保留但不再被 Controller 使用。
+
+数据库字段分别为:聚合会话 `aggregate_scene varchar(32) NOT NULL DEFAULT 'ORDER'`,支付流水 `aggregate_scene varchar(32) DEFAULT NULL`。线下业务编码是 `70`,收款请求号由商户和稳定 requestId 派生;不会创建硬件、商城或钱包充值业务订单。
+
+历史聚合会话通过新字段默认值标记为 `ORDER`;历史支付流水的新字段保持 `NULL`,本次不做数据回填。
+
+静态 URL 收款码长期有效,HMAC 绑定用途、版本、租户和商户;更换域名或密钥后需重新生成。`collectionCode` 继续保留 App 协议,`collectionUrlCode` 仍保存二维码图片地址。
+
+## 接口与故障恢复
+
+- 匿名查询:`GET /third/aggregate-merchant-pay/merchant?id=...&tenantId=...&v=1&sign=...`,返回商户 id/name/logoPic 和 `supportedPlatforms`(10 微信、20 支付宝),不返回支付配置。
+- 线下创建:`POST /third/aggregate-merchant-pay/create`,提交相同签名参数、整数分 amount 和 UUID requestId。重试不能修改金额或生成新 requestId。
+- 现有聚合入口:`/third/aggregate-order-pay/cashier/{tenantCode}/{token}`。两个客户端分别进入各自 OAuth;已确认终态的旧请求直接恢复结果页,不再下单。
+- OAuth 回跳只携带短期 ticket,H5 用 `/context/{tenantCode}/{ticket}` 读取最小渠道参数,用 `/status/{tenantCode}/{ticket}` 确认结果。票据有效期沿用 5 分钟,付款会话沿用 2 分钟;本机倒计时不能代替服务端查单/关单结果。
+- 取消付款可继续原单;客户端返回成功、未知或网络异常时仍等待服务端确认,不能自行写成功或新建另一笔交易。
+- 缺少 H5 配置时不输出无签名链接;OAuth 入口返回无敏感正文的不可用响应。配置齐全后失败跳固定 H5 错误入口,不反射平台原始异常。
+- H5 扫码客户端不在商户已启用渠道中时,展示专门的提示状态并引导换用已开通 App 扫码,隐藏金额和付款键盘;渠道全部停用时提示联系商户。付款创建和进入 OAuth 前再次检查渠道,前端展示不能替代后端校验。已有渠道子订单继续依据原绑定渠道和配置快照恢复。
+
+本轮未执行实际迁移、修改平台配置或发起真实扣款。集中验证需覆盖微信/支付宝真机、退款和分账、回调验签与重复通知、延迟成功、限流、跨租户及旧消费机状态同步。
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppPlusPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppPlusPayService.java
index 70d88df1..6263031e 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppPlusPayService.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppPlusPayService.java
@@ -27,6 +27,11 @@ import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(contextId = "remoterThirdAppPlusPayService", value = ServiceNameConstants.THIRDPARTY_SERVICE)
public interface RemoterThirdAppPlusPayService {
+ /** 为当前租户签发商户静态 H5 收款入口,签名密钥只保留在 third 服务。 */
+ @NoToken
+ @GetMapping("/pay-remoter/merchant-collection-url")
+ R merchantCollectionUrl(@RequestParam("mchId") Long mchId);
+
/**
* 编辑商户的回调
*/
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdAggregatePaySceneEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdAggregatePaySceneEnum.java
new file mode 100644
index 00000000..e97b44fb
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdAggregatePaySceneEnum.java
@@ -0,0 +1,18 @@
+package com.bsd.pjcloud.thirdparty.constant.pay;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/** 聚合收银场景;编码同时用于数据库、公开接口和管理端,不能随意改名。 */
+@Getter
+@AllArgsConstructor
+public enum ThirdAggregatePaySceneEnum {
+
+ /** 已存在业务订单的聚合支付,金额由内部业务订单确定。 */
+ ORDER("ORDER"),
+ /** 无商品订单的线下收款,金额由付款人确认后固定。 */
+ OFFLINE("OFFLINE");
+
+ /** 持久化及跨端协议编码。 */
+ private final String code;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java
index c4ccf1d7..7cd00b78 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java
@@ -13,18 +13,28 @@ import java.util.Arrays;
@Getter
public enum ThirdPayOrderBizTypeEnum {
+ /** 10:App 用户钱包充值,成功后进入充值业务回调。 */
MINI_APP_WALLET_RECHARGE("10", "APP用户钱包充值"),
+ /** 20:原云消费机付款码支付,保持历史协议。 */
YXFJ_PAY("20", "云消费机付款码支付"),
+ /** 30:App 商品订单支付,关联既有业务订单。 */
APP_SHOP_PAY("30", "APP支付-购买商品(有订单)"),
+ /** 40:监护人代充值,保持原充值回调。 */
GUARDIAN_WARD_WALLET_RECHARGE("40", "监护人代被监护人饭卡充值"),
+ /** 50:现有食堂或超市消费订单支付,聚合订单沿用该编码。 */
XFJ("50", "食堂或者超市的消费机支付"),
/** App 酒店预订支付:业务订单在 supermarket,本服务只记支付流水 */
HOTEL_ORDER_PAY("60", "酒店订单支付"),
+ /** 商户线下收款,无商品或消费订单;70 是持久化和跨服务业务编码。 */
+ MCH_OFFLINE_PAY("70", "商户线下收款"),
;
+ /** 数据库与跨服务协议使用的业务编码,不得随意变更。 */
private final String type;
+ /** 管理与诊断使用的中文业务描述。 */
private final String description;
+ /** 按已有协议编码解析支付业务类型,空值保持原有空结果约定。 */
public static ThirdPayOrderBizTypeEnum valueOfByType(String type) {
if (ObjectUtil.isEmpty(type)) return null;
return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().get();
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantCreateDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantCreateDto.java
new file mode 100644
index 00000000..b429de91
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantCreateDto.java
@@ -0,0 +1,19 @@
+package com.bsd.pjcloud.thirdparty.dto.appplus;
+
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Positive;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** 线下收款创建请求,不允许付款人指定渠道商户、业务类型或业务订单。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class ThirdAggregateMerchantCreateDto extends ThirdAggregateMerchantEntryDto {
+ /** 付款人确认的人民币整数分;后端还会检查可配置的线下单笔上限。 */
+ @NotNull @Positive
+ private Integer amount;
+ /** 浏览器为一次付款意图生成的 UUID;重试必须复用,不能改变商户或金额。 */
+ @NotNull @Pattern(regexp = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
+ private String requestId;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantEntryDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantEntryDto.java
new file mode 100644
index 00000000..083b2c2d
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantEntryDto.java
@@ -0,0 +1,23 @@
+package com.bsd.pjcloud.thirdparty.dto.appplus;
+
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Positive;
+import lombok.Data;
+
+/** 商户静态收款码凭据,只能由服务端签发,不能作为订单支付授权。 */
+@Data
+public class ThirdAggregateMerchantEntryDto {
+ /** 二维码对应的收款商户主键,与签名绑定。 */
+ @NotNull @Positive
+ private Long id;
+ /** 二维码对应的租户主键,验签后恢复该租户上下文。 */
+ @NotNull @Positive
+ private Long tenantId;
+ /** 收款链接协议版本,本轮仅支持 1。 */
+ @NotNull @Pattern(regexp = "1")
+ private String v;
+ /** HMAC-SHA256 小写十六进制签名,绑定用途、版本、租户和商户。 */
+ @NotNull @Pattern(regexp = "[0-9a-f]{64}")
+ private String sign;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdAggregatePaySessionEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdAggregatePaySessionEntity.java
index 729896e6..6a0d5fff 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdAggregatePaySessionEntity.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdAggregatePaySessionEntity.java
@@ -40,6 +40,10 @@ public class ThirdAggregatePaySessionEntity extends Model {
@Schema(description = "支付类型")
private String bizType;
+ /** 聚合场景,ORDER 为订单聚合、OFFLINE 为线下收款;空值表示非聚合支付。 */
+ @Schema(description = "聚合场景")
+ private String aggregateScene;
+
/**
* 支付相关id
*/
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateH5ContextVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateH5ContextVo.java
new file mode 100644
index 00000000..a2cb34bc
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateH5ContextVo.java
@@ -0,0 +1,28 @@
+package com.bsd.pjcloud.thirdparty.vo.appplus.pay;
+
+import lombok.Data;
+import java.time.LocalDateTime;
+import java.util.Map;
+
+/** 通过短期收银票据读取的最小上下文,不返回商户配置或 OAuth 身份。 */
+@Data
+public class ThirdAggregateH5ContextVo {
+ /** 服务端保存的 ORDER 或 OFFLINE 场景,不能由 URL 覆盖。 */
+ private String aggregateScene;
+ /** 收款商户公开资料。 */
+ private ThirdAggregateMerchantVo merchant;
+ /** 已确认且不可编辑的人民币整数分。 */
+ private Integer amount;
+ /** 服务端选定的渠道编码,10 为微信、20 为支付宝。 */
+ private String selectedPlatform;
+ /** 当前第三方商户支付单号。 */
+ private String payNo;
+ /** 聚合会话状态,与既有状态查询接口一致。 */
+ private String status;
+ /** 渠道已确认成功时为 true,前端 JSBridge 结果不能替代该值。 */
+ private boolean paySuccess;
+ /** 尚可继续支付时提供的最小 JSBridge 参数,终态时为空。 */
+ private Map launchParams;
+ /** 当前会话的服务端截止时间。 */
+ private LocalDateTime expireTime;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantCreateVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantCreateVo.java
new file mode 100644
index 00000000..23a1e604
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantCreateVo.java
@@ -0,0 +1,15 @@
+package com.bsd.pjcloud.thirdparty.vo.appplus.pay;
+
+import lombok.Data;
+import java.time.LocalDateTime;
+
+/** 匿名线下收款的创建结果,前端只能进入服务端固定的 OAuth 收银地址。 */
+@Data
+public class ThirdAggregateMerchantCreateVo {
+ /** 用于恢复当前付款意图的聚合会话编号。 */
+ private String aggregateNo;
+ /** 现有聚合入口 HTTPS 地址,内含随机访问令牌,不含付款人身份。 */
+ private String cashierUrl;
+ /** 服务端会话截止时间,渠道真实状态仍以服务端查单为准。 */
+ private LocalDateTime expireTime;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantVo.java
new file mode 100644
index 00000000..b770a455
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantVo.java
@@ -0,0 +1,17 @@
+package com.bsd.pjcloud.thirdparty.vo.appplus.pay;
+
+import lombok.Data;
+import java.util.List;
+
+/** 匿名收银商户视图,不包含支付配置、饭卡密钥、用户或结算资料。 */
+@Data
+public class ThirdAggregateMerchantVo {
+ /** 商户主键使用字符串,避免浏览器丢失长整数精度。 */
+ private String id;
+ /** 收款商户名称,以钱包服务的当前资料为准。 */
+ private String name;
+ /** 商户标识图片地址,未配置时为空,前端使用统一占位图形。 */
+ private String logoPic;
+ /** 当前商户已启用的渠道编码,10 微信、20 支付宝;空列表表示暂无可用渠道,不包含渠道凭据。 */
+ private List supportedPlatforms = List.of();
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayOrderVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayOrderVo.java
index 9e0c3aad..8ef2f4cc 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayOrderVo.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayOrderVo.java
@@ -9,6 +9,9 @@ import lombok.Data;
@Data
public class ThirdPayOrderVo extends ThirdPayOrderEntity {
+ /** 绑定的聚合会话号,按当前页 payNo 批量补充;非聚合支付为空。 */
+ private String aggregateNo;
+
/**
* 支付订单所属商户名称,来源于商户管理服务;商户已删除或不可见时为空。
*/
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregateH5CorsFilter.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregateH5CorsFilter.java
new file mode 100644
index 00000000..e702717d
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregateH5CorsFilter.java
@@ -0,0 +1,57 @@
+package com.bsd.pjcloud.thirdparty.config;
+
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateH5Support;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+/** 仅为匿名收银 JSON 接口开放固定 H5 来源,不扩大其他管理、OAuth 或内部接口的跨域权限。 */
+@Component
+@RequiredArgsConstructor
+@Order(Ordered.HIGHEST_PRECEDENCE + 1)
+public class AggregateH5CorsFilter extends OncePerRequestFilter {
+ /** 来源取自服务端固定 H5 配置,禁止通配符和凭据跨域。 */
+ private final ThirdAggregateH5Support h5Support;
+
+ /** 只处理收银公开 JSON 路径,同时兼容网关移除 third 前缀及 Boot 直连。 */
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ String path = request.getRequestURI().substring(request.getContextPath().length());
+ if (path.startsWith("/third/")) path = path.substring(6);
+ return !path.startsWith("/aggregate-merchant-pay/")
+ && !path.startsWith("/aggregate-order-pay/context/")
+ && !path.startsWith("/aggregate-order-pay/status/");
+ }
+
+ /** 在安全过滤链前响应合法预检;配置缺失时保持默认禁止跨域,不阻止其他服务启动。 */
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException {
+ String allowed = null;
+ try {
+ allowed = h5Support.allowedOrigin();
+ } catch (IllegalStateException ignored) {
+ // 缺失收银配置时不开放来源,其他业务仍沿用原安全链。
+ }
+ if (allowed != null && allowed.equals(request.getHeader("Origin"))) {
+ response.setHeader("Access-Control-Allow-Origin", allowed);
+ response.addHeader("Vary", "Origin");
+ response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+ response.setHeader("Access-Control-Allow-Headers", "Content-Type, TENANT-ID");
+ response.setHeader("Access-Control-Max-Age", "600");
+ if ("OPTIONS".equals(request.getMethod())) {
+ response.setStatus(HttpServletResponse.SC_NO_CONTENT);
+ return;
+ }
+ }
+ chain.doFilter(request, response);
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregatePayProperties.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregatePayProperties.java
new file mode 100644
index 00000000..a4eb7e3a
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregatePayProperties.java
@@ -0,0 +1,36 @@
+package com.bsd.pjcloud.thirdparty.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** 独立 H5 聚合收银配置,由 Nacos 或环境变量提供,密钥不进入前端。 */
+@Data
+@Component
+@ConfigurationProperties(prefix = "app-plus.pay.aggregate")
+public class AggregatePayProperties {
+
+ /** 固定 HTTPS H5 基础地址,不含 Hash、查询参数;未配置时拒绝生成收款入口。 */
+ private String h5BaseUrl;
+
+ /** 公网 API 前缀,微服务默认 /third,直接对接 Boot 聚合服务时配置为空字符串。 */
+ private String publicRoutePrefix = "/third";
+
+ /** 至少 32 字节的随机 HMAC 密钥;轮换后必须重新生成商户 URL 收款码。 */
+ private String merchantSigningKey;
+
+ /** 线下收款单笔上限,单位为人民币分,默认 9,999.99 元。 */
+ private int offlineMaxAmount = 999999;
+
+ /** 每个可信来源 IP 每分钟最多读取公开商户或收银状态的次数。 */
+ private int queryLimitPerMinute = 120;
+
+ /** 每个可信来源 IP 每分钟最多提交线下支付创建的次数。 */
+ private int createLimitPerMinute = 30;
+
+ /** 可提供 X-Real-IP 的直连代理地址;代理必须覆盖客户端传入的该头,默认全部不信任。 */
+ private List trustedProxyAddresses = new ArrayList<>();
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateMerchantPayController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateMerchantPayController.java
new file mode 100644
index 00000000..8cc41be5
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateMerchantPayController.java
@@ -0,0 +1,59 @@
+package com.bsd.pjcloud.thirdparty.controller.appplus;
+
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.security.annotation.Inner;
+import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantEntryDto;
+import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantCreateDto;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateMerchantPayService;
+import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantVo;
+import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantCreateVo;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.CacheControl;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
+
+/** 只开放签名商户码查询与线下收款创建,不放开原有商户详情或内部支付接口。 */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/aggregate-merchant-pay")
+public class ThirdAggregateMerchantPayController {
+ /** 线下收款边界服务,负责验签、租户恢复和幂等创建。 */
+ private final ThirdAggregateMerchantPayService service;
+
+ /** 匿名商户查询仅输出最小字段,失败不泄漏商户是否存在或远程异常。 */
+ @GetMapping("/merchant")
+ @Inner(value = false)
+ public ResponseEntity> merchant(@Valid @ModelAttribute ThirdAggregateMerchantEntryDto entry,
+ HttpServletRequest request) {
+ R body;
+ try {
+ body = R.ok(service.getMerchant(entry, request));
+ } catch (ResponseStatusException exception) {
+ throw exception;
+ } catch (RuntimeException exception) {
+ body = R.failed("收款链接或商户不可用,请重新扫码");
+ }
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(body);
+ }
+
+ /** 公共请求不允许指定业务类型、渠道配置或任意回跳地址。 */
+ @PostMapping("/create")
+ @Inner(value = false)
+ public ResponseEntity> create(@Valid @RequestBody ThirdAggregateMerchantCreateDto dto,
+ HttpServletRequest request) {
+ R body;
+ try {
+ body = R.ok(service.create(dto, request));
+ } catch (ResponseStatusException exception) {
+ throw exception;
+ } catch (IllegalArgumentException exception) {
+ body = R.failed(exception.getMessage());
+ } catch (RuntimeException exception) {
+ body = R.failed("付款暂未能发起,请稍后重试或重新扫码");
+ }
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(body);
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java
index 8dca08bc..f5d8ba69 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java
@@ -3,170 +3,127 @@ package com.bsd.pjcloud.thirdparty.controller.appplus;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData;
-import com.bsd.pjcloud.thirdparty.service.ThirdAggregateCashierHtmlRenderer;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateH5Support;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
+import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateH5ContextVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo;
+import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
-import org.springframework.http.CacheControl;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
+import org.springframework.http.*;
import org.springframework.validation.annotation.Validated;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.RequestHeader;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
import java.net.URI;
-/**
- * 支付宝与微信聚合订单码匿名收银入口。
- *
- * 所有入口先用租户唯一编码恢复租户上下文,不接收租户ID或任意回跳地址。
- */
+/** 聚合扫码与 OAuth 匿名入口;保留旧路径,收银展示统一通过 302 转入独立 H5。 */
@RestController
@RequiredArgsConstructor
@Validated
@RequestMapping("/aggregate-order-pay")
public class ThirdAggregateOrderPayController {
-
- /**
- * 聚合支付编排服务,负责访问令牌、OAuth state、付款人互斥和状态查询。
- */
+ /** 已有订单编排服务,负责 OAuth state、付款人互斥和渠道事实确认。 */
private final ThirdAggregateOrderPayService aggregateOrderPayService;
+ /** 固定 H5 地址与公开限流配置,不接受任意回跳目标。 */
+ private final ThirdAggregateH5Support h5Support;
- /**
- * 固定收银 HTML 渲染器,负责 CSP nonce 和 JSBridge 页面输出。
- */
- private final ThirdAggregateCashierHtmlRenderer htmlRenderer;
-
- /**
- * 识别支付宝或微信扫码客户端并跳转对应 OAuth 授权入口。
- *
- * @param tenantCode 聚合二维码中的租户唯一编码
- * @param token 聚合二维码中的随机访问令牌
- * @param userAgent 扫码客户端 User-Agent
- * @return OAuth 重定向;普通浏览器或失效请求返回固定提示页
- */
- @GetMapping(value = "/cashier/{tenantCode}/{token}", produces = MediaType.TEXT_HTML_VALUE)
+ /** 保留消费机二维码入口,按客户端进入对应的官方 OAuth。 */
+ @GetMapping("/cashier/{tenantCode}/{token}")
@Inner(value = false)
- public ResponseEntity cashier(@NotBlank @PathVariable String tenantCode, @NotBlank @PathVariable String token,
- @RequestHeader(value = HttpHeaders.USER_AGENT, required = false) String userAgent) {
+ public ResponseEntity cashier(@NotBlank @PathVariable String tenantCode, @NotBlank @PathVariable String token,
+ @RequestHeader(value = HttpHeaders.USER_AGENT, required = false) String userAgent,
+ HttpServletRequest request) {
+ h5Support.checkRate(request, false);
try {
- String oauthUrl = aggregateOrderPayService.getCashierOauthUrl(tenantCode, token, userAgent);
- return ResponseEntity.status(HttpStatus.FOUND).headers(baseHeaders(null)).location(URI.create(oauthUrl)).build();
+ h5Support.baseUrl();
+ return redirect(aggregateOrderPayService.getCashierOauthUrl(tenantCode, token, userAgent));
} catch (RuntimeException exception) {
- return messageResponse(exception.getMessage(), HttpStatus.BAD_REQUEST);
+ return unavailable();
}
}
- /**
- * 接收服务商统一公众号 OAuth 回调并返回微信 JSBridge 收银页。
- *
- * @param tenantCode 聚合 URL 中的租户唯一编码
- * @param code 微信一次性授权码
- * @param state 服务端创建的一次性 OAuth state
- * @return 带 CSP nonce 的固定微信收银页
- */
- @GetMapping(value = "/oauth/wechat/{tenantCode}", produces = MediaType.TEXT_HTML_VALUE)
+ /** 微信 OAuth 成功后只向固定 H5 传递短期收银票据。 */
+ @GetMapping("/oauth/wechat/{tenantCode}")
@Inner(value = false)
- public ResponseEntity wechatOauth(@NotBlank @PathVariable String tenantCode,
- @NotBlank @RequestParam("code") String code,
- @NotBlank @RequestParam("state") String state) {
+ public ResponseEntity wechatOauth(@NotBlank @PathVariable String tenantCode,
+ @RequestParam(value = "code", required = false) String code,
+ @RequestParam(value = "state", required = false) String state) {
try {
- ThirdAggregateCashierPageData pageData = aggregateOrderPayService.handleWechatOauth(tenantCode, code, state);
- return payPageResponse(pageData);
+ h5Support.baseUrl();
+ ThirdAggregateCashierPageData data = aggregateOrderPayService.handleWechatOauth(tenantCode, code, state);
+ return redirect(data.getH5Url());
} catch (RuntimeException exception) {
- return messageResponse(exception.getMessage(), HttpStatus.BAD_REQUEST);
+ return unavailable();
}
}
- /**
- * 接收固定地址的支付宝 OAuth 回调并返回 AlipayJSBridge 收银页。
- *
- * 租户编码由服务端一次性 state 恢复,确保所有租户共用同一个支付宝授权回调白名单地址。
- *
- * @param authCode 支付宝一次性授权码
- * @param state 包含租户编码且由服务端创建的一次性 OAuth state
- * @return 带 CSP nonce 的固定支付宝收银页
- */
- @GetMapping(value = "/oauth/alipay", produces = MediaType.TEXT_HTML_VALUE)
+ /** 支付宝沿用固定回调地址,租户由一次性 state 恢复,不把授权码传入 H5。 */
+ @GetMapping("/oauth/alipay")
@Inner(value = false)
- public ResponseEntity alipayOauth(@NotBlank @RequestParam("auth_code") String authCode,
- @NotBlank @RequestParam("state") String state) {
+ public ResponseEntity alipayOauth(@RequestParam(value = "auth_code", required = false) String authCode,
+ @RequestParam(value = "state", required = false) String state) {
try {
- ThirdAggregateCashierPageData pageData = aggregateOrderPayService.handleAlipayOauth(authCode, state);
- return payPageResponse(pageData);
+ h5Support.baseUrl();
+ ThirdAggregateCashierPageData data = aggregateOrderPayService.handleAlipayOauth(authCode, state);
+ return redirect(data.getH5Url());
} catch (RuntimeException exception) {
- return messageResponse(exception.getMessage(), HttpStatus.BAD_REQUEST);
+ return unavailable();
}
}
- /**
- * 使用短期 ticket 返回收银页轮询所需的最小支付状态。
- *
- * @param tenantCode 聚合 URL 中的租户唯一编码
- * @param ticket 收银页短期状态票据
- * @return 聚合状态、选定平台和支付成功标识
- */
+ /** 票据重复读取支持刷新恢复;固定金额和平台参数由后端绑定的会话提供。 */
+ @GetMapping("/context/{tenantCode}/{ticket}")
+ @Inner(value = false)
+ public ResponseEntity> context(@NotBlank @PathVariable String tenantCode,
+ @NotBlank @PathVariable String ticket,
+ HttpServletRequest request) {
+ h5Support.checkRate(request, false);
+ R body;
+ try {
+ body = R.ok(aggregateOrderPayService.getContextByTicket(tenantCode, ticket));
+ } catch (RuntimeException exception) {
+ body = R.failed("收银凭据已失效或暂不可用,请重新扫码");
+ }
+ return ResponseEntity.ok().headers(baseHeaders()).body(body);
+ }
+
+ /** 沿用既有短期票据状态契约,不以浏览器支付成功事件修改后端流水。 */
@GetMapping("/status/{tenantCode}/{ticket}")
@Inner(value = false)
public ResponseEntity> status(@NotBlank @PathVariable String tenantCode,
- @NotBlank @PathVariable String ticket) {
+ @NotBlank @PathVariable String ticket,
+ HttpServletRequest request) {
+ h5Support.checkRate(request, false);
R body;
try {
body = R.ok(aggregateOrderPayService.getStatusByTicket(tenantCode, ticket));
} catch (RuntimeException exception) {
- body = R.failed(exception.getMessage());
+ body = R.failed("支付状态暂不可用,请稍后重试或重新扫码");
}
- return ResponseEntity.ok().headers(baseHeaders(null)).body(body);
+ return ResponseEntity.ok().headers(baseHeaders()).body(body);
}
- /**
- * 构造支付页响应并设置严格 CSP、禁止缓存和禁止来源泄漏响应头。
- *
- * @param pageData 平台调起参数和短期状态地址
- * @return HTML 收银页响应
- */
- private ResponseEntity payPageResponse(ThirdAggregateCashierPageData pageData) {
- String nonce = htmlRenderer.createNonce();
- return ResponseEntity.ok().headers(baseHeaders(nonce)).contentType(MediaType.TEXT_HTML)
- .body(htmlRenderer.renderPayPage(pageData, nonce));
+ /** 用 Location 执行浏览器导航,不能把 URL 当作普通字符串返回。 */
+ private ResponseEntity redirect(String url) {
+ return ResponseEntity.status(HttpStatus.FOUND).headers(baseHeaders()).location(URI.create(url)).build();
}
- /**
- * 构造安全提示页响应,提示文本由渲染器执行 HTML 转义。
- *
- * @param message 面向付款人的短提示
- * @param status HTTP 状态码
- * @return 不包含平台参数的固定 HTML 响应
- */
- private ResponseEntity messageResponse(String message, HttpStatus status) {
- String nonce = htmlRenderer.createNonce();
- return ResponseEntity.status(status).headers(baseHeaders(nonce)).contentType(MediaType.TEXT_HTML)
- .body(htmlRenderer.renderMessagePage(message, nonce));
+ /** 错误只跳固定 H5 提示入口;配置缺失时返回无敏感正文的服务不可用响应。 */
+ private ResponseEntity unavailable() {
+ try {
+ return redirect(h5Support.errorUrl());
+ } catch (RuntimeException exception) {
+ return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).headers(baseHeaders()).build();
+ }
}
- /**
- * 构造聚合收银入口共用的安全响应头。
- *
- * @param nonce HTML 响应 CSP nonce;重定向和 JSON 响应时为空
- * @return 禁止缓存、禁止来源泄漏和禁止 MIME 猜测的响应头
- */
- private HttpHeaders baseHeaders(String nonce) {
+ /** 禁止票据缓存、来源泄漏和 MIME 猜测,响应不再包含动态 HTML。 */
+ private HttpHeaders baseHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setCacheControl(CacheControl.noStore());
headers.set("Pragma", "no-cache");
headers.set("Referrer-Policy", "no-referrer");
headers.set("X-Content-Type-Options", "nosniff");
- if (nonce != null) {
- headers.set("Content-Security-Policy", "default-src 'none'; script-src 'nonce-" + nonce
- + "'; style-src 'nonce-" + nonce + "'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
- }
return headers;
}
}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAppPlusController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAppPlusController.java
index a618ea40..6ce3c322 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAppPlusController.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAppPlusController.java
@@ -8,6 +8,7 @@ import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateMerchantPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo;
@@ -48,6 +49,16 @@ public class ThirdAppPlusController {
*/
private final ThirdAppPlusService thirdAppPlusService;
+ /** 商户收款链接签发服务,只有内部钱包服务可以调用。 */
+ private final ThirdAggregateMerchantPayService aggregateMerchantPayService;
+
+ /** 生成当前可信租户商户的 H5 链接,不对外开放签名能力。 */
+ @GetMapping("/merchant-collection-url")
+ @Inner
+ public R merchantCollectionUrl(@RequestParam("mchId") Long mchId) {
+ return R.ok(aggregateMerchantPayService.generateLink(mchId));
+ }
+
/* ************************************* start 远程调用 start ************************************* */
/**
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayOrderController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayOrderController.java
index cf507769..eb500821 100755
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayOrderController.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayOrderController.java
@@ -1,9 +1,7 @@
package com.bsd.pjcloud.thirdparty.controller.pay;
import cn.hutool.core.collection.CollUtil;
-import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.StrUtil;
-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.excel.annotation.ResponseExcel;
@@ -96,9 +94,8 @@ public class ThirdPayOrderController {
@ResponseExcel
@GetMapping("/export")
@PreAuthorize("@pms.hasPermission('third_thirdPayOrder_export')")
- public List export(ThirdPayOrderEntity thirdPayOrder, Long[] ids) {
- List orders = thirdPayOrderService.list(
- Wrappers.lambdaQuery(thirdPayOrder).in(ArrayUtil.isNotEmpty(ids), ThirdPayOrderEntity::getId, ids));
+ public List export(ThirdPayOrderDto dto, Long[] ids) {
+ List orders = thirdPayOrderService.exportList(dto, ids);
orders.forEach(this::sanitizeForClient);
return orders;
}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayOrderMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayOrderMapper.java
index ab280c5e..721d8c3f 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayOrderMapper.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayOrderMapper.java
@@ -8,10 +8,15 @@ import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
+import java.util.List;
@Mapper
public interface ThirdPayOrderMapper extends PjcloudBaseMapper {
+ /** 不分页导出当前租户及权限范围内匹配的支付流水,排序与分页一致。 */
+ List scopeList(@Param("dto") ThirdPayOrderDto dto, @Param("ids") Long[] ids, DataScope dataScope);
+
+ /** 按当前租户、权限和查询条件分页读取支付流水,以创建时间和主键倒序返回。 */
Page scopePage(Page page, @Param("dto") ThirdPayOrderDto dto, DataScope dataScope);
}
\ No newline at end of file
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierPageData.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierPageData.java
index 23309dfe..a131190f 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierPageData.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierPageData.java
@@ -15,6 +15,9 @@ public class ThirdAggregateCashierPageData {
*/
private final String statusUrl;
+ /** 新 H5 的固定 HTTPS 跳转地址,只包含租户编码和短期票据;旧渲染测试可为空。 */
+ private final String h5Url;
+
/**
* 创建聚合收银页数据。
*
@@ -22,8 +25,19 @@ public class ThirdAggregateCashierPageData {
* @param statusUrl 短期状态查询地址
*/
public ThirdAggregateCashierPageData(ThirdAggregateCashierLaunch launch, String statusUrl) {
+ this(launch, statusUrl, null);
+ }
+
+ /** 创建 OAuth 回调数据,H5 通过票据单独读取敏感调起参数。 */
+ public ThirdAggregateCashierPageData(ThirdAggregateCashierLaunch launch, String statusUrl, String h5Url) {
this.launch = launch;
this.statusUrl = statusUrl;
+ this.h5Url = h5Url;
+ }
+
+ /** 返回固定配置的 H5 票据入口,不能使用客户端提供的回跳地址。 */
+ public String getH5Url() {
+ return h5Url;
}
/**
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregatePayCreateContext.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregatePayCreateContext.java
index 03247a30..97620f42 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregatePayCreateContext.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregatePayCreateContext.java
@@ -18,13 +18,16 @@ public class ThirdAggregatePayCreateContext {
*/
private String payNo;
+ /** 由服务端聚合会话固定的场景,原样传递给支付宝或微信支付流水。 */
+ private String aggregateScene;
+
/**
- * 调用方业务订单唯一标识。
+ * 业务订单或线下收款请求唯一标识。
*/
private String bizId;
/**
- * 支付业务类型,首版固定为云消费机支付。
+ * 订单聚合沿用原业务类型,线下收款为 MCH_OFFLINE_PAY。
*/
private ThirdPayOrderBizTypeEnum bizType;
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5Support.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5Support.java
new file mode 100644
index 00000000..22793ec5
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5Support.java
@@ -0,0 +1,124 @@
+package com.bsd.pjcloud.thirdparty.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.bsd.pjcloud.thirdparty.config.AggregatePayProperties;
+import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantEntryDto;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.stereotype.Service;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.HexFormat;
+import java.util.List;
+
+/** 聚合 H5 固定跳转、商户签名和匿名限流,复用项目 Redis 的租户前缀。 */
+@Service
+@RequiredArgsConstructor
+public class ThirdAggregateH5Support {
+ /** 收银页配置,密钥和固定跳转域名仅由后端配置中心提供。 */
+ private final AggregatePayProperties properties;
+ /** 项目租户感知模板,不在业务键中手工附加租户编号。 */
+ private final StringRedisTemplate redis;
+ /** 固定窗口原子计数,首次访问设置 60 秒有效期,避免计数和过期之间出现竞态。 */
+ private static final DefaultRedisScript RATE_SCRIPT = new DefaultRedisScript<>(
+ "local n=redis.call('incr',KEYS[1]);if n==1 then redis.call('expire',KEYS[1],60) end;return n", Long.class);
+
+ /** 返回经过校验的固定 H5 基础地址,禁止用户信息、查询参数和 Hash 混入跳转配置。 */
+ public String baseUrl() {
+ try {
+ URI uri = URI.create(StrUtil.blankToDefault(properties.getH5BaseUrl(), ""));
+ if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getRawUserInfo() != null
+ || uri.getRawQuery() != null || uri.getRawFragment() != null) {
+ throw new IllegalArgumentException();
+ }
+ return StrUtil.removeSuffix(uri.toString(), "/");
+ } catch (IllegalArgumentException exception) {
+ throw new IllegalStateException("聚合收银 H5 地址未正确配置");
+ }
+ }
+
+ /** 仅支持已有网关前缀或 Boot 无前缀部署,禁止把该配置变为任意跳转路径。 */
+ public String publicRoutePrefix() {
+ String prefix = properties.getPublicRoutePrefix();
+ if (!"/third".equals(prefix) && !"".equals(prefix)) throw new IllegalStateException("聚合支付公网前缀配置无效");
+ return prefix;
+ }
+
+ /** 返回唯一允许跨域访问本收银接口的 H5 来源,端口参与匹配。 */
+ public String allowedOrigin() {
+ URI uri = URI.create(baseUrl());
+ return uri.getScheme() + "://" + uri.getRawAuthority();
+ }
+
+ /** 生成绑定线下用途的长期签名;调用方必须是可信内部服务。 */
+ public String sign(Long tenantId, Long merchantId) {
+ String secret = properties.getMerchantSigningKey();
+ if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < 32) {
+ throw new IllegalStateException("商户收款签名密钥未正确配置");
+ }
+ if (tenantId == null || tenantId <= 0 || merchantId == null || merchantId <= 0) {
+ throw new IllegalArgumentException("商户收款参数无效");
+ }
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+ return HexFormat.of().formatHex(mac.doFinal(("MCH_OFFLINE_PAY\n1\n" + tenantId + "\n" + merchantId)
+ .getBytes(StandardCharsets.UTF_8)));
+ } catch (java.security.GeneralSecurityException exception) {
+ throw new IllegalStateException("商户收款签名不可用", exception);
+ }
+ }
+
+ /** 以恒定时间比较服务端签名;不暴露商户是否存在,先验签再访问业务数据。 */
+ public void verify(ThirdAggregateMerchantEntryDto entry) {
+ if (entry == null || !"1".equals(entry.getV()) || entry.getSign() == null
+ || !entry.getSign().matches("[0-9a-f]{64}")
+ || !MessageDigest.isEqual(sign(entry.getTenantId(), entry.getId()).getBytes(StandardCharsets.US_ASCII),
+ entry.getSign().getBytes(StandardCharsets.US_ASCII))) {
+ throw new IllegalArgumentException("收款链接不可用,请重新扫码");
+ }
+ }
+
+ /** 为钱包服务构造静态收款入口,保留 id 参数兼容现有 App 的 URL 扫码解析。 */
+ public String merchantUrl(Long tenantId, Long merchantId) {
+ return baseUrl() + "/#/pages/pay/index?id=" + merchantId + "&tenantId=" + tenantId
+ + "&v=1&sign=" + sign(tenantId, merchantId);
+ }
+
+ /** OAuth 回跳只携带短期不透明票据,渠道签名参数由 H5 单独读取。 */
+ public String cashierUrl(String tenantCode, String ticket) {
+ return baseUrl() + "/#/pages/pay/index?tenantCode=" + encode(tenantCode) + "&ticket=" + encode(ticket);
+ }
+
+ /** 固定错误入口不回显异常、用户输入或内部配置。 */
+ public String errorUrl() {
+ return baseUrl() + "/#/pages/pay/index?error=UNAVAILABLE";
+ }
+
+ /** 对公开操作限流;默认只使用直连地址,可信代理必须覆盖 X-Real-IP。 */
+ public void checkRate(HttpServletRequest request, boolean creating) {
+ String source = request.getRemoteAddr();
+ if (properties.getTrustedProxyAddresses().contains(source)) {
+ String forwarded = request.getHeader("X-Real-IP");
+ if (forwarded != null && forwarded.matches("[0-9a-fA-F:.]{3,64}")) source = forwarded;
+ }
+ int limit = creating ? properties.getCreateLimitPerMinute() : properties.getQueryLimitPerMinute();
+ Long count = redis.execute(RATE_SCRIPT, List.of("thirdparty:aggregate-h5:rate:"
+ + (creating ? "create:" : "query:") + source));
+ if (count == null || count > Math.max(1, limit)) {
+ throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.TOO_MANY_REQUESTS, "请求较频繁,请稍后重试");
+ }
+ }
+
+ /** 编码固定跳转参数,防止租户编码改变 URL 结构。 */
+ private String encode(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8);
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateMerchantPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateMerchantPayService.java
new file mode 100644
index 00000000..cd7a67d6
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateMerchantPayService.java
@@ -0,0 +1,17 @@
+package com.bsd.pjcloud.thirdparty.service;
+
+import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantEntryDto;
+import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantCreateDto;
+import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantVo;
+import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantCreateVo;
+import jakarta.servlet.http.HttpServletRequest;
+
+/** 商户长期收款入口与短期付款请求之间的边界,不创建商品订单。 */
+public interface ThirdAggregateMerchantPayService {
+ /** 为可信钱包服务签发当前租户商户的长期 H5 入口。 */
+ String generateLink(Long merchantId);
+ /** 验签后恢复租户并查询最小商户资料,访问来源仅用于匿名限流。 */
+ ThirdAggregateMerchantVo getMerchant(ThirdAggregateMerchantEntryDto entry, HttpServletRequest request);
+ /** 校验整数分、请求幂等和客户端后创建线下聚合会话。 */
+ ThirdAggregateMerchantCreateVo create(ThirdAggregateMerchantCreateDto dto, HttpServletRequest request);
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java
index 92ae3788..ec7f141a 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java
@@ -6,6 +6,7 @@ import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
+import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateH5ContextVo;
/**
* 支付宝与微信聚合订单码支付编排服务。
@@ -20,6 +21,15 @@ public interface ThirdAggregateOrderPayService {
*/
ThirdAggregateOrderCodePayVo create(ThirdAggregateOrderCodePayDto dto);
+ /** 创建已验签的线下收款会话,金额与请求编号由线下入口服务校验后固定。 */
+ ThirdAggregateOrderCodePayVo createOffline(ThirdAggregateOrderCodePayDto dto);
+
+ /** 使用租户编码和短期票据读取固定金额、商户及尚可支付的 JSBridge 参数。 */
+ ThirdAggregateH5ContextVo getContextByTicket(String tenantCode, String ticket);
+
+ /** 至少开通一个渠道,仅校验已启用渠道的应用、商户参数及默认关系,不创建渠道订单。 */
+ void validateMerchantPayConfiguration(Long merchantId);
+
/**
* 按聚合会话编号查询当前租户最小支付状态。
*
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayOrderService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayOrderService.java
index ea3d9df5..79257a57 100755
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayOrderService.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayOrderService.java
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.thirdparty.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import java.util.List;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
@@ -13,6 +14,9 @@ public interface ThirdPayOrderService extends IService {
Page scopePage(Page page, ThirdPayOrderDto dto, DataScope dataScope);
+ /** 按与管理端分页相同条件导出,可按选中的主键集合进一步收窄。 */
+ List exportList(ThirdPayOrderDto dto, Long[] ids);
+
ThirdPayOrderEntity getByPayNo(String payNo);
ThirdPayOrderEntity getByBiz(ThirdPayOrderBizTypeEnum bizType, String bizId);
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java
index 7a336cbd..58c82627 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java
@@ -287,6 +287,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType());
payOrder.setBizType(context.getBizType().getType());
payOrder.setBizId(context.getBizId());
+ payOrder.setAggregateScene(context.getAggregateScene());
payOrder.setUserId(context.getUserId());
payOrder.setMchId(context.getMchId());
payOrder.setPrice(context.getAmount());
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java
index f6557662..17438105 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java
@@ -15,6 +15,10 @@ 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.thirdparty.config.AppPlusPayConfig;
+import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySceneEnum;
+import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateH5ContextVo;
+import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantVo;
+import org.springframework.beans.factory.annotation.Autowired;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
@@ -33,6 +37,7 @@ import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatu
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
+import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction;
import lombok.RequiredArgsConstructor;
@@ -56,6 +61,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Objects;
import java.util.UUID;
/**
@@ -77,11 +83,6 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
*/
private static final String QR_CODE_TYPE = "AGGREGATE_ORDER_PAY";
- /**
- * thirdparty 服务经网关暴露的固定公网前缀,网关转发后会移除该前缀。
- */
- private static final String PUBLIC_ROUTE_PREFIX = "/third";
-
/**
* 匿名消费机订单的内部用户ID占位值。
*/
@@ -227,8 +228,12 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
*/
private final SecureRandom secureRandom = new SecureRandom();
+ /** 固定 H5 跳转辅助服务;保持原构造器,兼容既有订单编排测试与调用。 */
+ @Autowired
+ private ThirdAggregateH5Support h5Support;
+
/**
- * 创建或复用同业务有效聚合二维码,并在创建前完整校验双平台配置。
+ * 创建或复用同业务有效聚合二维码,并在创建前校验商户已启用的渠道配置。
*
* @param dto 业务订单、商户、金额和可选用户参数
* @return 聚合会话编号、HTTPS 二维码内容和失效时间
@@ -236,6 +241,21 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
@Override
public ThirdAggregateOrderCodePayVo create(ThirdAggregateOrderCodePayDto dto) {
validateCreateRequest(dto);
+ return createWithScene(dto, ThirdAggregatePaySceneEnum.ORDER);
+ }
+
+ /** 仅供已验签的线下入口调用,业务类型由服务端固定,不能开放给内部订单接口的任意请求。 */
+ @Override
+ public ThirdAggregateOrderCodePayVo createOffline(ThirdAggregateOrderCodePayDto dto) {
+ if (dto == null || dto.getBizType() != ThirdPayOrderBizTypeEnum.MCH_OFFLINE_PAY
+ || StrUtil.isBlank(dto.getBizId()) || dto.getMchId() == null || dto.getAmount() == null || dto.getAmount() <= 0) {
+ throw new IllegalArgumentException("线下收款参数不完整");
+ }
+ return createWithScene(dto, ThirdAggregatePaySceneEnum.OFFLINE);
+ }
+
+ /** 两种场景复用同一创建锁、幂等检查、有效期和补偿任务,场景由可信入口确定。 */
+ private ThirdAggregateOrderCodePayVo createWithScene(ThirdAggregateOrderCodePayDto dto, ThirdAggregatePaySceneEnum scene) {
String lockKey = CREATE_LOCK_PREFIX + dto.getBizType().getType() + ":" + dto.getBizId();
String lockToken = UUID.randomUUID().toString();
Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockToken, CREATE_LOCK_TTL);
@@ -243,7 +263,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
throw new RuntimeException("聚合订单码正在处理中,请勿重复提交");
}
try {
- ThirdAggregateOrderCodePayVo result = createOrReuse(dto);
+ ThirdAggregateOrderCodePayVo result = createOrReuse(dto, scene);
pollingCoordinator.wakeAggregatePolling();
return result;
} finally {
@@ -251,6 +271,30 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
}
+ /** 商户至少开通一个渠道即可签发静态码,未开通的平台不参与配置和分佣关系校验。 */
+ @Override
+ public void validateMerchantPayConfiguration(Long merchantId) {
+ MchManageVo merchant = getMerchant(merchantId);
+ List settings = getEnabledPaySettings(merchant);
+ if (settings.isEmpty()) throw new IllegalArgumentException("请先启用商户的微信或支付宝支付配置");
+ for (MchPaySettingVo setting : settings) {
+ AppPlusPlatformTypeEnum platform = AppPlusPlatformTypeEnum.valueOfByType(setting.getPlatformType());
+ validateApplicationConfig(platform);
+ ThirdAggregatePayCreateContext context = new ThirdAggregatePayCreateContext();
+ context.setMchId(merchantId);
+ MchPaySettingVo snapshot = applyDefaultPaymentRelation(setting, context);
+ String subMchId;
+ if (platform == AppPlusPlatformTypeEnum.ALI_PAY) {
+ MchPaySettingEntity.AliPaySetting channel = JSONUtil.toBean(snapshot.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
+ subMchId = channel == null ? null : channel.getSubMchid();
+ } else {
+ MchPaySettingEntity.WechatPaySetting channel = JSONUtil.toBean(snapshot.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
+ subMchId = channel == null ? null : channel.getSubMchid();
+ }
+ if (StrUtil.isBlank(subMchId)) throw new IllegalArgumentException("商户" + platform.getDescription() + "渠道配置不完整");
+ }
+ }
+
/**
* 查询当前租户聚合会话,并同步仍有渠道子订单的真实终态。
*
@@ -318,7 +362,22 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
return TenantBroker.applyAs(tenant.getId(), ignored -> {
AppPlusPlatformTypeEnum platform = resolveClientPlatform(userAgent);
ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getByAccessTokenHash(sha256(token));
+ // 刷新或网络恢复后,先确认原单终态;已成功/已关闭的旧请求直接进入结果页,不再重新下单。
+ if (session != null && (isExpired(session) || !isActive(session))) {
+ getStatus(session.getAggregateNo());
+ session = aggregatePaySessionService.getById(session.getId());
+ if (session != null && !isActive(session)) {
+ String ticket = createStatusTicket(session.getAggregateNo());
+ return h5Support.cashierUrl(tenantCode, ticket);
+ }
+ throw new IllegalStateException("原付款结果仍在确认,请稍后重试");
+ }
validateCashierSession(session, platform);
+ // 尚未选定渠道时先检查商户开通情况。错误 App 只进入 H5 提示,不发起无效 OAuth 或锁单。
+ if (StrUtil.isBlank(session.getSelectedPlatform()) && getEnabledPaySettings(getMerchant(session.getMchId())).stream()
+ .noneMatch(setting -> platform.getType().equals(setting.getPlatformType()))) {
+ return h5Support.cashierUrl(tenantCode, createStatusTicket(session.getAggregateNo()));
+ }
String storedState = createOauthState(session.getId(), platform);
String state = ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY)
? buildAlipayOauthState(tenantCode, storedState) : storedState;
@@ -403,8 +462,8 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
public ThirdAggregateOrderCodePayStatusVo getStatusByTicket(String tenantCode, String ticket) {
SysTenant tenant = resolveTenant(tenantCode);
return TenantBroker.applyAs(tenant.getId(), ignored -> {
- if (StrUtil.isBlank(ticket)) {
- throw new RuntimeException("支付状态票据不能为空");
+ if (StrUtil.isBlank(ticket) || !ticket.matches("[A-Za-z0-9_-]{32}")) {
+ throw new RuntimeException("支付状态票据无效");
}
String aggregateNo = stringRedisTemplate.opsForValue().get(STATUS_TICKET_PREFIX + ticket);
if (StrUtil.isBlank(aggregateNo)) {
@@ -414,6 +473,44 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
});
}
+ /** 通过短期票据读取固定金额和最小渠道参数,终态不再提供支付调起参数。 */
+ @Override
+ public ThirdAggregateH5ContextVo getContextByTicket(String tenantCode, String ticket) {
+ SysTenant tenant = resolveTenant(tenantCode);
+ return TenantBroker.applyAs(tenant.getId(), ignored -> {
+ if (StrUtil.isBlank(ticket) || !ticket.matches("[A-Za-z0-9_-]{32}")) {
+ throw new IllegalArgumentException("收银票据不可用");
+ }
+ String aggregateNo = stringRedisTemplate.opsForValue().get(STATUS_TICKET_PREFIX + ticket);
+ if (StrUtil.isBlank(aggregateNo)) throw new IllegalArgumentException("收银票据已失效,请重新扫码");
+ ThirdAggregateOrderCodePayStatusVo status = getStatus(aggregateNo);
+ ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getByAggregateNo(aggregateNo);
+ R merchantResult = remoteMchService.getById(session.getMchId());
+ MchManageVo merchant = merchantResult != null && merchantResult.isOk() ? merchantResult.getData() : null;
+ if (merchant == null || !ObjectUtil.equals(merchant.getTenantId(), tenant.getId())) {
+ throw new IllegalArgumentException("收款商户不可用");
+ }
+ ThirdAggregateMerchantVo merchantVo = new ThirdAggregateMerchantVo();
+ merchantVo.setId(String.valueOf(merchant.getId()));
+ merchantVo.setName(merchant.getName());
+ merchantVo.setLogoPic(merchant.getLogoPic());
+ merchantVo.setSupportedPlatforms(getEnabledPaySettings(merchant).stream().map(MchPaySettingVo::getPlatformType).distinct().toList());
+ ThirdAggregateH5ContextVo context = new ThirdAggregateH5ContextVo();
+ context.setAggregateScene(StrUtil.blankToDefault(session.getAggregateScene(), ThirdAggregatePaySceneEnum.ORDER.getCode()));
+ context.setMerchant(merchantVo);
+ context.setAmount(session.getPrice());
+ context.setSelectedPlatform(session.getSelectedPlatform());
+ context.setPayNo(session.getPayNo());
+ context.setStatus(session.getStatus());
+ context.setPaySuccess(Boolean.TRUE.equals(status.getPaySuccess()));
+ context.setExpireTime(session.getExpireTime());
+ if (ThirdAggregatePaySessionStatusEnum.PAYING.getType().equals(session.getStatus()) && !isExpired(session)) {
+ context.setLaunchParams(restoreLaunch(session).getPayParams());
+ }
+ return context;
+ });
+ }
+
/**
* 处理一条待确认聚合会话或处于补偿中的过期会话。
*
@@ -488,8 +585,14 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
* @param dto 已校验聚合支付创建请求
* @return 可直接渲染为二维码的聚合 HTTPS 地址
*/
- private ThirdAggregateOrderCodePayVo createOrReuse(ThirdAggregateOrderCodePayDto dto) {
+ private ThirdAggregateOrderCodePayVo createOrReuse(ThirdAggregateOrderCodePayDto dto, ThirdAggregatePaySceneEnum scene) {
ThirdAggregatePaySessionEntity latest = aggregatePaySessionService.getLatestByBiz(dto.getBizType(), dto.getBizId());
+ if (latest != null && scene == ThirdAggregatePaySceneEnum.OFFLINE) {
+ validateReusableSession(latest, dto);
+ if (!isActive(latest)) {
+ throw new IllegalArgumentException("本次收款请求已结束,请查询结果或重新发起");
+ }
+ }
if (latest != null && ObjectUtil.equals(latest.getStatus(), ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS.getType())) {
throw new RuntimeException("该业务订单已支付,请勿重复下单");
}
@@ -511,14 +614,17 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
assertNoBlockingNativeOrder(dto);
MchManageVo merchant = getMerchant(dto.getMchId());
- getRequiredAliPaySetting(merchant);
- getRequiredWechatPaySetting(merchant);
- validateApplicationConfig();
+ List enabledSettings = getEnabledPaySettings(merchant);
+ if (enabledSettings.isEmpty()) throw new IllegalArgumentException("商户暂未开通微信或支付宝支付");
+ for (MchPaySettingVo setting : enabledSettings) {
+ validateApplicationConfig(AppPlusPlatformTypeEnum.valueOfByType(setting.getPlatformType()));
+ }
String tenantCode = getTenantCode(merchant.getTenantId());
String accessToken = createRandomToken(ACCESS_TOKEN_BYTES);
ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
session.setAggregateNo(createAggregateNo());
+ session.setAggregateScene(scene.getCode());
session.setAccessTokenHash(sha256(accessToken));
session.setAccessTokenCiphertext(stringEncryptor.encrypt(accessToken));
session.setBizType(dto.getBizType().getType());
@@ -557,6 +663,11 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
throw new RuntimeException("支付订单正在创建,请稍后重新扫码");
}
+ // OAuth 期间渠道可能被停用;未绑定子订单时再次校验,避免先锁单再发现当前平台未开通。
+ MchManageVo merchant = getMerchant(session.getMchId());
+ if (getEnabledPaySettings(merchant).stream().noneMatch(setting -> platform.getType().equals(setting.getPlatformType()))) {
+ throw new IllegalArgumentException("商户暂不支持当前支付方式,请重新扫码");
+ }
String payNo = createPayNo();
if (!aggregatePaySessionService.selectPlatform(session.getId(), platform, payNo, payerHash)) {
ThirdAggregatePaySessionEntity latest = aggregatePaySessionService.getById(session.getId());
@@ -568,7 +679,6 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
ThirdAggregatePayCreateContext context = buildCreateContext(session, payNo);
- MchManageVo merchant = getMerchant(session.getMchId());
try {
ThirdAggregateCashierLaunch launch;
if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY)) {
@@ -812,11 +922,17 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
*/
private ThirdAggregateCashierPageData buildCashierPageData(String tenantCode, String aggregateNo,
ThirdAggregateCashierLaunch launch) {
- String ticket = createRandomToken(24);
- stringRedisTemplate.opsForValue().set(STATUS_TICKET_PREFIX + ticket, aggregateNo, STATUS_TICKET_TTL);
+ String ticket = createStatusTicket(aggregateNo);
String statusUrl = buildPublicUrl(getPlatformBaseUrl(launch.getPlatform()),
"/aggregate-order-pay/status/" + tenantCode + "/" + ticket);
- return new ThirdAggregateCashierPageData(launch, statusUrl);
+ return new ThirdAggregateCashierPageData(launch, statusUrl, h5Support.cashierUrl(tenantCode, ticket));
+ }
+
+ /** 签发只绑定当前租户聚合会话的五分钟票据,兼用于支付结果和未开通渠道的 H5 提示。 */
+ private String createStatusTicket(String aggregateNo) {
+ String ticket = createRandomToken(24);
+ stringRedisTemplate.opsForValue().set(STATUS_TICKET_PREFIX + ticket, aggregateNo, STATUS_TICKET_TTL);
+ return ticket;
}
/**
@@ -1043,12 +1159,18 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
if (merchant == null || ObjectUtil.notEqual(merchant.getId(), mchId)) {
throw new RuntimeException("收款商户不存在");
}
- if (ObjectUtil.isEmpty(merchant.getPaySettingList())) {
- throw new RuntimeException("商户未配置支付参数");
- }
return merchant;
}
+ /** 仅保留当前商户已启用的微信/支付宝配置;空列表供 H5 展示停用状态,不能用于创建付款。 */
+ private List getEnabledPaySettings(MchManageVo merchant) {
+ if (merchant.getPaySettingList() == null) return List.of();
+ return merchant.getPaySettingList().stream().filter(Objects::nonNull)
+ .filter(setting -> YesNoEnum.YES.getCode().equals(setting.getEnable()))
+ .filter(setting -> AppPlusPlatformPayType.WECHAT.getType().equals(setting.getPlatformType())
+ || AppPlusPlatformPayType.ALI_PAY.getType().equals(setting.getPlatformType())).toList();
+ }
+
/**
* 选择已启用的支付宝支付配置;直付通渠道参数在选定平台后从默认关系解析。
*
@@ -1056,7 +1178,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
* @return 当前商户支付宝配置
*/
private MchPaySettingVo getRequiredAliPaySetting(MchManageVo merchant) {
- MchPaySettingVo setting = merchant.getPaySettingList().stream()
+ MchPaySettingVo setting = getEnabledPaySettings(merchant).stream()
.filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.ALI_PAY.getType()))
.filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode()))
.findFirst().orElse(null);
@@ -1073,7 +1195,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
* @return 当前商户微信支付配置
*/
private MchPaySettingVo getRequiredWechatPaySetting(MchManageVo merchant) {
- MchPaySettingVo setting = merchant.getPaySettingList().stream()
+ MchPaySettingVo setting = getEnabledPaySettings(merchant).stream()
.filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.WECHAT.getType()))
.filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode()))
.findFirst().orElse(null);
@@ -1084,17 +1206,19 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
/**
- * 校验支付宝和微信全局应用配置满足 OAuth、JSAPI 下单、回调与验签要求。
+ * 仅校验已启用平台的应用配置,避免未开通的另一个平台阻止商户收款。
+ *
+ * @param platform 当前启用的微信或支付宝平台
*/
- private void validateApplicationConfig() {
+ private void validateApplicationConfig(AppPlusPlatformTypeEnum platform) {
AppPlusPayConfig.AliPayConfig ali = appPlusPayConfig.getAliPayConfig();
- if (ali == null || StrUtil.hasBlank(ali.getAppId(), ali.getAppPrivateKey(), ali.getAlipayPublicKey(),
- ali.getBaseCallbackUrl(), ali.getPayCallbackUrl())) {
+ if (platform == AppPlusPlatformTypeEnum.ALI_PAY && (ali == null || StrUtil.hasBlank(ali.getAppId(), ali.getAppPrivateKey(), ali.getAlipayPublicKey(),
+ ali.getBaseCallbackUrl(), ali.getPayCallbackUrl()))) {
throw new RuntimeException("支付宝应用支付配置不完整");
}
AppPlusPayConfig.WechatConfig wechat = appPlusPayConfig.getWechatConfig();
- if (wechat == null || StrUtil.hasBlank(wechat.getGzhAppId(), wechat.getGzhAppSecret(), wechat.getMerchantId(), wechat.getMchPrivatePath(),
- wechat.getMerchantSerialNumber(), wechat.getApiV3Key(), wechat.getBaseCallbackUrl(), wechat.getPayCallbackUrl())) {
+ if (platform == AppPlusPlatformTypeEnum.WECHAT && (wechat == null || StrUtil.hasBlank(wechat.getGzhAppId(), wechat.getGzhAppSecret(), wechat.getMerchantId(), wechat.getMchPrivatePath(),
+ wechat.getMerchantSerialNumber(), wechat.getApiV3Key(), wechat.getBaseCallbackUrl(), wechat.getPayCallbackUrl()))) {
throw new RuntimeException("微信服务商JSAPI配置不完整");
}
}
@@ -1195,6 +1319,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
private ThirdAggregatePayCreateContext buildCreateContext(ThirdAggregatePaySessionEntity session, String payNo) {
ThirdAggregatePayCreateContext context = new ThirdAggregatePayCreateContext();
context.setPayNo(payNo);
+ context.setAggregateScene(StrUtil.blankToDefault(session.getAggregateScene(), ThirdAggregatePaySceneEnum.ORDER.getCode()));
context.setBizId(session.getBizId());
context.setBizType(ThirdPayOrderBizTypeEnum.valueOfByType(session.getBizType()));
context.setUserId(session.getUserId());
@@ -1274,7 +1399,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
* @return 完整 HTTPS 地址
*/
private String buildPublicUrl(String baseUrl, String path) {
- return normalizeBaseUrl(baseUrl) + PUBLIC_ROUTE_PREFIX + path;
+ return normalizeBaseUrl(baseUrl) + h5Support.publicRoutePrefix() + path;
}
/**
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java
index 80fd7c6c..7e61b3c7 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java
@@ -302,6 +302,7 @@ public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatP
payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType());
payOrder.setBizType(context.getBizType().getType());
payOrder.setBizId(context.getBizId());
+ payOrder.setAggregateScene(context.getAggregateScene());
payOrder.setUserId(context.getUserId());
payOrder.setMchId(context.getMchId());
payOrder.setPrice(context.getAmount());
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImpl.java
new file mode 100644
index 00000000..f8763721
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImpl.java
@@ -0,0 +1,126 @@
+package com.bsd.pjcloud.thirdparty.service.impl.pay;
+
+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.core.constant.enums.YesNoEnum;
+import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
+import com.bsd.pjcloud.thirdparty.config.AggregatePayProperties;
+import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
+import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantEntryDto;
+import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantCreateDto;
+import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateH5Support;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateMerchantPayService;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
+import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantVo;
+import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantCreateVo;
+import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
+import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
+import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.Locale;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Supplier;
+import org.springframework.web.server.ResponseStatusException;
+
+/** 线下收款实现;租户、商户及金额在创建前固定,后续仅复用现有聚合支付链路。 */
+@Service
+@RequiredArgsConstructor
+public class ThirdAggregateMerchantPayServiceImpl implements ThirdAggregateMerchantPayService {
+ /** 仅后端可访问的签名和固定跳转配置。 */
+ private final ThirdAggregateH5Support h5Support;
+ /** 复用既有聚合会话、平台互斥、OAuth 和渠道补偿。 */
+ private final ThirdAggregateOrderPayService aggregateService;
+ /** 钱包商户远程契约,返回实体只用于服务内部,不直接透传给匿名请求。 */
+ private final RemoteMchService remoteMchService;
+ /** 收款限额,默认最多 999999 分。 */
+ private final AggregatePayProperties properties;
+
+ /** 只从内部调用的当前租户生成签名,商户渠道配置完成后的刷新操作才会签发。 */
+ @Override
+ public String generateLink(Long merchantId) {
+ aggregateService.validateMerchantPayConfiguration(merchantId);
+ return h5Support.merchantUrl(TenantContextHolder.getTenantId(), merchantId);
+ }
+
+ /** 先验签再切换租户查询,客户端 TENANT-ID 不能覆盖已签名的租户。 */
+ @Override
+ public ThirdAggregateMerchantVo getMerchant(ThirdAggregateMerchantEntryDto entry, HttpServletRequest request) {
+ h5Support.verify(entry);
+ return inTenant(entry.getTenantId(), () -> {
+ h5Support.checkRate(request, false);
+ return publicMerchant(entry.getId(), entry.getTenantId());
+ });
+ }
+
+ /** 同一 requestId 派生同一业务请求号,网络重试不能创建不同的付款意图。 */
+ @Override
+ public ThirdAggregateMerchantCreateVo create(ThirdAggregateMerchantCreateDto dto, HttpServletRequest request) {
+ h5Support.verify(dto);
+ h5Support.baseUrl();
+ if (dto.getAmount() == null || dto.getAmount() <= 0 || dto.getAmount() > properties.getOfflineMaxAmount()) {
+ throw new IllegalArgumentException("收款金额超过允许范围");
+ }
+ String client = Objects.toString(request.getHeader("User-Agent"), "").toLowerCase(Locale.ROOT);
+ if (!client.contains("micromessenger") && !client.contains("alipayclient")) {
+ throw new IllegalArgumentException("请使用微信或支付宝扫一扫付款");
+ }
+ String platform = client.contains("micromessenger") ? AppPlusPlatformTypeEnum.WECHAT.getType() : AppPlusPlatformTypeEnum.ALI_PAY.getType();
+ return inTenant(dto.getTenantId(), () -> {
+ h5Support.checkRate(request, true);
+ ThirdAggregateMerchantVo merchant = publicMerchant(dto.getId(), dto.getTenantId());
+ // 前端提示不能代替服务端校验,配置在扫码后停用时也必须在创建会话前拒绝。
+ if (!merchant.getSupportedPlatforms().contains(platform)) {
+ throw new IllegalArgumentException("商户暂不支持" + (AppPlusPlatformTypeEnum.WECHAT.getType().equals(platform) ? "微信" : "支付宝") + "支付,请使用商户已开通的支付方式");
+ }
+ aggregateService.validateMerchantPayConfiguration(dto.getId());
+ ThirdAggregateOrderCodePayDto internal = new ThirdAggregateOrderCodePayDto();
+ internal.setMchId(dto.getId());
+ internal.setBizType(ThirdPayOrderBizTypeEnum.MCH_OFFLINE_PAY);
+ internal.setBizId("OFFLINE:" + dto.getId() + ":" + dto.getRequestId());
+ internal.setAmount(dto.getAmount());
+ ThirdAggregateOrderCodePayVo created = aggregateService.createOffline(internal);
+ ThirdAggregateMerchantCreateVo result = new ThirdAggregateMerchantCreateVo();
+ result.setAggregateNo(created.getAggregateNo());
+ result.setCashierUrl(created.getQrCode());
+ result.setExpireTime(created.getExpireTime());
+ return result;
+ });
+ }
+
+ /** 恢复租户后保留限流的 HTTP 429 语义,其余远程或数据异常仍由公开接口统一隐藏。 */
+ private T inTenant(Long tenantId, Supplier action) {
+ try {
+ return TenantBroker.applyAs(tenantId, ignored -> action.get());
+ } catch (TenantBroker.TenantBrokerExceptionWrapper exception) {
+ if (exception.getCause() instanceof ResponseStatusException statusException) throw statusException;
+ throw exception;
+ }
+ }
+
+ /** 最小公开投影,并再次核对远程商户的真实租户归属。 */
+ private ThirdAggregateMerchantVo publicMerchant(Long id, Long tenantId) {
+ R result = remoteMchService.getById(id);
+ MchManageVo merchant = result != null && result.isOk() ? result.getData() : null;
+ if (merchant == null || !Objects.equals(merchant.getId(), id) || !Objects.equals(merchant.getTenantId(), tenantId)) {
+ throw new IllegalArgumentException("收款链接不可用,请重新扫码");
+ }
+ ThirdAggregateMerchantVo view = new ThirdAggregateMerchantVo();
+ view.setId(String.valueOf(merchant.getId()));
+ view.setName(merchant.getName());
+ view.setLogoPic(merchant.getLogoPic());
+ // 匿名响应只输出开通渠道的编码,不向付款人暴露配置、商户号或结算关系。
+ view.setSupportedPlatforms(merchant.getPaySettingList() == null ? List.of() : merchant.getPaySettingList().stream()
+ .filter(Objects::nonNull)
+ .filter(setting -> YesNoEnum.YES.getCode().equals(setting.getEnable()))
+ .map(setting -> setting.getPlatformType())
+ .filter(platform -> AppPlusPlatformTypeEnum.WECHAT.getType().equals(platform) || AppPlusPlatformTypeEnum.ALI_PAY.getType().equals(platform))
+ .distinct().toList());
+ return view;
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java
index 31bd5391..8c334c3f 100755
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java
@@ -23,6 +23,8 @@ import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper;
import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
+import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -66,6 +68,10 @@ public class ThirdPayOrderServiceImpl extends ServiceImpl order.setMchPaySettingJson(null));
}
+ /** 导出复用分页的同一筛选 SQL,包含时间范围、商户和聚合场景,保持相同的脱敏投影。 */
+ @Override
+ public List exportList(ThirdPayOrderDto dto, Long[] ids) {
+ List rows = baseMapper.scopeList(dto, ids, null);
+ disposePageList(rows);
+ return rows;
+ }
+
+ /** 以当前结果集的支付单号一次查询聚合关联,不逐行请求数据库或远程服务。 */
+ private void fillAggregateSessions(List rows) {
+ if (aggregatePaySessionService == null) return;
+ List payNos = rows.stream().map(ThirdPayOrderEntity::getPayNo).filter(StrUtil::isNotBlank).distinct().toList();
+ if (payNos.isEmpty()) return;
+ Map sessions = aggregatePaySessionService.list(
+ new LambdaQueryWrapper().in(ThirdAggregatePaySessionEntity::getPayNo, payNos))
+ .stream().collect(Collectors.toMap(ThirdAggregatePaySessionEntity::getPayNo, Function.identity(), (first, second) -> first));
+ for (ThirdPayOrderVo row : rows) {
+ ThirdAggregatePaySessionEntity session = sessions.get(row.getPayNo());
+ if (session != null) row.setAggregateNo(session.getAggregateNo());
+ }
+ }
+
/**
* 批量加载支付用户,远程服务异常或缺失数据不阻断支付订单列表。
*
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayOrderMapper.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayOrderMapper.xml
index a120f539..f9ab9c2d 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayOrderMapper.xml
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayOrderMapper.xml
@@ -8,6 +8,7 @@
a.third_pay_no,
a.pay_platform_type,
a.biz_type,
+ a.aggregate_scene,
a.biz_id,
a.user_id,
a.user_code,
@@ -53,6 +54,9 @@
and a.biz_type = #{dto.bizType}
+
+ and a.aggregate_scene = #{dto.aggregateScene}
+
and a.user_id = #{dto.userId}
@@ -75,4 +79,16 @@
order by a.create_time desc, a.id desc
+
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayControllerTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayControllerTest.java
new file mode 100644
index 00000000..09c6f313
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayControllerTest.java
@@ -0,0 +1,41 @@
+package com.bsd.pjcloud.thirdparty.controller.appplus;
+
+import com.bsd.pjcloud.thirdparty.config.AggregatePayProperties;
+import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateH5Support;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.http.ResponseEntity;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/** OAuth 收银出口必须通过 302 导航,不能继续输出 HTML 或把异常带到公网 URL。 */
+class ThirdAggregateOrderPayControllerTest {
+ /** 正常回调只输出 Location,正文不包含渠道参数。 */
+ @Test
+ void oauthShouldRedirectToH5WithoutHtmlBody() {
+ ThirdAggregateOrderPayService service = mock(ThirdAggregateOrderPayService.class);
+ AggregatePayProperties config = new AggregatePayProperties(); config.setH5BaseUrl("https://pay.example.com");
+ ThirdAggregateH5Support support = new ThirdAggregateH5Support(config, mock(StringRedisTemplate.class));
+ String target = support.cashierUrl("tenant", "a".repeat(32));
+ when(service.handleAlipayOauth("code", "state")).thenReturn(new ThirdAggregateCashierPageData(null, null, target));
+ ResponseEntity response = new ThirdAggregateOrderPayController(service, support).alipayOauth("code", "state");
+ assertEquals(302, response.getStatusCode().value());
+ assertEquals(target, response.getHeaders().getLocation().toString());
+ assertNull(response.getBody());
+ assertEquals("no-store", response.getHeaders().getCacheControl());
+ }
+
+ /** 授权失败跳转固定提示入口,不反射渠道原始异常或用户参数。 */
+ @Test
+ void oauthFailureShouldNotLeakException() {
+ ThirdAggregateOrderPayService service = mock(ThirdAggregateOrderPayService.class);
+ AggregatePayProperties config = new AggregatePayProperties(); config.setH5BaseUrl("https://pay.example.com");
+ when(service.handleWechatOauth("tenant", "code", "state")).thenThrow(new RuntimeException("private-message"));
+ ResponseEntity response = new ThirdAggregateOrderPayController(service,
+ new ThirdAggregateH5Support(config, mock(StringRedisTemplate.class))).wechatOauth("tenant", "code", "state");
+ assertEquals("https://pay.example.com/#/pages/pay/index?error=UNAVAILABLE", response.getHeaders().getLocation().toString());
+ assertNull(response.getBody());
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5SupportTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5SupportTest.java
new file mode 100644
index 00000000..dc234132
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5SupportTest.java
@@ -0,0 +1,93 @@
+package com.bsd.pjcloud.thirdparty.service;
+
+import com.bsd.pjcloud.thirdparty.config.AggregatePayProperties;
+import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantEntryDto;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.script.RedisScript;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/** 商户签名、固定跳转和匿名限流的安全边界测试,不连接真实 Redis 或支付平台。 */
+class ThirdAggregateH5SupportTest {
+ /** 仅供测试的固定随机密钥,不能用于任何部署环境。 */
+ private static final String TEST_KEY = "0123456789abcdef0123456789abcdef";
+
+ /** 返回完整测试配置,域名为保留的 example.com。 */
+ private AggregatePayProperties properties() {
+ AggregatePayProperties properties = new AggregatePayProperties();
+ properties.setH5BaseUrl("https://pay.example.com/");
+ properties.setMerchantSigningKey(TEST_KEY);
+ return properties;
+ }
+
+ /** 正常凭据通过,任何商户、租户或版本篡改均不能跨越签名边界。 */
+ @Test
+ void signatureShouldBindPurposeTenantMerchantAndVersion() {
+ ThirdAggregateH5Support support = new ThirdAggregateH5Support(properties(), mock(StringRedisTemplate.class));
+ ThirdAggregateMerchantEntryDto entry = new ThirdAggregateMerchantEntryDto();
+ entry.setId(88L); entry.setTenantId(2L); entry.setV("1"); entry.setSign(support.sign(2L, 88L));
+ assertDoesNotThrow(() -> support.verify(entry));
+ entry.setId(89L);
+ assertThrows(IllegalArgumentException.class, () -> support.verify(entry));
+ entry.setId(88L); entry.setTenantId(3L);
+ assertThrows(IllegalArgumentException.class, () -> support.verify(entry));
+ entry.setTenantId(2L); entry.setV("2");
+ assertThrows(IllegalArgumentException.class, () -> support.verify(entry));
+ }
+
+ /** 禁止空密钥、HTTP、用户信息和查询参数进入固定回跳配置。 */
+ @Test
+ void shouldRejectWeakSecretsAndUnsafeRedirectConfiguration() {
+ AggregatePayProperties config = properties();
+ ThirdAggregateH5Support support = new ThirdAggregateH5Support(config, mock(StringRedisTemplate.class));
+ config.setMerchantSigningKey("short");
+ assertThrows(IllegalStateException.class, () -> support.sign(1L, 2L));
+ for (String url : List.of("http://pay.example.com", "javascript:alert(1)", "https://user@pay.example.com", "https://pay.example.com/?next=x", "https://pay.example.com/#/other")) {
+ config.setH5BaseUrl(url);
+ assertThrows(IllegalStateException.class, support::baseUrl);
+ }
+ }
+
+ /** 新入口保持 id 参数,OAuth 回跳不能包含密钥或渠道调起参数。 */
+ @Test
+ void shouldOnlyPutOpaqueTicketInCashierRedirect() {
+ ThirdAggregateH5Support support = new ThirdAggregateH5Support(properties(), mock(StringRedisTemplate.class));
+ String url = support.merchantUrl(2L, 88L);
+ assertTrue(url.startsWith("https://pay.example.com/#/pages/pay/index?id=88&tenantId=2&v=1&sign="));
+ assertFalse(url.contains(TEST_KEY));
+ assertEquals("https://pay.example.com/#/pages/pay/index?tenantCode=tenant-code&ticket=opaque-ticket",
+ support.cashierUrl("tenant-code", "opaque-ticket"));
+ }
+
+ /** Boot 无网关前缀和默认微服务前缀均可配置,拒绝其他任意路径。 */
+ @Test
+ void shouldSupportBootPublicRoutes() {
+ AggregatePayProperties config = properties();
+ ThirdAggregateH5Support support = new ThirdAggregateH5Support(config, mock(StringRedisTemplate.class));
+ assertEquals("/third", support.publicRoutePrefix());
+ config.setPublicRoutePrefix("");
+ assertEquals("", support.publicRoutePrefix());
+ config.setPublicRoutePrefix("//other.example.com");
+ assertThrows(IllegalStateException.class, support::publicRoutePrefix);
+ }
+
+ /** 伪造 X-Real-IP 不改变限流来源,只有明确配置的直连代理可以覆盖地址。 */
+ @Test
+ @SuppressWarnings("unchecked")
+ void shouldRateLimitDirectAddressAndRejectOverflow() {
+ StringRedisTemplate redis = mock(StringRedisTemplate.class);
+ when(redis.execute(any(RedisScript.class), any(List.class))).thenReturn(121L);
+ ThirdAggregateH5Support support = new ThirdAggregateH5Support(properties(), redis);
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setRemoteAddr("192.0.2.10"); request.addHeader("X-Real-IP", "198.51.100.1");
+ assertThrows(ResponseStatusException.class, () -> support.checkRate(request, false));
+ verify(redis).execute(any(RedisScript.class), eq(List.of("thirdparty:aggregate-h5:rate:query:192.0.2.10")));
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java
index e34e516f..c9a17899 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java
@@ -7,6 +7,9 @@ import com.bsd.pjcloud.admin.api.feign.RemoteTenantService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
+import com.bsd.pjcloud.thirdparty.config.AggregatePayProperties;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateH5Support;
+import org.springframework.test.util.ReflectionTestUtils;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
@@ -14,6 +17,7 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
+import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateAliPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
@@ -45,8 +49,10 @@ import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -146,6 +152,10 @@ class ThirdAggregateOrderPayServiceImplTest {
aggregateWechatPayService, thirdPayOrderService, mchFzRelationService, remoteMchService,
remoteTenantService, appPlusPayConfig, stringRedisTemplate, stringEncryptor, pollingCoordinator);
+ AggregatePayProperties h5Properties = new AggregatePayProperties();
+ h5Properties.setH5BaseUrl("https://pay.example.com");
+ ReflectionTestUtils.setField(service, "h5Support", new ThirdAggregateH5Support(h5Properties, stringRedisTemplate));
+
lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
lenient().when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true);
@@ -164,7 +174,7 @@ class ThirdAggregateOrderPayServiceImplTest {
}
/**
- * 验证新会话同时校验双平台配置,并只返回独立 aggregate-order-pay HTTPS 地址。
+ * 验证新会话校验商户已启用的配置,并只返回独立 aggregate-order-pay HTTPS 地址。
*/
@Test
void createShouldPersistHashedTokenAndReturnAggregateQrCode() {
@@ -193,6 +203,23 @@ class ThirdAggregateOrderPayServiceImplTest {
verify(pollingCoordinator).wakeAggregatePolling();
}
+ /** 线下收款使用独立业务类型与场景,仍复用原支付会话而不创建消费机业务订单。 */
+ @Test
+ void offlineShouldPersistSeparateSceneAndBusinessType() {
+ request.setBizType(ThirdPayOrderBizTypeEnum.MCH_OFFLINE_PAY);
+ request.setBizId("OFFLINE:88:test-request");
+ when(aggregatePaySessionService.getLatestByBiz(request.getBizType(), request.getBizId())).thenReturn(null);
+ when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of());
+ when(remoteMchService.getById(request.getMchId())).thenReturn(R.ok(merchant));
+ when(stringEncryptor.encrypt(anyString())).thenReturn("encrypted-access-token");
+ when(aggregatePaySessionService.save(any(ThirdAggregatePaySessionEntity.class))).thenReturn(true);
+ service.createOffline(request);
+ ArgumentCaptor capture = ArgumentCaptor.forClass(ThirdAggregatePaySessionEntity.class);
+ verify(aggregatePaySessionService).save(capture.capture());
+ Assertions.assertEquals("OFFLINE", capture.getValue().getAggregateScene());
+ Assertions.assertEquals("70", capture.getValue().getBizType());
+ }
+
/**
* 验证同业务未过期会话复用原访问令牌,不再次读取商户或创建数据库记录。
*/
@@ -210,21 +237,79 @@ class ThirdAggregateOrderPayServiceImplTest {
verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class));
}
- /**
- * 验证缺少任一平台商户配置时不创建聚合会话,避免生成只能被单平台使用的伪聚合码。
- */
+ /** 商户只开通一个平台时允许创建会话,未开通平台不要求全局应用凭据。 */
@Test
- void createShouldRejectMerchantWithoutWechatPaySetting() {
- merchant.setPaySettingList(List.of(merchant.getPaySettingList().get(0)));
+ void createShouldAllowSingleEnabledPlatform() {
+ List settings = merchant.getPaySettingList();
+ AppPlusPayConfig config = (AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig");
when(aggregatePaySessionService.getLatestByBiz(request.getBizType(), request.getBizId())).thenReturn(null);
when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of());
when(remoteMchService.getById(request.getMchId())).thenReturn(R.ok(merchant));
+ when(stringEncryptor.encrypt(anyString())).thenReturn("encrypted-access-token");
+ when(aggregatePaySessionService.save(any(ThirdAggregatePaySessionEntity.class))).thenReturn(true);
+ merchant.setPaySettingList(List.of(settings.get(0)));
+ AppPlusPayConfig.WechatConfig wechat = config.getWechatConfig();
+ config.setWechatConfig(null);
+ Assertions.assertNotNull(service.create(request));
+
+ merchant.setPaySettingList(List.of(settings.get(1)));
+ config.setWechatConfig(wechat);
+ config.setAliPayConfig(null);
+ Assertions.assertNotNull(service.create(request));
+ verify(aggregatePaySessionService, times(2)).save(any(ThirdAggregatePaySessionEntity.class));
+ }
+
+ /** 两个渠道都停用时拒绝创建,避免产生没有付款入口的会话。 */
+ @Test
+ void createShouldRejectMerchantWithoutEnabledPlatform() {
+ merchant.getPaySettingList().forEach(setting -> setting.setEnable(YesNoEnum.NO.getCode()));
+ when(aggregatePaySessionService.getLatestByBiz(request.getBizType(), request.getBizId())).thenReturn(null);
+ when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of());
+ when(remoteMchService.getById(request.getMchId())).thenReturn(R.ok(merchant));
Assertions.assertThrows(RuntimeException.class, () -> service.create(request));
-
verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class));
}
+ /** 仅微信启用时签发收款码,不校验已停用的支付宝应用或分佣关系。 */
+ @Test
+ void merchantCodeShouldValidateOnlyEnabledWechat() {
+ MchPaySettingVo ali = merchant.getPaySettingList().get(0);
+ MchPaySettingVo wechat = merchant.getPaySettingList().get(1);
+ ali.setEnable(YesNoEnum.NO.getCode());
+ ((AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig")).setAliPayConfig(null);
+ when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant));
+ Assertions.assertDoesNotThrow(() -> service.validateMerchantPayConfiguration(88L));
+ verify(mchFzRelationService).findBoundDefault(wechat);
+ verify(mchFzRelationService, never()).findBoundDefault(ali);
+ }
+
+ /** 仅支付宝启用时只校验其已绑定关系,微信应用缺省不影响签发。 */
+ @Test
+ void merchantCodeShouldValidateOnlyEnabledAlipay() {
+ MchPaySettingVo ali = merchant.getPaySettingList().get(0);
+ merchant.setPaySettingList(List.of(ali));
+ ((AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig")).setWechatConfig(null);
+ ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity();
+ when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant));
+ when(mchFzRelationService.findBoundDefault(ali)).thenReturn(relation);
+ when(mchFzRelationService.createPaymentSettingSnapshot(ali, relation)).thenReturn(ali);
+ Assertions.assertDoesNotThrow(() -> service.validateMerchantPayConfiguration(88L));
+ }
+
+ /** 错误 App 扫原订单二维码时直接进 H5 提示,不发起 OAuth、渠道下单或平台锁定。 */
+ @Test
+ void unsupportedClientShouldEnterH5WithoutOauth() {
+ merchant.setPaySettingList(List.of(merchant.getPaySettingList().get(1)));
+ when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant));
+ when(aggregatePaySessionService.getByAccessTokenHash(anyString())).thenReturn(reusableSession());
+ String url = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10");
+ Assertions.assertTrue(url.startsWith("https://pay.example.com/#/pages/pay/index?tenantCode=tenant-code&ticket="));
+ verify(valueOperations).set(anyString(), eq("AGG202608050001"), eq(Duration.ofMinutes(5)));
+ verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService);
+ verify(aggregatePaySessionService, never()).selectPlatform(any(), any(), anyString(), anyString());
+ }
+
/**
* 验证支付宝扫码授权统一使用不含租户编码的固定回调地址,租户信息由一次性 state 承载。
*/
@@ -232,6 +317,7 @@ class ThirdAggregateOrderPayServiceImplTest {
void alipayOauthShouldUseFixedCallbackUrl() {
ThirdAggregatePaySessionEntity session = reusableSession();
when(aggregatePaySessionService.getByAccessTokenHash(anyString())).thenReturn(session);
+ when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant));
String oauthUrl = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10.6.80");
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImplTest.java
new file mode 100644
index 00000000..08b78cfc
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImplTest.java
@@ -0,0 +1,117 @@
+package com.bsd.pjcloud.thirdparty.service.impl.pay;
+
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
+import com.bsd.pjcloud.thirdparty.config.AggregatePayProperties;
+import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
+import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantCreateDto;
+import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateH5Support;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
+import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
+import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
+import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
+import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.mock.web.MockHttpServletRequest;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/** 线下收款的固定业务类型、金额上限、幂等编号和租户恢复测试。 */
+class ThirdAggregateMerchantPayServiceImplTest {
+ /** 构造已由签名辅助服务校验的测试请求,单位为分。 */
+ private ThirdAggregateMerchantCreateDto request(int amount) {
+ ThirdAggregateMerchantCreateDto dto = new ThirdAggregateMerchantCreateDto();
+ dto.setId(88L); dto.setTenantId(2L); dto.setV("1"); dto.setSign("a".repeat(64));
+ dto.setAmount(amount); dto.setRequestId("12345678-1234-4234-8234-123456789abc");
+ return dto;
+ }
+
+ /** 重试派生同一收款请求号,不能误入消费机订单或钱包充值回调。 */
+ @Test
+ void retryShouldKeepSameRequestAndRestoreSignedTenant() {
+ ThirdAggregateH5Support support = mock(ThirdAggregateH5Support.class);
+ ThirdAggregateOrderPayService aggregate = mock(ThirdAggregateOrderPayService.class);
+ RemoteMchService remote = mock(RemoteMchService.class);
+ ThirdAggregateMerchantPayServiceImpl service = new ThirdAggregateMerchantPayServiceImpl(support, aggregate, remote, new AggregatePayProperties());
+ MchManageVo merchant = new MchManageVo();
+ merchant.setId(88L); merchant.setTenantId(2L); merchant.setName("测试商户");
+ MchPaySettingVo alipay = new MchPaySettingVo();
+ alipay.setPlatformType("20"); alipay.setEnable(YesNoEnum.YES.getCode());
+ merchant.setPaySettingList(List.of(alipay));
+ when(remote.getById(88L)).thenAnswer(invocation -> {
+ assertEquals(2L, TenantContextHolder.getTenantId());
+ return R.ok(merchant);
+ });
+ ThirdAggregateOrderCodePayVo created = new ThirdAggregateOrderCodePayVo();
+ created.setAggregateNo("aggregate-test"); created.setQrCode("https://api.example.com/cashier/token");
+ when(aggregate.createOffline(any())).thenReturn(created);
+ MockHttpServletRequest http = new MockHttpServletRequest(); http.addHeader("User-Agent", "AlipayClient/10");
+ TenantContextHolder.setTenantId(1L);
+ try {
+ service.create(request(1234), http); service.create(request(1234), http);
+ assertEquals(1L, TenantContextHolder.getTenantId());
+ ArgumentCaptor capture = ArgumentCaptor.forClass(ThirdAggregateOrderCodePayDto.class);
+ verify(aggregate, times(2)).createOffline(capture.capture());
+ assertEquals(capture.getAllValues().get(0).getBizId(), capture.getAllValues().get(1).getBizId());
+ assertEquals(ThirdPayOrderBizTypeEnum.MCH_OFFLINE_PAY, capture.getValue().getBizType());
+ assertEquals(1234, capture.getValue().getAmount());
+ } finally { TenantContextHolder.clear(); }
+ }
+
+ /** 零、负数和超过 9,999.99 元时不得访问商户或创建平台订单。 */
+ @Test
+ void invalidAmountShouldNotCreatePayment() {
+ ThirdAggregateOrderPayService aggregate = mock(ThirdAggregateOrderPayService.class);
+ RemoteMchService remote = mock(RemoteMchService.class);
+ ThirdAggregateMerchantPayServiceImpl service = new ThirdAggregateMerchantPayServiceImpl(mock(ThirdAggregateH5Support.class), aggregate, remote, new AggregatePayProperties());
+ for (int amount : new int[]{0, -1, 1000000}) {
+ assertThrows(IllegalArgumentException.class, () -> service.create(request(amount), new MockHttpServletRequest()));
+ }
+ verifyNoInteractions(aggregate, remote);
+ }
+
+ /** 匿名查询只返回启用渠道编码,停用渠道及其他支付类型均不能成为可用付款入口。 */
+ @Test
+ void merchantShouldExposeOnlyEnabledChannels() {
+ ThirdAggregateOrderPayService aggregate = mock(ThirdAggregateOrderPayService.class);
+ RemoteMchService remote = mock(RemoteMchService.class);
+ ThirdAggregateMerchantPayServiceImpl service = new ThirdAggregateMerchantPayServiceImpl(mock(ThirdAggregateH5Support.class), aggregate, remote, new AggregatePayProperties());
+ MchManageVo merchant = new MchManageVo();
+ merchant.setId(88L); merchant.setTenantId(2L);
+ MchPaySettingVo wechat = new MchPaySettingVo();
+ wechat.setPlatformType("10"); wechat.setEnable(YesNoEnum.YES.getCode());
+ MchPaySettingVo alipay = new MchPaySettingVo();
+ alipay.setPlatformType("20"); alipay.setEnable(YesNoEnum.NO.getCode());
+ MchPaySettingVo other = new MchPaySettingVo();
+ other.setPlatformType("30"); other.setEnable(YesNoEnum.YES.getCode());
+ merchant.setPaySettingList(List.of(wechat, alipay, other));
+ when(remote.getById(88L)).thenReturn(R.ok(merchant));
+ assertEquals(List.of("10"), service.getMerchant(request(100), new MockHttpServletRequest()).getSupportedPlatforms());
+ alipay.setEnable(YesNoEnum.YES.getCode());
+ assertEquals(List.of("10", "20"), service.getMerchant(request(100), new MockHttpServletRequest()).getSupportedPlatforms());
+ }
+
+ /** 商户只开通微信时,支付宝请求必须在创建聚合会话前被后端拒绝。 */
+ @Test
+ void unsupportedClientMustNotCreateSession() {
+ ThirdAggregateOrderPayService aggregate = mock(ThirdAggregateOrderPayService.class);
+ RemoteMchService remote = mock(RemoteMchService.class);
+ ThirdAggregateMerchantPayServiceImpl service = new ThirdAggregateMerchantPayServiceImpl(mock(ThirdAggregateH5Support.class), aggregate, remote, new AggregatePayProperties());
+ MchManageVo merchant = new MchManageVo();
+ merchant.setId(88L); merchant.setTenantId(2L);
+ MchPaySettingVo wechat = new MchPaySettingVo();
+ wechat.setPlatformType("10"); wechat.setEnable(YesNoEnum.YES.getCode());
+ merchant.setPaySettingList(List.of(wechat));
+ when(remote.getById(88L)).thenReturn(R.ok(merchant));
+ MockHttpServletRequest http = new MockHttpServletRequest();
+ http.addHeader("User-Agent", "AlipayClient/10");
+ RuntimeException error = assertThrows(RuntimeException.class, () -> service.create(request(100), http));
+ assertTrue(error.getMessage().contains("商户暂不支持支付宝支付"));
+ verifyNoInteractions(aggregate);
+ }
+}
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java
index 63df6731..3a32ab12 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java
@@ -148,10 +148,14 @@ public class SysUserServiceImpl extends ServiceImpl impl
private final SysUserPostMapper sysUserPostMapper;
- /** 角色批量查询 Mapper,用于按当前分页用户一次性回填角色。 */
+ /**
+ * 角色批量查询 Mapper,用于按当前分页用户一次性回填角色。
+ */
private final SysRoleMapper sysRoleMapper;
- /** 岗位批量查询 Mapper,用于按当前分页用户一次性回填岗位。 */
+ /**
+ * 岗位批量查询 Mapper,用于按当前分页用户一次性回填岗位。
+ */
private final SysPostMapper sysPostMapper;
private final CacheManager cacheManager;
@@ -217,7 +221,9 @@ public class SysUserServiceImpl extends ServiceImpl impl
}
}
- /** 演示用户统一初始密码(仅喂数路径),整批只 BCrypt 一次 */
+ /**
+ * 演示用户统一初始密码(仅喂数路径),整批只 BCrypt 一次
+ */
private static final String AI_FEED_DEFAULT_PASSWORD = "AiFeed@123456";
/**
@@ -358,7 +364,7 @@ public class SysUserServiceImpl extends ServiceImpl impl
* 操作人角色快照:一次或一批用户创建期间,操作人的角色集合与权限结论保持不变。
*/
private record OperationRoleSnapshot(boolean hasApplyUser, List applyUserRoles, boolean roleAdmin,
- List nonAdminNewRoleIds) {
+ List nonAdminNewRoleIds) {
}
/**
@@ -366,9 +372,9 @@ public class SysUserServiceImpl extends ServiceImpl impl
* 只有当前上下文明确定义了 {@code ROLE_ADMIN} 时才保留请求角色;其他操作人以及无认证上下文的调用,
* 请求角色都会被固定替换为普通用户和自动创建钱包角色。免审参数只控制审批流程,不放宽角色权限。
*
- * @param userDto 用户创建参数;普通后台创建场景中的角色可能被安全规则覆盖
+ * @param userDto 用户创建参数;普通后台创建场景中的角色可能被安全规则覆盖
* @param skipAudit 是否跳过新增用户审批,不影响角色选择权限
- * @param snapshot 预先解析的操作人角色快照
+ * @param snapshot 预先解析的操作人角色快照
* @return 新建用户ID
*/
private Long doSaveUser(UserDTO userDto, boolean skipAudit, OperationRoleSnapshot snapshot) {
@@ -492,8 +498,8 @@ public class SysUserServiceImpl extends ServiceImpl impl
/**
* 分页查询用户信息,并批量补齐角色、人脸、身份及身份扩展资料。
*
- * @param page 分页参数,包含当前页和每页数量
- * @param userDTO 用户筛选条件及是否查询审批状态的控制参数
+ * @param page 分页参数,包含当前页和每页数量
+ * @param userDTO 用户筛选条件及是否查询审批状态的控制参数
* @param dataScope 当前调用人的数据权限范围;为空时不附加数据范围限制
* @return 含角色、人脸、身份和审批信息的用户分页结果
*/
@@ -529,8 +535,8 @@ public class SysUserServiceImpl extends ServiceImpl impl
* 预约等业务列表只需要用户、部门和身份信息,因此这里使用无嵌套角色、岗位映射的专用 SQL,
* 同时不返回人脸资料和审批信息,但保留人脸录入状态筛选。
*
- * @param page 分页参数
- * @param userDTO 用户关键字、部门树和指定用户筛选条件
+ * @param page 分页参数
+ * @param userDTO 用户关键字、部门树和指定用户筛选条件
* @param dataScope 当前调用人的数据权限范围;为空时不附加数据范围限制
* @return 已批量补齐身份类型、学生资料和老师资料的轻量用户分页
*/
@@ -562,7 +568,7 @@ public class SysUserServiceImpl extends ServiceImpl impl
public IPage getSelectUsersPage(Page page, UserDTO userDTO) {
// 下拉组件按关键词远程搜索,不展示总条数,关闭 COUNT 查询。
page.setSearchCount(false);
- return baseMapper.getBasicUserPage(page, userDTO, null);
+ return baseMapper.getBasicUserPage(page, userDTO, new DataScope().only(true));
}
/**
@@ -570,8 +576,8 @@ public class SysUserServiceImpl extends ServiceImpl impl
*
* 复用现有角色、岗位映射,保持与系统用户导出字段一致;不查询人脸和审批流程,避免后台分片导出执行无关远程调用。
*
- * @param page 分页参数
- * @param userDTO 用户关键字、部门树和指定用户筛选条件
+ * @param page 分页参数
+ * @param userDTO 用户关键字、部门树和指定用户筛选条件
* @param dataScope 当前调用人的数据权限范围;为空时不附加数据范围限制
* @return 已补齐主资料、角色岗位和身份扩展的用户分页
*/
@@ -906,7 +912,7 @@ public class SysUserServiceImpl extends ServiceImpl impl
* 新部门据此新增人脸。重复或乱序消息由终端按用户回查 V3 真源实现幂等。
*
* @param databaseUser 更新前的数据库用户,用于取得旧部门
- * @param updatedUser 本次待更新用户,用于取得新部门;未提交部门字段时不发送通知
+ * @param updatedUser 本次待更新用户,用于取得新部门;未提交部门字段时不发送通知
*/
private void publishDepartmentFaceChange(SysUser databaseUser, SysUser updatedUser) {
if (updatedUser.getDeptId() == null || Objects.equals(databaseUser.getDeptId(), updatedUser.getDeptId())) {
@@ -1143,15 +1149,13 @@ public class SysUserServiceImpl extends ServiceImpl impl
this.removeBatchByIds(existIds);
try {
remoteWalletService.delWallet(existIds);
- }
- catch (Exception ex) {
+ } catch (Exception ex) {
log.warn("AI喂数清理钱包失败 userIds={}", existIds, ex);
}
for (Long userId : existIds) {
try {
remoteWalletService.userChange(userId, Boolean.TRUE);
- }
- catch (Exception ex) {
+ } catch (Exception ex) {
log.warn("AI喂数清理钱包同步失败 userId={}", userId, ex);
}
}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java
index 1e71e174..cef04a2d 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java
@@ -30,6 +30,9 @@ import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService;
import com.bsd.pjcloud.hard.api.utils.Drawing2D;
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdLbApiService;
+import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService;
+import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
+import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.thirdparty.dto.lb.LbH5LoginDto;
import com.bsd.pjcloud.thirdparty.vo.lb.LbH5LoginVo;
import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchSupType;
@@ -97,6 +100,9 @@ public class MchManageServiceImpl extends ServiceImpl settings = paySettingService.list(new LambdaQueryWrapper()
+ .eq(MchPaySettingEntity::getMchId, mchDetail.getId())
+ .eq(MchPaySettingEntity::getEnable, YesNoEnum.YES.getCode()));
+ boolean wechat = settings.stream().anyMatch(item -> AppPlusPlatformPayType.WECHAT.getType().equals(item.getPlatformType()));
+ boolean alipay = settings.stream().anyMatch(item -> AppPlusPlatformPayType.ALI_PAY.getType().equals(item.getPlatformType()));
+ if (!wechat && !alipay) throw new IllegalArgumentException("请先启用商户的微信或支付宝支付配置");
+ R link = remoterThirdAppPlusPayService.merchantCollectionUrl(mchDetail.getId());
+ if (link == null || !link.isOk() || StrUtil.isBlank(link.getData())) {
+ throw new IllegalStateException("签名收款入口暂不可用,请检查 third 服务的收银配置");
+ }
Drawing2D drawing = new Drawing2D();
- byte[] bytes = QRCodeImageGenerator.generateQrCodeImage(url, 300);
+ byte[] bytes = QRCodeImageGenerator.generateQrCodeImage(link.getData(), 300);
byte[] mchCodeByte = drawing.drawImage(new ByteArrayInputStream(bytes)).setTitleTextStyle().drawingCenterText(mchDetail.getName()).getBytes();
MultipartFile multipartFile = new MockMultipartFile("file", fileName, "image/png", new ByteArrayInputStream(mchCodeByte));
R upload = remoteFileService.innerUpload(multipartFile, null, null, null);
- Map data = (Map) upload.getData();
- return (String) data.get("url");
- } catch (Exception e) {
- log.error("|{}|生成url二维码,dto:|{}|error:|{}|", TAG, JSON.toJSONString(mchDetail), e.getMessage());
- return null;
+ if (upload == null || !upload.isOk() || !(upload.getData() instanceof Map data)
+ || !(data.get("url") instanceof String imageUrl) || StrUtil.isBlank(imageUrl)) {
+ throw new IllegalStateException("收款码图片上传失败");
+ }
+ return imageUrl;
+ } catch (IllegalArgumentException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ log.warn("|{}|生成H5收款码失败,mchId:{},errorType:{}", TAG, mchDetail.getId(), exception.getClass().getSimpleName());
+ throw new IllegalStateException("生成H5收款码失败,请检查支付渠道与收银配置");
}
}