From 667b87fc00833ebd0cd27823c98c7d60d6ff8d0f Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 20 Oct 2026 13:40:25 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=E9=80=82=E9=85=8D=E8=81=9A=E5=90=88?= =?UTF-8?q?=E6=94=AF=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 + db/20260825_third_payment_schema.sql | 2 +- db/20261020_add_aggregate_pay_scene.sql | 23 +++ pjcloud-thirdparty/README.md | 4 + pjcloud-thirdparty/docs/aggregate-h5.md | 59 ++++++ .../feign/RemoterThirdAppPlusPayService.java | 5 + .../pay/ThirdAggregatePaySceneEnum.java | 18 ++ .../pay/ThirdPayOrderBizTypeEnum.java | 10 + .../ThirdAggregateMerchantCreateDto.java | 19 ++ .../ThirdAggregateMerchantEntryDto.java | 23 +++ .../pay/ThirdAggregatePaySessionEntity.java | 8 +- .../entity/pay/ThirdPayOrderEntity.java | 4 + .../pay/ThirdAggregateH5ContextVo.java | 28 +++ .../pay/ThirdAggregateMerchantCreateVo.java | 15 ++ .../appplus/pay/ThirdAggregateMerchantVo.java | 17 ++ .../thirdparty/vo/pay/ThirdPayOrderVo.java | 3 + .../config/AggregateH5CorsFilter.java | 57 ++++++ .../config/AggregatePayProperties.java | 36 ++++ .../ThirdAggregateMerchantPayController.java | 59 ++++++ .../ThirdAggregateOrderPayController.java | 181 +++++++---------- .../appplus/ThirdAppPlusController.java | 11 ++ .../pay/ThirdPayOrderController.java | 7 +- .../mapper/pay/ThirdPayOrderMapper.java | 5 + .../pay/ThirdAggregateCashierPageData.java | 14 ++ .../pay/ThirdAggregatePayCreateContext.java | 7 +- .../service/ThirdAggregateH5Support.java | 124 ++++++++++++ .../ThirdAggregateMerchantPayService.java | 17 ++ .../ThirdAggregateOrderPayService.java | 10 + .../service/ThirdPayOrderService.java | 4 + .../ThirdAggregateAliPayServiceImpl.java | 1 + .../ThirdAggregateOrderPayServiceImpl.java | 183 +++++++++++++++--- .../ThirdAggregateWechatPayServiceImpl.java | 1 + .../ThirdAggregateMerchantPayServiceImpl.java | 126 ++++++++++++ .../impl/pay/ThirdPayOrderServiceImpl.java | 29 +++ .../resources/mapper/ThirdPayOrderMapper.xml | 16 ++ .../ThirdAggregateOrderPayControllerTest.java | 41 ++++ .../service/ThirdAggregateH5SupportTest.java | 93 +++++++++ ...ThirdAggregateOrderPayServiceImplTest.java | 100 +++++++++- ...rdAggregateMerchantPayServiceImplTest.java | 117 +++++++++++ .../service/impl/SysUserServiceImpl.java | 40 ++-- .../impl/mch/MchManageServiceImpl.java | 65 +++++-- 41 files changed, 1391 insertions(+), 197 deletions(-) create mode 100644 db/20261020_add_aggregate_pay_scene.sql create mode 100644 pjcloud-thirdparty/docs/aggregate-h5.md create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdAggregatePaySceneEnum.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantCreateDto.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantEntryDto.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateH5ContextVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantCreateVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregateH5CorsFilter.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregatePayProperties.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateMerchantPayController.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5Support.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateMerchantPayService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayControllerTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5SupportTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImplTest.java diff --git a/AGENTS.md b/AGENTS.md index 893e0b0d1..735a8483d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,12 @@ - Feign 返回 `R` 时必须检查业务状态与空数据;不得只判断 HTTP 成功。 - 接口加解密已有 `pjcloud-common-encrypt-api`,前端也已有对应拦截器。不得在单个接口另造不兼容的加密协议。 +## 数据库迁移交付规则 + +- 用户授权数据库调整后,**必须在本项目 `db/` 下新建独立的 `YYYYMMDD_变更说明.sql` 迁移文件**,不能仅修改历史迁移或初始化 SQL。 +- 迁移保持简单、可重复执行;新增表或列时先检查是否存在,避免重复执行报错,不混入无关业务数据改写。 +- 编写迁移不代表获准执行;未经用户额外授权,不得对现有环境数据库执行脚本。 + ## 配置、安全与接口约定 - 各服务 `application.yml` 通常只保存启动端口、服务名和 Nacos 引导配置,并导入 `application-{profile}.yml` 与服务级配置。修改配置前同时检查本地文件、环境变量占位符和 Nacos 配置来源。 diff --git a/db/20260825_third_payment_schema.sql b/db/20260825_third_payment_schema.sql index 9841ed270..701249a09 100644 --- a/db/20260825_third_payment_schema.sql +++ b/db/20260825_third_payment_schema.sql @@ -1,5 +1,5 @@ -- thirdparty 支付、聚合支付、任务补偿和商户分账关系统一迁移脚本。 --- 线上只执行本文件;脚本可重复执行,兼容全新数据库和已经执行部分旧迁移的数据库。 +-- 本文件为统一支付基线迁移;后续功能增量在 db 中独立提供,不通过重跑本文件交付。 -- 本脚本不删除支付任务、订单、分账关系或历史配置数据;仅在迁移核对通过后清理旧配置字段。 USE `pjcloudx_thirdparty`; diff --git a/db/20261020_add_aggregate_pay_scene.sql b/db/20261020_add_aggregate_pay_scene.sql new file mode 100644 index 000000000..9953ecf90 --- /dev/null +++ b/db/20261020_add_aggregate_pay_scene.sql @@ -0,0 +1,23 @@ +-- 双场景聚合支付:仅新增场景字段,前置为统一支付基线表已存在。 +-- 可重复执行:字段已存在则跳过,不修改已有字段定义。 +USE `pjcloudx_thirdparty`; + +-- 聚合会话默认 ORDER,兼容原订单聚合支付。 +SET @ddl = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `third_aggregate_pay_session` ADD COLUMN `aggregate_scene` varchar(32) NOT NULL DEFAULT ''ORDER'' COMMENT ''聚合场景:ORDER订单聚合,OFFLINE线下收款'' AFTER `aggregate_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND COLUMN_NAME = 'aggregate_scene'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 支付流水默认 NULL,避免把普通充值等非聚合支付标为订单聚合。 +SET @ddl = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `third_pay_order` ADD COLUMN `aggregate_scene` varchar(32) DEFAULT NULL COMMENT ''聚合场景:ORDER订单聚合,OFFLINE线下收款,NULL非聚合'' AFTER `biz_type`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME = 'aggregate_scene'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/pjcloud-thirdparty/README.md b/pjcloud-thirdparty/README.md index 77b54353a..22e0b27e2 100644 --- a/pjcloud-thirdparty/README.md +++ b/pjcloud-thirdparty/README.md @@ -20,3 +20,7 @@ ``` + +## 聚合 H5 收银 + +双场景配置、迁移与联调见 [部署说明](docs/aggregate-h5.md)。 diff --git a/pjcloud-thirdparty/docs/aggregate-h5.md b/pjcloud-thirdparty/docs/aggregate-h5.md new file mode 100644 index 000000000..2ee5d0170 --- /dev/null +++ b/pjcloud-thirdparty/docs/aggregate-h5.md @@ -0,0 +1,59 @@ +# 双场景聚合支付部署与联调 + +## 配置 + +在 third 服务的 Nacos 配置中增加以下配置。H5 域名由配置提供,不编入 Java 源码;签名密钥需要由部署环境注入至少 32 字节的随机值,不要使用本文或测试中的字符串。 + +```yaml +app-plus: + pay: + aggregate: + h5-base-url: https://baac9a029ea84f36ba28c023fed4fc5f.hd.takin.cc + public-route-prefix: /third # 直接使用 Boot 服务时设为 "" + merchant-signing-key: ${MERCHANT_COLLECTION_SIGNING_KEY} + offline-max-amount: 999999 # 整数分,9,999.99 元;提高上限时同时调整 H5 的输入限制 + query-limit-per-minute: 120 + create-limit-per-minute: 30 + trusted-proxy-addresses: [] +``` + +`h5-base-url` 只接受 HTTPS 基础地址,不能带查询参数、Hash 或用户信息。可以包含实际部署子路径;H5 的路由 base 需对应配置。 + +仅在网关/反向代理**覆盖客户端传入的 X-Real-IP**时,将其直连地址加入 `trusted-proxy-addresses`。默认使用直连 IP 限流。Redis 业务键继续由项目基础设施附加租户前缀,不手工拼接租户编号。 + +H5 只使用项目根目录一个 `.env`,变量命名沿用参考 App:开发请求走 `VITE_API_URL`(默认 `/api`),由 Vite 代理到 `VITE_APP_DEV_SERVICE`;生产读取 `VITE_APP_PROD_SERVICE`。这些后端地址与 H5 页面地址分别配置,不重复附加 `/third`。`VITE_IS_MICRO=true` 保留 `/third`;`false` 移除前缀,此时后端 `public-route-prefix` 同时设为 `""`,确保二维码、OAuth 回调与状态地址也使用 Boot 路径。若线上部署已提供同源接口,生产后端地址可留空。本次未修改实际 Nacos、网关部署或平台控制台。 + +后端仅为配置的 H5 精确 Origin 开放匿名商户、创建、上下文和状态 JSON 接口的跨域访问,不开放凭据跨域;其他接口保持既有安全规则。 + +## 平台配置 + +- 微信沿用服务商统一公众号 OAuth 与 JSAPI 下单配置,确认公众号网页授权域名对应现有后端 OAuth 回调域名。 +- 将新 H5 的实际页面目录核对加入微信支付 JSAPI 支付授权目录;Hash 部分不作为目录填写。当前根目录部署对应 `https://baac9a029ea84f36ba28c023fed4fc5f.hd.takin.cc/`。 +- 支付宝沿用现有应用和固定 OAuth 回调 `/third/aggregate-order-pay/oauth/alipay`,租户通过一次性 state 恢复。不要把独立 H5 地址替换为后端回调地址。 +- 两种渠道继续使用商户既有支付配置、子商户和默认分佣关系快照,配置需已具备相应 OAuth/JSAPI 权限。仅域名能访问不代表上述平台配置已完成。 + +## 发布顺序与兼容 + +1. 先确认统一支付基线迁移 `db/20260825_third_payment_schema.sql` 已完成;已完成的环境无需重跑。双场景升级单独执行新文件 [`db/20261020_add_aggregate_pay_scene.sql`](../../db/20261020_add_aggregate_pay_scene.sql),不得只靠修改历史迁移升级现有库。 +2. 新迁移仅新增两个 `aggregate_scene` 字段,先查字段是否存在,可重复执行;不包含存储过程、字典写入或历史流水回填。业务类型字典 `70 = 商户线下收款` 可通过管理端配置。 +3. 配置 Nacos 的 H5 地址与随机签名密钥,配套发布 third、wallet 与 H5,再发布后台页面。完整测试与构建由集中验证指令执行。 +4. 商户至少启用并配置完成微信或支付宝其中一个渠道后,即可通过原刷新二维码操作生成新的 URL 收款码。允许仅微信、仅支付宝或双渠道;只校验已启用渠道。新增商户仍先生成原 App Base64 码;没有可用渠道时不生成 H5 码。手动刷新失败会明确反馈并保留原图。 +5. 消费机原 `AGGREGATE_ORDER_PAY` 类型、二维码路径及内部创建/状态接口保持兼容,OAuth 出口改为 302 到新 H5。原 HTML 渲染器文件保留但不再被 Controller 使用。 + +数据库字段分别为:聚合会话 `aggregate_scene varchar(32) NOT NULL DEFAULT 'ORDER'`,支付流水 `aggregate_scene varchar(32) DEFAULT NULL`。线下业务编码是 `70`,收款请求号由商户和稳定 requestId 派生;不会创建硬件、商城或钱包充值业务订单。 + +历史聚合会话通过新字段默认值标记为 `ORDER`;历史支付流水的新字段保持 `NULL`,本次不做数据回填。 + +静态 URL 收款码长期有效,HMAC 绑定用途、版本、租户和商户;更换域名或密钥后需重新生成。`collectionCode` 继续保留 App 协议,`collectionUrlCode` 仍保存二维码图片地址。 + +## 接口与故障恢复 + +- 匿名查询:`GET /third/aggregate-merchant-pay/merchant?id=...&tenantId=...&v=1&sign=...`,返回商户 id/name/logoPic 和 `supportedPlatforms`(10 微信、20 支付宝),不返回支付配置。 +- 线下创建:`POST /third/aggregate-merchant-pay/create`,提交相同签名参数、整数分 amount 和 UUID requestId。重试不能修改金额或生成新 requestId。 +- 现有聚合入口:`/third/aggregate-order-pay/cashier/{tenantCode}/{token}`。两个客户端分别进入各自 OAuth;已确认终态的旧请求直接恢复结果页,不再下单。 +- OAuth 回跳只携带短期 ticket,H5 用 `/context/{tenantCode}/{ticket}` 读取最小渠道参数,用 `/status/{tenantCode}/{ticket}` 确认结果。票据有效期沿用 5 分钟,付款会话沿用 2 分钟;本机倒计时不能代替服务端查单/关单结果。 +- 取消付款可继续原单;客户端返回成功、未知或网络异常时仍等待服务端确认,不能自行写成功或新建另一笔交易。 +- 缺少 H5 配置时不输出无签名链接;OAuth 入口返回无敏感正文的不可用响应。配置齐全后失败跳固定 H5 错误入口,不反射平台原始异常。 +- H5 扫码客户端不在商户已启用渠道中时,展示专门的提示状态并引导换用已开通 App 扫码,隐藏金额和付款键盘;渠道全部停用时提示联系商户。付款创建和进入 OAuth 前再次检查渠道,前端展示不能替代后端校验。已有渠道子订单继续依据原绑定渠道和配置快照恢复。 + +本轮未执行实际迁移、修改平台配置或发起真实扣款。集中验证需覆盖微信/支付宝真机、退款和分账、回调验签与重复通知、延迟成功、限流、跨租户及旧消费机状态同步。 diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppPlusPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppPlusPayService.java index 70d88df11..6263031e1 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppPlusPayService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppPlusPayService.java @@ -27,6 +27,11 @@ import org.springframework.web.bind.annotation.RequestParam; @FeignClient(contextId = "remoterThirdAppPlusPayService", value = ServiceNameConstants.THIRDPARTY_SERVICE) public interface RemoterThirdAppPlusPayService { + /** 为当前租户签发商户静态 H5 收款入口,签名密钥只保留在 third 服务。 */ + @NoToken + @GetMapping("/pay-remoter/merchant-collection-url") + R merchantCollectionUrl(@RequestParam("mchId") Long mchId); + /** * 编辑商户的回调 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdAggregatePaySceneEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdAggregatePaySceneEnum.java new file mode 100644 index 000000000..e97b44fbf --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdAggregatePaySceneEnum.java @@ -0,0 +1,18 @@ +package com.bsd.pjcloud.thirdparty.constant.pay; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** 聚合收银场景;编码同时用于数据库、公开接口和管理端,不能随意改名。 */ +@Getter +@AllArgsConstructor +public enum ThirdAggregatePaySceneEnum { + + /** 已存在业务订单的聚合支付,金额由内部业务订单确定。 */ + ORDER("ORDER"), + /** 无商品订单的线下收款,金额由付款人确认后固定。 */ + OFFLINE("OFFLINE"); + + /** 持久化及跨端协议编码。 */ + private final String code; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java index c4ccf1d74..7cd00b78b 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java @@ -13,18 +13,28 @@ import java.util.Arrays; @Getter public enum ThirdPayOrderBizTypeEnum { + /** 10:App 用户钱包充值,成功后进入充值业务回调。 */ MINI_APP_WALLET_RECHARGE("10", "APP用户钱包充值"), + /** 20:原云消费机付款码支付,保持历史协议。 */ YXFJ_PAY("20", "云消费机付款码支付"), + /** 30:App 商品订单支付,关联既有业务订单。 */ APP_SHOP_PAY("30", "APP支付-购买商品(有订单)"), + /** 40:监护人代充值,保持原充值回调。 */ GUARDIAN_WARD_WALLET_RECHARGE("40", "监护人代被监护人饭卡充值"), + /** 50:现有食堂或超市消费订单支付,聚合订单沿用该编码。 */ XFJ("50", "食堂或者超市的消费机支付"), /** App 酒店预订支付:业务订单在 supermarket,本服务只记支付流水 */ HOTEL_ORDER_PAY("60", "酒店订单支付"), + /** 商户线下收款,无商品或消费订单;70 是持久化和跨服务业务编码。 */ + MCH_OFFLINE_PAY("70", "商户线下收款"), ; + /** 数据库与跨服务协议使用的业务编码,不得随意变更。 */ private final String type; + /** 管理与诊断使用的中文业务描述。 */ private final String description; + /** 按已有协议编码解析支付业务类型,空值保持原有空结果约定。 */ public static ThirdPayOrderBizTypeEnum valueOfByType(String type) { if (ObjectUtil.isEmpty(type)) return null; return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().get(); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantCreateDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantCreateDto.java new file mode 100644 index 000000000..b429de911 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantCreateDto.java @@ -0,0 +1,19 @@ +package com.bsd.pjcloud.thirdparty.dto.appplus; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** 线下收款创建请求,不允许付款人指定渠道商户、业务类型或业务订单。 */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ThirdAggregateMerchantCreateDto extends ThirdAggregateMerchantEntryDto { + /** 付款人确认的人民币整数分;后端还会检查可配置的线下单笔上限。 */ + @NotNull @Positive + private Integer amount; + /** 浏览器为一次付款意图生成的 UUID;重试必须复用,不能改变商户或金额。 */ + @NotNull @Pattern(regexp = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + private String requestId; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantEntryDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantEntryDto.java new file mode 100644 index 000000000..083b2c2de --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateMerchantEntryDto.java @@ -0,0 +1,23 @@ +package com.bsd.pjcloud.thirdparty.dto.appplus; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +/** 商户静态收款码凭据,只能由服务端签发,不能作为订单支付授权。 */ +@Data +public class ThirdAggregateMerchantEntryDto { + /** 二维码对应的收款商户主键,与签名绑定。 */ + @NotNull @Positive + private Long id; + /** 二维码对应的租户主键,验签后恢复该租户上下文。 */ + @NotNull @Positive + private Long tenantId; + /** 收款链接协议版本,本轮仅支持 1。 */ + @NotNull @Pattern(regexp = "1") + private String v; + /** HMAC-SHA256 小写十六进制签名,绑定用途、版本、租户和商户。 */ + @NotNull @Pattern(regexp = "[0-9a-f]{64}") + private String sign; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdAggregatePaySessionEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdAggregatePaySessionEntity.java index 729896e6a..6a0d5fff9 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdAggregatePaySessionEntity.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdAggregatePaySessionEntity.java @@ -40,6 +40,10 @@ public class ThirdAggregatePaySessionEntity extends Model { @Schema(description = "支付类型") private String bizType; + /** 聚合场景,ORDER 为订单聚合、OFFLINE 为线下收款;空值表示非聚合支付。 */ + @Schema(description = "聚合场景") + private String aggregateScene; + /** * 支付相关id */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateH5ContextVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateH5ContextVo.java new file mode 100644 index 000000000..a2cb34bcb --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateH5ContextVo.java @@ -0,0 +1,28 @@ +package com.bsd.pjcloud.thirdparty.vo.appplus.pay; + +import lombok.Data; +import java.time.LocalDateTime; +import java.util.Map; + +/** 通过短期收银票据读取的最小上下文,不返回商户配置或 OAuth 身份。 */ +@Data +public class ThirdAggregateH5ContextVo { + /** 服务端保存的 ORDER 或 OFFLINE 场景,不能由 URL 覆盖。 */ + private String aggregateScene; + /** 收款商户公开资料。 */ + private ThirdAggregateMerchantVo merchant; + /** 已确认且不可编辑的人民币整数分。 */ + private Integer amount; + /** 服务端选定的渠道编码,10 为微信、20 为支付宝。 */ + private String selectedPlatform; + /** 当前第三方商户支付单号。 */ + private String payNo; + /** 聚合会话状态,与既有状态查询接口一致。 */ + private String status; + /** 渠道已确认成功时为 true,前端 JSBridge 结果不能替代该值。 */ + private boolean paySuccess; + /** 尚可继续支付时提供的最小 JSBridge 参数,终态时为空。 */ + private Map launchParams; + /** 当前会话的服务端截止时间。 */ + private LocalDateTime expireTime; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantCreateVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantCreateVo.java new file mode 100644 index 000000000..23a1e604c --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantCreateVo.java @@ -0,0 +1,15 @@ +package com.bsd.pjcloud.thirdparty.vo.appplus.pay; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 匿名线下收款的创建结果,前端只能进入服务端固定的 OAuth 收银地址。 */ +@Data +public class ThirdAggregateMerchantCreateVo { + /** 用于恢复当前付款意图的聚合会话编号。 */ + private String aggregateNo; + /** 现有聚合入口 HTTPS 地址,内含随机访问令牌,不含付款人身份。 */ + private String cashierUrl; + /** 服务端会话截止时间,渠道真实状态仍以服务端查单为准。 */ + private LocalDateTime expireTime; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantVo.java new file mode 100644 index 000000000..b770a4554 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateMerchantVo.java @@ -0,0 +1,17 @@ +package com.bsd.pjcloud.thirdparty.vo.appplus.pay; + +import lombok.Data; +import java.util.List; + +/** 匿名收银商户视图,不包含支付配置、饭卡密钥、用户或结算资料。 */ +@Data +public class ThirdAggregateMerchantVo { + /** 商户主键使用字符串,避免浏览器丢失长整数精度。 */ + private String id; + /** 收款商户名称,以钱包服务的当前资料为准。 */ + private String name; + /** 商户标识图片地址,未配置时为空,前端使用统一占位图形。 */ + private String logoPic; + /** 当前商户已启用的渠道编码,10 微信、20 支付宝;空列表表示暂无可用渠道,不包含渠道凭据。 */ + private List supportedPlatforms = List.of(); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayOrderVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayOrderVo.java index 9e0c3aad9..8ef2f4ccc 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayOrderVo.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayOrderVo.java @@ -9,6 +9,9 @@ import lombok.Data; @Data public class ThirdPayOrderVo extends ThirdPayOrderEntity { + /** 绑定的聚合会话号,按当前页 payNo 批量补充;非聚合支付为空。 */ + private String aggregateNo; + /** * 支付订单所属商户名称,来源于商户管理服务;商户已删除或不可见时为空。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregateH5CorsFilter.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregateH5CorsFilter.java new file mode 100644 index 000000000..e702717dd --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregateH5CorsFilter.java @@ -0,0 +1,57 @@ +package com.bsd.pjcloud.thirdparty.config; + +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateH5Support; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** 仅为匿名收银 JSON 接口开放固定 H5 来源,不扩大其他管理、OAuth 或内部接口的跨域权限。 */ +@Component +@RequiredArgsConstructor +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class AggregateH5CorsFilter extends OncePerRequestFilter { + /** 来源取自服务端固定 H5 配置,禁止通配符和凭据跨域。 */ + private final ThirdAggregateH5Support h5Support; + + /** 只处理收银公开 JSON 路径,同时兼容网关移除 third 前缀及 Boot 直连。 */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI().substring(request.getContextPath().length()); + if (path.startsWith("/third/")) path = path.substring(6); + return !path.startsWith("/aggregate-merchant-pay/") + && !path.startsWith("/aggregate-order-pay/context/") + && !path.startsWith("/aggregate-order-pay/status/"); + } + + /** 在安全过滤链前响应合法预检;配置缺失时保持默认禁止跨域,不阻止其他服务启动。 */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String allowed = null; + try { + allowed = h5Support.allowedOrigin(); + } catch (IllegalStateException ignored) { + // 缺失收银配置时不开放来源,其他业务仍沿用原安全链。 + } + if (allowed != null && allowed.equals(request.getHeader("Origin"))) { + response.setHeader("Access-Control-Allow-Origin", allowed); + response.addHeader("Vary", "Origin"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type, TENANT-ID"); + response.setHeader("Access-Control-Max-Age", "600"); + if ("OPTIONS".equals(request.getMethod())) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + return; + } + } + chain.doFilter(request, response); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregatePayProperties.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregatePayProperties.java new file mode 100644 index 000000000..a4eb7e3a7 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregatePayProperties.java @@ -0,0 +1,36 @@ +package com.bsd.pjcloud.thirdparty.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** 独立 H5 聚合收银配置,由 Nacos 或环境变量提供,密钥不进入前端。 */ +@Data +@Component +@ConfigurationProperties(prefix = "app-plus.pay.aggregate") +public class AggregatePayProperties { + + /** 固定 HTTPS H5 基础地址,不含 Hash、查询参数;未配置时拒绝生成收款入口。 */ + private String h5BaseUrl; + + /** 公网 API 前缀,微服务默认 /third,直接对接 Boot 聚合服务时配置为空字符串。 */ + private String publicRoutePrefix = "/third"; + + /** 至少 32 字节的随机 HMAC 密钥;轮换后必须重新生成商户 URL 收款码。 */ + private String merchantSigningKey; + + /** 线下收款单笔上限,单位为人民币分,默认 9,999.99 元。 */ + private int offlineMaxAmount = 999999; + + /** 每个可信来源 IP 每分钟最多读取公开商户或收银状态的次数。 */ + private int queryLimitPerMinute = 120; + + /** 每个可信来源 IP 每分钟最多提交线下支付创建的次数。 */ + private int createLimitPerMinute = 30; + + /** 可提供 X-Real-IP 的直连代理地址;代理必须覆盖客户端传入的该头,默认全部不信任。 */ + private List trustedProxyAddresses = new ArrayList<>(); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateMerchantPayController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateMerchantPayController.java new file mode 100644 index 000000000..8cc41be5a --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateMerchantPayController.java @@ -0,0 +1,59 @@ +package com.bsd.pjcloud.thirdparty.controller.appplus; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantEntryDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantCreateDto; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateMerchantPayService; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantCreateVo; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +/** 只开放签名商户码查询与线下收款创建,不放开原有商户详情或内部支付接口。 */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/aggregate-merchant-pay") +public class ThirdAggregateMerchantPayController { + /** 线下收款边界服务,负责验签、租户恢复和幂等创建。 */ + private final ThirdAggregateMerchantPayService service; + + /** 匿名商户查询仅输出最小字段,失败不泄漏商户是否存在或远程异常。 */ + @GetMapping("/merchant") + @Inner(value = false) + public ResponseEntity> merchant(@Valid @ModelAttribute ThirdAggregateMerchantEntryDto entry, + HttpServletRequest request) { + R body; + try { + body = R.ok(service.getMerchant(entry, request)); + } catch (ResponseStatusException exception) { + throw exception; + } catch (RuntimeException exception) { + body = R.failed("收款链接或商户不可用,请重新扫码"); + } + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(body); + } + + /** 公共请求不允许指定业务类型、渠道配置或任意回跳地址。 */ + @PostMapping("/create") + @Inner(value = false) + public ResponseEntity> create(@Valid @RequestBody ThirdAggregateMerchantCreateDto dto, + HttpServletRequest request) { + R body; + try { + body = R.ok(service.create(dto, request)); + } catch (ResponseStatusException exception) { + throw exception; + } catch (IllegalArgumentException exception) { + body = R.failed(exception.getMessage()); + } catch (RuntimeException exception) { + body = R.failed("付款暂未能发起,请稍后重试或重新扫码"); + } + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(body); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java index 8dca08bcb..f5d8ba699 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java @@ -3,170 +3,127 @@ package com.bsd.pjcloud.thirdparty.controller.appplus; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.security.annotation.Inner; import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData; -import com.bsd.pjcloud.thirdparty.service.ThirdAggregateCashierHtmlRenderer; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateH5Support; import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateH5ContextVo; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; -import org.springframework.http.CacheControl; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; +import org.springframework.http.*; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.net.URI; -/** - * 支付宝与微信聚合订单码匿名收银入口。 - * - *

