feat:适配聚合支付

This commit is contained in:
dzc committed 2026-10-20 13:40:25 +08:00
1 parent c0d503f00e
commit 667b87fc00
41 files changed
+1391 -197

No files matched your search

+6
View File
@@ -60,6 +60,12 @@
- Feign 返回 `R<T>` 时必须检查业务状态与空数据;不得只判断 HTTP 成功。
- 接口加解密已有 `pjcloud-common-encrypt-api`,前端也已有对应拦截器。不得在单个接口另造不兼容的加密协议。
## 数据库迁移交付规则
- 用户授权数据库调整后,**必须在本项目 `db/` 下新建独立的 `YYYYMMDD_变更说明.sql` 迁移文件**,不能仅修改历史迁移或初始化 SQL。
- 迁移保持简单、可重复执行;新增表或列时先检查是否存在,避免重复执行报错,不混入无关业务数据改写。
- 编写迁移不代表获准执行;未经用户额外授权,不得对现有环境数据库执行脚本。
## 配置、安全与接口约定
- 各服务 `application.yml` 通常只保存启动端口、服务名和 Nacos 引导配置,并导入 `application-{profile}.yml` 与服务级配置。修改配置前同时检查本地文件、环境变量占位符和 Nacos 配置来源。
+1 -1
View File
@@ -1,5 +1,5 @@
-- thirdparty 支付、聚合支付、任务补偿和商户分账关系统一迁移脚本。
-- 线上只执行本文件;脚本可重复执行,兼容全新数据库和已经执行部分旧迁移的数据库。
-- 本文件为统一支付基线迁移;后续功能增量在 db 中独立提供,不通过重跑本文件交付。
-- 本脚本不删除支付任务、订单、分账关系或历史配置数据;仅在迁移核对通过后清理旧配置字段。
USE `pjcloudx_thirdparty`;
+23
View File
@@ -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;
+4
View File
@@ -20,3 +20,7 @@
```
## 聚合 H5 收银
双场景配置、迁移与联调见 [部署说明](docs/aggregate-h5.md)。
+59
View File
@@ -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 前再次检查渠道,前端展示不能替代后端校验。已有渠道子订单继续依据原绑定渠道和配置快照恢复。
本轮未执行实际迁移、修改平台配置或发起真实扣款。集中验证需覆盖微信/支付宝真机、退款和分账、回调验签与重复通知、延迟成功、限流、跨租户及旧消费机状态同步。
@@ -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);
/**
* 编辑商户的回调
*/
@@ -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;
}
@@ -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();
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -48,6 +48,10 @@ public class ThirdPayOrderEntity extends Model<ThirdPayOrderEntity> {
@Schema(description = "支付类型")
private String bizType;
/** 聚合场景,ORDER 为订单聚合、OFFLINE 为线下收款;空值表示非聚合支付。 */
@Schema(description = "聚合场景")
private String aggregateScene;
/**
* 支付相关id
*/
@@ -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;
}
@@ -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;
}
@@ -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();
}
@@ -9,6 +9,9 @@ import lombok.Data;
@Data
public class ThirdPayOrderVo extends ThirdPayOrderEntity {
/** 绑定的聚合会话号,按当前页 payNo 批量补充;非聚合支付为空。 */
private String aggregateNo;
/**
* 支付订单所属商户名称,来源于商户管理服务;商户已删除或不可见时为空。
*/
@@ -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);
}
}
@@ -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<>();
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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 ************************************* */
/**
@@ -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;
}
@@ -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);
}
@@ -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;
}
/**
@@ -18,13 +18,16 @@ public class ThirdAggregatePayCreateContext {
*/
private String payNo;
/** 由服务端聚合会话固定的场景,原样传递给支付宝或微信支付流水。 */
private String aggregateScene;
/**
* 调用方业务订单唯一标识。
* 业务订单或线下收款请求唯一标识。
*/
private String bizId;
/**
* 支付业务类型,首版固定为云消费机支付。
* 订单聚合沿用原业务类型,线下收款为 MCH_OFFLINE_PAY。
*/
private ThirdPayOrderBizTypeEnum bizType;
@@ -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);
}
}
@@ -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);
}
@@ -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);
/**
* 按聚合会话编号查询当前租户最小支付状态。
*
@@ -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);
@@ -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());
@@ -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;
}
/**
@@ -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());
@@ -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;
}
}
@@ -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());
}
}
/**
* 批量加载支付用户,远程服务异常或缺失数据不阻断支付订单列表。
*
@@ -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>
@@ -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());
}
}
@@ -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")));
}
}
@@ -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");
@@ -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);
}
}
@@ -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);
}
}
@@ -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收款码失败,请检查支付渠道与收银配置");
}
}