feat:适配聚合支付
This commit is contained in:
1 parent
c0d503f00e
commit
667b87fc00
41 files changed
+1391
-197
No files matched your search
@@ -60,6 +60,12 @@
|
||||
- Feign 返回 `R<T>` 时必须检查业务状态与空数据;不得只判断 HTTP 成功。
|
||||
- 接口加解密已有 `pjcloud-common-encrypt-api`,前端也已有对应拦截器。不得在单个接口另造不兼容的加密协议。
|
||||
|
||||
## 数据库迁移交付规则
|
||||
|
||||
- 用户授权数据库调整后,**必须在本项目 `db/` 下新建独立的 `YYYYMMDD_变更说明.sql` 迁移文件**,不能仅修改历史迁移或初始化 SQL。
|
||||
- 迁移保持简单、可重复执行;新增表或列时先检查是否存在,避免重复执行报错,不混入无关业务数据改写。
|
||||
- 编写迁移不代表获准执行;未经用户额外授权,不得对现有环境数据库执行脚本。
|
||||
|
||||
## 配置、安全与接口约定
|
||||
|
||||
- 各服务 `application.yml` 通常只保存启动端口、服务名和 Nacos 引导配置,并导入 `application-{profile}.yml` 与服务级配置。修改配置前同时检查本地文件、环境变量占位符和 Nacos 配置来源。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- thirdparty 支付、聚合支付、任务补偿和商户分账关系统一迁移脚本。
|
||||
-- 线上只执行本文件;脚本可重复执行,兼容全新数据库和已经执行部分旧迁移的数据库。
|
||||
-- 本文件为统一支付基线迁移;后续功能增量在 db 中独立提供,不通过重跑本文件交付。
|
||||
-- 本脚本不删除支付任务、订单、分账关系或历史配置数据;仅在迁移核对通过后清理旧配置字段。
|
||||
|
||||
USE `pjcloudx_thirdparty`;
|
||||
|
||||
@@ -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;
|
||||
Vendored
+4
@@ -20,3 +20,7 @@
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 聚合 H5 收银
|
||||
|
||||
双场景配置、迁移与联调见 [部署说明](docs/aggregate-h5.md)。
|
||||
+59
@@ -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 前再次检查渠道,前端展示不能替代后端校验。已有渠道子订单继续依据原绑定渠道和配置快照恢复。
|
||||
|
||||
本轮未执行实际迁移、修改平台配置或发起真实扣款。集中验证需覆盖微信/支付宝真机、退款和分账、回调验签与重复通知、延迟成功、限流、跨租户及旧消费机状态同步。
|
||||
+5
@@ -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<String> merchantCollectionUrl(@RequestParam("mchId") Long mchId);
|
||||
|
||||
/**
|
||||
* 编辑商户的回调
|
||||
*/
|
||||
|
||||
+18
@@ -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;
|
||||
}
|
||||
+10
@@ -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();
|
||||
|
||||
+19
@@ -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;
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
+6
-2
@@ -40,6 +40,10 @@ public class ThirdAggregatePaySessionEntity extends Model<ThirdAggregatePaySessi
|
||||
@Schema(description = "聚合会话编号")
|
||||
private String aggregateNo;
|
||||
|
||||
/** 聚合场景,ORDER 为业务订单,OFFLINE 为线下收款;历史会话默认为 ORDER。 */
|
||||
@Schema(description = "聚合场景")
|
||||
private String aggregateScene;
|
||||
|
||||
/**
|
||||
* 收银 URL 随机访问令牌的 SHA-256 十六进制摘要,用于扫码入口等值查询。
|
||||
*/
|
||||
@@ -53,13 +57,13 @@ public class ThirdAggregatePaySessionEntity extends Model<ThirdAggregatePaySessi
|
||||
private String accessTokenCiphertext;
|
||||
|
||||
/**
|
||||
* 支付业务类型编码,首版固定为云消费机支付业务。
|
||||
* 支付业务类型编码;订单聚合沿用原业务类型,线下收款固定为 70。
|
||||
*/
|
||||
@Schema(description = "支付业务类型编码")
|
||||
private String bizType;
|
||||
|
||||
/**
|
||||
* 调用方业务订单唯一标识,同一租户内用于支付幂等。
|
||||
* 业务订单或线下收款请求唯一标识,同一租户内用于支付幂等。
|
||||
*/
|
||||
@Schema(description = "支付业务id")
|
||||
private String bizId;
|
||||
|
||||
+4
@@ -48,6 +48,10 @@ public class ThirdPayOrderEntity extends Model<ThirdPayOrderEntity> {
|
||||
@Schema(description = "支付类型")
|
||||
private String bizType;
|
||||
|
||||
/** 聚合场景,ORDER 为订单聚合、OFFLINE 为线下收款;空值表示非聚合支付。 */
|
||||
@Schema(description = "聚合场景")
|
||||
private String aggregateScene;
|
||||
|
||||
/**
|
||||
* 支付相关id
|
||||
*/
|
||||
|
||||
+28
@@ -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<String, Object> launchParams;
|
||||
/** 当前会话的服务端截止时间。 */
|
||||
private LocalDateTime expireTime;
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
+17
@@ -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<String> supportedPlatforms = List.of();
|
||||
}
|
||||
+3
@@ -9,6 +9,9 @@ import lombok.Data;
|
||||
@Data
|
||||
public class ThirdPayOrderVo extends ThirdPayOrderEntity {
|
||||
|
||||
/** 绑定的聚合会话号,按当前页 payNo 批量补充;非聚合支付为空。 */
|
||||
private String aggregateNo;
|
||||
|
||||
/**
|
||||
* 支付订单所属商户名称,来源于商户管理服务;商户已删除或不可见时为空。
|
||||
*/
|
||||
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
@@ -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<String> trustedProxyAddresses = new ArrayList<>();
|
||||
}
|
||||
+59
@@ -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<R<ThirdAggregateMerchantVo>> merchant(@Valid @ModelAttribute ThirdAggregateMerchantEntryDto entry,
|
||||
HttpServletRequest request) {
|
||||
R<ThirdAggregateMerchantVo> 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<R<ThirdAggregateMerchantCreateVo>> create(@Valid @RequestBody ThirdAggregateMerchantCreateDto dto,
|
||||
HttpServletRequest request) {
|
||||
R<ThirdAggregateMerchantCreateVo> 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);
|
||||
}
|
||||
}
|
||||
+69
-112
@@ -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;
|
||||
|
||||
/**
|
||||
* 支付宝与微信聚合订单码匿名收银入口。
|
||||
*
|
||||
* <p>所有入口先用租户唯一编码恢复租户上下文,不接收租户ID或任意回跳地址。</p>
|
||||
*/
|
||||
/** 聚合扫码与 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<String> cashier(@NotBlank @PathVariable String tenantCode, @NotBlank @PathVariable String token,
|
||||
@RequestHeader(value = HttpHeaders.USER_AGENT, required = false) String userAgent) {
|
||||
public ResponseEntity<Void> 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<String> wechatOauth(@NotBlank @PathVariable String tenantCode,
|
||||
@NotBlank @RequestParam("code") String code,
|
||||
@NotBlank @RequestParam("state") String state) {
|
||||
public ResponseEntity<Void> 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 收银页。
|
||||
*
|
||||
* <p>租户编码由服务端一次性 state 恢复,确保所有租户共用同一个支付宝授权回调白名单地址。</p>
|
||||
*
|
||||
* @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<String> alipayOauth(@NotBlank @RequestParam("auth_code") String authCode,
|
||||
@NotBlank @RequestParam("state") String state) {
|
||||
public ResponseEntity<Void> 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<R<ThirdAggregateH5ContextVo>> context(@NotBlank @PathVariable String tenantCode,
|
||||
@NotBlank @PathVariable String ticket,
|
||||
HttpServletRequest request) {
|
||||
h5Support.checkRate(request, false);
|
||||
R<ThirdAggregateH5ContextVo> 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<R<ThirdAggregateOrderCodePayStatusVo>> status(@NotBlank @PathVariable String tenantCode,
|
||||
@NotBlank @PathVariable String ticket) {
|
||||
@NotBlank @PathVariable String ticket,
|
||||
HttpServletRequest request) {
|
||||
h5Support.checkRate(request, false);
|
||||
R<ThirdAggregateOrderCodePayStatusVo> 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<String> 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<Void> 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<String> 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<Void> 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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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<String> merchantCollectionUrl(@RequestParam("mchId") Long mchId) {
|
||||
return R.ok(aggregateMerchantPayService.generateLink(mchId));
|
||||
}
|
||||
|
||||
/* ************************************* start 远程调用 start ************************************* */
|
||||
|
||||
/**
|
||||
|
||||
+2
-5
@@ -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<ThirdPayOrderEntity> export(ThirdPayOrderEntity thirdPayOrder, Long[] ids) {
|
||||
List<ThirdPayOrderEntity> orders = thirdPayOrderService.list(
|
||||
Wrappers.lambdaQuery(thirdPayOrder).in(ArrayUtil.isNotEmpty(ids), ThirdPayOrderEntity::getId, ids));
|
||||
public List<ThirdPayOrderVo> export(ThirdPayOrderDto dto, Long[] ids) {
|
||||
List<ThirdPayOrderVo> orders = thirdPayOrderService.exportList(dto, ids);
|
||||
orders.forEach(this::sanitizeForClient);
|
||||
return orders;
|
||||
}
|
||||
|
||||
+5
@@ -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<ThirdPayOrderEntity> {
|
||||
|
||||
/** 不分页导出当前租户及权限范围内匹配的支付流水,排序与分页一致。 */
|
||||
List<ThirdPayOrderVo> scopeList(@Param("dto") ThirdPayOrderDto dto, @Param("ids") Long[] ids, DataScope dataScope);
|
||||
|
||||
/** 按当前租户、权限和查询条件分页读取支付流水,以创建时间和主键倒序返回。 */
|
||||
Page<ThirdPayOrderVo> scopePage(Page page, @Param("dto") ThirdPayOrderDto dto, DataScope dataScope);
|
||||
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
-2
@@ -18,13 +18,16 @@ public class ThirdAggregatePayCreateContext {
|
||||
*/
|
||||
private String payNo;
|
||||
|
||||
/** 由服务端聚合会话固定的场景,原样传递给支付宝或微信支付流水。 */
|
||||
private String aggregateScene;
|
||||
|
||||
/**
|
||||
* 调用方业务订单唯一标识。
|
||||
* 业务订单或线下收款请求唯一标识。
|
||||
*/
|
||||
private String bizId;
|
||||
|
||||
/**
|
||||
* 支付业务类型,首版固定为云消费机支付。
|
||||
* 订单聚合沿用原业务类型,线下收款为 MCH_OFFLINE_PAY。
|
||||
*/
|
||||
private ThirdPayOrderBizTypeEnum bizType;
|
||||
|
||||
|
||||
+124
@@ -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<Long> 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);
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+10
@@ -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);
|
||||
|
||||
/**
|
||||
* 按聚合会话编号查询当前租户最小支付状态。
|
||||
*
|
||||
|
||||
+4
@@ -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<ThirdPayOrderEntity> {
|
||||
|
||||
Page<ThirdPayOrderVo> scopePage(Page page, ThirdPayOrderDto dto, DataScope dataScope);
|
||||
|
||||
/** 按与管理端分页相同条件导出,可按选中的主键集合进一步收窄。 */
|
||||
List<ThirdPayOrderVo> exportList(ThirdPayOrderDto dto, Long[] ids);
|
||||
|
||||
ThirdPayOrderEntity getByPayNo(String payNo);
|
||||
|
||||
ThirdPayOrderEntity getByBiz(ThirdPayOrderBizTypeEnum bizType, String bizId);
|
||||
|
||||
+1
@@ -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());
|
||||
|
||||
+154
-29
@@ -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<MchPaySettingVo> 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<MchManageVo> 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<MchPaySettingVo> 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<MchPaySettingVo> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
@@ -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());
|
||||
|
||||
+126
@@ -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> T inTenant(Long tenantId, Supplier<T> 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<MchManageVo> 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;
|
||||
}
|
||||
}
|
||||
+29
@@ -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<ThirdPayOrderMapper, T
|
||||
@Autowired(required = false)
|
||||
private ThirdPaymentTaskMapper thirdPaymentTaskMapper;
|
||||
|
||||
/** 聚合关联批量查询;保留构造器兼容既有管理端单元测试。 */
|
||||
@Autowired
|
||||
private ThirdAggregatePaySessionService aggregatePaySessionService;
|
||||
|
||||
/**
|
||||
* 按数据权限分页查询支付订单,并补充只用于管理端展示的关联信息。
|
||||
*
|
||||
@@ -116,6 +122,7 @@ public class ThirdPayOrderServiceImpl extends ServiceImpl<ThirdPayOrderMapper, T
|
||||
if (ObjectUtil.isEmpty(list)) {
|
||||
return;
|
||||
}
|
||||
fillAggregateSessions(list);
|
||||
fillUsers(list);
|
||||
fillMerchants(list);
|
||||
fillProfitSharingStages(list);
|
||||
@@ -123,6 +130,28 @@ public class ThirdPayOrderServiceImpl extends ServiceImpl<ThirdPayOrderMapper, T
|
||||
list.forEach(order -> order.setMchPaySettingJson(null));
|
||||
}
|
||||
|
||||
/** 导出复用分页的同一筛选 SQL,包含时间范围、商户和聚合场景,保持相同的脱敏投影。 */
|
||||
@Override
|
||||
public List<ThirdPayOrderVo> exportList(ThirdPayOrderDto dto, Long[] ids) {
|
||||
List<ThirdPayOrderVo> rows = baseMapper.scopeList(dto, ids, null);
|
||||
disposePageList(rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 以当前结果集的支付单号一次查询聚合关联,不逐行请求数据库或远程服务。 */
|
||||
private void fillAggregateSessions(List<ThirdPayOrderVo> rows) {
|
||||
if (aggregatePaySessionService == null) return;
|
||||
List<String> payNos = rows.stream().map(ThirdPayOrderEntity::getPayNo).filter(StrUtil::isNotBlank).distinct().toList();
|
||||
if (payNos.isEmpty()) return;
|
||||
Map<String, ThirdAggregatePaySessionEntity> sessions = aggregatePaySessionService.list(
|
||||
new LambdaQueryWrapper<ThirdAggregatePaySessionEntity>().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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量加载支付用户,远程服务异常或缺失数据不阻断支付订单列表。
|
||||
*
|
||||
|
||||
Vendored
+16
@@ -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 @@
|
||||
<if test="dto.bizType != null and dto.bizType != '' ">
|
||||
and a.biz_type = #{dto.bizType}
|
||||
</if>
|
||||
<if test="dto.aggregateScene != null and dto.aggregateScene != ''">
|
||||
and a.aggregate_scene = #{dto.aggregateScene}
|
||||
</if>
|
||||
<if test="dto.userId != null">
|
||||
and a.user_id = #{dto.userId}
|
||||
</if>
|
||||
@@ -75,4 +79,16 @@
|
||||
</where>
|
||||
order by a.create_time desc, a.id desc
|
||||
</select>
|
||||
<select id="scopeList" resultType="com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo">
|
||||
<include refid="selectA"/>
|
||||
from third_pay_order a
|
||||
<where>
|
||||
<include refid="whereA"/>
|
||||
<if test="ids != null and ids.length > 0">
|
||||
and a.id in
|
||||
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</if>
|
||||
</where>
|
||||
order by a.create_time desc, a.id desc
|
||||
</select>
|
||||
</mapper>
|
||||
+41
@@ -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<Void> 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<Void> 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());
|
||||
}
|
||||
}
|
||||
+93
@@ -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")));
|
||||
}
|
||||
}
|
||||
+93
-7
@@ -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<ThirdAggregatePaySessionEntity> 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<MchPaySettingVo> 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");
|
||||
|
||||
|
||||
+117
@@ -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<ThirdAggregateOrderCodePayDto> 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);
|
||||
}
|
||||
}
|
||||
+22
-18
@@ -148,10 +148,14 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> 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<SysUserMapper, SysUser> impl
|
||||
}
|
||||
}
|
||||
|
||||
/** 演示用户统一初始密码(仅喂数路径),整批只 BCrypt 一次 */
|
||||
/**
|
||||
* 演示用户统一初始密码(仅喂数路径),整批只 BCrypt 一次
|
||||
*/
|
||||
private static final String AI_FEED_DEFAULT_PASSWORD = "AiFeed@123456";
|
||||
|
||||
/**
|
||||
@@ -358,7 +364,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
|
||||
* 操作人角色快照:一次或一批用户创建期间,操作人的角色集合与权限结论保持不变。
|
||||
*/
|
||||
private record OperationRoleSnapshot(boolean hasApplyUser, List<SysRole> applyUserRoles, boolean roleAdmin,
|
||||
List<Long> nonAdminNewRoleIds) {
|
||||
List<Long> nonAdminNewRoleIds) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,9 +372,9 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> 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<SysUserMapper, SysUser> impl
|
||||
/**
|
||||
* 分页查询用户信息,并批量补齐角色、人脸、身份及身份扩展资料。
|
||||
*
|
||||
* @param page 分页参数,包含当前页和每页数量
|
||||
* @param userDTO 用户筛选条件及是否查询审批状态的控制参数
|
||||
* @param page 分页参数,包含当前页和每页数量
|
||||
* @param userDTO 用户筛选条件及是否查询审批状态的控制参数
|
||||
* @param dataScope 当前调用人的数据权限范围;为空时不附加数据范围限制
|
||||
* @return 含角色、人脸、身份和审批信息的用户分页结果
|
||||
*/
|
||||
@@ -529,8 +535,8 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
|
||||
* <p>预约等业务列表只需要用户、部门和身份信息,因此这里使用无嵌套角色、岗位映射的专用 SQL,
|
||||
* 同时不返回人脸资料和审批信息,但保留人脸录入状态筛选。</p>
|
||||
*
|
||||
* @param page 分页参数
|
||||
* @param userDTO 用户关键字、部门树和指定用户筛选条件
|
||||
* @param page 分页参数
|
||||
* @param userDTO 用户关键字、部门树和指定用户筛选条件
|
||||
* @param dataScope 当前调用人的数据权限范围;为空时不附加数据范围限制
|
||||
* @return 已批量补齐身份类型、学生资料和老师资料的轻量用户分页
|
||||
*/
|
||||
@@ -562,7 +568,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
|
||||
public IPage<UserVO> 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<SysUserMapper, SysUser> impl
|
||||
*
|
||||
* <p>复用现有角色、岗位映射,保持与系统用户导出字段一致;不查询人脸和审批流程,避免后台分片导出执行无关远程调用。</p>
|
||||
*
|
||||
* @param page 分页参数
|
||||
* @param userDTO 用户关键字、部门树和指定用户筛选条件
|
||||
* @param page 分页参数
|
||||
* @param userDTO 用户关键字、部门树和指定用户筛选条件
|
||||
* @param dataScope 当前调用人的数据权限范围;为空时不附加数据范围限制
|
||||
* @return 已补齐主资料、角色岗位和身份扩展的用户分页
|
||||
*/
|
||||
@@ -906,7 +912,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
|
||||
* 新部门据此新增人脸。重复或乱序消息由终端按用户回查 V3 真源实现幂等。</p>
|
||||
*
|
||||
* @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<SysUserMapper, SysUser> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+44
-21
@@ -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<MchManageMapper, MchManage
|
||||
private final RemoteHardOrderService remoteHardOrderService;
|
||||
private final RemoterThirdLbApiService remoterThirdLbApiService;
|
||||
|
||||
/** 收款链接只由 third 服务签发,钱包不保存或复制 HMAC 密钥。 */
|
||||
private final RemoterThirdAppPlusPayService remoterThirdAppPlusPayService;
|
||||
|
||||
@Lazy
|
||||
private final WalletThirdZyService walletThirdZyService;
|
||||
private final WalletTypeMapper walletTypeMapper;
|
||||
@@ -138,7 +144,14 @@ public class MchManageServiceImpl extends ServiceImpl<MchManageMapper, MchManage
|
||||
dto.setMchCount(0L);
|
||||
}
|
||||
baseMapper.insert(dto);
|
||||
getSelf().refreshQrcode(dto.getId());
|
||||
// 新商户尚未配置渠道,先生成兼容 App 的收款码;至少一个渠道配置完成后可刷新生成 H5 码。
|
||||
String appQrCode = generateMchQrCode(dto);
|
||||
if (StrUtil.isNotBlank(appQrCode)) {
|
||||
MchManageEntity qrUpdate = new MchManageEntity();
|
||||
qrUpdate.setId(dto.getId());
|
||||
qrUpdate.setCollectionCode(appQrCode);
|
||||
updateById(qrUpdate);
|
||||
}
|
||||
updateMchCount(dto.getParentId());
|
||||
// walletThirdZyService.syncAllMch(dto.getId(), Boolean.FALSE);
|
||||
return dto.getId();
|
||||
@@ -555,18 +568,14 @@ public class MchManageServiceImpl extends ServiceImpl<MchManageMapper, MchManage
|
||||
// 查询商户
|
||||
MchManageEntity mchDetail = baseMapper.selectById(mchId);
|
||||
if (ObjectUtil.isEmpty(mchDetail)) return;
|
||||
try {
|
||||
String qrcode = generateMchQrCode(mchDetail);
|
||||
String urlQrcode = generateUrlQrCode(mchDetail);
|
||||
MchManageEntity updateEntity = new MchManageEntity();
|
||||
updateEntity.setId(mchDetail.getId());
|
||||
updateEntity.setCollectionCode(qrcode);
|
||||
updateEntity.setCollectionUrlCode(urlQrcode);
|
||||
updateById(updateEntity);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
log.error("|{}|新增商户,生成收款码错误dto:|{}|error:|{}|", TAG, JSON.toJSONString(mchDetail), e.getMessage());
|
||||
}
|
||||
// 先确认 URL 码成功再写库,失败明确反馈且不覆盖原图,防止管理端误报刷新成功。
|
||||
String urlQrcode = generateUrlQrCode(mchDetail);
|
||||
String qrcode = generateMchQrCode(mchDetail);
|
||||
MchManageEntity updateEntity = new MchManageEntity();
|
||||
updateEntity.setId(mchDetail.getId());
|
||||
if (StrUtil.isNotBlank(qrcode)) updateEntity.setCollectionCode(qrcode);
|
||||
updateEntity.setCollectionUrlCode(urlQrcode);
|
||||
updateById(updateEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -721,21 +730,35 @@ public class MchManageServiceImpl extends ServiceImpl<MchManageMapper, MchManage
|
||||
}
|
||||
}
|
||||
|
||||
// 生成url二维码
|
||||
/** 生成签名 H5 二维码,允许商户只开通一个渠道;未开通渠道或生成失败时保留原图。 */
|
||||
private String generateUrlQrCode(MchManageEntity mchDetail) {
|
||||
String url = String.format("https://www.pjzhhq.cn/h5/#/pages/pay/scan-pay?id=%s", mchDetail.getId());
|
||||
String fileName = String.format("%s-%s.png", mchDetail.getId(), System.currentTimeMillis());
|
||||
try {
|
||||
List<MchPaySettingEntity> settings = paySettingService.list(new LambdaQueryWrapper<MchPaySettingEntity>()
|
||||
.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<String> 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收款码失败,请检查支付渠道与收银配置");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user