所有入口先用租户唯一编码恢复租户上下文,不接收租户ID或任意回跳地址。

- */ +/** 聚合扫码与 OAuth 匿名入口;保留旧路径,收银展示统一通过 302 转入独立 H5。 */ @RestController @RequiredArgsConstructor @Validated @RequestMapping("/aggregate-order-pay") public class ThirdAggregateOrderPayController { - - /** - * 聚合支付编排服务,负责访问令牌、OAuth state、付款人互斥和状态查询。 - */ + /** 已有订单编排服务,负责 OAuth state、付款人互斥和渠道事实确认。 */ private final ThirdAggregateOrderPayService aggregateOrderPayService; + /** 固定 H5 地址与公开限流配置,不接受任意回跳目标。 */ + private final ThirdAggregateH5Support h5Support; - /** - * 固定收银 HTML 渲染器,负责 CSP nonce 和 JSBridge 页面输出。 - */ - private final ThirdAggregateCashierHtmlRenderer htmlRenderer; - - /** - * 识别支付宝或微信扫码客户端并跳转对应 OAuth 授权入口。 - * - * @param tenantCode 聚合二维码中的租户唯一编码 - * @param token 聚合二维码中的随机访问令牌 - * @param userAgent 扫码客户端 User-Agent - * @return OAuth 重定向;普通浏览器或失效请求返回固定提示页 - */ - @GetMapping(value = "/cashier/{tenantCode}/{token}", produces = MediaType.TEXT_HTML_VALUE) + /** 保留消费机二维码入口,按客户端进入对应的官方 OAuth。 */ + @GetMapping("/cashier/{tenantCode}/{token}") @Inner(value = false) - public ResponseEntity cashier(@NotBlank @PathVariable String tenantCode, @NotBlank @PathVariable String token, - @RequestHeader(value = HttpHeaders.USER_AGENT, required = false) String userAgent) { + public ResponseEntity cashier(@NotBlank @PathVariable String tenantCode, @NotBlank @PathVariable String token, + @RequestHeader(value = HttpHeaders.USER_AGENT, required = false) String userAgent, + HttpServletRequest request) { + h5Support.checkRate(request, false); try { - String oauthUrl = aggregateOrderPayService.getCashierOauthUrl(tenantCode, token, userAgent); - return ResponseEntity.status(HttpStatus.FOUND).headers(baseHeaders(null)).location(URI.create(oauthUrl)).build(); + h5Support.baseUrl(); + return redirect(aggregateOrderPayService.getCashierOauthUrl(tenantCode, token, userAgent)); } catch (RuntimeException exception) { - return messageResponse(exception.getMessage(), HttpStatus.BAD_REQUEST); + return unavailable(); } } - /** - * 接收服务商统一公众号 OAuth 回调并返回微信 JSBridge 收银页。 - * - * @param tenantCode 聚合 URL 中的租户唯一编码 - * @param code 微信一次性授权码 - * @param state 服务端创建的一次性 OAuth state - * @return 带 CSP nonce 的固定微信收银页 - */ - @GetMapping(value = "/oauth/wechat/{tenantCode}", produces = MediaType.TEXT_HTML_VALUE) + /** 微信 OAuth 成功后只向固定 H5 传递短期收银票据。 */ + @GetMapping("/oauth/wechat/{tenantCode}") @Inner(value = false) - public ResponseEntity wechatOauth(@NotBlank @PathVariable String tenantCode, - @NotBlank @RequestParam("code") String code, - @NotBlank @RequestParam("state") String state) { + public ResponseEntity wechatOauth(@NotBlank @PathVariable String tenantCode, + @RequestParam(value = "code", required = false) String code, + @RequestParam(value = "state", required = false) String state) { try { - ThirdAggregateCashierPageData pageData = aggregateOrderPayService.handleWechatOauth(tenantCode, code, state); - return payPageResponse(pageData); + h5Support.baseUrl(); + ThirdAggregateCashierPageData data = aggregateOrderPayService.handleWechatOauth(tenantCode, code, state); + return redirect(data.getH5Url()); } catch (RuntimeException exception) { - return messageResponse(exception.getMessage(), HttpStatus.BAD_REQUEST); + return unavailable(); } } - /** - * 接收固定地址的支付宝 OAuth 回调并返回 AlipayJSBridge 收银页。 - * - *

租户编码由服务端一次性 state 恢复,确保所有租户共用同一个支付宝授权回调白名单地址。

- * - * @param authCode 支付宝一次性授权码 - * @param state 包含租户编码且由服务端创建的一次性 OAuth state - * @return 带 CSP nonce 的固定支付宝收银页 - */ - @GetMapping(value = "/oauth/alipay", produces = MediaType.TEXT_HTML_VALUE) + /** 支付宝沿用固定回调地址,租户由一次性 state 恢复,不把授权码传入 H5。 */ + @GetMapping("/oauth/alipay") @Inner(value = false) - public ResponseEntity alipayOauth(@NotBlank @RequestParam("auth_code") String authCode, - @NotBlank @RequestParam("state") String state) { + public ResponseEntity alipayOauth(@RequestParam(value = "auth_code", required = false) String authCode, + @RequestParam(value = "state", required = false) String state) { try { - ThirdAggregateCashierPageData pageData = aggregateOrderPayService.handleAlipayOauth(authCode, state); - return payPageResponse(pageData); + h5Support.baseUrl(); + ThirdAggregateCashierPageData data = aggregateOrderPayService.handleAlipayOauth(authCode, state); + return redirect(data.getH5Url()); } catch (RuntimeException exception) { - return messageResponse(exception.getMessage(), HttpStatus.BAD_REQUEST); + return unavailable(); } } - /** - * 使用短期 ticket 返回收银页轮询所需的最小支付状态。 - * - * @param tenantCode 聚合 URL 中的租户唯一编码 - * @param ticket 收银页短期状态票据 - * @return 聚合状态、选定平台和支付成功标识 - */ + /** 票据重复读取支持刷新恢复;固定金额和平台参数由后端绑定的会话提供。 */ + @GetMapping("/context/{tenantCode}/{ticket}") + @Inner(value = false) + public ResponseEntity> context(@NotBlank @PathVariable String tenantCode, + @NotBlank @PathVariable String ticket, + HttpServletRequest request) { + h5Support.checkRate(request, false); + R body; + try { + body = R.ok(aggregateOrderPayService.getContextByTicket(tenantCode, ticket)); + } catch (RuntimeException exception) { + body = R.failed("收银凭据已失效或暂不可用,请重新扫码"); + } + return ResponseEntity.ok().headers(baseHeaders()).body(body); + } + + /** 沿用既有短期票据状态契约,不以浏览器支付成功事件修改后端流水。 */ @GetMapping("/status/{tenantCode}/{ticket}") @Inner(value = false) public ResponseEntity> status(@NotBlank @PathVariable String tenantCode, - @NotBlank @PathVariable String ticket) { + @NotBlank @PathVariable String ticket, + HttpServletRequest request) { + h5Support.checkRate(request, false); R body; try { body = R.ok(aggregateOrderPayService.getStatusByTicket(tenantCode, ticket)); } catch (RuntimeException exception) { - body = R.failed(exception.getMessage()); + body = R.failed("支付状态暂不可用,请稍后重试或重新扫码"); } - return ResponseEntity.ok().headers(baseHeaders(null)).body(body); + return ResponseEntity.ok().headers(baseHeaders()).body(body); } - /** - * 构造支付页响应并设置严格 CSP、禁止缓存和禁止来源泄漏响应头。 - * - * @param pageData 平台调起参数和短期状态地址 - * @return HTML 收银页响应 - */ - private ResponseEntity payPageResponse(ThirdAggregateCashierPageData pageData) { - String nonce = htmlRenderer.createNonce(); - return ResponseEntity.ok().headers(baseHeaders(nonce)).contentType(MediaType.TEXT_HTML) - .body(htmlRenderer.renderPayPage(pageData, nonce)); + /** 用 Location 执行浏览器导航,不能把 URL 当作普通字符串返回。 */ + private ResponseEntity redirect(String url) { + return ResponseEntity.status(HttpStatus.FOUND).headers(baseHeaders()).location(URI.create(url)).build(); } - /** - * 构造安全提示页响应,提示文本由渲染器执行 HTML 转义。 - * - * @param message 面向付款人的短提示 - * @param status HTTP 状态码 - * @return 不包含平台参数的固定 HTML 响应 - */ - private ResponseEntity messageResponse(String message, HttpStatus status) { - String nonce = htmlRenderer.createNonce(); - return ResponseEntity.status(status).headers(baseHeaders(nonce)).contentType(MediaType.TEXT_HTML) - .body(htmlRenderer.renderMessagePage(message, nonce)); + /** 错误只跳固定 H5 提示入口;配置缺失时返回无敏感正文的服务不可用响应。 */ + private ResponseEntity unavailable() { + try { + return redirect(h5Support.errorUrl()); + } catch (RuntimeException exception) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).headers(baseHeaders()).build(); + } } - /** - * 构造聚合收银入口共用的安全响应头。 - * - * @param nonce HTML 响应 CSP nonce;重定向和 JSON 响应时为空 - * @return 禁止缓存、禁止来源泄漏和禁止 MIME 猜测的响应头 - */ - private HttpHeaders baseHeaders(String nonce) { + /** 禁止票据缓存、来源泄漏和 MIME 猜测,响应不再包含动态 HTML。 */ + private HttpHeaders baseHeaders() { HttpHeaders headers = new HttpHeaders(); headers.setCacheControl(CacheControl.noStore()); headers.set("Pragma", "no-cache"); headers.set("Referrer-Policy", "no-referrer"); headers.set("X-Content-Type-Options", "nosniff"); - if (nonce != null) { - headers.set("Content-Security-Policy", "default-src 'none'; script-src 'nonce-" + nonce - + "'; style-src 'nonce-" + nonce + "'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"); - } return headers; } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAppPlusController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAppPlusController.java index a618ea407..6ce3c3220 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAppPlusController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAppPlusController.java @@ -8,6 +8,7 @@ import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateMerchantPayService; import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo; @@ -48,6 +49,16 @@ public class ThirdAppPlusController { */ private final ThirdAppPlusService thirdAppPlusService; + /** 商户收款链接签发服务,只有内部钱包服务可以调用。 */ + private final ThirdAggregateMerchantPayService aggregateMerchantPayService; + + /** 生成当前可信租户商户的 H5 链接,不对外开放签名能力。 */ + @GetMapping("/merchant-collection-url") + @Inner + public R merchantCollectionUrl(@RequestParam("mchId") Long mchId) { + return R.ok(aggregateMerchantPayService.generateLink(mchId)); + } + /* ************************************* start 远程调用 start ************************************* */ /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayOrderController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayOrderController.java index cf5077693..eb5008216 100755 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayOrderController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayOrderController.java @@ -1,9 +1,7 @@ package com.bsd.pjcloud.thirdparty.controller.pay; import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; @@ -96,9 +94,8 @@ public class ThirdPayOrderController { @ResponseExcel @GetMapping("/export") @PreAuthorize("@pms.hasPermission('third_thirdPayOrder_export')") - public List export(ThirdPayOrderEntity thirdPayOrder, Long[] ids) { - List orders = thirdPayOrderService.list( - Wrappers.lambdaQuery(thirdPayOrder).in(ArrayUtil.isNotEmpty(ids), ThirdPayOrderEntity::getId, ids)); + public List export(ThirdPayOrderDto dto, Long[] ids) { + List orders = thirdPayOrderService.exportList(dto, ids); orders.forEach(this::sanitizeForClient); return orders; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayOrderMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayOrderMapper.java index ab280c5e4..721d8c3f9 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayOrderMapper.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayOrderMapper.java @@ -8,10 +8,15 @@ import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import java.util.List; @Mapper public interface ThirdPayOrderMapper extends PjcloudBaseMapper { + /** 不分页导出当前租户及权限范围内匹配的支付流水,排序与分页一致。 */ + List scopeList(@Param("dto") ThirdPayOrderDto dto, @Param("ids") Long[] ids, DataScope dataScope); + + /** 按当前租户、权限和查询条件分页读取支付流水,以创建时间和主键倒序返回。 */ Page scopePage(Page page, @Param("dto") ThirdPayOrderDto dto, DataScope dataScope); } \ No newline at end of file diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierPageData.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierPageData.java index 23309dfea..a131190fb 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierPageData.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierPageData.java @@ -15,6 +15,9 @@ public class ThirdAggregateCashierPageData { */ private final String statusUrl; + /** 新 H5 的固定 HTTPS 跳转地址,只包含租户编码和短期票据;旧渲染测试可为空。 */ + private final String h5Url; + /** * 创建聚合收银页数据。 * @@ -22,8 +25,19 @@ public class ThirdAggregateCashierPageData { * @param statusUrl 短期状态查询地址 */ public ThirdAggregateCashierPageData(ThirdAggregateCashierLaunch launch, String statusUrl) { + this(launch, statusUrl, null); + } + + /** 创建 OAuth 回调数据,H5 通过票据单独读取敏感调起参数。 */ + public ThirdAggregateCashierPageData(ThirdAggregateCashierLaunch launch, String statusUrl, String h5Url) { this.launch = launch; this.statusUrl = statusUrl; + this.h5Url = h5Url; + } + + /** 返回固定配置的 H5 票据入口,不能使用客户端提供的回跳地址。 */ + public String getH5Url() { + return h5Url; } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregatePayCreateContext.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregatePayCreateContext.java index 03247a301..97620f429 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregatePayCreateContext.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregatePayCreateContext.java @@ -18,13 +18,16 @@ public class ThirdAggregatePayCreateContext { */ private String payNo; + /** 由服务端聚合会话固定的场景,原样传递给支付宝或微信支付流水。 */ + private String aggregateScene; + /** - * 调用方业务订单唯一标识。 + * 业务订单或线下收款请求唯一标识。 */ private String bizId; /** - * 支付业务类型,首版固定为云消费机支付。 + * 订单聚合沿用原业务类型,线下收款为 MCH_OFFLINE_PAY。 */ private ThirdPayOrderBizTypeEnum bizType; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5Support.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5Support.java new file mode 100644 index 000000000..22793ec5d --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5Support.java @@ -0,0 +1,124 @@ +package com.bsd.pjcloud.thirdparty.service; + +import cn.hutool.core.util.StrUtil; +import com.bsd.pjcloud.thirdparty.config.AggregatePayProperties; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantEntryDto; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; + +/** 聚合 H5 固定跳转、商户签名和匿名限流,复用项目 Redis 的租户前缀。 */ +@Service +@RequiredArgsConstructor +public class ThirdAggregateH5Support { + /** 收银页配置,密钥和固定跳转域名仅由后端配置中心提供。 */ + private final AggregatePayProperties properties; + /** 项目租户感知模板,不在业务键中手工附加租户编号。 */ + private final StringRedisTemplate redis; + /** 固定窗口原子计数,首次访问设置 60 秒有效期,避免计数和过期之间出现竞态。 */ + private static final DefaultRedisScript RATE_SCRIPT = new DefaultRedisScript<>( + "local n=redis.call('incr',KEYS[1]);if n==1 then redis.call('expire',KEYS[1],60) end;return n", Long.class); + + /** 返回经过校验的固定 H5 基础地址,禁止用户信息、查询参数和 Hash 混入跳转配置。 */ + public String baseUrl() { + try { + URI uri = URI.create(StrUtil.blankToDefault(properties.getH5BaseUrl(), "")); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getRawUserInfo() != null + || uri.getRawQuery() != null || uri.getRawFragment() != null) { + throw new IllegalArgumentException(); + } + return StrUtil.removeSuffix(uri.toString(), "/"); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException("聚合收银 H5 地址未正确配置"); + } + } + + /** 仅支持已有网关前缀或 Boot 无前缀部署,禁止把该配置变为任意跳转路径。 */ + public String publicRoutePrefix() { + String prefix = properties.getPublicRoutePrefix(); + if (!"/third".equals(prefix) && !"".equals(prefix)) throw new IllegalStateException("聚合支付公网前缀配置无效"); + return prefix; + } + + /** 返回唯一允许跨域访问本收银接口的 H5 来源,端口参与匹配。 */ + public String allowedOrigin() { + URI uri = URI.create(baseUrl()); + return uri.getScheme() + "://" + uri.getRawAuthority(); + } + + /** 生成绑定线下用途的长期签名;调用方必须是可信内部服务。 */ + public String sign(Long tenantId, Long merchantId) { + String secret = properties.getMerchantSigningKey(); + if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < 32) { + throw new IllegalStateException("商户收款签名密钥未正确配置"); + } + if (tenantId == null || tenantId <= 0 || merchantId == null || merchantId <= 0) { + throw new IllegalArgumentException("商户收款参数无效"); + } + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(("MCH_OFFLINE_PAY\n1\n" + tenantId + "\n" + merchantId) + .getBytes(StandardCharsets.UTF_8))); + } catch (java.security.GeneralSecurityException exception) { + throw new IllegalStateException("商户收款签名不可用", exception); + } + } + + /** 以恒定时间比较服务端签名;不暴露商户是否存在,先验签再访问业务数据。 */ + public void verify(ThirdAggregateMerchantEntryDto entry) { + if (entry == null || !"1".equals(entry.getV()) || entry.getSign() == null + || !entry.getSign().matches("[0-9a-f]{64}") + || !MessageDigest.isEqual(sign(entry.getTenantId(), entry.getId()).getBytes(StandardCharsets.US_ASCII), + entry.getSign().getBytes(StandardCharsets.US_ASCII))) { + throw new IllegalArgumentException("收款链接不可用,请重新扫码"); + } + } + + /** 为钱包服务构造静态收款入口,保留 id 参数兼容现有 App 的 URL 扫码解析。 */ + public String merchantUrl(Long tenantId, Long merchantId) { + return baseUrl() + "/#/pages/pay/index?id=" + merchantId + "&tenantId=" + tenantId + + "&v=1&sign=" + sign(tenantId, merchantId); + } + + /** OAuth 回跳只携带短期不透明票据,渠道签名参数由 H5 单独读取。 */ + public String cashierUrl(String tenantCode, String ticket) { + return baseUrl() + "/#/pages/pay/index?tenantCode=" + encode(tenantCode) + "&ticket=" + encode(ticket); + } + + /** 固定错误入口不回显异常、用户输入或内部配置。 */ + public String errorUrl() { + return baseUrl() + "/#/pages/pay/index?error=UNAVAILABLE"; + } + + /** 对公开操作限流;默认只使用直连地址,可信代理必须覆盖 X-Real-IP。 */ + public void checkRate(HttpServletRequest request, boolean creating) { + String source = request.getRemoteAddr(); + if (properties.getTrustedProxyAddresses().contains(source)) { + String forwarded = request.getHeader("X-Real-IP"); + if (forwarded != null && forwarded.matches("[0-9a-fA-F:.]{3,64}")) source = forwarded; + } + int limit = creating ? properties.getCreateLimitPerMinute() : properties.getQueryLimitPerMinute(); + Long count = redis.execute(RATE_SCRIPT, List.of("thirdparty:aggregate-h5:rate:" + + (creating ? "create:" : "query:") + source)); + if (count == null || count > Math.max(1, limit)) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.TOO_MANY_REQUESTS, "请求较频繁,请稍后重试"); + } + } + + /** 编码固定跳转参数,防止租户编码改变 URL 结构。 */ + private String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateMerchantPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateMerchantPayService.java new file mode 100644 index 000000000..cd7a67d60 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateMerchantPayService.java @@ -0,0 +1,17 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantEntryDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantCreateDto; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantCreateVo; +import jakarta.servlet.http.HttpServletRequest; + +/** 商户长期收款入口与短期付款请求之间的边界,不创建商品订单。 */ +public interface ThirdAggregateMerchantPayService { + /** 为可信钱包服务签发当前租户商户的长期 H5 入口。 */ + String generateLink(Long merchantId); + /** 验签后恢复租户并查询最小商户资料,访问来源仅用于匿名限流。 */ + ThirdAggregateMerchantVo getMerchant(ThirdAggregateMerchantEntryDto entry, HttpServletRequest request); + /** 校验整数分、请求幂等和客户端后创建线下聚合会话。 */ + ThirdAggregateMerchantCreateVo create(ThirdAggregateMerchantCreateDto dto, HttpServletRequest request); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java index 92ae37881..ec7f141a6 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java @@ -6,6 +6,7 @@ import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateH5ContextVo; /** * 支付宝与微信聚合订单码支付编排服务。 @@ -20,6 +21,15 @@ public interface ThirdAggregateOrderPayService { */ ThirdAggregateOrderCodePayVo create(ThirdAggregateOrderCodePayDto dto); + /** 创建已验签的线下收款会话,金额与请求编号由线下入口服务校验后固定。 */ + ThirdAggregateOrderCodePayVo createOffline(ThirdAggregateOrderCodePayDto dto); + + /** 使用租户编码和短期票据读取固定金额、商户及尚可支付的 JSBridge 参数。 */ + ThirdAggregateH5ContextVo getContextByTicket(String tenantCode, String ticket); + + /** 至少开通一个渠道,仅校验已启用渠道的应用、商户参数及默认关系,不创建渠道订单。 */ + void validateMerchantPayConfiguration(Long merchantId); + /** * 按聚合会话编号查询当前租户最小支付状态。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayOrderService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayOrderService.java index ea3d9df58..79257a577 100755 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayOrderService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayOrderService.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.thirdparty.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import java.util.List; import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; @@ -13,6 +14,9 @@ public interface ThirdPayOrderService extends IService { Page scopePage(Page page, ThirdPayOrderDto dto, DataScope dataScope); + /** 按与管理端分页相同条件导出,可按选中的主键集合进一步收窄。 */ + List exportList(ThirdPayOrderDto dto, Long[] ids); + ThirdPayOrderEntity getByPayNo(String payNo); ThirdPayOrderEntity getByBiz(ThirdPayOrderBizTypeEnum bizType, String bizId); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java index 7a336cbdb..58c82627c 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java @@ -287,6 +287,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); payOrder.setBizType(context.getBizType().getType()); payOrder.setBizId(context.getBizId()); + payOrder.setAggregateScene(context.getAggregateScene()); payOrder.setUserId(context.getUserId()); payOrder.setMchId(context.getMchId()); payOrder.setPrice(context.getAmount()); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java index f6557662c..17438105c 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java @@ -15,6 +15,10 @@ import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.tenant.TenantBroker; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySceneEnum; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateH5ContextVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantVo; +import org.springframework.beans.factory.annotation.Autowired; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; @@ -33,6 +37,7 @@ import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatu import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction; import lombok.RequiredArgsConstructor; @@ -56,6 +61,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.UUID; /** @@ -77,11 +83,6 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay */ private static final String QR_CODE_TYPE = "AGGREGATE_ORDER_PAY"; - /** - * thirdparty 服务经网关暴露的固定公网前缀,网关转发后会移除该前缀。 - */ - private static final String PUBLIC_ROUTE_PREFIX = "/third"; - /** * 匿名消费机订单的内部用户ID占位值。 */ @@ -227,8 +228,12 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay */ private final SecureRandom secureRandom = new SecureRandom(); + /** 固定 H5 跳转辅助服务;保持原构造器,兼容既有订单编排测试与调用。 */ + @Autowired + private ThirdAggregateH5Support h5Support; + /** - * 创建或复用同业务有效聚合二维码,并在创建前完整校验双平台配置。 + * 创建或复用同业务有效聚合二维码,并在创建前校验商户已启用的渠道配置。 * * @param dto 业务订单、商户、金额和可选用户参数 * @return 聚合会话编号、HTTPS 二维码内容和失效时间 @@ -236,6 +241,21 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay @Override public ThirdAggregateOrderCodePayVo create(ThirdAggregateOrderCodePayDto dto) { validateCreateRequest(dto); + return createWithScene(dto, ThirdAggregatePaySceneEnum.ORDER); + } + + /** 仅供已验签的线下入口调用,业务类型由服务端固定,不能开放给内部订单接口的任意请求。 */ + @Override + public ThirdAggregateOrderCodePayVo createOffline(ThirdAggregateOrderCodePayDto dto) { + if (dto == null || dto.getBizType() != ThirdPayOrderBizTypeEnum.MCH_OFFLINE_PAY + || StrUtil.isBlank(dto.getBizId()) || dto.getMchId() == null || dto.getAmount() == null || dto.getAmount() <= 0) { + throw new IllegalArgumentException("线下收款参数不完整"); + } + return createWithScene(dto, ThirdAggregatePaySceneEnum.OFFLINE); + } + + /** 两种场景复用同一创建锁、幂等检查、有效期和补偿任务,场景由可信入口确定。 */ + private ThirdAggregateOrderCodePayVo createWithScene(ThirdAggregateOrderCodePayDto dto, ThirdAggregatePaySceneEnum scene) { String lockKey = CREATE_LOCK_PREFIX + dto.getBizType().getType() + ":" + dto.getBizId(); String lockToken = UUID.randomUUID().toString(); Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockToken, CREATE_LOCK_TTL); @@ -243,7 +263,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay throw new RuntimeException("聚合订单码正在处理中,请勿重复提交"); } try { - ThirdAggregateOrderCodePayVo result = createOrReuse(dto); + ThirdAggregateOrderCodePayVo result = createOrReuse(dto, scene); pollingCoordinator.wakeAggregatePolling(); return result; } finally { @@ -251,6 +271,30 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay } } + /** 商户至少开通一个渠道即可签发静态码,未开通的平台不参与配置和分佣关系校验。 */ + @Override + public void validateMerchantPayConfiguration(Long merchantId) { + MchManageVo merchant = getMerchant(merchantId); + List settings = getEnabledPaySettings(merchant); + if (settings.isEmpty()) throw new IllegalArgumentException("请先启用商户的微信或支付宝支付配置"); + for (MchPaySettingVo setting : settings) { + AppPlusPlatformTypeEnum platform = AppPlusPlatformTypeEnum.valueOfByType(setting.getPlatformType()); + validateApplicationConfig(platform); + ThirdAggregatePayCreateContext context = new ThirdAggregatePayCreateContext(); + context.setMchId(merchantId); + MchPaySettingVo snapshot = applyDefaultPaymentRelation(setting, context); + String subMchId; + if (platform == AppPlusPlatformTypeEnum.ALI_PAY) { + MchPaySettingEntity.AliPaySetting channel = JSONUtil.toBean(snapshot.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + subMchId = channel == null ? null : channel.getSubMchid(); + } else { + MchPaySettingEntity.WechatPaySetting channel = JSONUtil.toBean(snapshot.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); + subMchId = channel == null ? null : channel.getSubMchid(); + } + if (StrUtil.isBlank(subMchId)) throw new IllegalArgumentException("商户" + platform.getDescription() + "渠道配置不完整"); + } + } + /** * 查询当前租户聚合会话,并同步仍有渠道子订单的真实终态。 * @@ -318,7 +362,22 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay return TenantBroker.applyAs(tenant.getId(), ignored -> { AppPlusPlatformTypeEnum platform = resolveClientPlatform(userAgent); ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getByAccessTokenHash(sha256(token)); + // 刷新或网络恢复后,先确认原单终态;已成功/已关闭的旧请求直接进入结果页,不再重新下单。 + if (session != null && (isExpired(session) || !isActive(session))) { + getStatus(session.getAggregateNo()); + session = aggregatePaySessionService.getById(session.getId()); + if (session != null && !isActive(session)) { + String ticket = createStatusTicket(session.getAggregateNo()); + return h5Support.cashierUrl(tenantCode, ticket); + } + throw new IllegalStateException("原付款结果仍在确认,请稍后重试"); + } validateCashierSession(session, platform); + // 尚未选定渠道时先检查商户开通情况。错误 App 只进入 H5 提示,不发起无效 OAuth 或锁单。 + if (StrUtil.isBlank(session.getSelectedPlatform()) && getEnabledPaySettings(getMerchant(session.getMchId())).stream() + .noneMatch(setting -> platform.getType().equals(setting.getPlatformType()))) { + return h5Support.cashierUrl(tenantCode, createStatusTicket(session.getAggregateNo())); + } String storedState = createOauthState(session.getId(), platform); String state = ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY) ? buildAlipayOauthState(tenantCode, storedState) : storedState; @@ -403,8 +462,8 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay public ThirdAggregateOrderCodePayStatusVo getStatusByTicket(String tenantCode, String ticket) { SysTenant tenant = resolveTenant(tenantCode); return TenantBroker.applyAs(tenant.getId(), ignored -> { - if (StrUtil.isBlank(ticket)) { - throw new RuntimeException("支付状态票据不能为空"); + if (StrUtil.isBlank(ticket) || !ticket.matches("[A-Za-z0-9_-]{32}")) { + throw new RuntimeException("支付状态票据无效"); } String aggregateNo = stringRedisTemplate.opsForValue().get(STATUS_TICKET_PREFIX + ticket); if (StrUtil.isBlank(aggregateNo)) { @@ -414,6 +473,44 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay }); } + /** 通过短期票据读取固定金额和最小渠道参数,终态不再提供支付调起参数。 */ + @Override + public ThirdAggregateH5ContextVo getContextByTicket(String tenantCode, String ticket) { + SysTenant tenant = resolveTenant(tenantCode); + return TenantBroker.applyAs(tenant.getId(), ignored -> { + if (StrUtil.isBlank(ticket) || !ticket.matches("[A-Za-z0-9_-]{32}")) { + throw new IllegalArgumentException("收银票据不可用"); + } + String aggregateNo = stringRedisTemplate.opsForValue().get(STATUS_TICKET_PREFIX + ticket); + if (StrUtil.isBlank(aggregateNo)) throw new IllegalArgumentException("收银票据已失效,请重新扫码"); + ThirdAggregateOrderCodePayStatusVo status = getStatus(aggregateNo); + ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getByAggregateNo(aggregateNo); + R merchantResult = remoteMchService.getById(session.getMchId()); + MchManageVo merchant = merchantResult != null && merchantResult.isOk() ? merchantResult.getData() : null; + if (merchant == null || !ObjectUtil.equals(merchant.getTenantId(), tenant.getId())) { + throw new IllegalArgumentException("收款商户不可用"); + } + ThirdAggregateMerchantVo merchantVo = new ThirdAggregateMerchantVo(); + merchantVo.setId(String.valueOf(merchant.getId())); + merchantVo.setName(merchant.getName()); + merchantVo.setLogoPic(merchant.getLogoPic()); + merchantVo.setSupportedPlatforms(getEnabledPaySettings(merchant).stream().map(MchPaySettingVo::getPlatformType).distinct().toList()); + ThirdAggregateH5ContextVo context = new ThirdAggregateH5ContextVo(); + context.setAggregateScene(StrUtil.blankToDefault(session.getAggregateScene(), ThirdAggregatePaySceneEnum.ORDER.getCode())); + context.setMerchant(merchantVo); + context.setAmount(session.getPrice()); + context.setSelectedPlatform(session.getSelectedPlatform()); + context.setPayNo(session.getPayNo()); + context.setStatus(session.getStatus()); + context.setPaySuccess(Boolean.TRUE.equals(status.getPaySuccess())); + context.setExpireTime(session.getExpireTime()); + if (ThirdAggregatePaySessionStatusEnum.PAYING.getType().equals(session.getStatus()) && !isExpired(session)) { + context.setLaunchParams(restoreLaunch(session).getPayParams()); + } + return context; + }); + } + /** * 处理一条待确认聚合会话或处于补偿中的过期会话。 * @@ -488,8 +585,14 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay * @param dto 已校验聚合支付创建请求 * @return 可直接渲染为二维码的聚合 HTTPS 地址 */ - private ThirdAggregateOrderCodePayVo createOrReuse(ThirdAggregateOrderCodePayDto dto) { + private ThirdAggregateOrderCodePayVo createOrReuse(ThirdAggregateOrderCodePayDto dto, ThirdAggregatePaySceneEnum scene) { ThirdAggregatePaySessionEntity latest = aggregatePaySessionService.getLatestByBiz(dto.getBizType(), dto.getBizId()); + if (latest != null && scene == ThirdAggregatePaySceneEnum.OFFLINE) { + validateReusableSession(latest, dto); + if (!isActive(latest)) { + throw new IllegalArgumentException("本次收款请求已结束,请查询结果或重新发起"); + } + } if (latest != null && ObjectUtil.equals(latest.getStatus(), ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS.getType())) { throw new RuntimeException("该业务订单已支付,请勿重复下单"); } @@ -511,14 +614,17 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay assertNoBlockingNativeOrder(dto); MchManageVo merchant = getMerchant(dto.getMchId()); - getRequiredAliPaySetting(merchant); - getRequiredWechatPaySetting(merchant); - validateApplicationConfig(); + List enabledSettings = getEnabledPaySettings(merchant); + if (enabledSettings.isEmpty()) throw new IllegalArgumentException("商户暂未开通微信或支付宝支付"); + for (MchPaySettingVo setting : enabledSettings) { + validateApplicationConfig(AppPlusPlatformTypeEnum.valueOfByType(setting.getPlatformType())); + } String tenantCode = getTenantCode(merchant.getTenantId()); String accessToken = createRandomToken(ACCESS_TOKEN_BYTES); ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity(); session.setAggregateNo(createAggregateNo()); + session.setAggregateScene(scene.getCode()); session.setAccessTokenHash(sha256(accessToken)); session.setAccessTokenCiphertext(stringEncryptor.encrypt(accessToken)); session.setBizType(dto.getBizType().getType()); @@ -557,6 +663,11 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay throw new RuntimeException("支付订单正在创建,请稍后重新扫码"); } + // OAuth 期间渠道可能被停用;未绑定子订单时再次校验,避免先锁单再发现当前平台未开通。 + MchManageVo merchant = getMerchant(session.getMchId()); + if (getEnabledPaySettings(merchant).stream().noneMatch(setting -> platform.getType().equals(setting.getPlatformType()))) { + throw new IllegalArgumentException("商户暂不支持当前支付方式,请重新扫码"); + } String payNo = createPayNo(); if (!aggregatePaySessionService.selectPlatform(session.getId(), platform, payNo, payerHash)) { ThirdAggregatePaySessionEntity latest = aggregatePaySessionService.getById(session.getId()); @@ -568,7 +679,6 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay } ThirdAggregatePayCreateContext context = buildCreateContext(session, payNo); - MchManageVo merchant = getMerchant(session.getMchId()); try { ThirdAggregateCashierLaunch launch; if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY)) { @@ -812,11 +922,17 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay */ private ThirdAggregateCashierPageData buildCashierPageData(String tenantCode, String aggregateNo, ThirdAggregateCashierLaunch launch) { - String ticket = createRandomToken(24); - stringRedisTemplate.opsForValue().set(STATUS_TICKET_PREFIX + ticket, aggregateNo, STATUS_TICKET_TTL); + String ticket = createStatusTicket(aggregateNo); String statusUrl = buildPublicUrl(getPlatformBaseUrl(launch.getPlatform()), "/aggregate-order-pay/status/" + tenantCode + "/" + ticket); - return new ThirdAggregateCashierPageData(launch, statusUrl); + return new ThirdAggregateCashierPageData(launch, statusUrl, h5Support.cashierUrl(tenantCode, ticket)); + } + + /** 签发只绑定当前租户聚合会话的五分钟票据,兼用于支付结果和未开通渠道的 H5 提示。 */ + private String createStatusTicket(String aggregateNo) { + String ticket = createRandomToken(24); + stringRedisTemplate.opsForValue().set(STATUS_TICKET_PREFIX + ticket, aggregateNo, STATUS_TICKET_TTL); + return ticket; } /** @@ -1043,12 +1159,18 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay if (merchant == null || ObjectUtil.notEqual(merchant.getId(), mchId)) { throw new RuntimeException("收款商户不存在"); } - if (ObjectUtil.isEmpty(merchant.getPaySettingList())) { - throw new RuntimeException("商户未配置支付参数"); - } return merchant; } + /** 仅保留当前商户已启用的微信/支付宝配置;空列表供 H5 展示停用状态,不能用于创建付款。 */ + private List getEnabledPaySettings(MchManageVo merchant) { + if (merchant.getPaySettingList() == null) return List.of(); + return merchant.getPaySettingList().stream().filter(Objects::nonNull) + .filter(setting -> YesNoEnum.YES.getCode().equals(setting.getEnable())) + .filter(setting -> AppPlusPlatformPayType.WECHAT.getType().equals(setting.getPlatformType()) + || AppPlusPlatformPayType.ALI_PAY.getType().equals(setting.getPlatformType())).toList(); + } + /** * 选择已启用的支付宝支付配置;直付通渠道参数在选定平台后从默认关系解析。 * @@ -1056,7 +1178,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay * @return 当前商户支付宝配置 */ private MchPaySettingVo getRequiredAliPaySetting(MchManageVo merchant) { - MchPaySettingVo setting = merchant.getPaySettingList().stream() + MchPaySettingVo setting = getEnabledPaySettings(merchant).stream() .filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.ALI_PAY.getType())) .filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode())) .findFirst().orElse(null); @@ -1073,7 +1195,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay * @return 当前商户微信支付配置 */ private MchPaySettingVo getRequiredWechatPaySetting(MchManageVo merchant) { - MchPaySettingVo setting = merchant.getPaySettingList().stream() + MchPaySettingVo setting = getEnabledPaySettings(merchant).stream() .filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.WECHAT.getType())) .filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode())) .findFirst().orElse(null); @@ -1084,17 +1206,19 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay } /** - * 校验支付宝和微信全局应用配置满足 OAuth、JSAPI 下单、回调与验签要求。 + * 仅校验已启用平台的应用配置,避免未开通的另一个平台阻止商户收款。 + * + * @param platform 当前启用的微信或支付宝平台 */ - private void validateApplicationConfig() { + private void validateApplicationConfig(AppPlusPlatformTypeEnum platform) { AppPlusPayConfig.AliPayConfig ali = appPlusPayConfig.getAliPayConfig(); - if (ali == null || StrUtil.hasBlank(ali.getAppId(), ali.getAppPrivateKey(), ali.getAlipayPublicKey(), - ali.getBaseCallbackUrl(), ali.getPayCallbackUrl())) { + if (platform == AppPlusPlatformTypeEnum.ALI_PAY && (ali == null || StrUtil.hasBlank(ali.getAppId(), ali.getAppPrivateKey(), ali.getAlipayPublicKey(), + ali.getBaseCallbackUrl(), ali.getPayCallbackUrl()))) { throw new RuntimeException("支付宝应用支付配置不完整"); } AppPlusPayConfig.WechatConfig wechat = appPlusPayConfig.getWechatConfig(); - if (wechat == null || StrUtil.hasBlank(wechat.getGzhAppId(), wechat.getGzhAppSecret(), wechat.getMerchantId(), wechat.getMchPrivatePath(), - wechat.getMerchantSerialNumber(), wechat.getApiV3Key(), wechat.getBaseCallbackUrl(), wechat.getPayCallbackUrl())) { + if (platform == AppPlusPlatformTypeEnum.WECHAT && (wechat == null || StrUtil.hasBlank(wechat.getGzhAppId(), wechat.getGzhAppSecret(), wechat.getMerchantId(), wechat.getMchPrivatePath(), + wechat.getMerchantSerialNumber(), wechat.getApiV3Key(), wechat.getBaseCallbackUrl(), wechat.getPayCallbackUrl()))) { throw new RuntimeException("微信服务商JSAPI配置不完整"); } } @@ -1195,6 +1319,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay private ThirdAggregatePayCreateContext buildCreateContext(ThirdAggregatePaySessionEntity session, String payNo) { ThirdAggregatePayCreateContext context = new ThirdAggregatePayCreateContext(); context.setPayNo(payNo); + context.setAggregateScene(StrUtil.blankToDefault(session.getAggregateScene(), ThirdAggregatePaySceneEnum.ORDER.getCode())); context.setBizId(session.getBizId()); context.setBizType(ThirdPayOrderBizTypeEnum.valueOfByType(session.getBizType())); context.setUserId(session.getUserId()); @@ -1274,7 +1399,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay * @return 完整 HTTPS 地址 */ private String buildPublicUrl(String baseUrl, String path) { - return normalizeBaseUrl(baseUrl) + PUBLIC_ROUTE_PREFIX + path; + return normalizeBaseUrl(baseUrl) + h5Support.publicRoutePrefix() + path; } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java index 80fd7c6c0..7e61b3c77 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java @@ -302,6 +302,7 @@ public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatP payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); payOrder.setBizType(context.getBizType().getType()); payOrder.setBizId(context.getBizId()); + payOrder.setAggregateScene(context.getAggregateScene()); payOrder.setUserId(context.getUserId()); payOrder.setMchId(context.getMchId()); payOrder.setPrice(context.getAmount()); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImpl.java new file mode 100644 index 000000000..f87637215 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImpl.java @@ -0,0 +1,126 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.config.AggregatePayProperties; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantEntryDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantCreateDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateH5Support; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateMerchantPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantCreateVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Locale; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; +import org.springframework.web.server.ResponseStatusException; + +/** 线下收款实现;租户、商户及金额在创建前固定,后续仅复用现有聚合支付链路。 */ +@Service +@RequiredArgsConstructor +public class ThirdAggregateMerchantPayServiceImpl implements ThirdAggregateMerchantPayService { + /** 仅后端可访问的签名和固定跳转配置。 */ + private final ThirdAggregateH5Support h5Support; + /** 复用既有聚合会话、平台互斥、OAuth 和渠道补偿。 */ + private final ThirdAggregateOrderPayService aggregateService; + /** 钱包商户远程契约,返回实体只用于服务内部,不直接透传给匿名请求。 */ + private final RemoteMchService remoteMchService; + /** 收款限额,默认最多 999999 分。 */ + private final AggregatePayProperties properties; + + /** 只从内部调用的当前租户生成签名,商户渠道配置完成后的刷新操作才会签发。 */ + @Override + public String generateLink(Long merchantId) { + aggregateService.validateMerchantPayConfiguration(merchantId); + return h5Support.merchantUrl(TenantContextHolder.getTenantId(), merchantId); + } + + /** 先验签再切换租户查询,客户端 TENANT-ID 不能覆盖已签名的租户。 */ + @Override + public ThirdAggregateMerchantVo getMerchant(ThirdAggregateMerchantEntryDto entry, HttpServletRequest request) { + h5Support.verify(entry); + return inTenant(entry.getTenantId(), () -> { + h5Support.checkRate(request, false); + return publicMerchant(entry.getId(), entry.getTenantId()); + }); + } + + /** 同一 requestId 派生同一业务请求号,网络重试不能创建不同的付款意图。 */ + @Override + public ThirdAggregateMerchantCreateVo create(ThirdAggregateMerchantCreateDto dto, HttpServletRequest request) { + h5Support.verify(dto); + h5Support.baseUrl(); + if (dto.getAmount() == null || dto.getAmount() <= 0 || dto.getAmount() > properties.getOfflineMaxAmount()) { + throw new IllegalArgumentException("收款金额超过允许范围"); + } + String client = Objects.toString(request.getHeader("User-Agent"), "").toLowerCase(Locale.ROOT); + if (!client.contains("micromessenger") && !client.contains("alipayclient")) { + throw new IllegalArgumentException("请使用微信或支付宝扫一扫付款"); + } + String platform = client.contains("micromessenger") ? AppPlusPlatformTypeEnum.WECHAT.getType() : AppPlusPlatformTypeEnum.ALI_PAY.getType(); + return inTenant(dto.getTenantId(), () -> { + h5Support.checkRate(request, true); + ThirdAggregateMerchantVo merchant = publicMerchant(dto.getId(), dto.getTenantId()); + // 前端提示不能代替服务端校验,配置在扫码后停用时也必须在创建会话前拒绝。 + if (!merchant.getSupportedPlatforms().contains(platform)) { + throw new IllegalArgumentException("商户暂不支持" + (AppPlusPlatformTypeEnum.WECHAT.getType().equals(platform) ? "微信" : "支付宝") + "支付,请使用商户已开通的支付方式"); + } + aggregateService.validateMerchantPayConfiguration(dto.getId()); + ThirdAggregateOrderCodePayDto internal = new ThirdAggregateOrderCodePayDto(); + internal.setMchId(dto.getId()); + internal.setBizType(ThirdPayOrderBizTypeEnum.MCH_OFFLINE_PAY); + internal.setBizId("OFFLINE:" + dto.getId() + ":" + dto.getRequestId()); + internal.setAmount(dto.getAmount()); + ThirdAggregateOrderCodePayVo created = aggregateService.createOffline(internal); + ThirdAggregateMerchantCreateVo result = new ThirdAggregateMerchantCreateVo(); + result.setAggregateNo(created.getAggregateNo()); + result.setCashierUrl(created.getQrCode()); + result.setExpireTime(created.getExpireTime()); + return result; + }); + } + + /** 恢复租户后保留限流的 HTTP 429 语义,其余远程或数据异常仍由公开接口统一隐藏。 */ + private T inTenant(Long tenantId, Supplier action) { + try { + return TenantBroker.applyAs(tenantId, ignored -> action.get()); + } catch (TenantBroker.TenantBrokerExceptionWrapper exception) { + if (exception.getCause() instanceof ResponseStatusException statusException) throw statusException; + throw exception; + } + } + + /** 最小公开投影,并再次核对远程商户的真实租户归属。 */ + private ThirdAggregateMerchantVo publicMerchant(Long id, Long tenantId) { + R result = remoteMchService.getById(id); + MchManageVo merchant = result != null && result.isOk() ? result.getData() : null; + if (merchant == null || !Objects.equals(merchant.getId(), id) || !Objects.equals(merchant.getTenantId(), tenantId)) { + throw new IllegalArgumentException("收款链接不可用,请重新扫码"); + } + ThirdAggregateMerchantVo view = new ThirdAggregateMerchantVo(); + view.setId(String.valueOf(merchant.getId())); + view.setName(merchant.getName()); + view.setLogoPic(merchant.getLogoPic()); + // 匿名响应只输出开通渠道的编码,不向付款人暴露配置、商户号或结算关系。 + view.setSupportedPlatforms(merchant.getPaySettingList() == null ? List.of() : merchant.getPaySettingList().stream() + .filter(Objects::nonNull) + .filter(setting -> YesNoEnum.YES.getCode().equals(setting.getEnable())) + .map(setting -> setting.getPlatformType()) + .filter(platform -> AppPlusPlatformTypeEnum.WECHAT.getType().equals(platform) || AppPlusPlatformTypeEnum.ALI_PAY.getType().equals(platform)) + .distinct().toList()); + return view; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java index 31bd53918..8c334c3f2 100755 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java @@ -23,6 +23,8 @@ import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper; import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; @@ -66,6 +68,10 @@ public class ThirdPayOrderServiceImpl extends ServiceImpl order.setMchPaySettingJson(null)); } + /** 导出复用分页的同一筛选 SQL,包含时间范围、商户和聚合场景,保持相同的脱敏投影。 */ + @Override + public List exportList(ThirdPayOrderDto dto, Long[] ids) { + List rows = baseMapper.scopeList(dto, ids, null); + disposePageList(rows); + return rows; + } + + /** 以当前结果集的支付单号一次查询聚合关联,不逐行请求数据库或远程服务。 */ + private void fillAggregateSessions(List rows) { + if (aggregatePaySessionService == null) return; + List payNos = rows.stream().map(ThirdPayOrderEntity::getPayNo).filter(StrUtil::isNotBlank).distinct().toList(); + if (payNos.isEmpty()) return; + Map sessions = aggregatePaySessionService.list( + new LambdaQueryWrapper().in(ThirdAggregatePaySessionEntity::getPayNo, payNos)) + .stream().collect(Collectors.toMap(ThirdAggregatePaySessionEntity::getPayNo, Function.identity(), (first, second) -> first)); + for (ThirdPayOrderVo row : rows) { + ThirdAggregatePaySessionEntity session = sessions.get(row.getPayNo()); + if (session != null) row.setAggregateNo(session.getAggregateNo()); + } + } + /** * 批量加载支付用户,远程服务异常或缺失数据不阻断支付订单列表。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayOrderMapper.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayOrderMapper.xml index a120f5399..f9ab9c2de 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayOrderMapper.xml +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayOrderMapper.xml @@ -8,6 +8,7 @@ a.third_pay_no, a.pay_platform_type, a.biz_type, + a.aggregate_scene, a.biz_id, a.user_id, a.user_code, @@ -53,6 +54,9 @@ and a.biz_type = #{dto.bizType} + + and a.aggregate_scene = #{dto.aggregateScene} + and a.user_id = #{dto.userId} @@ -75,4 +79,16 @@ order by a.create_time desc, a.id desc + diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayControllerTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayControllerTest.java new file mode 100644 index 000000000..09c6f3135 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayControllerTest.java @@ -0,0 +1,41 @@ +package com.bsd.pjcloud.thirdparty.controller.appplus; + +import com.bsd.pjcloud.thirdparty.config.AggregatePayProperties; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateH5Support; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.ResponseEntity; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** OAuth 收银出口必须通过 302 导航,不能继续输出 HTML 或把异常带到公网 URL。 */ +class ThirdAggregateOrderPayControllerTest { + /** 正常回调只输出 Location,正文不包含渠道参数。 */ + @Test + void oauthShouldRedirectToH5WithoutHtmlBody() { + ThirdAggregateOrderPayService service = mock(ThirdAggregateOrderPayService.class); + AggregatePayProperties config = new AggregatePayProperties(); config.setH5BaseUrl("https://pay.example.com"); + ThirdAggregateH5Support support = new ThirdAggregateH5Support(config, mock(StringRedisTemplate.class)); + String target = support.cashierUrl("tenant", "a".repeat(32)); + when(service.handleAlipayOauth("code", "state")).thenReturn(new ThirdAggregateCashierPageData(null, null, target)); + ResponseEntity response = new ThirdAggregateOrderPayController(service, support).alipayOauth("code", "state"); + assertEquals(302, response.getStatusCode().value()); + assertEquals(target, response.getHeaders().getLocation().toString()); + assertNull(response.getBody()); + assertEquals("no-store", response.getHeaders().getCacheControl()); + } + + /** 授权失败跳转固定提示入口,不反射渠道原始异常或用户参数。 */ + @Test + void oauthFailureShouldNotLeakException() { + ThirdAggregateOrderPayService service = mock(ThirdAggregateOrderPayService.class); + AggregatePayProperties config = new AggregatePayProperties(); config.setH5BaseUrl("https://pay.example.com"); + when(service.handleWechatOauth("tenant", "code", "state")).thenThrow(new RuntimeException("private-message")); + ResponseEntity response = new ThirdAggregateOrderPayController(service, + new ThirdAggregateH5Support(config, mock(StringRedisTemplate.class))).wechatOauth("tenant", "code", "state"); + assertEquals("https://pay.example.com/#/pages/pay/index?error=UNAVAILABLE", response.getHeaders().getLocation().toString()); + assertNull(response.getBody()); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5SupportTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5SupportTest.java new file mode 100644 index 000000000..dc2341322 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateH5SupportTest.java @@ -0,0 +1,93 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.config.AggregatePayProperties; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantEntryDto; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** 商户签名、固定跳转和匿名限流的安全边界测试,不连接真实 Redis 或支付平台。 */ +class ThirdAggregateH5SupportTest { + /** 仅供测试的固定随机密钥,不能用于任何部署环境。 */ + private static final String TEST_KEY = "0123456789abcdef0123456789abcdef"; + + /** 返回完整测试配置,域名为保留的 example.com。 */ + private AggregatePayProperties properties() { + AggregatePayProperties properties = new AggregatePayProperties(); + properties.setH5BaseUrl("https://pay.example.com/"); + properties.setMerchantSigningKey(TEST_KEY); + return properties; + } + + /** 正常凭据通过,任何商户、租户或版本篡改均不能跨越签名边界。 */ + @Test + void signatureShouldBindPurposeTenantMerchantAndVersion() { + ThirdAggregateH5Support support = new ThirdAggregateH5Support(properties(), mock(StringRedisTemplate.class)); + ThirdAggregateMerchantEntryDto entry = new ThirdAggregateMerchantEntryDto(); + entry.setId(88L); entry.setTenantId(2L); entry.setV("1"); entry.setSign(support.sign(2L, 88L)); + assertDoesNotThrow(() -> support.verify(entry)); + entry.setId(89L); + assertThrows(IllegalArgumentException.class, () -> support.verify(entry)); + entry.setId(88L); entry.setTenantId(3L); + assertThrows(IllegalArgumentException.class, () -> support.verify(entry)); + entry.setTenantId(2L); entry.setV("2"); + assertThrows(IllegalArgumentException.class, () -> support.verify(entry)); + } + + /** 禁止空密钥、HTTP、用户信息和查询参数进入固定回跳配置。 */ + @Test + void shouldRejectWeakSecretsAndUnsafeRedirectConfiguration() { + AggregatePayProperties config = properties(); + ThirdAggregateH5Support support = new ThirdAggregateH5Support(config, mock(StringRedisTemplate.class)); + config.setMerchantSigningKey("short"); + assertThrows(IllegalStateException.class, () -> support.sign(1L, 2L)); + for (String url : List.of("http://pay.example.com", "javascript:alert(1)", "https://user@pay.example.com", "https://pay.example.com/?next=x", "https://pay.example.com/#/other")) { + config.setH5BaseUrl(url); + assertThrows(IllegalStateException.class, support::baseUrl); + } + } + + /** 新入口保持 id 参数,OAuth 回跳不能包含密钥或渠道调起参数。 */ + @Test + void shouldOnlyPutOpaqueTicketInCashierRedirect() { + ThirdAggregateH5Support support = new ThirdAggregateH5Support(properties(), mock(StringRedisTemplate.class)); + String url = support.merchantUrl(2L, 88L); + assertTrue(url.startsWith("https://pay.example.com/#/pages/pay/index?id=88&tenantId=2&v=1&sign=")); + assertFalse(url.contains(TEST_KEY)); + assertEquals("https://pay.example.com/#/pages/pay/index?tenantCode=tenant-code&ticket=opaque-ticket", + support.cashierUrl("tenant-code", "opaque-ticket")); + } + + /** Boot 无网关前缀和默认微服务前缀均可配置,拒绝其他任意路径。 */ + @Test + void shouldSupportBootPublicRoutes() { + AggregatePayProperties config = properties(); + ThirdAggregateH5Support support = new ThirdAggregateH5Support(config, mock(StringRedisTemplate.class)); + assertEquals("/third", support.publicRoutePrefix()); + config.setPublicRoutePrefix(""); + assertEquals("", support.publicRoutePrefix()); + config.setPublicRoutePrefix("//other.example.com"); + assertThrows(IllegalStateException.class, support::publicRoutePrefix); + } + + /** 伪造 X-Real-IP 不改变限流来源,只有明确配置的直连代理可以覆盖地址。 */ + @Test + @SuppressWarnings("unchecked") + void shouldRateLimitDirectAddressAndRejectOverflow() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + when(redis.execute(any(RedisScript.class), any(List.class))).thenReturn(121L); + ThirdAggregateH5Support support = new ThirdAggregateH5Support(properties(), redis); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("192.0.2.10"); request.addHeader("X-Real-IP", "198.51.100.1"); + assertThrows(ResponseStatusException.class, () -> support.checkRate(request, false)); + verify(redis).execute(any(RedisScript.class), eq(List.of("thirdparty:aggregate-h5:rate:query:192.0.2.10"))); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java index e34e516fe..c9a178991 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java @@ -7,6 +7,9 @@ import com.bsd.pjcloud.admin.api.feign.RemoteTenantService; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.config.AggregatePayProperties; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateH5Support; +import org.springframework.test.util.ReflectionTestUtils; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; @@ -14,6 +17,7 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.ThirdAggregateAliPayService; import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; @@ -45,8 +49,10 @@ import java.util.List; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -146,6 +152,10 @@ class ThirdAggregateOrderPayServiceImplTest { aggregateWechatPayService, thirdPayOrderService, mchFzRelationService, remoteMchService, remoteTenantService, appPlusPayConfig, stringRedisTemplate, stringEncryptor, pollingCoordinator); + AggregatePayProperties h5Properties = new AggregatePayProperties(); + h5Properties.setH5BaseUrl("https://pay.example.com"); + ReflectionTestUtils.setField(service, "h5Support", new ThirdAggregateH5Support(h5Properties, stringRedisTemplate)); + lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); lenient().when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true); @@ -164,7 +174,7 @@ class ThirdAggregateOrderPayServiceImplTest { } /** - * 验证新会话同时校验双平台配置,并只返回独立 aggregate-order-pay HTTPS 地址。 + * 验证新会话校验商户已启用的配置,并只返回独立 aggregate-order-pay HTTPS 地址。 */ @Test void createShouldPersistHashedTokenAndReturnAggregateQrCode() { @@ -193,6 +203,23 @@ class ThirdAggregateOrderPayServiceImplTest { verify(pollingCoordinator).wakeAggregatePolling(); } + /** 线下收款使用独立业务类型与场景,仍复用原支付会话而不创建消费机业务订单。 */ + @Test + void offlineShouldPersistSeparateSceneAndBusinessType() { + request.setBizType(ThirdPayOrderBizTypeEnum.MCH_OFFLINE_PAY); + request.setBizId("OFFLINE:88:test-request"); + when(aggregatePaySessionService.getLatestByBiz(request.getBizType(), request.getBizId())).thenReturn(null); + when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of()); + when(remoteMchService.getById(request.getMchId())).thenReturn(R.ok(merchant)); + when(stringEncryptor.encrypt(anyString())).thenReturn("encrypted-access-token"); + when(aggregatePaySessionService.save(any(ThirdAggregatePaySessionEntity.class))).thenReturn(true); + service.createOffline(request); + ArgumentCaptor capture = ArgumentCaptor.forClass(ThirdAggregatePaySessionEntity.class); + verify(aggregatePaySessionService).save(capture.capture()); + Assertions.assertEquals("OFFLINE", capture.getValue().getAggregateScene()); + Assertions.assertEquals("70", capture.getValue().getBizType()); + } + /** * 验证同业务未过期会话复用原访问令牌,不再次读取商户或创建数据库记录。 */ @@ -210,21 +237,79 @@ class ThirdAggregateOrderPayServiceImplTest { verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class)); } - /** - * 验证缺少任一平台商户配置时不创建聚合会话,避免生成只能被单平台使用的伪聚合码。 - */ + /** 商户只开通一个平台时允许创建会话,未开通平台不要求全局应用凭据。 */ @Test - void createShouldRejectMerchantWithoutWechatPaySetting() { - merchant.setPaySettingList(List.of(merchant.getPaySettingList().get(0))); + void createShouldAllowSingleEnabledPlatform() { + List settings = merchant.getPaySettingList(); + AppPlusPayConfig config = (AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig"); when(aggregatePaySessionService.getLatestByBiz(request.getBizType(), request.getBizId())).thenReturn(null); when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of()); when(remoteMchService.getById(request.getMchId())).thenReturn(R.ok(merchant)); + when(stringEncryptor.encrypt(anyString())).thenReturn("encrypted-access-token"); + when(aggregatePaySessionService.save(any(ThirdAggregatePaySessionEntity.class))).thenReturn(true); + merchant.setPaySettingList(List.of(settings.get(0))); + AppPlusPayConfig.WechatConfig wechat = config.getWechatConfig(); + config.setWechatConfig(null); + Assertions.assertNotNull(service.create(request)); + + merchant.setPaySettingList(List.of(settings.get(1))); + config.setWechatConfig(wechat); + config.setAliPayConfig(null); + Assertions.assertNotNull(service.create(request)); + verify(aggregatePaySessionService, times(2)).save(any(ThirdAggregatePaySessionEntity.class)); + } + + /** 两个渠道都停用时拒绝创建,避免产生没有付款入口的会话。 */ + @Test + void createShouldRejectMerchantWithoutEnabledPlatform() { + merchant.getPaySettingList().forEach(setting -> setting.setEnable(YesNoEnum.NO.getCode())); + when(aggregatePaySessionService.getLatestByBiz(request.getBizType(), request.getBizId())).thenReturn(null); + when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of()); + when(remoteMchService.getById(request.getMchId())).thenReturn(R.ok(merchant)); Assertions.assertThrows(RuntimeException.class, () -> service.create(request)); - verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class)); } + /** 仅微信启用时签发收款码,不校验已停用的支付宝应用或分佣关系。 */ + @Test + void merchantCodeShouldValidateOnlyEnabledWechat() { + MchPaySettingVo ali = merchant.getPaySettingList().get(0); + MchPaySettingVo wechat = merchant.getPaySettingList().get(1); + ali.setEnable(YesNoEnum.NO.getCode()); + ((AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig")).setAliPayConfig(null); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + Assertions.assertDoesNotThrow(() -> service.validateMerchantPayConfiguration(88L)); + verify(mchFzRelationService).findBoundDefault(wechat); + verify(mchFzRelationService, never()).findBoundDefault(ali); + } + + /** 仅支付宝启用时只校验其已绑定关系,微信应用缺省不影响签发。 */ + @Test + void merchantCodeShouldValidateOnlyEnabledAlipay() { + MchPaySettingVo ali = merchant.getPaySettingList().get(0); + merchant.setPaySettingList(List.of(ali)); + ((AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig")).setWechatConfig(null); + ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity(); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + when(mchFzRelationService.findBoundDefault(ali)).thenReturn(relation); + when(mchFzRelationService.createPaymentSettingSnapshot(ali, relation)).thenReturn(ali); + Assertions.assertDoesNotThrow(() -> service.validateMerchantPayConfiguration(88L)); + } + + /** 错误 App 扫原订单二维码时直接进 H5 提示,不发起 OAuth、渠道下单或平台锁定。 */ + @Test + void unsupportedClientShouldEnterH5WithoutOauth() { + merchant.setPaySettingList(List.of(merchant.getPaySettingList().get(1))); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + when(aggregatePaySessionService.getByAccessTokenHash(anyString())).thenReturn(reusableSession()); + String url = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10"); + Assertions.assertTrue(url.startsWith("https://pay.example.com/#/pages/pay/index?tenantCode=tenant-code&ticket=")); + verify(valueOperations).set(anyString(), eq("AGG202608050001"), eq(Duration.ofMinutes(5))); + verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService); + verify(aggregatePaySessionService, never()).selectPlatform(any(), any(), anyString(), anyString()); + } + /** * 验证支付宝扫码授权统一使用不含租户编码的固定回调地址,租户信息由一次性 state 承载。 */ @@ -232,6 +317,7 @@ class ThirdAggregateOrderPayServiceImplTest { void alipayOauthShouldUseFixedCallbackUrl() { ThirdAggregatePaySessionEntity session = reusableSession(); when(aggregatePaySessionService.getByAccessTokenHash(anyString())).thenReturn(session); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); String oauthUrl = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10.6.80"); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImplTest.java new file mode 100644 index 000000000..08b78cfca --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregateMerchantPayServiceImplTest.java @@ -0,0 +1,117 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.thirdparty.config.AggregatePayProperties; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateMerchantCreateDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateH5Support; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockHttpServletRequest; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** 线下收款的固定业务类型、金额上限、幂等编号和租户恢复测试。 */ +class ThirdAggregateMerchantPayServiceImplTest { + /** 构造已由签名辅助服务校验的测试请求,单位为分。 */ + private ThirdAggregateMerchantCreateDto request(int amount) { + ThirdAggregateMerchantCreateDto dto = new ThirdAggregateMerchantCreateDto(); + dto.setId(88L); dto.setTenantId(2L); dto.setV("1"); dto.setSign("a".repeat(64)); + dto.setAmount(amount); dto.setRequestId("12345678-1234-4234-8234-123456789abc"); + return dto; + } + + /** 重试派生同一收款请求号,不能误入消费机订单或钱包充值回调。 */ + @Test + void retryShouldKeepSameRequestAndRestoreSignedTenant() { + ThirdAggregateH5Support support = mock(ThirdAggregateH5Support.class); + ThirdAggregateOrderPayService aggregate = mock(ThirdAggregateOrderPayService.class); + RemoteMchService remote = mock(RemoteMchService.class); + ThirdAggregateMerchantPayServiceImpl service = new ThirdAggregateMerchantPayServiceImpl(support, aggregate, remote, new AggregatePayProperties()); + MchManageVo merchant = new MchManageVo(); + merchant.setId(88L); merchant.setTenantId(2L); merchant.setName("测试商户"); + MchPaySettingVo alipay = new MchPaySettingVo(); + alipay.setPlatformType("20"); alipay.setEnable(YesNoEnum.YES.getCode()); + merchant.setPaySettingList(List.of(alipay)); + when(remote.getById(88L)).thenAnswer(invocation -> { + assertEquals(2L, TenantContextHolder.getTenantId()); + return R.ok(merchant); + }); + ThirdAggregateOrderCodePayVo created = new ThirdAggregateOrderCodePayVo(); + created.setAggregateNo("aggregate-test"); created.setQrCode("https://api.example.com/cashier/token"); + when(aggregate.createOffline(any())).thenReturn(created); + MockHttpServletRequest http = new MockHttpServletRequest(); http.addHeader("User-Agent", "AlipayClient/10"); + TenantContextHolder.setTenantId(1L); + try { + service.create(request(1234), http); service.create(request(1234), http); + assertEquals(1L, TenantContextHolder.getTenantId()); + ArgumentCaptor capture = ArgumentCaptor.forClass(ThirdAggregateOrderCodePayDto.class); + verify(aggregate, times(2)).createOffline(capture.capture()); + assertEquals(capture.getAllValues().get(0).getBizId(), capture.getAllValues().get(1).getBizId()); + assertEquals(ThirdPayOrderBizTypeEnum.MCH_OFFLINE_PAY, capture.getValue().getBizType()); + assertEquals(1234, capture.getValue().getAmount()); + } finally { TenantContextHolder.clear(); } + } + + /** 零、负数和超过 9,999.99 元时不得访问商户或创建平台订单。 */ + @Test + void invalidAmountShouldNotCreatePayment() { + ThirdAggregateOrderPayService aggregate = mock(ThirdAggregateOrderPayService.class); + RemoteMchService remote = mock(RemoteMchService.class); + ThirdAggregateMerchantPayServiceImpl service = new ThirdAggregateMerchantPayServiceImpl(mock(ThirdAggregateH5Support.class), aggregate, remote, new AggregatePayProperties()); + for (int amount : new int[]{0, -1, 1000000}) { + assertThrows(IllegalArgumentException.class, () -> service.create(request(amount), new MockHttpServletRequest())); + } + verifyNoInteractions(aggregate, remote); + } + + /** 匿名查询只返回启用渠道编码,停用渠道及其他支付类型均不能成为可用付款入口。 */ + @Test + void merchantShouldExposeOnlyEnabledChannels() { + ThirdAggregateOrderPayService aggregate = mock(ThirdAggregateOrderPayService.class); + RemoteMchService remote = mock(RemoteMchService.class); + ThirdAggregateMerchantPayServiceImpl service = new ThirdAggregateMerchantPayServiceImpl(mock(ThirdAggregateH5Support.class), aggregate, remote, new AggregatePayProperties()); + MchManageVo merchant = new MchManageVo(); + merchant.setId(88L); merchant.setTenantId(2L); + MchPaySettingVo wechat = new MchPaySettingVo(); + wechat.setPlatformType("10"); wechat.setEnable(YesNoEnum.YES.getCode()); + MchPaySettingVo alipay = new MchPaySettingVo(); + alipay.setPlatformType("20"); alipay.setEnable(YesNoEnum.NO.getCode()); + MchPaySettingVo other = new MchPaySettingVo(); + other.setPlatformType("30"); other.setEnable(YesNoEnum.YES.getCode()); + merchant.setPaySettingList(List.of(wechat, alipay, other)); + when(remote.getById(88L)).thenReturn(R.ok(merchant)); + assertEquals(List.of("10"), service.getMerchant(request(100), new MockHttpServletRequest()).getSupportedPlatforms()); + alipay.setEnable(YesNoEnum.YES.getCode()); + assertEquals(List.of("10", "20"), service.getMerchant(request(100), new MockHttpServletRequest()).getSupportedPlatforms()); + } + + /** 商户只开通微信时,支付宝请求必须在创建聚合会话前被后端拒绝。 */ + @Test + void unsupportedClientMustNotCreateSession() { + ThirdAggregateOrderPayService aggregate = mock(ThirdAggregateOrderPayService.class); + RemoteMchService remote = mock(RemoteMchService.class); + ThirdAggregateMerchantPayServiceImpl service = new ThirdAggregateMerchantPayServiceImpl(mock(ThirdAggregateH5Support.class), aggregate, remote, new AggregatePayProperties()); + MchManageVo merchant = new MchManageVo(); + merchant.setId(88L); merchant.setTenantId(2L); + MchPaySettingVo wechat = new MchPaySettingVo(); + wechat.setPlatformType("10"); wechat.setEnable(YesNoEnum.YES.getCode()); + merchant.setPaySettingList(List.of(wechat)); + when(remote.getById(88L)).thenReturn(R.ok(merchant)); + MockHttpServletRequest http = new MockHttpServletRequest(); + http.addHeader("User-Agent", "AlipayClient/10"); + RuntimeException error = assertThrows(RuntimeException.class, () -> service.create(request(100), http)); + assertTrue(error.getMessage().contains("商户暂不支持支付宝支付")); + verifyNoInteractions(aggregate); + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index 63df6731f..3a32ab121 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -148,10 +148,14 @@ public class SysUserServiceImpl extends ServiceImpl impl private final SysUserPostMapper sysUserPostMapper; - /** 角色批量查询 Mapper,用于按当前分页用户一次性回填角色。 */ + /** + * 角色批量查询 Mapper,用于按当前分页用户一次性回填角色。 + */ private final SysRoleMapper sysRoleMapper; - /** 岗位批量查询 Mapper,用于按当前分页用户一次性回填岗位。 */ + /** + * 岗位批量查询 Mapper,用于按当前分页用户一次性回填岗位。 + */ private final SysPostMapper sysPostMapper; private final CacheManager cacheManager; @@ -217,7 +221,9 @@ public class SysUserServiceImpl extends ServiceImpl impl } } - /** 演示用户统一初始密码(仅喂数路径),整批只 BCrypt 一次 */ + /** + * 演示用户统一初始密码(仅喂数路径),整批只 BCrypt 一次 + */ private static final String AI_FEED_DEFAULT_PASSWORD = "AiFeed@123456"; /** @@ -358,7 +364,7 @@ public class SysUserServiceImpl extends ServiceImpl impl * 操作人角色快照:一次或一批用户创建期间,操作人的角色集合与权限结论保持不变。 */ private record OperationRoleSnapshot(boolean hasApplyUser, List applyUserRoles, boolean roleAdmin, - List nonAdminNewRoleIds) { + List nonAdminNewRoleIds) { } /** @@ -366,9 +372,9 @@ public class SysUserServiceImpl extends ServiceImpl impl * 只有当前上下文明确定义了 {@code ROLE_ADMIN} 时才保留请求角色;其他操作人以及无认证上下文的调用, * 请求角色都会被固定替换为普通用户和自动创建钱包角色。免审参数只控制审批流程,不放宽角色权限。 * - * @param userDto 用户创建参数;普通后台创建场景中的角色可能被安全规则覆盖 + * @param userDto 用户创建参数;普通后台创建场景中的角色可能被安全规则覆盖 * @param skipAudit 是否跳过新增用户审批,不影响角色选择权限 - * @param snapshot 预先解析的操作人角色快照 + * @param snapshot 预先解析的操作人角色快照 * @return 新建用户ID */ private Long doSaveUser(UserDTO userDto, boolean skipAudit, OperationRoleSnapshot snapshot) { @@ -492,8 +498,8 @@ public class SysUserServiceImpl extends ServiceImpl impl /** * 分页查询用户信息,并批量补齐角色、人脸、身份及身份扩展资料。 * - * @param page 分页参数,包含当前页和每页数量 - * @param userDTO 用户筛选条件及是否查询审批状态的控制参数 + * @param page 分页参数,包含当前页和每页数量 + * @param userDTO 用户筛选条件及是否查询审批状态的控制参数 * @param dataScope 当前调用人的数据权限范围;为空时不附加数据范围限制 * @return 含角色、人脸、身份和审批信息的用户分页结果 */ @@ -529,8 +535,8 @@ public class SysUserServiceImpl extends ServiceImpl impl *

预约等业务列表只需要用户、部门和身份信息,因此这里使用无嵌套角色、岗位映射的专用 SQL, * 同时不返回人脸资料和审批信息,但保留人脸录入状态筛选。

* - * @param page 分页参数 - * @param userDTO 用户关键字、部门树和指定用户筛选条件 + * @param page 分页参数 + * @param userDTO 用户关键字、部门树和指定用户筛选条件 * @param dataScope 当前调用人的数据权限范围;为空时不附加数据范围限制 * @return 已批量补齐身份类型、学生资料和老师资料的轻量用户分页 */ @@ -562,7 +568,7 @@ public class SysUserServiceImpl extends ServiceImpl impl public IPage getSelectUsersPage(Page page, UserDTO userDTO) { // 下拉组件按关键词远程搜索,不展示总条数,关闭 COUNT 查询。 page.setSearchCount(false); - return baseMapper.getBasicUserPage(page, userDTO, null); + return baseMapper.getBasicUserPage(page, userDTO, new DataScope().only(true)); } /** @@ -570,8 +576,8 @@ public class SysUserServiceImpl extends ServiceImpl impl * *

复用现有角色、岗位映射,保持与系统用户导出字段一致;不查询人脸和审批流程,避免后台分片导出执行无关远程调用。

* - * @param page 分页参数 - * @param userDTO 用户关键字、部门树和指定用户筛选条件 + * @param page 分页参数 + * @param userDTO 用户关键字、部门树和指定用户筛选条件 * @param dataScope 当前调用人的数据权限范围;为空时不附加数据范围限制 * @return 已补齐主资料、角色岗位和身份扩展的用户分页 */ @@ -906,7 +912,7 @@ public class SysUserServiceImpl extends ServiceImpl impl * 新部门据此新增人脸。重复或乱序消息由终端按用户回查 V3 真源实现幂等。

* * @param databaseUser 更新前的数据库用户,用于取得旧部门 - * @param updatedUser 本次待更新用户,用于取得新部门;未提交部门字段时不发送通知 + * @param updatedUser 本次待更新用户,用于取得新部门;未提交部门字段时不发送通知 */ private void publishDepartmentFaceChange(SysUser databaseUser, SysUser updatedUser) { if (updatedUser.getDeptId() == null || Objects.equals(databaseUser.getDeptId(), updatedUser.getDeptId())) { @@ -1143,15 +1149,13 @@ public class SysUserServiceImpl extends ServiceImpl impl this.removeBatchByIds(existIds); try { remoteWalletService.delWallet(existIds); - } - catch (Exception ex) { + } catch (Exception ex) { log.warn("AI喂数清理钱包失败 userIds={}", existIds, ex); } for (Long userId : existIds) { try { remoteWalletService.userChange(userId, Boolean.TRUE); - } - catch (Exception ex) { + } catch (Exception ex) { log.warn("AI喂数清理钱包同步失败 userId={}", userId, ex); } } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java index 1e71e1744..cef04a2d0 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java @@ -30,6 +30,9 @@ import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService; import com.bsd.pjcloud.hard.api.utils.Drawing2D; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdLbApiService; +import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.thirdparty.dto.lb.LbH5LoginDto; import com.bsd.pjcloud.thirdparty.vo.lb.LbH5LoginVo; import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchSupType; @@ -97,6 +100,9 @@ public class MchManageServiceImpl extends ServiceImpl settings = paySettingService.list(new LambdaQueryWrapper() + .eq(MchPaySettingEntity::getMchId, mchDetail.getId()) + .eq(MchPaySettingEntity::getEnable, YesNoEnum.YES.getCode())); + boolean wechat = settings.stream().anyMatch(item -> AppPlusPlatformPayType.WECHAT.getType().equals(item.getPlatformType())); + boolean alipay = settings.stream().anyMatch(item -> AppPlusPlatformPayType.ALI_PAY.getType().equals(item.getPlatformType())); + if (!wechat && !alipay) throw new IllegalArgumentException("请先启用商户的微信或支付宝支付配置"); + R link = remoterThirdAppPlusPayService.merchantCollectionUrl(mchDetail.getId()); + if (link == null || !link.isOk() || StrUtil.isBlank(link.getData())) { + throw new IllegalStateException("签名收款入口暂不可用,请检查 third 服务的收银配置"); + } Drawing2D drawing = new Drawing2D(); - byte[] bytes = QRCodeImageGenerator.generateQrCodeImage(url, 300); + byte[] bytes = QRCodeImageGenerator.generateQrCodeImage(link.getData(), 300); byte[] mchCodeByte = drawing.drawImage(new ByteArrayInputStream(bytes)).setTitleTextStyle().drawingCenterText(mchDetail.getName()).getBytes(); MultipartFile multipartFile = new MockMultipartFile("file", fileName, "image/png", new ByteArrayInputStream(mchCodeByte)); R upload = remoteFileService.innerUpload(multipartFile, null, null, null); - Map data = (Map) upload.getData(); - return (String) data.get("url"); - } catch (Exception e) { - log.error("|{}|生成url二维码,dto:|{}|error:|{}|", TAG, JSON.toJSONString(mchDetail), e.getMessage()); - return null; + if (upload == null || !upload.isOk() || !(upload.getData() instanceof Map data) + || !(data.get("url") instanceof String imageUrl) || StrUtil.isBlank(imageUrl)) { + throw new IllegalStateException("收款码图片上传失败"); + } + return imageUrl; + } catch (IllegalArgumentException exception) { + throw exception; + } catch (Exception exception) { + log.warn("|{}|生成H5收款码失败,mchId:{},errorType:{}", TAG, mchDetail.getId(), exception.getClass().getSimpleName()); + throw new IllegalStateException("生成H5收款码失败,请检查支付渠道与收银配置"); } } From 7564fef1964fbc39901355d2d8c551b96ad6964d Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 20 Oct 2026 16:28:22 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=E5=8A=A8=E6=80=81=E9=A2=84=E7=BA=A6?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bsd/pjcloud/hard/api/dto/HardApptDto.java | 6 + .../pjcloud/hard/api/vo/HardApptItemVo.java | 7 + .../pjcloud/hard/api/vo/HardApptOptionVo.java | 3 + .../pjcloud/hard/api/vo/HardApptWindowVo.java | 4 + .../hard/controller/HardApptController.java | 8 + .../service/HardApptReservationService.java | 7 +- .../pjcloud/hard/service/HardApptService.java | 23 +- .../impl/HardApptReservationServiceImpl.java | 114 ++------- .../service/impl/HardApptServiceImpl.java | 237 ++++++++++-------- .../support/HardApptWindowResolver.java | 197 +++++++++++++++ .../HardApptReservationServiceImplTest.java | 33 +++ .../service/impl/HardApptServiceImplTest.java | 118 ++++++++- ...rderServiceImplAutoRefundSnapshotTest.java | 98 ++++++++ .../support/HardApptWindowResolverTest.java | 148 +++++++++++ 14 files changed, 794 insertions(+), 209 deletions(-) create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/support/HardApptWindowResolver.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplAutoRefundSnapshotTest.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/support/HardApptWindowResolverTest.java diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptDto.java index 816cf73c5..7a0bd3b8b 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptDto.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.hard.api.dto; import com.bsd.pjcloud.hard.api.entity.HardApptEntity; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.List; @@ -11,6 +12,11 @@ import java.util.List; @Data public class HardApptDto extends HardApptEntity { + /** 规则包含的完整窗口及目标集合;编辑时为空表示只更新规则头。 */ private List windowList; + /** 只筛选存在匹配窗口的规则:true 已过期、false 未过期、null 全部;不参与持久化。 */ + @Schema(description = "开放时间是否已过期:true 已过期,false 未过期,不传查全部;返回的 windowList 保留完整窗口供编辑") + private Boolean windowExpired; + } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptItemVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptItemVo.java index 3186053ba..f600336b0 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptItemVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptItemVo.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.time.LocalDate; +import java.time.LocalDateTime; /** * 动态时间窗可预约目标项展示:目标用餐日期 × 餐段。 @@ -12,9 +13,15 @@ import java.time.LocalDate; @Schema(description = "动态时间窗可预约目标项展示") public class HardApptItemVo { + /** 此窗口配置的实际用餐日期,不代表开放预约的日期。 */ @Schema(description = "目标用餐日期") private LocalDate targetDate; + /** 此日期的餐段持久化编码,来自 daily_menu 字典。 */ @Schema(description = "餐段字典值(daily_menu)") private String hourType; + + /** 同商户全部启用窗口合并后的免审申请截止,北京时间;无启用目标时为空,仅用于展示。 */ + @Schema(description = "同日期餐段最终免审退款截止,只读计算值") + private LocalDateTime autoRefundDeadline; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java index b3ea7414b..06a21a992 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java @@ -47,4 +47,7 @@ public class HardApptOptionVo { /** 该日期和餐段本批次的实际截止时间。 */ private LocalDateTime reserveEndDateTime; + + /** 动态模式同日期餐段全部启用窗口的最终免审申请截止,北京时间;周期模式为空,仅供展示。 */ + private LocalDateTime autoRefundDeadline; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptWindowVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptWindowVo.java index 704c2e9e3..2cdf7fe13 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptWindowVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptWindowVo.java @@ -22,6 +22,10 @@ public class HardApptWindowVo { @Schema(description = "结束预约时间") private LocalDateTime endTime; + /** 查询时服务器北京时间严格晚于结束时间才过期,结束时刻仍有效;只读且不持久化。 */ + @Schema(description = "开放预约时间是否已过期,结束时刻仍有效", accessMode = Schema.AccessMode.READ_ONLY) + private boolean expired; + @Schema(description = "免审退款申请截止时间(闭区间),空=开放预约结束时间") private LocalDateTime autoRefundTime; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java index e24c4fea0..cda91d872 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java @@ -52,6 +52,14 @@ public class HardApptController { /* ************************************* end app end ************************************* */ + /** 按当前用户商户数据权限只读合并草稿,返回涉及日期餐段的实际开放范围与免审退款截止。 */ + @Operation(summary = "推演预约规则草稿", description = "只读合并草稿与其他启用规则,不写配置或订单") + @PostMapping("/preview") + @PreAuthorize("@pms.hasPermission('hard_hardAppt_view')") + public R> preview(@RequestBody HardApptDto dto) { + return R.ok(hardApptService.preview(dto, DataScope.of())); + } + @Operation(summary = "分页查询", description = "分页查询") @GetMapping("/page") @PreAuthorize("@pms.hasPermission('hard_hardAppt_view')") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java index ace408dfe..eda629e60 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java @@ -11,6 +11,7 @@ import java.util.List; */ public interface HardApptReservationService { + /** 按商户当前预约模式返回日期餐段;动态模式叠加全部启用窗口,每个目标最多一个选项。 */ List listOptions(Long mchId); /** @@ -22,17 +23,19 @@ public interface HardApptReservationService { */ List previewOptions(Long mchId, Integer days); + /** 校验真实窗口归属和目标后匹配当前开放区间,返回仅供服务端固化订单的可信时间快照。 */ ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType, LocalDate apptDate); /** - * 服务端校验通过的预约上下文,窗口/周期批次截止时间可直接作为自动退款订单快照,后续不再重算规则。 + * 服务端校验通过的预约上下文。动态模式按同目标全部启用窗口计算免审截止, + * 周期模式保留原批次截止;订单保存快照后,规则增删改均不回算历史订单。 * * @param apptId 动态预约窗口 ID(订单 appt_id 固化该值);周期模式为空 * @param hourType 标准化餐段编码 * @param apptDate 实际用餐日期 * @param apptRuleId 周期预约规则 ID;动态模式为空 - * @param eligibilityDeadline 当前已校验预约窗口的截止时间,使用服务器现有预约时间口径 + * @param eligibilityDeadline 动态目标最终免审截止或周期批次截止,北京时间,包含截止准点 * @param usageEndDateTime 实际用餐日的核销有效期结束时间,固定为该日 23:59:59,供订单固化后重扫使用 */ record ReservationValidation(Long apptId, String hourType, LocalDate apptDate, Long apptRuleId, diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptService.java index dcbe7dff5..fc1e57add 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptService.java @@ -8,6 +8,8 @@ import com.bsd.pjcloud.hard.api.dto.HardApptDto; import com.bsd.pjcloud.hard.api.dto.HardApptWindowDto; import com.bsd.pjcloud.hard.api.entity.HardApptEntity; import com.bsd.pjcloud.hard.api.vo.HardApptVo; +import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.WindowSource; import java.util.ArrayList; import java.util.List; @@ -30,7 +32,7 @@ public interface HardApptService extends IService { void delByIds(ArrayList list); /** - * 窗口级修改:更新单个开放窗口(时间/免审退款截止/明细全量重建),商户级唯一性排除本窗口。 + * 窗口级修改:更新时间/免审退款截止并重建明细,允许与同商户其他启用窗口叠加。 */ void updateWindow(HardApptWindowDto window); @@ -50,4 +52,21 @@ public interface HardApptService extends IService { * 查询详情 */ HardApptVo join(Long id); -} \ No newline at end of file + + /** + * 读取同商户全部启用规则的窗口和明细,不受管理端分页影响;租户隔离沿用现有 Mapper。 + * + * @param mchId 目标商户 ID,不能混入其他商户的数据 + * @return 窗口及其明细,无配置时返回空集合 + */ + List listEnabledWindows(Long mchId); + + /** + * 只读推演草稿涉及的日期餐段,合并同商户其他启用规则;编辑时先验证归属及数据范围再排除原规则。 + * + * @param dto 未保存的规则头及窗口明细,停用草稿不贡献开放窗口 + * @param dataScope 当前后台用户的数据权限范围 + * @return 草稿目标的最终预约选项及退款截止,不写数据库或订单 + */ + List preview(HardApptDto dto, DataScope dataScope); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java index 838b680f5..45d6c6dc5 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java @@ -23,6 +23,9 @@ import com.bsd.pjcloud.hard.service.HardApptMerchantModeService; import com.bsd.pjcloud.hard.service.HardApptReservationService; import com.bsd.pjcloud.hard.service.HardApptService; import com.bsd.pjcloud.hard.service.support.HardApptCycleRuleCalculator; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.Target; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.ResolvedTarget; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -39,7 +42,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.TreeMap; -import java.util.stream.Collectors; /** * 预约列表和下单共用同一套规则,避免客户端展示状态与服务端最终校验不一致。 @@ -49,7 +51,8 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class HardApptReservationServiceImpl implements HardApptReservationService { - static final ZoneId RESERVATION_ZONE = ZoneId.of("Asia/Shanghai"); + /** 与动态解析器使用同一北京时间,周期计算继续保持原时区。 */ + static final ZoneId RESERVATION_ZONE = HardApptWindowResolver.RESERVATION_ZONE; private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); private static final int DEFAULT_PREVIEW_DAYS = 7; private static final int MAX_PREVIEW_DAYS = 31; @@ -60,6 +63,7 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic private final HardApptCycleRuleService cycleRuleService; private final HardApptMerchantModeService merchantModeService; + /** 返回当前商户的日期餐段,动态模式使用统一解析器,按周模式保留原批次计算。 */ @Override public List listOptions(Long mchId) { return listOptions(mchId, nowInShanghai()); @@ -143,25 +147,16 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic } /** - * 动态模式按"窗口×明细"展开:仅输出 startDate 及以后的目标日期,endDate 非空时限制在推演范围内。 - * 选项按 "窗口ID:餐段" 去重,正常数据由保存时的商户级唯一性保证不重复,这里兜底脏数据。 + * 按日期餐段汇总商户全部启用窗口,每个目标只返回一个选项;推演范围只筛选用餐日期, + * 不能提前过滤未来开放窗口,否则最终预约范围和退款截止会随查询时刻漂移。 */ private void addDynamicOptions(Long mchId, LocalDateTime now, LocalDate startDate, LocalDate endDate, Map> optionsByDate) { - for (EnabledWindow enabled : findEnabledWindows(mchId)) { - Availability availability = windowAvailability(enabled.window(), now); - for (HardApptItemEntity item : enabled.items()) { - if (item.getTargetDate() == null || item.getTargetDate().isBefore(startDate)) { - continue; - } - if (endDate != null && item.getTargetDate().isAfter(endDate)) { - continue; - } - HardApptOptionVo option = buildDynamicOption(enabled.window(), item, availability); - optionsByDate.computeIfAbsent(item.getTargetDate(), key -> new LinkedHashMap<>()) - .put("appt:" + enabled.window().getId() + ":" + item.getHourType(), option); - } - } + HardApptWindowResolver.resolve(hardApptService.listEnabledWindows(mchId), now).forEach((target, resolved) -> { + if (target.date().isBefore(startDate) || endDate != null && target.date().isAfter(endDate)) return; + optionsByDate.computeIfAbsent(target.date(), key -> new LinkedHashMap<>()) + .put("appt:" + target.hourType(), HardApptWindowResolver.toOption(target, resolved, now)); + }); } /** @@ -253,6 +248,7 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic return validateReservation(mchId, apptId, apptRuleId, hourType, apptDate, nowInShanghai()); } + /** 固定当前时刻校验目标和窗口归属,并返回当前代表窗口及不可由客户端覆盖的最终退款截止。 */ ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType, LocalDate apptDate, LocalDateTime now) { HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId); @@ -294,21 +290,20 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic throw new I18nBizException(ErrorCodes.HARD_APPT_DATE_NO_SLOT); } - Availability availability = windowAvailability(window, now); - if (!availability.available()) { - throwUnavailable(availability); + // 先验证客户端原窗口归属和目标,之后才能在同一目标的其他启用窗口间重匹配。 + // 旧页面跨过一个窗口截止时,仍可沿后续开放窗口下单,最终退款截止始终由全部窗口计算。 + ResolvedTarget resolved = HardApptWindowResolver.resolve(hardApptService.listEnabledWindows(mchId), now) + .get(new Target(apptDate, hourType)); + if (resolved == null) throw new I18nBizException(ErrorCodes.HARD_APPT_DATE_NO_SLOT); + if (!resolved.available()) { + throw new I18nBizException(resolved.start().isAfter(now) + ? ErrorCodes.HARD_APPT_NOT_OPEN : ErrorCodes.HARD_APPT_CLOSED); } - return new ReservationValidation(window.getId(), hourType, apptDate, null, resolveAutoRefundDeadline(window), - resolveUsageEndDateTime(apptDate)); - } - - /** - * 免审退款申请截止:窗口持久化的可自动退款时间优先,为空回退开放预约结束时间(与周期模式批次截止同源)。 - */ - private LocalDateTime resolveAutoRefundDeadline(HardApptWindowEntity window) { - return window.getAutoRefundTime() != null ? window.getAutoRefundTime() : window.getEndTime(); + return new ReservationValidation(resolved.window().getId(), hourType, apptDate, null, + resolved.autoRefundDeadline(), resolveUsageEndDateTime(apptDate)); } + /** 校验按周预约配置,继续沿用原有周批次和截止口径。 */ private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType, LocalDate apptDate, LocalDateTime now) { if (apptDate == null || HardHourTypeEnum.valueOfByType(hourType) == null) { throw new I18nBizException(ErrorCodes.HARD_APPT_DATE_MEAL_REQUIRED); @@ -361,41 +356,6 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic return new CycleMatch(rule, cycleAvailability(rule, batch)); } - /** - * 商户全部启用规则的开放窗口及其明细(窗口按开始时间升序,明细按日期+餐段排序)。 - */ - private List findEnabledWindows(Long mchId) { - List ruleIds = hardApptService.list(new LambdaQueryWrapper() - .eq(HardApptEntity::getMchId, mchId) - .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())).stream() - .map(HardApptEntity::getId).toList(); - if (ruleIds.isEmpty()) { - return List.of(); - } - List windows = hardApptWindowMapper.selectList( - new LambdaQueryWrapper() - .in(HardApptWindowEntity::getApptId, ruleIds) - .orderByAsc(HardApptWindowEntity::getStartTime)); - if (windows.isEmpty()) { - return List.of(); - } - Map> itemsByWindow = hardApptItemMapper.selectList( - new LambdaQueryWrapper() - .in(HardApptItemEntity::getWindowId, - windows.stream().map(HardApptWindowEntity::getId).toList())).stream() - .collect(Collectors.groupingBy(HardApptItemEntity::getWindowId)); - return windows.stream() - .map(window -> new EnabledWindow(window, - itemsByWindow.getOrDefault(window.getId(), List.of()).stream() - .filter(item -> item.getTargetDate() != null && item.getHourType() != null) - .sorted((left, right) -> { - int byDate = left.getTargetDate().compareTo(right.getTargetDate()); - return byDate != 0 ? byDate : left.getHourType().compareTo(right.getHourType()); - }) - .toList())) - .toList(); - } - /** * 窗口开放性判定:闭区间 [start, end],开始和结束准点均可提交。 */ @@ -413,28 +373,6 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic return Availability.available(window.getStartTime(), window.getEndTime()); } - /** - * 动态选项以窗口 ID 作为 id(App 提交订单 appt_id 即该值),窗口起止时间原样透出供客户端展示。 - */ - private static HardApptOptionVo buildDynamicOption(HardApptWindowEntity window, HardApptItemEntity item, - Availability availability) { - HardApptOptionVo option = new HardApptOptionVo(); - option.setId(window.getId()); - option.setReserveMode(HardApptReserveModeEnum.TIME_WINDOW.getType()); - HardHourTypeEnum hourType = HardHourTypeEnum.valueOfByType(item.getHourType()); - String hourTypeName = hourType != null ? hourType.getDescription() : item.getHourType(); - option.setHourType(item.getHourType()); - option.setHourTypeName(hourTypeName); - option.setTitle(hourTypeName); - option.setStartTime(window.getStartTime()); - option.setEndTime(window.getEndTime()); - option.setAvailable(availability.available()); - option.setUnavailableReason(availability.resolvedUnavailableReason()); - option.setReserveStartDateTime(availability.startDateTime()); - option.setReserveEndDateTime(availability.endDateTime()); - return option; - } - private HardApptOptionVo buildCycleOption(HardHourTypeEnum hourType, Availability availability) { HardApptOptionVo option = new HardApptOptionVo(); option.setReserveMode(HardApptReserveModeEnum.WEEKLY_CYCLE.getType()); @@ -524,6 +462,4 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic record CycleMatch(HardApptCycleRuleVo rule, Availability availability) { } - record EnabledWindow(HardApptWindowEntity window, List items) { - } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java index 557a939db..95bfd001d 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java @@ -5,6 +5,7 @@ import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.common.core.constant.CommonConstants; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.hard.api.constant.HardApptReserveModeEnum; @@ -17,6 +18,16 @@ import com.bsd.pjcloud.hard.api.entity.HardApptItemEntity; import com.bsd.pjcloud.hard.api.entity.HardApptWindowEntity; import com.bsd.pjcloud.hard.api.vo.HardApptItemVo; import com.bsd.pjcloud.hard.api.vo.HardApptVo; +import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo; +import com.bsd.pjcloud.hard.api.vo.HardApptOptionVo; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.WindowSource; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.Target; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.ResolvedTarget; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.wallet.api.dto.MchManageDto; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import com.bsd.pjcloud.hard.api.vo.HardApptWindowVo; import com.bsd.pjcloud.hard.mapper.HardApptItemMapper; import com.bsd.pjcloud.hard.mapper.HardApptMapper; @@ -30,13 +41,12 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.stream.Collectors; /** @@ -48,20 +58,26 @@ import java.util.stream.Collectors; @Service public class HardApptServiceImpl extends ServiceImpl implements HardApptService { + /** 窗口持久化入口,沿用租户拦截和逻辑删除。 */ private final HardApptWindowMapper hardApptWindowMapper; + /** 窗口目标明细入口,用于批量加载和窗口内去重后的保存。 */ private final HardApptItemMapper hardApptItemMapper; + /** 商户当前预约模式及部门来源。 */ private final HardApptMerchantModeService merchantModeService; + /** 草稿预览通过既有带数据权限的商户接口校验访问范围。 */ + private final RemoteMchService remoteMchService; + /** - * 新增:一条规则携带多个开放窗口,逐窗口校验并做商户级唯一性检查。 + * 新增:一条规则携带多个开放窗口,逐窗口校验,允许同一目标由多个窗口叠加。 */ @Override @Transactional(rollbackFor = Exception.class) public Long create(HardApptDto dto) { HardApptMerchantModeService.MerchantModeContext merchantContext = requireTimeWindowMerchant(dto.getMchId()); - List windows = validateAndNormalizeWindows(dto, null); + List windows = validateAndNormalizeWindows(dto); dto.setStatus(ObjectUtil.defaultIfEmpty(dto.getStatus(), YesNoEnum.YES.getCode())); dto.setDeptId(merchantContext.merchant().getDeptId()); baseMapper.insert(dto); @@ -85,7 +101,7 @@ public class HardApptServiceImpl extends ServiceImpl windows = null; if (ObjectUtil.isNotEmpty(dto.getWindowList())) { HardApptMerchantModeService.MerchantModeContext merchantContext = requireTimeWindowMerchant(existing.getMchId()); - windows = validateAndNormalizeWindows(dto, dto.getId()); + windows = validateAndNormalizeWindows(dto); dto.setDeptId(merchantContext.merchant().getDeptId()); } baseMapper.updateById(dto); @@ -110,7 +126,7 @@ public class HardApptServiceImpl extends ServiceImpl scopePage(Page page, HardApptDto dto, DataScope of) { - LambdaQueryWrapper wrapper = buildQueryWrapper(dto); + LocalDateTime now = LocalDateTime.now(HardApptWindowResolver.RESERVATION_ZONE); + LambdaQueryWrapper wrapper = buildQueryWrapper(dto, now); Page pageList = baseMapper.selectPageByScope(page, wrapper, of); List hardApptVos = BeanUtil.copyToList(pageList.getRecords(), HardApptVo.class); pageList.setRecords(hardApptVos); - disposeWindowList(hardApptVos); + disposeWindowList(hardApptVos, now); return pageList; } + /** 按与分页相同的筛选口径查询规则,窗口保留完整数据用于编辑和退款预览。 */ @Override public List scopeList(HardApptDto dto, DataScope of) { - List list = baseMapper.selectListByScope(buildQueryWrapper(dto), of); + LocalDateTime now = LocalDateTime.now(HardApptWindowResolver.RESERVATION_ZONE); + List list = baseMapper.selectListByScope(buildQueryWrapper(dto, now), of); List resultList = BeanUtil.copyToList(list, HardApptVo.class); - disposeWindowList(resultList); + disposeWindowList(resultList, now); return resultList; } @@ -198,47 +216,38 @@ public class HardApptServiceImpl extends ServiceImpl resultList = List.of(BeanUtil.toBean(hardAppt, HardApptVo.class)); - disposeWindowList(resultList); + disposeWindowList(resultList, LocalDateTime.now(HardApptWindowResolver.RESERVATION_ZONE)); return resultList.get(0); } - private LambdaQueryWrapper buildQueryWrapper(HardApptDto dto) { - return new LambdaQueryWrapper() + /** 规则列表沿用数据范围查询;过期筛选只关联同租户、同商户且未删除的窗口,时间取本次查询快照。 */ + private LambdaQueryWrapper buildQueryWrapper(HardApptDto dto, LocalDateTime now) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(ObjectUtil.isNotEmpty(dto.getMchId()), HardApptEntity::getMchId, dto.getMchId()) .eq(ObjectUtil.isNotEmpty(dto.getDeptId()), HardApptEntity::getDeptId, dto.getDeptId()) .orderByDesc(HardApptEntity::getId); + if (dto.getWindowExpired() != null) { + // 比较符仅由布尔条件决定,值始终绑定参数;显式关联租户和商户,保留外层数据权限及租户拦截。 + String comparison = dto.getWindowExpired() ? "<" : ">="; + wrapper.exists("SELECT 1 FROM hard_appt_window w WHERE w.appt_id = hard_appt.id" + + " AND w.mch_id = hard_appt.mch_id AND w.tenant_id = hard_appt.tenant_id" + + " AND w.del_flag = {0} AND w.end_time " + comparison + " {1}", CommonConstants.STATUS_NORMAL, now); + } + return wrapper; } /** - * 服务端负责规范化窗口,避免绕过后台页面提交倒置时间窗、非法餐段或重复目标。 - * 商户级唯一性:与该商户其他启用规则的所有窗口比较,开放时间不得重叠、可预约日期不得重复。 + * 逐窗口校验和明细去重。跨窗口时间、日期均允许重复,实际可约范围由统一解析器取并集。 * - * @param dto 规则入参 - * @param selfRuleId 编辑时的当前规则 ID,唯一性检查排除自身;新增传 null + * @param dto 规则草稿,至少含一个有日期餐段的窗口 + * @return 归一化后的窗口列表,仅调整当前入参,不写数据库 */ - private List validateAndNormalizeWindows(HardApptDto dto, Long selfRuleId) { + private List validateAndNormalizeWindows(HardApptDto dto) { List windowList = dto.getWindowList(); if (ObjectUtil.isEmpty(windowList)) { throw new RuntimeException("至少需要配置一个预约窗口"); } - for (int i = 0; i < windowList.size(); i++) { - normalizeWindow(windowList.get(i), i + 1); - } - // 规则内:窗口之间开放时间不重叠、可预约日期不重复 - for (int i = 0; i < windowList.size(); i++) { - for (int j = i + 1; j < windowList.size(); j++) { - HardApptWindowDto left = windowList.get(i); - HardApptWindowDto right = windowList.get(j); - if (left.getStartTime().isBefore(right.getEndTime()) && right.getStartTime().isBefore(left.getEndTime())) { - throw new RuntimeException("窗口 " + (i + 1) + " 与窗口 " + (j + 1) + " 开放时间重叠"); - } - LocalDate duplicatedDate = findFirstDuplicatedDate(left, right); - if (duplicatedDate != null) { - throw new RuntimeException("窗口 " + (i + 1) + " 与窗口 " + (j + 1) + " 可预约日期重复:" + duplicatedDate); - } - } - } - checkMerchantUniqueness(dto.getMchId(), windowList, selfRuleId, null); + for (int i = 0; i < windowList.size(); i++) normalizeWindow(windowList.get(i), i + 1); return windowList; } @@ -248,7 +257,7 @@ public class HardApptServiceImpl extends ServiceImpl windowList, Long selfRuleId, Long selfWindowId) { - List existingWindows = findMerchantWindows(mchId, selfRuleId, selfWindowId); - if (existingWindows.isEmpty()) { - return; - } - Map> existingDates = loadWindowDates(existingWindows); - for (int i = 0; i < windowList.size(); i++) { - HardApptWindowDto window = windowList.get(i); - for (HardApptWindowEntity existing : existingWindows) { - if (window.getStartTime().isBefore(existing.getEndTime()) - && existing.getStartTime().isBefore(window.getEndTime())) { - throw new RuntimeException("窗口 " + (i + 1) + " 开放时间与商户已存在窗口重叠"); - } - } - Set windowDates = window.getItemList().stream() - .map(HardApptItemDto::getTargetDate).collect(Collectors.toSet()); - for (Map.Entry> entry : existingDates.entrySet()) { - LocalDate duplicatedDate = windowDates.stream() - .filter(entry.getValue()::contains).findFirst().orElse(null); - if (duplicatedDate != null) { - throw new RuntimeException("窗口 " + (i + 1) + " 可预约日期 " + duplicatedDate + " 已存在于商户其他窗口"); - } - } - } - } - - private LocalDate findFirstDuplicatedDate(HardApptWindowDto left, HardApptWindowDto right) { - Set rightDates = right.getItemList().stream() - .map(HardApptItemDto::getTargetDate).collect(Collectors.toSet()); - for (HardApptItemDto item : left.getItemList()) { - if (rightDates.contains(item.getTargetDate())) { - return item.getTargetDate(); - } - } - return null; + @Override + public List listEnabledWindows(Long mchId) { + if (mchId == null) return List.of(); + List ruleIds = baseMapper.selectList(new LambdaQueryWrapper() + .eq(HardApptEntity::getMchId, mchId) + .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())).stream().map(HardApptEntity::getId).toList(); + if (ruleIds.isEmpty()) return List.of(); + List windows = hardApptWindowMapper.selectList(new LambdaQueryWrapper() + .eq(HardApptWindowEntity::getMchId, mchId) + .in(HardApptWindowEntity::getApptId, ruleIds).orderByAsc(HardApptWindowEntity::getStartTime)); + if (windows.isEmpty()) return List.of(); + Map> items = hardApptItemMapper.selectList(new LambdaQueryWrapper() + .eq(HardApptItemEntity::getMchId, mchId) + .in(HardApptItemEntity::getWindowId, windows.stream().map(HardApptWindowEntity::getId).toList())) + .stream().collect(Collectors.groupingBy(HardApptItemEntity::getWindowId)); + return windows.stream().map(window -> new WindowSource(window, items.getOrDefault(window.getId(), List.of()))).toList(); } /** - * 查询商户启用规则的开放窗口(可按规则或窗口排除自身)。 + * 将草稿叠加到同商户其他启用配置,使用与下单相同的解析器,只返回草稿涉及的目标。 * - * @param selfRuleId 规则级编辑排除的规则 ID;null 不排除 - * @param selfWindowId 窗口级编辑排除的窗口 ID;null 不排除 + * @param dto 当前规则草稿,编辑时带原规则 ID + * @param dataScope 当前后台用户的数据权限上下文 + * @return 每个目标唯一的预约选项;停用且没有其他覆盖时该日餐段为空 */ - private List findMerchantWindows(Long mchId, Long selfRuleId, Long selfWindowId) { - LambdaQueryWrapper ruleWrapper = new LambdaQueryWrapper() - .eq(HardApptEntity::getMchId, mchId) - .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode()) - .ne(selfRuleId != null, HardApptEntity::getId, selfRuleId); - List ruleIds = baseMapper.selectList(ruleWrapper).stream() - .map(HardApptEntity::getId).toList(); - if (ruleIds.isEmpty()) { - return List.of(); + @Override + public List preview(HardApptDto dto, DataScope dataScope) { + return preview(dto, dataScope, LocalDateTime.now(HardApptWindowResolver.RESERVATION_ZONE)); + } + + /** 使用固定北京时间推演,保证同一请求内状态一致,也便于按业务示例核对边界。 */ + List preview(HardApptDto dto, DataScope dataScope, LocalDateTime now) { + if (dto == null || dto.getMchId() == null) throw new RuntimeException("商户不能为空"); + R> scoped = remoteMchService.scopeList(MchManageDto.builder().ids(List.of(dto.getMchId())).build()); + if (scoped == null || !scoped.isOk() || scoped.getData() == null + || scoped.getData().stream().noneMatch(merchant -> Objects.equals(merchant.getId(), dto.getMchId()))) { + throw new RuntimeException("无权预览当前商户或商户数据权限查询失败"); } - return hardApptWindowMapper.selectList(new LambdaQueryWrapper() - .in(HardApptWindowEntity::getApptId, ruleIds)) - .stream() - .filter(window -> !Objects.equals(window.getId(), selfWindowId)) - .toList(); - } - - private Map> loadWindowDates(List windows) { - List windowIds = windows.stream().map(HardApptWindowEntity::getId).toList(); - return hardApptItemMapper.selectList(new LambdaQueryWrapper() - .in(HardApptItemEntity::getWindowId, windowIds)).stream() - .collect(Collectors.groupingBy(HardApptItemEntity::getWindowId, - Collectors.mapping(HardApptItemEntity::getTargetDate, Collectors.toSet()))); + requireTimeWindowMerchant(dto.getMchId()); + if (dto.getId() != null && baseMapper.selectListByScope(new LambdaQueryWrapper() + .eq(HardApptEntity::getId, dto.getId()).eq(HardApptEntity::getMchId, dto.getMchId()), dataScope).isEmpty()) { + throw new RuntimeException("预约规则不存在或不在当前数据权限范围内"); + } + List draft = validateAndNormalizeWindows(dto); + List sources = new ArrayList<>(listEnabledWindows(dto.getMchId()).stream() + .filter(source -> !Objects.equals(source.window().getApptId(), dto.getId())).toList()); + Map> dates = new java.util.TreeMap<>(); + List targets = new ArrayList<>(); + for (HardApptWindowDto window : draft) { + HardApptWindowEntity entity = new HardApptWindowEntity(); + entity.setMchId(dto.getMchId()); + entity.setStartTime(window.getStartTime()); + entity.setEndTime(window.getEndTime()); + entity.setAutoRefundTime(window.getAutoRefundTime()); + List items = window.getItemList().stream().map(item -> { + targets.add(new Target(item.getTargetDate(), item.getHourType())); + dates.computeIfAbsent(item.getTargetDate(), key -> new ArrayList<>()); + HardApptItemEntity value = new HardApptItemEntity(); + value.setTargetDate(item.getTargetDate()); + value.setHourType(item.getHourType()); + return value; + }).toList(); + if (!Objects.equals(dto.getStatus(), YesNoEnum.NO.getCode())) sources.add(new WindowSource(entity, items)); + } + Map resolved = HardApptWindowResolver.resolve(sources, now); + targets.stream().distinct().sorted(java.util.Comparator.comparing(Target::date).thenComparing(Target::hourType)) + .forEach(target -> { + ResolvedTarget value = resolved.get(target); + if (value != null) dates.get(target.date()).add(HardApptWindowResolver.toOption(target, value, now)); + }); + return dates.entrySet().stream().map(entry -> { + HardApptDateOptionsVo day = new HardApptDateOptionsVo(); + day.setApptDate(entry.getKey()); + day.setApptList(entry.getValue()); + return day; + }).toList(); } + /** 保存归一化后的窗口和日期餐段明细,不改写任何已有订单快照。 */ private void saveWindows(Long apptId, Long mchId, List windows) { for (HardApptWindowDto window : windows) { HardApptWindowEntity windowEntity = new HardApptWindowEntity(); @@ -374,6 +394,7 @@ public class HardApptServiceImpl extends ServiceImpl windows = hardApptWindowMapper.selectList( new LambdaQueryWrapper().eq(HardApptWindowEntity::getApptId, apptId)); @@ -386,9 +407,9 @@ public class HardApptServiceImpl extends ServiceImpl records) { + private void disposeWindowList(List records, LocalDateTime now) { if (ObjectUtil.isEmpty(records)) return; List ruleIds = records.stream().map(HardApptVo::getId).filter(ObjectUtil::isNotNull).toList(); if (ruleIds.isEmpty()) return; @@ -400,6 +421,10 @@ public class HardApptServiceImpl extends ServiceImpl() .in(HardApptItemEntity::getWindowId, windows.stream().map(HardApptWindowEntity::getId).toList())) .stream().collect(Collectors.groupingBy(HardApptItemEntity::getWindowId)); + // 必须按商户加载全部启用规则,当前页以外的后续窗口也可能决定最终退款截止。 + Map> resolvedByMerchant = new LinkedHashMap<>(); + records.stream().map(HardApptVo::getMchId).filter(Objects::nonNull).distinct().forEach(mchId -> + resolvedByMerchant.put(mchId, HardApptWindowResolver.resolve(listEnabledWindows(mchId), now))); for (HardApptVo record : records) { record.setWindowList(windows.stream() .filter(window -> Objects.equals(window.getApptId(), record.getId())) @@ -408,6 +433,7 @@ public class HardApptServiceImpl extends ServiceImpl { @@ -418,6 +444,9 @@ public class HardApptServiceImpl extends ServiceImpl ACTIVE_ORDER = Comparator + .comparing((WindowSource source) -> source.window().getEndTime(), Comparator.reverseOrder()) + .thenComparing(source -> source.window().getId(), Comparator.nullsLast(Comparator.naturalOrder())); + + /** 工具类没有实例状态,禁止实例化。 */ + private HardApptWindowResolver() { + } + + /** + * 同一商户下的预约目标,按用餐日期、餐段区分;不得把早餐和午餐的退款截止混在一起。 + * + * @param date 实际用餐日期 + * @param hourType daily_menu 餐段编码 + */ + public record Target(LocalDate date, String hourType) { + } + + /** + * 调用方提供的一个启用窗口及其明细;草稿窗口 ID 可以为空,不能用于真实下单。 + * + * @param window 原始窗口,只读使用 + * @param items 此窗口的日期餐段明细,空集合表示没有目标 + */ + public record WindowSource(HardApptWindowEntity window, List items) { + } + + /** + * 当前应展示的连续区间和该目标全部窗口的退款截止。区间只合并重叠或相接窗口, + * 退款截止包含尚未开放的后续窗口,因此不能从当前选中的代表窗口反推。 + * + * @param window 当前/最近将开放/最近结束的代表窗口 + * @param available 当前时刻是否命中实际开放区间 + * @param start 当前展示的连续区间起点,闭区间 + * @param end 当前展示的连续区间终点,闭区间 + * @param autoRefundDeadline 所有匹配窗口有效退款截止的最大值 + */ + public record ResolvedTarget(HardApptWindowEntity window, boolean available, LocalDateTime start, + LocalDateTime end, LocalDateTime autoRefundDeadline) { + } + + /** + * 汇总同一商户的全部启用窗口,按目标返回唯一结果;脏窗口和非法餐段不参与计算。 + * + * @param sources 已完成商户、租户和启用状态筛选的窗口集合 + * @param now 北京时间当前时刻,同一次解析内固定不变 + * @return 按日期、餐段稳定排序的目标结果 + */ + public static Map resolve(List sources, LocalDateTime now) { + Map> grouped = new TreeMap<>(Comparator + .comparing(Target::date).thenComparing(Target::hourType)); + for (WindowSource source : sources) { + if (!validWindow(source.window())) continue; + for (HardApptItemEntity item : source.items()) { + if (item == null || item.getTargetDate() == null + || HardHourTypeEnum.valueOfByType(item.getHourType()) == null) continue; + Target target = new Target(item.getTargetDate(), item.getHourType()); + List matching = grouped.computeIfAbsent(target, key -> new ArrayList<>()); + if (!matching.contains(source)) matching.add(source); + } + } + Map result = new LinkedHashMap<>(); + grouped.forEach((target, matching) -> result.put(target, resolveTarget(matching, now))); + return result; + } + + /** + * 校验配置窗口可参与计算。历史非法退款时间不允许扩大退款资格,整个脏窗口跳过。 + * + * @param window 持久化或草稿窗口 + * @return 时间和可选退款截止均合法时返回 true + */ + private static boolean validWindow(HardApptWindowEntity window) { + if (window == null || window.getStartTime() == null || window.getEndTime() == null + || !window.getEndTime().isAfter(window.getStartTime())) return false; + LocalDateTime refund = window.getAutoRefundTime(); + return refund == null || refund.isAfter(window.getStartTime()) && !refund.isAfter(window.getEndTime()); + } + + /** + * 按开放状态选择代表窗口,再展开它所在的连续区间。不能简单取最早开始、最晚结束, + * 否则中间关闭的空档会被错误标为可预约。 + * + * @param sources 同一个日期餐段的合法窗口 + * @param now 固定的北京时间当前时刻 + * @return 唯一代表窗口、所在区间及最终退款截止 + */ + private static ResolvedTarget resolveTarget(List sources, LocalDateTime now) { + List active = sources.stream().filter(source -> contains(source.window(), now)).toList(); + WindowSource chosen; + if (!active.isEmpty()) { + chosen = active.stream().min(ACTIVE_ORDER).orElseThrow(); + } else { + chosen = sources.stream().filter(source -> source.window().getStartTime().isAfter(now)) + .min(Comparator.comparing((WindowSource source) -> source.window().getStartTime()) + .thenComparing(ACTIVE_ORDER)) + .orElseGet(() -> sources.stream().min(ACTIVE_ORDER).orElseThrow()); + } + List ordered = sources.stream() + .sorted(Comparator.comparing(source -> source.window().getStartTime())).toList(); + LocalDateTime start = ordered.get(0).window().getStartTime(); + LocalDateTime end = ordered.get(0).window().getEndTime(); + LocalDateTime selectedStart = start; + LocalDateTime selectedEnd = end; + for (int i = 1; i <= ordered.size(); i++) { + if (i < ordered.size() && !ordered.get(i).window().getStartTime().isAfter(end)) { + LocalDateTime nextEnd = ordered.get(i).window().getEndTime(); + if (nextEnd.isAfter(end)) end = nextEnd; + continue; + } + if (!chosen.window().getStartTime().isBefore(start) && !chosen.window().getEndTime().isAfter(end)) { + selectedStart = start; + selectedEnd = end; + break; + } + if (i < ordered.size()) { + start = ordered.get(i).window().getStartTime(); + end = ordered.get(i).window().getEndTime(); + } + } + LocalDateTime refund = sources.stream().map(source -> source.window().getAutoRefundTime() == null + ? source.window().getEndTime() : source.window().getAutoRefundTime()) + .max(Comparator.naturalOrder()).orElseThrow(); + return new ResolvedTarget(chosen.window(), !active.isEmpty(), selectedStart, selectedEnd, refund); + } + + /** + * 真实开放窗口按闭区间判断,开始和结束准点均可下单。 + * + * @param window 已校验时间的窗口 + * @param now 北京时间待判定时刻 + * @return 是否处于窗口内 + */ + private static boolean contains(HardApptWindowEntity window, LocalDateTime now) { + return !now.isBefore(window.getStartTime()) && !now.isAfter(window.getEndTime()); + } + + /** + * 将统一解析结果转换为现有 App/后台预约选项协议;原 startTime/endTime 保持代表窗口原值, + * reserveStartDateTime/reserveEndDateTime 表示真实连续区间,新增截止仅供展示,不作为客户端可信入参。 + * + * @param target 日期餐段目标 + * @param resolved 当前解析结果 + * @param now 与解析共用的北京时间,不能读取系统时钟导致推演理由与状态不一致 + * @return 每个目标唯一的展示选项 + */ + public static HardApptOptionVo toOption(Target target, ResolvedTarget resolved, LocalDateTime now) { + HardApptOptionVo option = new HardApptOptionVo(); + option.setId(resolved.window().getId()); + option.setReserveMode(HardApptReserveModeEnum.TIME_WINDOW.getType()); + option.setHourType(target.hourType()); + String name = Objects.requireNonNull(HardHourTypeEnum.valueOfByType(target.hourType())).getDescription(); + option.setHourTypeName(name); + option.setTitle(name); + option.setStartTime(resolved.window().getStartTime()); + option.setEndTime(resolved.window().getEndTime()); + option.setAvailable(resolved.available()); + if (!resolved.available()) { + option.setUnavailableReason(MsgUtils.getMessage(resolved.start().isAfter(now) + ? ErrorCodes.HARD_APPT_NOT_OPEN : ErrorCodes.HARD_APPT_CLOSED)); + } + option.setReserveStartDateTime(resolved.start()); + option.setReserveEndDateTime(resolved.end()); + option.setAutoRefundDeadline(resolved.autoRefundDeadline()); + return option; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java index 89a38fe87..f00160431 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java @@ -18,6 +18,7 @@ import com.bsd.pjcloud.common.core.exception.ErrorCodes; import com.bsd.pjcloud.common.core.exception.I18nBizException; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.bsd.pjcloud.hard.service.support.HardApptCycleRuleCalculator; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.WindowSource; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import org.junit.jupiter.api.Test; @@ -178,6 +179,36 @@ class HardApptReservationServiceImplTest { .map(HardApptDateOptionsVo::getApptDate).toList()); } + /** 重叠窗口只显示一个餐段;旧窗口 ID 在后续窗口开放时仍可提交,固化最终退款截止。 */ + @Test + void oldWindowIdRebindsToActiveWindowAndKeepsFinalRefundDeadline() { + HardApptEntity rule = dynamicRule(); + HardApptWindowEntity first = wideWindow(); + first.setStartTime(LocalDateTime.of(2026, 10, 19, 0, 0)); + first.setEndTime(LocalDateTime.of(2026, 10, 20, 12, 0)); + first.setAutoRefundTime(null); + HardApptWindowEntity next = wideWindow(); + next.setId(2L); + next.setStartTime(LocalDateTime.of(2026, 10, 20, 0, 0)); + next.setEndTime(LocalDateTime.of(2026, 10, 22, 12, 0)); + next.setAutoRefundTime(null); + LocalDate target = LocalDate.of(2026, 10, 23); + List firstItems = List.of(windowItem(1L, target, "1")); + HardApptReservationServiceImpl service = dynamicService(rule, first, firstItems, 1L); + HardApptService apptService = (HardApptService) org.springframework.test.util.ReflectionTestUtils + .getField(service, "hardApptService"); + when(apptService.listEnabledWindows(10L)).thenReturn(List.of(new WindowSource(first, firstItems), + new WindowSource(next, List.of(windowItem(2L, target, "1"))))); + LocalDateTime now = LocalDateTime.of(2026, 10, 21, 8, 0); + var options = service.listOptions(10L, now); + assertEquals(1, options.get(0).getApptList().size()); + assertEquals(next.getEndTime(), options.get(0).getApptList().get(0).getAutoRefundDeadline()); + var validation = service.validateReservation(10L, 1L, null, "1", target, now); + assertEquals(2L, validation.apptId()); + assertEquals(next.getEndTime(), validation.eligibilityDeadline()); + assertEquals(target.atTime(23, 59, 59), validation.usageEndDateTime()); + } + /* ************************************* 周期预约 ************************************* */ /** 周期预约固化用餐日当天 23:59:59,不读取动态窗口配置。 */ @@ -399,6 +430,8 @@ class HardApptReservationServiceImplTest { when(apptService.list(org.mockito.ArgumentMatchers.>any())) .thenReturn(window == null ? List.of() : List.of(rule)); when(apptService.getById(rule.getId())).thenReturn(rule); + when(apptService.listEnabledWindows(any())).thenReturn(window == null || !"1".equals(rule.getStatus()) + ? List.of() : List.of(new WindowSource(window, items))); if (window != null) { when(windowMapper.selectById(window.getId())).thenReturn(window); when(windowMapper.selectList(org.mockito.ArgumentMatchers.>any())) diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImplTest.java index 12957c5cd..701462088 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImplTest.java @@ -11,6 +11,9 @@ import com.bsd.pjcloud.hard.mapper.HardApptItemMapper; import com.bsd.pjcloud.hard.mapper.HardApptMapper; import com.bsd.pjcloud.hard.mapper.HardApptWindowMapper; import com.bsd.pjcloud.hard.service.HardApptMerchantModeService; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import org.junit.jupiter.api.Test; @@ -22,6 +25,7 @@ import java.time.LocalDateTime; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -114,9 +118,9 @@ class HardApptServiceImplTest { verify(fixture.itemMapper).insert(anyList()); } - /** 窗口级编辑与商户其他窗口时间重叠拒绝。 */ + /** 窗口级编辑允许与同商户其他规则的窗口时间重叠。 */ @Test - void updateWindowRejectsOverlapWithOtherWindow() { + void updateWindowAllowsOverlapWithOtherWindow() { Fixture fixture = new Fixture(); HardApptWindowEntity existing = existingWindow(); when(fixture.windowMapper.selectById(existing.getId())).thenReturn(existing); @@ -133,15 +137,13 @@ class HardApptServiceImplTest { HardApptWindowDto dto = window(null); dto.setId(existing.getId()); - RuntimeException exception = assertThrows(RuntimeException.class, - () -> fixture.service.updateWindow(dto)); - assertTrue(exception.getMessage().contains("开放时间与商户已存在窗口重叠")); - verify(fixture.windowMapper, never()).updateById(any(HardApptWindowEntity.class)); + assertDoesNotThrow(() -> fixture.service.updateWindow(dto)); + verify(fixture.windowMapper).updateById(any(HardApptWindowEntity.class)); } - /** 窗口级编辑撞商户其他窗口的可预约日期拒绝。 */ + /** 窗口级编辑允许同商户其他窗口配置相同用餐日期餐段。 */ @Test - void updateWindowRejectsDuplicatedTargetDate() { + void updateWindowAllowsDuplicatedTargetDate() { Fixture fixture = new Fixture(); HardApptWindowEntity existing = existingWindow(); when(fixture.windowMapper.selectById(existing.getId())).thenReturn(existing); @@ -163,9 +165,96 @@ class HardApptServiceImplTest { HardApptWindowDto duplicatedDto = window(null); duplicatedDto.setId(existing.getId()); - RuntimeException exception = assertThrows(RuntimeException.class, - () -> fixture.service.updateWindow(duplicatedDto)); - assertTrue(exception.getMessage().contains("已存在于商户其他窗口")); + assertDoesNotThrow(() -> fixture.service.updateWindow(duplicatedDto)); + verify(fixture.windowMapper).updateById(any(HardApptWindowEntity.class)); + } + + /** 同一规则内重叠窗口、相同目标均可保存,但每个窗口内部仍去重。 */ + @Test + void createAllowsOverlappingWindowsAndDeduplicatesItems() { + Fixture fixture = new Fixture(); + HardApptWindowDto first = window(null); + first.setItemList(List.of(first.getItemList().get(0), first.getItemList().get(0))); + HardApptDto dto = ruleDto(first); + dto.setWindowList(List.of(first, window(null))); + assertDoesNotThrow(() -> fixture.service.create(dto)); + assertEquals(1, first.getItemList().size()); + } + + /** 草稿合并同商户其他规则,停用草稿不贡献窗口,预览不写持久化层。 */ + @Test + void previewMergesSavedTargetsWithoutWriting() { + Fixture fixture = new Fixture(); + when(fixture.apptMapper.selectList(any())).thenReturn(List.of(existingRule())); + HardApptWindowEntity saved = existingWindow(); + saved.setEndTime(LocalDateTime.of(2026, 10, 1, 15, 0)); + saved.setAutoRefundTime(null); + when(fixture.windowMapper.selectList(any())).thenReturn(List.of(saved)); + HardApptItemEntity item = new HardApptItemEntity(); + item.setWindowId(saved.getId()); + item.setTargetDate(LocalDate.of(2026, 10, 2)); + item.setHourType("1"); + when(fixture.itemMapper.selectList(any())).thenReturn(List.of(item)); + HardApptDto dto = ruleDto(window(null)); + var result = fixture.service.preview(dto, DataScope.of(), LocalDateTime.of(2026, 10, 1, 9, 0)); + assertEquals(1, result.size()); + assertEquals(1, result.get(0).getApptList().size()); + assertEquals(saved.getEndTime(), result.get(0).getApptList().get(0).getAutoRefundDeadline()); + dto.setStatus("0"); + assertEquals(saved.getEndTime(), fixture.service.preview(dto, DataScope.of(), LocalDateTime.of(2026, 10, 1, 9, 0)).get(0) + .getApptList().get(0).getAutoRefundDeadline()); + verify(fixture.apptMapper, never()).insert(any(HardApptEntity.class)); + verify(fixture.windowMapper, never()).insert(any(HardApptWindowEntity.class)); + } + + /** 编辑预览排除原规则,且不能通过其他商户或超出数据范围的规则 ID 绕过校验。 */ + @Test + void previewChecksMerchantScopeAndExcludesOriginalRule() { + Fixture fixture = new Fixture(); + HardApptDto dto = ruleDto(window(null)); + dto.setId(1L); + when(fixture.apptMapper.selectListByScope(any(), any())).thenReturn(List.of(existingRule())); + when(fixture.apptMapper.selectList(any())).thenReturn(List.of(existingRule())); + HardApptWindowEntity saved = existingWindow(); + saved.setAutoRefundTime(null); + saved.setEndTime(LocalDateTime.of(2026, 10, 1, 18, 0)); + when(fixture.windowMapper.selectList(any())).thenReturn(List.of(saved)); + var result = fixture.service.preview(dto, DataScope.of(), LocalDateTime.of(2026, 10, 1, 9, 0)); + assertEquals(LocalDateTime.of(2026, 10, 1, 12, 0), result.get(0).getApptList().get(0).getAutoRefundDeadline()); + when(fixture.apptMapper.selectListByScope(any(), any())).thenReturn(List.of()); + assertThrows(RuntimeException.class, () -> fixture.service.preview(dto, DataScope.of(), LocalDateTime.of(2026, 10, 1, 9, 0))); + dto.setId(null); + when(fixture.remoteMchService.scopeList(any())).thenReturn(R.ok(List.of())); + assertThrows(RuntimeException.class, () -> fixture.service.preview(dto, DataScope.of(), LocalDateTime.of(2026, 10, 1, 9, 0))); + } + + /** 当前列表只包含规则 1 时,目标截止仍应包含规则 2 的后续窗口,不能受分页截断。 */ + @Test + void detailDeadlineIncludesRulesOutsideCurrentPage() { + Fixture fixture = new Fixture(); + when(fixture.apptMapper.selectById(1L)).thenReturn(existingRule()); + HardApptEntity anotherRule = existingRule(); + anotherRule.setId(2L); + when(fixture.apptMapper.selectList(any())).thenReturn(List.of(existingRule(), anotherRule)); + HardApptWindowEntity first = existingWindow(); + HardApptWindowEntity later = existingWindow(); + later.setId(6L); + later.setApptId(2L); + later.setEndTime(LocalDateTime.of(2026, 10, 1, 18, 0)); + later.setAutoRefundTime(null); + when(fixture.windowMapper.selectList(any())).thenReturn(List.of(first), List.of(first, later)); + HardApptItemEntity firstItem = new HardApptItemEntity(); + firstItem.setWindowId(5L); + firstItem.setTargetDate(LocalDate.of(2026, 10, 2)); + firstItem.setHourType("1"); + HardApptItemEntity laterItem = new HardApptItemEntity(); + laterItem.setWindowId(6L); + laterItem.setTargetDate(firstItem.getTargetDate()); + laterItem.setHourType("1"); + when(fixture.itemMapper.selectList(any())).thenReturn(List.of(firstItem), List.of(firstItem, laterItem)); + var result = fixture.service.join(1L); + assertEquals(1, result.getWindowList().size()); + assertEquals(later.getEndTime(), result.getWindowList().get(0).getItemList().get(0).getAutoRefundDeadline()); } /* ************************************* 窗口级删除 ************************************* */ @@ -247,13 +336,18 @@ class HardApptServiceImplTest { final HardApptMapper apptMapper = mock(HardApptMapper.class); final HardApptWindowMapper windowMapper = mock(HardApptWindowMapper.class); final HardApptItemMapper itemMapper = mock(HardApptItemMapper.class); + /** 商户数据权限远程接口替身,默认只允许商户 10。 */ + final RemoteMchService remoteMchService = mock(RemoteMchService.class); final HardApptServiceImpl service; Fixture() { HardApptMerchantModeService merchantModeService = mock(HardApptMerchantModeService.class); when(merchantModeService.resolve(any())).thenReturn(new HardApptMerchantModeService.MerchantModeContext( new MchManageVo(), HardApptReserveModeEnum.TIME_WINDOW)); - service = new HardApptServiceImpl(windowMapper, itemMapper, merchantModeService); + MchManageVo merchant = new MchManageVo(); + merchant.setId(MCH_ID); + when(remoteMchService.scopeList(any())).thenReturn(R.ok(List.of(merchant))); + service = new HardApptServiceImpl(windowMapper, itemMapper, merchantModeService, remoteMchService); ReflectionTestUtils.setField(service, "baseMapper", apptMapper); doAnswer(invocation -> { ((HardApptEntity) invocation.getArgument(0)).setId(100L); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplAutoRefundSnapshotTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplAutoRefundSnapshotTest.java new file mode 100644 index 000000000..2e152143f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplAutoRefundSnapshotTest.java @@ -0,0 +1,98 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import com.bsd.pjcloud.hard.api.entity.HardApptItemEntity; +import com.bsd.pjcloud.hard.api.entity.HardApptWindowEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.service.HardApptReservationService.ReservationValidation; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.Target; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.WindowSource; +import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; + +/** 统一窗口计算结果落到新订单快照,过期退款和支付限时模式继续保持独立口径。 */ +class HardOrderServiceImplAutoRefundSnapshotTest { + + /** 只运行快照固化逻辑,不调用钱包、库存或订单持久化。 */ + private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS); + + /** 周一下单预约周五固化周四截止,后续修改窗口不会改写该订单,过期退款仍使用用餐日末加延迟。 */ + @Test + void finalTargetDeadlineIsFrozenIndependentlyFromExpiredRefund() { + WindowSource early = source(1L, 19, 20); + WindowSource later = source(2L, 21, 22); + HardOrderEntity paid = snapshot(reservation(List.of(early, later)), config("APPT_CYCLE_CLOSE", "1")); + assertEquals(LocalDateTime.of(2026, 10, 22, 12, 0), paid.getAutoRefundDeadline()); + assertEquals(LocalDateTime.of(2026, 10, 23, 23, 59, 59), paid.getExpiredRefundUsageEndTime()); + assertEquals(LocalDateTime.of(2026, 10, 24, 1, 59, 59), paid.getExpiredRefundDeadline()); + + later.window().setEndTime(LocalDateTime.of(2026, 10, 22, 18, 0)); + HardOrderEntity next = snapshot(reservation(List.of(early, later)), config("APPT_CYCLE_CLOSE", "1")); + assertEquals(LocalDateTime.of(2026, 10, 22, 18, 0), next.getAutoRefundDeadline()); + assertEquals(LocalDateTime.of(2026, 10, 22, 12, 0), paid.getAutoRefundDeadline()); + assertEquals(paid.getExpiredRefundDeadline(), next.getExpiredRefundDeadline()); + } + + /** 支付后限时的免审截止由支付时间计算,不能被目标预约截止覆盖;关闭免审也不关闭过期退款。 */ + @Test + void payLimitAndDisabledApprovalKeepExpiredRefundIndependent() { + ReservationValidation reservation = reservation(List.of(source(1L, 19, 20))); + HardOrderEntity payLimit = snapshot(reservation, config("PAY_TIME_LIMIT", "1")); + assertEquals("PAY_TIME_LIMIT", payLimit.getAutoRefundRuleType()); + assertNull(payLimit.getAutoRefundDeadline()); + assertEquals(LocalDateTime.of(2026, 10, 24, 1, 59, 59), payLimit.getExpiredRefundDeadline()); + HardOrderEntity disabled = snapshot(reservation, config("APPT_CYCLE_CLOSE", "0")); + assertNull(disabled.getAutoRefundConfigId()); + assertNull(disabled.getAutoRefundDeadline()); + assertEquals(payLimit.getExpiredRefundDeadline(), disabled.getExpiredRefundDeadline()); + } + + /** 按周一固定时刻解析周五早餐,把统一计算结果作为订单服务的可信预约上下文。 */ + private ReservationValidation reservation(List sources) { + LocalDate date = LocalDate.of(2026, 10, 23); + var resolved = HardApptWindowResolver.resolve(sources, LocalDateTime.of(2026, 10, 19, 8, 0)) + .get(new Target(date, "1")); + return new ReservationValidation(resolved.window().getId(), "1", date, null, + resolved.autoRefundDeadline(), date.atTime(23, 59, 59)); + } + + /** 构造覆盖周五早餐的窗口,允许未来窗口参与免审截止计算。 */ + private WindowSource source(Long id, int openDay, int closeDay) { + HardApptWindowEntity window = new HardApptWindowEntity(); + window.setId(id); + window.setStartTime(LocalDateTime.of(2026, 10, openDay, 0, 0)); + window.setEndTime(LocalDateTime.of(2026, 10, closeDay, 12, 0)); + HardApptItemEntity item = new HardApptItemEntity(); + item.setTargetDate(LocalDate.of(2026, 10, 23)); + item.setHourType("1"); + return new WindowSource(window, List.of(item)); + } + + /** 开启独立的过期退款,延迟固定为用餐日结束后 2 小时。 */ + private MchAutoRefundConfigVo config(String ruleType, String enabled) { + MchAutoRefundConfigVo config = new MchAutoRefundConfigVo(); + config.setId(99L); + config.setEnabled(enabled); + config.setRuleType(ruleType); + config.setExpiredReserveRefundEnabled("1"); + config.setExpiredReserveRefundDelayHours(2); + return config; + } + + /** 对新建订单执行现有快照方法,验证新解析器无需改变订单或任务持久化协议。 */ + private HardOrderEntity snapshot(ReservationValidation reservation, MchAutoRefundConfigVo config) { + HardOrderEntity order = new HardOrderEntity(); + ReflectionTestUtils.invokeMethod(service, "applyDailyMenuAutoRefundSnapshot", order, reservation, config); + return order; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/support/HardApptWindowResolverTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/support/HardApptWindowResolverTest.java new file mode 100644 index 000000000..53ede8e0b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/support/HardApptWindowResolverTest.java @@ -0,0 +1,148 @@ +package com.bsd.pjcloud.hard.service.support; + +import com.bsd.pjcloud.hard.api.entity.HardApptItemEntity; +import com.bsd.pjcloud.hard.api.entity.HardApptWindowEntity; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.Target; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.WindowSource; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** 多窗口叠加的业务回归:逐日截止、真实空档、未来窗口退款和提前退款配置。 */ +class HardApptWindowResolverTest { + + /** 周一下单时,尚未开放的后续窗口已经决定周四、周五的最终退款截止。 */ + @Test + void mondayOrdersUseFinalDeadlineOfEachTarget() { + var result = HardApptWindowResolver.resolve(example(), time(19, 0)); + assertEquals(3, result.size()); + for (int targetDay = 21; targetDay <= 23; targetDay++) { + var target = result.get(target(targetDay, "1")); + assertTrue(target.available()); + assertEquals(time(19, 0), target.start()); + assertEquals(time(targetDay - 1, 12), target.end()); + assertEquals(time(targetDay - 1, 12), target.autoRefundDeadline()); + assertEquals(1L, target.window().getId()); + } + } + + /** 每次午间截止使用闭区间,结束一纳秒后只关闭相应日期,不影响后续用餐日。 */ + @Test + void dailyNoonBoundariesAreInclusive() { + for (int day = 20; day <= 22; day++) { + var atEnd = HardApptWindowResolver.resolve(example(), time(day, 12)); + assertTrue(atEnd.get(target(day + 1, "1")).available()); + var afterEnd = HardApptWindowResolver.resolve(example(), time(day, 12).plusNanos(1)); + assertFalse(afterEnd.get(target(day + 1, "1")).available()); + for (int later = day + 2; later <= 23; later++) { + assertTrue(afterEnd.get(target(later, "1")).available()); + } + } + } + + /** 同时开放选择结束更晚的窗口;结束相同则按窗口 ID 升序,结果不依赖查询返回顺序。 */ + @Test + void overlappingSourcesHaveDeterministicRepresentative() { + WindowSource first = source(3L, 19, 21, 23); + WindowSource second = source(2L, 20, 21, 23); + var resolved = HardApptWindowResolver.resolve(List.of(first, second), time(20, 8)).get(target(23, "1")); + assertEquals(2L, resolved.window().getId()); + assertEquals(time(19, 0), resolved.start()); + assertEquals(time(21, 12), resolved.end()); + } + + /** 两次开放之间的空档必须保持不可约,选择最近将开放区间展示;退款仍按最终窗口截止。 */ + @Test + void gapsAreNotFilledByEarliestAndLatestBounds() { + List sources = List.of(source(1L, 19, 19, 23), source(2L, 21, 21, 23)); + var gap = HardApptWindowResolver.resolve(sources, time(20, 8)).get(target(23, "1")); + assertFalse(gap.available()); + assertEquals(2L, gap.window().getId()); + assertEquals(time(21, 0), gap.start()); + assertEquals(time(21, 12), gap.end()); + assertEquals(time(21, 12), gap.autoRefundDeadline()); + var closed = HardApptWindowResolver.resolve(sources, time(22, 0)).get(target(23, "1")); + assertFalse(closed.available()); + assertEquals(2L, closed.window().getId()); + } + + /** 相接窗口准点有真实覆盖,可合并;完全重复的目标明细仍只返回一个餐段。 */ + @Test + void touchingIntervalsAndDuplicateItemsAreMerged() { + WindowSource first = source(1L, 19, 20, 23, 23); + WindowSource second = source(2L, 20, 21, 23); + second.window().setStartTime(time(20, 12)); + var result = HardApptWindowResolver.resolve(List.of(first, second), time(20, 12)); + assertEquals(1, result.size()); + var resolved = result.get(target(23, "1")); + assertTrue(resolved.available()); + assertEquals(time(19, 0), resolved.start()); + assertEquals(time(21, 12), resolved.end()); + } + + /** 早餐和午餐独立汇总,各窗口合法的自定义提前退款值取最大,空值才回退预约结束。 */ + @Test + void customRefundsAndDifferentMealsRemainIndependent() { + WindowSource breakfast = source(1L, 19, 21, 23); + breakfast.window().setAutoRefundTime(time(21, 10)); + WindowSource laterBreakfast = source(2L, 20, 22, 23); + laterBreakfast.window().setAutoRefundTime(time(22, 9)); + WindowSource lunch = source(3L, 19, 20, 23); + lunch.items().get(0).setHourType("2"); + var result = HardApptWindowResolver.resolve(List.of(breakfast, laterBreakfast, lunch), time(19, 8)); + assertEquals(time(22, 9), result.get(target(23, "1")).autoRefundDeadline()); + assertEquals(time(20, 12), result.get(target(23, "2")).autoRefundDeadline()); + } + + /** 不完整时间和越界退款脏配置不扩大可约范围或退款资格。 */ + @Test + void invalidSourcesDoNotExtendValidTargets() { + WindowSource valid = source(1L, 19, 20, 23); + WindowSource broken = source(2L, 21, 22, 23); + broken.window().setAutoRefundTime(time(23, 12)); + WindowSource reversed = source(3L, 22, 21, 23); + var result = HardApptWindowResolver.resolve(List.of(valid, broken, reversed), time(21, 8)); + assertFalse(result.get(target(23, "1")).available()); + assertEquals(time(20, 12), result.get(target(23, "1")).autoRefundDeadline()); + } + + /** 用户确认的三组窗口,窗口明细允许覆盖同一用餐日。 */ + private List example() { + return List.of(source(1L, 19, 20, 21, 22, 23), source(2L, 20, 21, 22, 23), source(3L, 21, 22, 23)); + } + + /** 创建北京时间 2026 年 10 月的固定测试时刻,避免依赖执行机器当前日期。 */ + private LocalDateTime time(int day, int hour) { + return LocalDateTime.of(2026, 10, day, hour, 0); + } + + /** 构造实际用餐日期及餐段键。 */ + private Target target(int day, String hourType) { + return new Target(LocalDate.of(2026, 10, day), hourType); + } + + /** 构造商户 10 的窗口及早餐明细,窗口默认没有单独配置退款截止。 */ + private WindowSource source(Long id, int openDay, int closeDay, int... targetDays) { + HardApptWindowEntity window = new HardApptWindowEntity(); + window.setId(id); + window.setMchId(10L); + window.setApptId(id); + window.setStartTime(time(openDay, 0)); + window.setEndTime(time(closeDay, 12)); + List items = Arrays.stream(targetDays).mapToObj(day -> { + HardApptItemEntity item = new HardApptItemEntity(); + item.setWindowId(id); + item.setTargetDate(LocalDate.of(2026, 10, day)); + item.setHourType("1"); + return item; + }).toList(); + return new WindowSource(window, items); + } +}