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收款码失败,请检查支付渠道与收银配置"); } }