From fb1b05b0b7362c76bdd593a39c0764f2109198a6 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Wed, 28 Oct 2026 10:09:07 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=94=AF=E4=BB=98=E5=AE=9DH5=E6=94=AF?= =?UTF-8?q?=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._mengkanuo01_offline_order_time_121500.sql | 130 +++++ ..._transaction_balance_20261019_20261026.sql | 503 ++++++++++++++++++ pjcloud-thirdparty/docs/aggregate-h5.md | 32 +- .../thirdparty/config/AppPlusPayConfig.java | 50 +- .../ThirdAggregateAliPayServiceImpl.java | 12 +- .../ThirdAggregateOrderPayServiceImpl.java | 22 +- .../ThirdAppPlusAliPayServiceImpl.java | 146 +++-- .../ThirdAggregateAliPayServiceImplTest.java | 36 +- ...ThirdAggregateOrderPayServiceImplTest.java | 61 ++- .../ThirdAppPlusAliPayServiceImplTest.java | 175 +++++- 10 files changed, 1104 insertions(+), 63 deletions(-) create mode 100644 db/20261026_fix_mengkanuo01_offline_order_time_121500.sql create mode 100644 db/20261028_recalculate_offline_wallet_transaction_balance_20261019_20261026.sql diff --git a/db/20261026_fix_mengkanuo01_offline_order_time_121500.sql b/db/20261026_fix_mengkanuo01_offline_order_time_121500.sql new file mode 100644 index 00000000..9cbcea03 --- /dev/null +++ b/db/20261026_fix_mengkanuo01_offline_order_time_121500.sql @@ -0,0 +1,130 @@ +-- 梦卡诺01离线订单历史创建时间修复(2026-10-26 生成,仅生成,尚未执行)。 +-- 日期口径已经用户确认:使用 hard_order.appt_use_date 当天的 12:15:00。 +-- 此为用户指定的统一时刻,不代表已从梦卡诺01本地数据库恢复原始时分秒。 +-- 不使用云端已错误的 create_time/pay_time 推断日期;appt_use_date 为空时跳过。 +-- 范围:租户1、梦卡诺餐厅、梦卡诺01、is_offline_order='1'、未删除,消费日期为10月19日至26日。 +-- 仅处理没有匹配支付流水、或匹配支付流水 remark 为 NULL/空串/空格的剩余订单及流水。 +-- 已有非空备注的付款流水不更新;尚无付款流水时仅修订单,不创建流水、不补扣款。 +-- 不限制订单状态和审批状态,已审批扣款的离线订单同样按付款流水关联。 +-- 仅修改 hard_order.create_time、wallet_transaction.create_time 和缺失的 wallet_transaction.remark。 +-- 付款流水限定 biz_type='4',并核对订单号、租户、用户、钱包及删除标记;退款流水不进入修复。 +-- 例:订单2114557001439100930 -> 2026-10-22 12:15:00;2114556468313702401 -> 2026-10-21 12:15:00。 +-- 沿用仓库库名 pjcloudx_hardware / pjcloudx_wallet,要求同实例且目标表支持事务。 +-- 在同一连接先执行参数和第1节预览,确认目标后执行第2节;未改变任何表结构。 + +SET NAMES utf8mb4; +SET @repair_tenant_id = 1; +SET @repair_hardware_id = 2048955151738671106; +SET @repair_merchant_id = 2030260730023755777; +SET @repair_start_date = '2026-10-19'; +SET @repair_end_date = '2026-10-27'; + +-- 1. 更新前预览:按消费日期汇总本次需要修复的订单及支付流水数量。 +SELECT + o.appt_use_date AS consumption_date, + TIMESTAMP(o.appt_use_date, '12:15:00') AS target_create_time, + COUNT(DISTINCT o.id) AS orders_to_update, + COUNT(DISTINCT w.id) AS payment_transactions_to_update, + COUNT(DISTINCT CASE WHEN w.id IS NULL THEN o.id END) AS orders_without_payment_transaction +FROM pjcloudx_hardware.hard_order o +LEFT JOIN pjcloudx_wallet.wallet_transaction w + ON w.biz_id = CAST(o.id AS CHAR) + AND w.biz_type = '4' + AND w.tenant_id = o.tenant_id + AND w.user_id = o.user_id + AND w.wallet_id = o.wallet_id + AND w.del_flag = '0' +WHERE o.tenant_id = @repair_tenant_id + AND o.merchant_id = @repair_merchant_id + AND o.hard_id = @repair_hardware_id + AND o.is_offline_order = '1' + AND o.del_flag = '0' + AND o.appt_use_date >= @repair_start_date + AND o.appt_use_date < @repair_end_date + -- 只处理剩余未备注付款;条件放在 WHERE,避免已备注付款被 LEFT JOIN 误当作不存在。 + AND (w.id IS NULL OR NULLIF(TRIM(w.remark), '') IS NULL) + AND ( + NOT (o.create_time <=> TIMESTAMP(o.appt_use_date, '12:15:00')) + OR (w.id IS NOT NULL AND ( + NOT (w.create_time <=> TIMESTAMP(o.appt_use_date, '12:15:00')) + OR NULLIF(TRIM(w.remark), '') IS NULL + )) + ) +GROUP BY o.appt_use_date +ORDER BY o.appt_use_date; + +-- 预览明细:可核对旧订单时间、旧流水时间、目标日期和待补备注;只读查询。 +SELECT + o.id AS order_id, + o.android_order_code, + o.appt_use_date, + o.create_time AS old_order_create_time, + TIMESTAMP(o.appt_use_date, '12:15:00') AS target_create_time, + w.id AS wallet_transaction_id, + w.create_time AS old_transaction_create_time, + w.remark AS old_transaction_remark +FROM pjcloudx_hardware.hard_order o +LEFT JOIN pjcloudx_wallet.wallet_transaction w + ON w.biz_id = CAST(o.id AS CHAR) + AND w.biz_type = '4' + AND w.tenant_id = o.tenant_id + AND w.user_id = o.user_id + AND w.wallet_id = o.wallet_id + AND w.del_flag = '0' +WHERE o.tenant_id = @repair_tenant_id + AND o.merchant_id = @repair_merchant_id + AND o.hard_id = @repair_hardware_id + AND o.is_offline_order = '1' + AND o.del_flag = '0' + AND o.appt_use_date >= @repair_start_date + AND o.appt_use_date < @repair_end_date + -- 只处理剩余未备注付款;条件放在 WHERE,避免已备注付款被 LEFT JOIN 误当作不存在。 + AND (w.id IS NULL OR NULLIF(TRIM(w.remark), '') IS NULL) + AND ( + NOT (o.create_time <=> TIMESTAMP(o.appt_use_date, '12:15:00')) + OR (w.id IS NOT NULL AND ( + NOT (w.create_time <=> TIMESTAMP(o.appt_use_date, '12:15:00')) + OR NULLIF(TRIM(w.remark), '') IS NULL + )) + ) +ORDER BY o.appt_use_date, o.id, w.id; + +-- 2. 修复:同一条多表 UPDATE 原子修改订单和已存在的付款流水。 +-- 已有备注付款由 WHERE 排除;重复执行会跳过已补齐备注的付款及无需再改时间的无流水订单。 +START TRANSACTION; + +UPDATE pjcloudx_hardware.hard_order o +LEFT JOIN pjcloudx_wallet.wallet_transaction w + ON w.biz_id = CAST(o.id AS CHAR) + AND w.biz_type = '4' + AND w.tenant_id = o.tenant_id + AND w.user_id = o.user_id + AND w.wallet_id = o.wallet_id + AND w.del_flag = '0' +SET o.create_time = TIMESTAMP(o.appt_use_date, '12:15:00'), + w.create_time = TIMESTAMP(o.appt_use_date, '12:15:00'), + w.remark = '离线订单扣除' +WHERE o.tenant_id = @repair_tenant_id + AND o.merchant_id = @repair_merchant_id + AND o.hard_id = @repair_hardware_id + AND o.is_offline_order = '1' + AND o.del_flag = '0' + AND o.appt_use_date >= @repair_start_date + AND o.appt_use_date < @repair_end_date + -- 只处理剩余未备注付款;条件放在 WHERE,避免已备注付款被 LEFT JOIN 误当作不存在。 + AND (w.id IS NULL OR NULLIF(TRIM(w.remark), '') IS NULL) + AND ( + NOT (o.create_time <=> TIMESTAMP(o.appt_use_date, '12:15:00')) + OR (w.id IS NOT NULL AND ( + NOT (w.create_time <=> TIMESTAMP(o.appt_use_date, '12:15:00')) + OR NULLIF(TRIM(w.remark), '') IS NULL + )) + ); + +-- 订单和钱包流水合计受影响记录数,不等于订单数,且受客户端 affected/found-rows 选项影响。 +SELECT ROW_COUNT() AS affected_target_rows; + +COMMIT; + +-- 3. 执行后可重跑第1节,正常情况下应没有剩余待修复行。 +-- 尚未审批扣款的订单只修创建时间;后续扣款由更新后的后端沿用订单时间并写入离线备注。 diff --git a/db/20261028_recalculate_offline_wallet_transaction_balance_20261019_20261026.sql b/db/20261028_recalculate_offline_wallet_transaction_balance_20261019_20261026.sql new file mode 100644 index 00000000..de82d681 --- /dev/null +++ b/db/20261028_recalculate_offline_wallet_transaction_balance_20261019_20261026.sql @@ -0,0 +1,503 @@ +-- 2026-10-28:修复离线订单流水时间调整后,历史余额快照与新时间顺序不一致的问题。 +-- 仅生成 SQL,未连接或执行业务数据库;MySQL 8 / 支持窗口函数的 OceanBase MySQL 模式。 +-- 目标区间:[2026-10-19 00:00:00, 2026-10-27 00:00:00),完整包含10月26日。 +-- 按“人”选范围:该区间任一有效流水 remark='离线订单扣除',即选中该租户下的用户。 +-- 再处理该用户各钱包在区间内的全部有效流水,不限制设备、商户、业务类型或流水备注。 +-- 同一用户的钱包分别计算,禁止把不同钱包的资金混在一起;金额单位均为分。 +-- 只更新 wallet_transaction.balance,不修改真实钱包余额、price、create_time、remark或订单。 +-- +-- 重算公式(price本身已带正负号,充值/消费/退款/转账/调账/清零等全部参与): +-- 19日期初 = 当前 wallet_manage.balance - SUM(19日起至当前的全部有效流水price) +-- 26日期末 = 当前 wallet_manage.balance - SUM(27日起至当前的全部有效流水price) +-- 每笔新balance = 19日期初 + 按(create_time ASC,id ASC)累计的区间price。 +-- 因此27日后的流水只参与回退资金,不更新其balance;不假设期初为0,不用已乱序的首笔balance作基准。 +-- 同秒流水按id稳定排序;不改price来“强制清零”,RESET流水也按其真实增减金额参与。 +-- +-- 安全校验(整钱包跳过,预览会标明原因): +-- INVALID_WALLET:钱包已删除或真实余额为空。 +-- OWNER_MISMATCH:钱包当前所有者不在本次冻结用户集合。 +-- INVALID_HISTORY:存在未知时间,或19日起存在删除流水/空金额/流水用户归属冲突。 +-- 删除流水不会自动退回资金,不能直接忽略其资金影响后强行计算,须先核对。 +-- OPENING_MISMATCH:回推期初与19日前最后有效流水balance不一致。 +-- CLOSING_MISMATCH:回推期末与27日起第一有效流水(balance-price)不一致。 +-- wallet_has_invalid_new_balance=1:任一新balance为空或超出INT范围,整钱包跳过。 +-- CURRENT_BALANCE_ONLY:区间外没有余额快照辅助校验,仍以真实当前余额回推,不把缺样本当作余额为0。 +-- 缺少wallet_manage的钱包单独列出,无法取得真实余额时不更新。 +-- +-- 执行条件:当前真实钱包余额正确、资金账本完整。钱包直接改余额/手工删改流水可能造成不一致, +-- 必须先处理预览中的异常钱包;本脚本不会把异常钱包强行改成“看起来连续”。 +-- 请在短维护窗口暂停钱包开户、资金写入、离线补传和历史流水编辑/日期修复。 +-- 同一连接顺序执行;事务内先冻结用户和钱包集合,再锁钱包行,再读取最新快照计算。 +-- 普通资金写入会被钱包行锁阻挡,但行锁挡不住后台直接删改流水;不要并发执行此类操作。 +-- 如只执行预览不更新,立即 ROLLBACK 释放行锁;任何语句报错都应 ROLLBACK,不继续 COMMIT。 +-- 出错后可用 SET SESSION group_concat_max_len=@repair_saved_group_concat_max_len 恢复会话参数。 +-- 不创建临时表,不改变表结构;窗口派生表强制物化,用于避免UPDATE自读目标表的1093限制。 + +SET NAMES utf8mb4; +SET @repair_tenant_id = 1; +SET @repair_start_time = '2026-10-19 00:00:00'; +SET @repair_end_time = '2026-10-27 00:00:00'; +SET @repair_remark = '离线订单扣除'; +SET @repair_saved_group_concat_max_len = @@SESSION.group_concat_max_len; +SET SESSION group_concat_max_len = 16777216; +-- 防止同一连接重跑时,失败语句残留上一次的用户或钱包集合。 +SET @repair_user_ids = NULL, @repair_wallet_ids = NULL, + @repair_user_expected_bytes = NULL, @repair_wallet_expected_bytes = NULL, + @repair_users_complete = 0, @repair_wallets_complete = 0, @repair_lists_complete = 0; + +-- 1. 使用新的已提交快照;仅设置本次事务的隔离级别,不改变会话后续事务默认值。 +SET TRANSACTION ISOLATION LEVEL READ COMMITTED; +START TRANSACTION; + +-- 2. 同一条SELECT同时冻结用户列表、人数和完整长度,避免分次查询集合漂移。 +SELECT GROUP_CONCAT(CAST(u.user_id AS CHAR) ORDER BY u.user_id SEPARATOR ','), + COUNT(*), + COALESCE(SUM(OCTET_LENGTH(CAST(u.user_id AS CHAR))), 0) + GREATEST(COUNT(*) - 1, 0) +INTO @repair_user_ids, @repair_user_count, @repair_user_expected_bytes +FROM ( + SELECT DISTINCT t.user_id + FROM pjcloudx_wallet.wallet_transaction t + WHERE t.tenant_id = @repair_tenant_id AND t.del_flag = '0' + AND t.create_time >= @repair_start_time AND t.create_time < @repair_end_time + AND t.remark = @repair_remark AND t.user_id IS NOT NULL +) u; +SET @repair_users_complete = (COALESCE(OCTET_LENGTH(@repair_user_ids), 0) = @repair_user_expected_bytes); + +-- 只冻结这些用户区间内有流水且账户已存在的钱包;后续不动态扩大钱包范围。 +SELECT GROUP_CONCAT(CAST(k.wallet_id AS CHAR) ORDER BY k.wallet_id SEPARATOR ','), + COUNT(*), + COALESCE(SUM(OCTET_LENGTH(CAST(k.wallet_id AS CHAR))), 0) + GREATEST(COUNT(*) - 1, 0) +INTO @repair_wallet_ids, @repair_wallet_count, @repair_wallet_expected_bytes +FROM ( + SELECT DISTINCT m.id AS wallet_id + FROM pjcloudx_wallet.wallet_manage m + JOIN pjcloudx_wallet.wallet_transaction t + ON t.tenant_id = m.tenant_id AND t.wallet_id = m.id + WHERE m.tenant_id = @repair_tenant_id AND t.del_flag = '0' + AND t.create_time >= @repair_start_time AND t.create_time < @repair_end_time + AND @repair_users_complete = 1 + AND (FIND_IN_SET(CAST(m.user_id AS CHAR), @repair_user_ids) > 0 + OR FIND_IN_SET(CAST(t.user_id AS CHAR), @repair_user_ids) > 0) +) k; +SET @repair_wallets_complete = (COALESCE(OCTET_LENGTH(@repair_wallet_ids), 0) = @repair_wallet_expected_bytes); +SET @repair_lists_complete = (@repair_users_complete = 1 AND @repair_wallets_complete = 1); + +-- lists_complete必须为1;任一列表被截断时整批UPDATE自然不命中,不允许部分钱包误修。 +SELECT @repair_user_count AS affected_user_count, + @repair_wallet_count AS frozen_wallet_count, + @repair_lists_complete AS lists_complete; + +-- 3. 按固定主键顺序锁定全部目标钱包;等待已有资金事务完成后,再进行后续计算。 +SELECT m.tenant_id, m.id AS wallet_id, m.user_id, m.balance AS current_wallet_balance +FROM pjcloudx_wallet.wallet_manage m +WHERE m.tenant_id = @repair_tenant_id AND @repair_lists_complete = 1 + AND FIND_IN_SET(CAST(m.id AS CHAR), @repair_wallet_ids) > 0 +ORDER BY m.id +FOR UPDATE; + +-- 4. 缺少真实钱包账户的区间流水,仅诊断,不更新。 +SELECT t.tenant_id, t.user_id, t.wallet_id, COUNT(*) AS period_transaction_count, + 'MISSING_WALLET' AS repair_status +FROM pjcloudx_wallet.wallet_transaction t +LEFT JOIN pjcloudx_wallet.wallet_manage m + ON m.tenant_id = t.tenant_id AND m.id = t.wallet_id +WHERE t.tenant_id = @repair_tenant_id AND t.del_flag = '0' + AND t.create_time >= @repair_start_time AND t.create_time < @repair_end_time + AND @repair_lists_complete = 1 + AND FIND_IN_SET(CAST(t.user_id AS CHAR), @repair_user_ids) > 0 + AND m.id IS NULL +GROUP BY t.tenant_id, t.user_id, t.wallet_id; + +-- 5. 钱包级双端校验:缺少某端快照不会自动判错;有快照但对不上则整钱包不更新。 +SELECT c.* +FROM ( +SELECT b.*, + CASE + WHEN b.current_wallet_balance IS NULL OR NOT (b.wallet_del_flag <=> '0') + THEN 'INVALID_WALLET' + WHEN COALESCE(FIND_IN_SET(CAST(b.user_id AS CHAR), @repair_user_ids), 0) = 0 + THEN 'OWNER_MISMATCH' + WHEN b.invalid_history_count > 0 THEN 'INVALID_HISTORY' + WHEN b.before_anchor_id IS NOT NULL AND NOT (b.before_anchor_balance <=> b.opening_balance) + THEN 'OPENING_MISMATCH' + WHEN b.after_anchor_id IS NOT NULL AND NOT (b.after_anchor_opening_balance <=> b.closing_balance) + THEN 'CLOSING_MISMATCH' + ELSE 'READY' + END AS repair_status, + CASE WHEN b.before_anchor_id IS NULL AND b.after_anchor_id IS NULL + THEN 'CURRENT_BALANCE_ONLY' ELSE 'BOUNDARY_CHECKED' END AS anchor_mode +FROM ( +SELECT + m.tenant_id, + m.id AS wallet_id, + m.user_id, + m.balance AS current_wallet_balance, + m.del_flag AS wallet_del_flag, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_start_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS opening_balance, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_end_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS closing_balance, + SUM(CASE WHEN h.create_time >= @repair_start_time AND h.create_time < @repair_end_time + AND h.del_flag = '0' THEN 1 ELSE 0 END) AS period_transaction_count, + SUM(CASE WHEN h.create_time IS NULL OR ( + h.create_time >= @repair_start_time AND ( + h.price IS NULL OR NOT (h.del_flag <=> '0') OR NOT (h.user_id <=> m.user_id) + )) THEN 1 ELSE 0 END) AS invalid_history_count, + (SELECT p0.id FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_id, + (SELECT CAST(p0.balance AS DECIMAL(65, 0)) FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_balance, + (SELECT n0.id FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_id, + (SELECT CAST(n0.balance AS DECIMAL(65, 0)) - CAST(n0.price AS DECIMAL(65, 0)) + FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_opening_balance +FROM pjcloudx_wallet.wallet_manage m +JOIN pjcloudx_wallet.wallet_transaction h + ON h.tenant_id = m.tenant_id AND h.wallet_id = m.id +WHERE m.tenant_id = @repair_tenant_id + AND @repair_lists_complete = 1 + AND FIND_IN_SET(CAST(m.id AS CHAR), @repair_wallet_ids) > 0 +GROUP BY m.tenant_id, m.id, m.user_id, m.balance, m.del_flag +) b +) c +ORDER BY c.repair_status, c.user_id, c.wallet_id; + +-- 6. 更新前明细:列出需要变化或存在异常的流水,old_balance可导出保存作为本次修复前快照。 +SELECT c.tenant_id, c.user_id, c.wallet_id, c.id AS transaction_id, c.create_time, + c.price, c.old_balance, c.new_balance, c.repair_status, c.anchor_mode, + c.wallet_has_invalid_new_balance, + CASE WHEN c.repair_status = 'READY' AND c.wallet_has_invalid_new_balance = 0 + AND NOT (c.old_balance <=> c.new_balance) THEN 1 ELSE 0 END AS will_update +FROM ( +SELECT r.*, + MAX(CASE WHEN r.new_balance IS NULL OR r.new_balance < -2147483648 + OR r.new_balance > 2147483647 THEN 1 ELSE 0 END) OVER ( + PARTITION BY r.tenant_id, r.wallet_id + ) AS wallet_has_invalid_new_balance +FROM ( +SELECT + t.id, + t.tenant_id, + t.user_id, + t.wallet_id, + t.create_time, + t.price, + t.balance AS old_balance, + a.current_wallet_balance, + a.opening_balance, + a.closing_balance, + a.repair_status, + a.anchor_mode, + a.opening_balance + SUM(CAST(t.price AS DECIMAL(65, 0))) OVER ( + PARTITION BY t.tenant_id, t.wallet_id + ORDER BY t.create_time ASC, t.id ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS new_balance +FROM ( +SELECT b.*, + CASE + WHEN b.current_wallet_balance IS NULL OR NOT (b.wallet_del_flag <=> '0') + THEN 'INVALID_WALLET' + WHEN COALESCE(FIND_IN_SET(CAST(b.user_id AS CHAR), @repair_user_ids), 0) = 0 + THEN 'OWNER_MISMATCH' + WHEN b.invalid_history_count > 0 THEN 'INVALID_HISTORY' + WHEN b.before_anchor_id IS NOT NULL AND NOT (b.before_anchor_balance <=> b.opening_balance) + THEN 'OPENING_MISMATCH' + WHEN b.after_anchor_id IS NOT NULL AND NOT (b.after_anchor_opening_balance <=> b.closing_balance) + THEN 'CLOSING_MISMATCH' + ELSE 'READY' + END AS repair_status, + CASE WHEN b.before_anchor_id IS NULL AND b.after_anchor_id IS NULL + THEN 'CURRENT_BALANCE_ONLY' ELSE 'BOUNDARY_CHECKED' END AS anchor_mode +FROM ( +SELECT + m.tenant_id, + m.id AS wallet_id, + m.user_id, + m.balance AS current_wallet_balance, + m.del_flag AS wallet_del_flag, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_start_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS opening_balance, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_end_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS closing_balance, + SUM(CASE WHEN h.create_time >= @repair_start_time AND h.create_time < @repair_end_time + AND h.del_flag = '0' THEN 1 ELSE 0 END) AS period_transaction_count, + SUM(CASE WHEN h.create_time IS NULL OR ( + h.create_time >= @repair_start_time AND ( + h.price IS NULL OR NOT (h.del_flag <=> '0') OR NOT (h.user_id <=> m.user_id) + )) THEN 1 ELSE 0 END) AS invalid_history_count, + (SELECT p0.id FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_id, + (SELECT CAST(p0.balance AS DECIMAL(65, 0)) FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_balance, + (SELECT n0.id FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_id, + (SELECT CAST(n0.balance AS DECIMAL(65, 0)) - CAST(n0.price AS DECIMAL(65, 0)) + FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_opening_balance +FROM pjcloudx_wallet.wallet_manage m +JOIN pjcloudx_wallet.wallet_transaction h + ON h.tenant_id = m.tenant_id AND h.wallet_id = m.id +WHERE m.tenant_id = @repair_tenant_id + AND @repair_lists_complete = 1 + AND FIND_IN_SET(CAST(m.id AS CHAR), @repair_wallet_ids) > 0 +GROUP BY m.tenant_id, m.id, m.user_id, m.balance, m.del_flag +) b +) a +JOIN pjcloudx_wallet.wallet_transaction t + ON t.tenant_id = a.tenant_id AND t.wallet_id = a.wallet_id +WHERE t.del_flag = '0' + AND t.create_time >= @repair_start_time + AND t.create_time < @repair_end_time +) r +) c +WHERE NOT (c.old_balance <=> c.new_balance) + OR c.repair_status <> 'READY' OR c.wallet_has_invalid_new_balance = 1 +ORDER BY c.user_id, c.wallet_id, c.create_time, c.id; + +-- 7. 只修改通过钱包校验且新余额未溢出的区间流水balance。 +-- 计算源涵盖该用户各钱包的全部区间业务,不按离线备注筛选待更新明细。 +UPDATE pjcloudx_wallet.wallet_transaction target +JOIN ( +SELECT r.*, + MAX(CASE WHEN r.new_balance IS NULL OR r.new_balance < -2147483648 + OR r.new_balance > 2147483647 THEN 1 ELSE 0 END) OVER ( + PARTITION BY r.tenant_id, r.wallet_id + ) AS wallet_has_invalid_new_balance +FROM ( +SELECT + t.id, + t.tenant_id, + t.user_id, + t.wallet_id, + t.create_time, + t.price, + t.balance AS old_balance, + a.current_wallet_balance, + a.opening_balance, + a.closing_balance, + a.repair_status, + a.anchor_mode, + a.opening_balance + SUM(CAST(t.price AS DECIMAL(65, 0))) OVER ( + PARTITION BY t.tenant_id, t.wallet_id + ORDER BY t.create_time ASC, t.id ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS new_balance +FROM ( +SELECT b.*, + CASE + WHEN b.current_wallet_balance IS NULL OR NOT (b.wallet_del_flag <=> '0') + THEN 'INVALID_WALLET' + WHEN COALESCE(FIND_IN_SET(CAST(b.user_id AS CHAR), @repair_user_ids), 0) = 0 + THEN 'OWNER_MISMATCH' + WHEN b.invalid_history_count > 0 THEN 'INVALID_HISTORY' + WHEN b.before_anchor_id IS NOT NULL AND NOT (b.before_anchor_balance <=> b.opening_balance) + THEN 'OPENING_MISMATCH' + WHEN b.after_anchor_id IS NOT NULL AND NOT (b.after_anchor_opening_balance <=> b.closing_balance) + THEN 'CLOSING_MISMATCH' + ELSE 'READY' + END AS repair_status, + CASE WHEN b.before_anchor_id IS NULL AND b.after_anchor_id IS NULL + THEN 'CURRENT_BALANCE_ONLY' ELSE 'BOUNDARY_CHECKED' END AS anchor_mode +FROM ( +SELECT + m.tenant_id, + m.id AS wallet_id, + m.user_id, + m.balance AS current_wallet_balance, + m.del_flag AS wallet_del_flag, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_start_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS opening_balance, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_end_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS closing_balance, + SUM(CASE WHEN h.create_time >= @repair_start_time AND h.create_time < @repair_end_time + AND h.del_flag = '0' THEN 1 ELSE 0 END) AS period_transaction_count, + SUM(CASE WHEN h.create_time IS NULL OR ( + h.create_time >= @repair_start_time AND ( + h.price IS NULL OR NOT (h.del_flag <=> '0') OR NOT (h.user_id <=> m.user_id) + )) THEN 1 ELSE 0 END) AS invalid_history_count, + (SELECT p0.id FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_id, + (SELECT CAST(p0.balance AS DECIMAL(65, 0)) FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_balance, + (SELECT n0.id FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_id, + (SELECT CAST(n0.balance AS DECIMAL(65, 0)) - CAST(n0.price AS DECIMAL(65, 0)) + FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_opening_balance +FROM pjcloudx_wallet.wallet_manage m +JOIN pjcloudx_wallet.wallet_transaction h + ON h.tenant_id = m.tenant_id AND h.wallet_id = m.id +WHERE m.tenant_id = @repair_tenant_id + AND @repair_lists_complete = 1 + AND FIND_IN_SET(CAST(m.id AS CHAR), @repair_wallet_ids) > 0 +GROUP BY m.tenant_id, m.id, m.user_id, m.balance, m.del_flag +) b +) a +JOIN pjcloudx_wallet.wallet_transaction t + ON t.tenant_id = a.tenant_id AND t.wallet_id = a.wallet_id +WHERE t.del_flag = '0' + AND t.create_time >= @repair_start_time + AND t.create_time < @repair_end_time +) r +) c ON c.id = target.id AND c.tenant_id = target.tenant_id +SET target.balance = CAST(c.new_balance AS SIGNED) +WHERE target.tenant_id = @repair_tenant_id AND target.del_flag = '0' + AND target.create_time >= @repair_start_time AND target.create_time < @repair_end_time + AND c.repair_status = 'READY' AND c.wallet_has_invalid_new_balance = 0 + AND target.wallet_id = c.wallet_id AND target.user_id = c.user_id + AND target.price = c.price AND target.create_time = c.create_time + AND target.balance <=> c.old_balance + AND NOT (target.balance <=> c.new_balance); +SELECT ROW_COUNT() AS updated_transaction_rows; + +-- 8. 提交前复核:remaining_repairable_rows应为0;异常钱包单独统计,未被强行改写。 +SET @repair_checked_wallets = NULL, @repair_remaining_rows = NULL, @repair_skipped_wallets = NULL; +SELECT + COUNT(DISTINCT CASE WHEN c.repair_status = 'READY' + AND c.wallet_has_invalid_new_balance = 0 THEN c.wallet_id END) AS checked_wallets, + COALESCE(SUM(CASE WHEN c.repair_status = 'READY' AND c.wallet_has_invalid_new_balance = 0 + AND NOT (c.old_balance <=> c.new_balance) THEN 1 ELSE 0 END), 0) AS remaining_repairable_rows, + COUNT(DISTINCT CASE WHEN c.repair_status <> 'READY' + OR c.wallet_has_invalid_new_balance = 1 THEN c.wallet_id END) AS skipped_wallets +INTO @repair_checked_wallets, @repair_remaining_rows, @repair_skipped_wallets +FROM ( +SELECT r.*, + MAX(CASE WHEN r.new_balance IS NULL OR r.new_balance < -2147483648 + OR r.new_balance > 2147483647 THEN 1 ELSE 0 END) OVER ( + PARTITION BY r.tenant_id, r.wallet_id + ) AS wallet_has_invalid_new_balance +FROM ( +SELECT + t.id, + t.tenant_id, + t.user_id, + t.wallet_id, + t.create_time, + t.price, + t.balance AS old_balance, + a.current_wallet_balance, + a.opening_balance, + a.closing_balance, + a.repair_status, + a.anchor_mode, + a.opening_balance + SUM(CAST(t.price AS DECIMAL(65, 0))) OVER ( + PARTITION BY t.tenant_id, t.wallet_id + ORDER BY t.create_time ASC, t.id ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS new_balance +FROM ( +SELECT b.*, + CASE + WHEN b.current_wallet_balance IS NULL OR NOT (b.wallet_del_flag <=> '0') + THEN 'INVALID_WALLET' + WHEN COALESCE(FIND_IN_SET(CAST(b.user_id AS CHAR), @repair_user_ids), 0) = 0 + THEN 'OWNER_MISMATCH' + WHEN b.invalid_history_count > 0 THEN 'INVALID_HISTORY' + WHEN b.before_anchor_id IS NOT NULL AND NOT (b.before_anchor_balance <=> b.opening_balance) + THEN 'OPENING_MISMATCH' + WHEN b.after_anchor_id IS NOT NULL AND NOT (b.after_anchor_opening_balance <=> b.closing_balance) + THEN 'CLOSING_MISMATCH' + ELSE 'READY' + END AS repair_status, + CASE WHEN b.before_anchor_id IS NULL AND b.after_anchor_id IS NULL + THEN 'CURRENT_BALANCE_ONLY' ELSE 'BOUNDARY_CHECKED' END AS anchor_mode +FROM ( +SELECT + m.tenant_id, + m.id AS wallet_id, + m.user_id, + m.balance AS current_wallet_balance, + m.del_flag AS wallet_del_flag, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_start_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS opening_balance, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_end_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS closing_balance, + SUM(CASE WHEN h.create_time >= @repair_start_time AND h.create_time < @repair_end_time + AND h.del_flag = '0' THEN 1 ELSE 0 END) AS period_transaction_count, + SUM(CASE WHEN h.create_time IS NULL OR ( + h.create_time >= @repair_start_time AND ( + h.price IS NULL OR NOT (h.del_flag <=> '0') OR NOT (h.user_id <=> m.user_id) + )) THEN 1 ELSE 0 END) AS invalid_history_count, + (SELECT p0.id FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_id, + (SELECT CAST(p0.balance AS DECIMAL(65, 0)) FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_balance, + (SELECT n0.id FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_id, + (SELECT CAST(n0.balance AS DECIMAL(65, 0)) - CAST(n0.price AS DECIMAL(65, 0)) + FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_opening_balance +FROM pjcloudx_wallet.wallet_manage m +JOIN pjcloudx_wallet.wallet_transaction h + ON h.tenant_id = m.tenant_id AND h.wallet_id = m.id +WHERE m.tenant_id = @repair_tenant_id + AND @repair_lists_complete = 1 + AND FIND_IN_SET(CAST(m.id AS CHAR), @repair_wallet_ids) > 0 +GROUP BY m.tenant_id, m.id, m.user_id, m.balance, m.del_flag +) b +) a +JOIN pjcloudx_wallet.wallet_transaction t + ON t.tenant_id = a.tenant_id AND t.wallet_id = a.wallet_id +WHERE t.del_flag = '0' + AND t.create_time >= @repair_start_time + AND t.create_time < @repair_end_time +) r +) c; + +SELECT @repair_checked_wallets AS checked_wallets, + @repair_remaining_rows AS remaining_repairable_rows, + @repair_skipped_wallets AS skipped_wallets; + +-- 只有列表完整且可修复差异全部清零才提交,否则整批回滚;检查本身失败时NULL也会走回滚。 +SET @repair_transaction_action = IF(@repair_lists_complete = 1 AND @repair_remaining_rows = 0, + 'COMMIT', 'ROLLBACK'); +PREPARE repair_transaction_stmt FROM @repair_transaction_action; +EXECUTE repair_transaction_stmt; +DEALLOCATE PREPARE repair_transaction_stmt; +SELECT @repair_transaction_action AS transaction_result; +SET SESSION group_concat_max_len = @repair_saved_group_concat_max_len; + +-- 仅生成此脚本不代表已修复线上数据;执行后保留人数、钱包校验、更新前明细、更新行数及提交前复核结果。 +-- 可重复执行:计算只依赖真实余额和price,已修复行的新balance相同,再次运行不会重复扣款或累加。 diff --git a/pjcloud-thirdparty/docs/aggregate-h5.md b/pjcloud-thirdparty/docs/aggregate-h5.md index 35019255..89b4d66c 100644 --- a/pjcloud-thirdparty/docs/aggregate-h5.md +++ b/pjcloud-thirdparty/docs/aggregate-h5.md @@ -7,6 +7,30 @@ ```yaml app-plus: pay: + # 原配置键保留:网页/移动应用,供既有 App 支付、当面付及历史交易使用。 + ali-pay-config: + app-id: ${ALIPAY_WEB_APP_ID} + app-private-key: ${ALIPAY_WEB_APP_PRIVATE_KEY} + alipay-public-key: ${ALIPAY_WEB_PUBLIC_KEY} + base-callback-url: ${THIRD_PUBLIC_BASE_URL} # 网关公网地址,包含实际 /api 子路径 + pay-callback-url: /third/third-app-pay/app/pay-callback + refund-callback-url: /third/third-app-pay/app/refund-callback + # 小程序应用:聚合 OAuth、身份交换和 JSAPI 下单必须使用同一组凭据。 + ali-pay-mini-config: + app-id: ${ALIPAY_MINI_APP_ID} + app-private-key: ${ALIPAY_MINI_APP_PRIVATE_KEY} + alipay-public-key: ${ALIPAY_MINI_PUBLIC_KEY} + base-callback-url: ${THIRD_PUBLIC_BASE_URL} + pay-callback-url: /third/third-app-pay/app/pay-callback + refund-callback-url: /third/third-app-pay/app/refund-callback + # 第三方应用独立预留,当前不参与 App/聚合新下单;不用时整组省略。 + # ali-pay-third-party-config: + # app-id: ${ALIPAY_THIRD_PARTY_APP_ID} + # app-private-key: ${ALIPAY_THIRD_PARTY_APP_PRIVATE_KEY} + # alipay-public-key: ${ALIPAY_THIRD_PARTY_PUBLIC_KEY} + # base-callback-url: ${THIRD_PUBLIC_BASE_URL} + # pay-callback-url: /third/third-app-pay/app/pay-callback + # refund-callback-url: /third/third-app-pay/app/refund-callback aggregate: h5-base-url: https://baac9a029ea84f36ba28c023fed4fc5f.hd.takin.cc public-route-prefix: /third # 直接使用 Boot 服务时设为 "" @@ -19,6 +43,10 @@ app-plus: `h5-base-url` 只接受 HTTPS 基础地址,不能带查询参数、Hash 或用户信息。可以包含实际部署子路径;H5 的路由 base 需对应配置。 +支付宝三类应用各自使用配套的 AppID、应用私钥和支付宝公钥;不同组不能填写重复 AppID,也不能只更换 AppID 而沿用另一应用的密钥。聚合支付缺少小程序组时明确拒绝,不回退到旧 `ali-pay-config`;只启用微信的商户不要求配置支付宝小程序组。应用客户端在服务进程中缓存,更新应用凭据后需重启 third 的全部实例。 + +新支付单在已有 `mch_pay_setting_json` 中保存非敏感 `alipayAppId`,查单、撤销、退款、退款查询及结算/分账按这个 AppID 选择原应用;支付与退款回调先按已登记 AppID 验签,再校验是否与支付单一致。旧快照没有该标识时继续使用原网页/移动应用,不能把所有历史聚合订单直接切换到新小程序应用。本次不增加数据库字段。 + 仅在网关/反向代理**覆盖客户端传入的 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、网关部署或平台控制台。 @@ -29,13 +57,15 @@ H5 只使用项目根目录一个 `.env`,变量命名沿用参考 App:开发 - 微信沿用服务商统一公众号 OAuth 与 JSAPI 下单配置,确认公众号网页授权域名对应现有后端 OAuth 回调域名。 - 将新 H5 的实际页面目录核对加入微信支付 JSAPI 支付授权目录;Hash 部分不作为目录填写。当前根目录部署对应 `https://baac9a029ea84f36ba28c023fed4fc5f.hd.takin.cc/`。 -- 支付宝沿用现有应用和固定 OAuth 回调 `/third/aggregate-order-pay/oauth/alipay`,租户通过一次性 state 恢复。不要把独立 H5 地址替换为后端回调地址。 +- 支付宝聚合入口使用 `ali-pay-mini-config` 的 AppID 与回调基础地址,固定 OAuth 回调仍为 `/third/aggregate-order-pay/oauth/alipay`,租户通过一次性 state 恢复;需在对应小程序应用核对授权回调配置。不要把独立 H5 地址替换为后端回调地址。 - 两种渠道继续使用商户既有支付配置、子商户和默认分佣关系快照,配置需已具备相应 OAuth/JSAPI 权限。仅域名能访问不代表上述平台配置已完成。 支付宝 H5 通过 `alipay.trade.create` 创建交易并返回 `tradeNO`,前端使用 `AlipayJSBridge.tradePay` 调起支付。直付通结算参数沿用现有 `ThirdAppPlusAliPayServiceImpl.appPay`:始终携带 `sub_merchant.merchant_id`,以及 `settle_info.settle_period_time=1d` 和一条 `defaultSettle` 结算明细;明细金额为交易全额,单位为元,`trans_in` 留空。关闭平台抽佣仍保留这组结算参数,不在创建交易时扣减平台分佣。 产品码保持当前 JSAPI 场景的 `JSAPI_PAY`,商户授权 Token 继续按已有关系快照和抽佣规则传递。`1d` 账期只在商户签约账期结算模式时有效;补齐结算参数后仍需验证应用及商户权限,不能据此认定 `40006 / isv.insufficient-isv-permissions` 已解除。 +此次拆分只改变应用凭据选择,不创建支付宝小程序页面,也不把 H5 转换为小程序。仍需由对应应用具备实际运行场景的 OAuth、`alipay.trade.create` 和支付调起能力,并在客户端联调确认。直付通关系管理仍归原应用;复用二级商户前需确认两应用属于同一服务商且渠道允许使用该 smid。已有 `app_auth_token` 也不能视为自动适用于新应用,开启代调用时应核对其应用授权范围;配置凭据不代表新增渠道授权。 + ## 发布顺序与兼容 1. 先确认统一支付基线迁移 `db/20260825_third_payment_schema.sql` 已完成;已完成的环境无需重跑。双场景升级单独执行新文件 [`db/20261020_add_aggregate_pay_scene.sql`](../../db/20261020_add_aggregate_pay_scene.sql),不得只靠修改历史迁移升级现有库。 diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java index 5c01d96c..7792d9d4 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java @@ -1,22 +1,68 @@ package com.bsd.pjcloud.thirdparty.config; +import cn.hutool.core.util.StrUtil; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; /** - * 小程序平台配置 + * App、聚合收银与小程序支付配置;支付宝按开放平台应用类型隔离凭据。 */ @Component @ConfigurationProperties(prefix = "app-plus.pay") @Data public class AppPlusPayConfig { + /** 支付单配置 JSON 中固定实际下单 AppID 的键;仅保存应用标识,不保存应用密钥。 */ + public static final String ALIPAY_APP_ID_SNAPSHOT_KEY = "alipayAppId"; + /** 微信服务商应用配置,沿用现有公众号与开放平台配置。 */ private WechatConfig wechatConfig; + /** 网页/移动应用凭据;保留 ali-pay-config 配置键及原访问方法,兼容既有 App 和历史订单。 */ private AliPayConfig AliPayConfig; + /** 小程序应用凭据,配置键为 ali-pay-mini-config;聚合 JSAPI 不得回退到网页/移动应用。 */ + private AliPayConfig aliPayMiniConfig; + + /** 第三方应用凭据,配置键为 ali-pay-third-party-config;独立预留,不自动替代支付应用。 */ + private AliPayConfig aliPayThirdPartyConfig; + + /** + * 读取聚合 OAuth 和 JSAPI 共用的小程序应用,缺少任一支付必需项时拒绝发起收银。 + * + * @return 完整的小程序应用凭据与支付回调地址 + */ + public AliPayConfig requireAliPayMiniConfig() { + if (aliPayMiniConfig == null || StrUtil.hasBlank(aliPayMiniConfig.getAppId(), aliPayMiniConfig.getAppPrivateKey(), + aliPayMiniConfig.getAlipayPublicKey(), aliPayMiniConfig.getBaseCallbackUrl(), aliPayMiniConfig.getPayCallbackUrl())) { + throw new IllegalStateException("支付宝小程序支付配置不完整,请配置 app-plus.pay.ali-pay-mini-config"); + } + // 同一 AppID 出现在多类配置中会导致回调无法唯一选取公钥,创建支付前即拒绝该配置。 + return findAliPayConfig(aliPayMiniConfig.getAppId()); + } + + /** + * 在服务端已登记的三类应用中按 AppID 精确选取凭据;未知或空 AppID 不使用默认应用。 + * + * @param appId 支付单快照或待验签通知中的应用标识;通知仍须验签并核对订单 + * @return 唯一匹配的应用;未知时返回 null,重复 AppID 配置时拒绝歧义 + */ + public AliPayConfig findAliPayConfig(String appId) { + if (StrUtil.isBlank(appId)) { + return null; + } + AliPayConfig matched = null; + for (AliPayConfig candidate : new AliPayConfig[]{AliPayConfig, aliPayMiniConfig, aliPayThirdPartyConfig}) { + if (candidate != null && appId.equals(candidate.getAppId())) { + if (matched != null) { + throw new IllegalStateException("支付宝不同应用配置的 AppID 重复,请分别配置对应应用凭据"); + } + matched = candidate; + } + } + return matched; + } @Data public static class WechatConfig { @@ -118,9 +164,11 @@ public class AppPlusPayConfig { } + /** 同一支付宝应用的完整凭据与回调配置,不允许只替换 AppID 而混用其他应用密钥。 */ @Data public static class AliPayConfig { + /** 支付宝开放平台的应用 ID,必须与当前组的应用私钥及支付宝公钥配套。 */ private String appId; /** 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 0cd63dcc..3f85c6bd 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 @@ -191,7 +191,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ } AlipayTradeCreateRequest request = new AlipayTradeCreateRequest(); - AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig(); + AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.requireAliPayMiniConfig(); request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl()); request.setBizModel(model); // 关闭分账不携带平台抽佣专属授权;开启分账且配置了代调用授权时才发送 token。 @@ -226,7 +226,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ throw new I18nBizException(ErrorCodes.THIRD_AGGREGATE_SUB_ORDER_SAVE_FAIL); } } catch (RuntimeException exception) { - thirdAppPlusAliPayService.cancel(context.getPayNo(), JSONUtil.toJsonStr(paySetting)); + thirdAppPlusAliPayService.cancel(context.getPayNo(), payOrder.getMchPaySettingJson()); log.error("|{}|支付宝JSAPI子订单保存失败,已执行最佳努力撤销,payNo:{}", TAG, context.getPayNo()); throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_JSAPI_CREATE_FAIL); } @@ -260,12 +260,12 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ /** * 获取延迟初始化的支付宝客户端。 * - * @return 使用现有应用密钥和支付宝公钥初始化的客户端 + * @return 使用小程序应用的 AppID、应用私钥和支付宝公钥初始化的客户端 */ protected synchronized AlipayClient getAlipayClient() { if (alipayClient == null) { try { - AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig(); + AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.requireAliPayMiniConfig(); AlipayConfig sdkConfig = new AlipayConfig(); sdkConfig.setServerUrl("https://openapi.alipay.com/gateway.do"); sdkConfig.setAppId(config.getAppId()); @@ -319,7 +319,9 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ payOrder.setPayNo(context.getPayNo()); payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); payOrder.setExpireTime(context.getExpireTime()); - payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(paySetting)); + // 使用现有 JSON 保存实际下单 AppID,后续查单、退款及回调均按原应用处理。 + payOrder.setMchPaySettingJson(JSONUtil.parseObj(JSONUtil.toJsonStr(paySetting)) + .set(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY, appPlusPayConfig.requireAliPayMiniConfig().getAppId()).toString()); return payOrder; } 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 17438105..5d38a661 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 @@ -391,7 +391,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay + "&response_type=code&scope=" + WECHAT_OAUTH_SCOPE + "&state=" + encode(state) + "#wechat_redirect"; } - return ALIPAY_OAUTH_URL + "?app_id=" + encode(appPlusPayConfig.getAliPayConfig().getAppId()) + return ALIPAY_OAUTH_URL + "?app_id=" + encode(appPlusPayConfig.requireAliPayMiniConfig().getAppId()) + "&scope=" + ALIPAY_OAUTH_SCOPE + "&redirect_uri=" + encode(callbackUrl) + "&state=" + encode(state); @@ -1211,10 +1211,8 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay * @param platform 当前启用的微信或支付宝平台 */ private void validateApplicationConfig(AppPlusPlatformTypeEnum platform) { - AppPlusPayConfig.AliPayConfig ali = appPlusPayConfig.getAliPayConfig(); - if (platform == AppPlusPlatformTypeEnum.ALI_PAY && (ali == null || StrUtil.hasBlank(ali.getAppId(), ali.getAppPrivateKey(), ali.getAlipayPublicKey(), - ali.getBaseCallbackUrl(), ali.getPayCallbackUrl()))) { - throw new RuntimeException("支付宝应用支付配置不完整"); + if (platform == AppPlusPlatformTypeEnum.ALI_PAY) { + appPlusPayConfig.requireAliPayMiniConfig(); } AppPlusPayConfig.WechatConfig wechat = appPlusPayConfig.getWechatConfig(); if (platform == AppPlusPlatformTypeEnum.WECHAT && (wechat == null || StrUtil.hasBlank(wechat.getGzhAppId(), wechat.getGzhAppSecret(), wechat.getMerchantId(), wechat.getMchPrivatePath(), @@ -1355,10 +1353,18 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay /** * 获取聚合二维码和状态查询共用的公网基础地址。 * - * @return 去除末尾斜杠的支付宝现有回调基础地址 + * @return 优先保持旧入口基础地址;未配置旧应用时使用已配置的小程序或微信回调基础地址 */ private String getPublicBaseUrl() { - return normalizeBaseUrl(appPlusPayConfig.getAliPayConfig().getBaseCallbackUrl()); + AppPlusPayConfig.AliPayConfig original = appPlusPayConfig.getAliPayConfig(); + if (original != null && StrUtil.isNotBlank(original.getBaseCallbackUrl())) { + return normalizeBaseUrl(original.getBaseCallbackUrl()); + } + AppPlusPayConfig.AliPayConfig mini = appPlusPayConfig.getAliPayMiniConfig(); + if (mini != null && StrUtil.isNotBlank(mini.getBaseCallbackUrl())) { + return normalizeBaseUrl(mini.getBaseCallbackUrl()); + } + return normalizeBaseUrl(appPlusPayConfig.getWechatConfig() == null ? null : appPlusPayConfig.getWechatConfig().getBaseCallbackUrl()); } /** @@ -1371,7 +1377,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.WECHAT)) { return normalizeBaseUrl(appPlusPayConfig.getWechatConfig().getBaseCallbackUrl()); } - return getPublicBaseUrl(); + return normalizeBaseUrl(appPlusPayConfig.requireAliPayMiniConfig().getBaseCallbackUrl()); } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java index 20312eaa..46b8eb90 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java @@ -83,12 +83,13 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.BiConsumer; /** - * 支付宝小程序(app版本) + * 支付宝网页/移动应用支付,以及按原应用处理的多应用交易生命周期。 */ @Service @Validated @@ -136,6 +137,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService */ private volatile AlipayClient alipayClient; + /** 非默认支付宝应用的 SDK 客户端,按已登记 AppID 缓存;仅在同步方法内读写。 */ + private final Map applicationClients = new HashMap<>(); + /** * 支付宝应用级配置,来源于现有 app-plus.pay 配置中心。 */ @@ -181,10 +185,13 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 构建支付宝 SDK 客户端配置,不输出或复制任何密钥到日志。 * - * @return 当前应用的支付宝 SDK 配置 + * @param aliPayConfig 已按支付单 AppID 选定的服务端应用配置 + * @return 指定应用的支付宝 SDK 配置 */ - private AlipayConfig getAlipayConfig() { - AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig(); + private AlipayConfig getAlipayConfig(AppPlusPayConfig.AliPayConfig aliPayConfig) { + if (aliPayConfig == null || StrUtil.hasBlank(aliPayConfig.getAppId(), aliPayConfig.getAppPrivateKey(), aliPayConfig.getAlipayPublicKey())) { + throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_APP_CONFIG_INCOMPLETE); + } AlipayConfig alipayConfig = new AlipayConfig(); alipayConfig.setServerUrl("https://openapi.alipay.com/gateway.do"); alipayConfig.setAppId(aliPayConfig.getAppId()); @@ -206,7 +213,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService protected synchronized AlipayClient getAlipayClient() { if (alipayClient == null) { try { - alipayClient = new DefaultAlipayClient(getAlipayConfig()); + alipayClient = new DefaultAlipayClient(getAlipayConfig(appPlusPayConfig.getAliPayConfig())); } catch (Exception e) { log.error("初始化支付宝客户端失败", e); throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_CLIENT_INIT_FAIL); @@ -215,6 +222,74 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService return alipayClient; } + /** + * 读取支付单固定应用,历史无应用标识的快照继续使用原网页/移动应用。 + * + * @param settingJson 支付单创建时保存的商户配置 JSON,仅应用标识参与凭据选择 + * @return 已登记应用;显式标识未知或为空时拒绝回退,防止对错误应用查单或退款 + */ + private AppPlusPayConfig.AliPayConfig resolveOrderAliPayConfig(String settingJson) { + JSONObject snapshot = StrUtil.isBlank(settingJson) ? JSONUtil.createObj() : JSONUtil.parseObj(settingJson); + if (!snapshot.containsKey(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY)) { + if (appPlusPayConfig.getAliPayConfig() == null) { + throw new IllegalStateException("历史支付宝订单的网页/移动应用配置不存在"); + } + return appPlusPayConfig.getAliPayConfig(); + } + AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.findAliPayConfig(snapshot.getStr(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY)); + if (config == null) { + throw new IllegalStateException("支付单原支付宝应用未配置,不能使用其他应用处理"); + } + return config; + } + + /** + * 按支付单固定应用取得 SDK 客户端,不根据当前默认应用重定向历史交易。 + * + * @param settingJson 创建时保存的商户和应用标识快照 + * @return 对应应用的客户端 + */ + private AlipayClient getOrderAlipayClient(String settingJson) { + return getAlipayClient(resolveOrderAliPayConfig(settingJson)); + } + + /** + * 缓存已登记应用的客户端;默认应用沿用原方法,保留 App 链路及现有测试扩展点。 + * + * @param config 从可信服务端配置选出的完整应用凭据 + * @return 对应 AppID 的 SDK 客户端 + */ + protected synchronized AlipayClient getAlipayClient(AppPlusPayConfig.AliPayConfig config) { + if (config == appPlusPayConfig.getAliPayConfig()) { + return getAlipayClient(); + } + AlipayClient client = applicationClients.get(config.getAppId()); + if (client == null) { + try { + client = new DefaultAlipayClient(getAlipayConfig(config)); + applicationClients.put(config.getAppId(), client); + } catch (Exception exception) { + throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_CLIENT_INIT_FAIL); + } + } + return client; + } + + /** + * 固定网页/移动应用 AppID 到新 App/订单码支付单,不将应用密钥写入订单快照。 + * + * @param setting 已由服务端关系编排生成的商户配置 + * @return 包含原有商户字段和实际下单 AppID 的独立 JSON + */ + private String buildApplicationSnapshot(MchPaySettingVo setting) { + AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig(); + if (config == null || StrUtil.isBlank(config.getAppId())) { + throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_APP_CONFIG_INCOMPLETE); + } + appPlusPayConfig.findAliPayConfig(config.getAppId()); + return JSONUtil.parseObj(JSONUtil.toJsonStr(setting)).set(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY, config.getAppId()).toString(); + } + /** * 仅在配置了代调用授权令牌时加入公共参数,避免把空 token 发送给支付宝。 * @@ -279,12 +354,13 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 解析平台统一支付宝退款回调地址。 + * 按支付单实际应用解析支付宝退款回调地址。 * - * @return 完整退款回调地址 + * @param settingJson 原支付单的应用与商户配置快照 + * @return 原应用的完整退款回调地址 */ - private String resolveRefundNotifyUrl() { - AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig(); + private String resolveRefundNotifyUrl(String settingJson) { + AppPlusPayConfig.AliPayConfig config = resolveOrderAliPayConfig(settingJson); String baseUrl = config == null ? null : config.getBaseCallbackUrl(); String callbackUrl = config == null ? null : config.getRefundCallbackUrl(); if (StrUtil.hasBlank(baseUrl, callbackUrl)) { @@ -319,7 +395,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService model.setOutTradeNo(payNo); request.setBizModel(model); putAppAuthToken(request::putOtherTextParam, aliPaySetting); - return getAlipayClient().execute(request); + return getOrderAlipayClient(settingJson).execute(request); } catch (Exception e) { log.error("|{}|查询支付宝订单失败,payNo:{},exceptionType:{}", TAG, payNo, e.getClass().getSimpleName()); } @@ -412,7 +488,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService model.setOutTradeNo(payNo); request.setBizModel(model); putAppAuthToken(request::putOtherTextParam, aliPaySetting); - return getAlipayClient().execute(request); + return getOrderAlipayClient(settingJson).execute(request); } catch (Exception exception) { log.error("|{}|撤销支付宝订单失败,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName()); return null; @@ -446,7 +522,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService payOrder.setPayNo(dto.getOutTradeNo()); payOrder.setExpireTime(now.plusMinutes(15)); payOrder.setMchId(dto.getMchId()); - payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + payOrder.setMchPaySettingJson(buildApplicationSnapshot(setting)); payOrder.setFzRelationId(dto.getFzRelationId()); validateFzRatioAndRelation(dto, setting); if (dto.getAmount() == null || dto.getAmount() <= 0) { @@ -551,7 +627,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService updateObj.setIsBizCallback(com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode()); AlipayTradeRefundRequest request = new AlipayTradeRefundRequest(); - request.setNotifyUrl(resolveRefundNotifyUrl()); + request.setNotifyUrl(resolveRefundNotifyUrl(thirdPayOrder.getMchPaySettingJson())); AlipayTradeRefundModel model = new AlipayTradeRefundModel(); model.setOutTradeNo(thirdPayOrder.getPayNo()); @@ -615,7 +691,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } } try { - AlipayTradeRefundResponse response = getAlipayClient().execute(request); + AlipayTradeRefundResponse response = getOrderAlipayClient(thirdPayOrder.getMchPaySettingJson()).execute(request); updateObj.setRefundRes(response == null ? null : response.getBody()); // 退款接口返回 10000 只表示请求已受理,最终结果必须通过退款查询或回调确认。 if (response != null && !response.isSuccess() && !isRetryableAlipayError(response.getSubCode())) { @@ -658,7 +734,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService AlipayTradeFastpayRefundQueryRequest request = new AlipayTradeFastpayRefundQueryRequest(); request.setBizModel(model); putAppAuthToken(request::putOtherTextParam, aliPaySetting); - AlipayTradeFastpayRefundQueryResponse response = getAlipayClient().execute(request); + AlipayTradeFastpayRefundQueryResponse response = getOrderAlipayClient(payOrder.getMchPaySettingJson()).execute(request); if (isAlipayRefundQueryMissing(response)) { AlipayTradeRefundResponse retryResponse = retryAlipayRefund(payOrder, aliPaySetting); // 重提成功只表示支付宝受理;网络异常或系统错误的结果仍未知,下一轮必须继续查询。 @@ -714,9 +790,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } AlipayTradeRefundRequest retryRequest = new AlipayTradeRefundRequest(); retryRequest.setBizModel(retryModel); - retryRequest.setNotifyUrl(resolveRefundNotifyUrl()); + retryRequest.setNotifyUrl(resolveRefundNotifyUrl(payOrder.getMchPaySettingJson())); putAppAuthToken(retryRequest::putOtherTextParam, aliPaySetting); - AlipayTradeRefundResponse retryResponse = getAlipayClient().execute(retryRequest); + AlipayTradeRefundResponse retryResponse = getOrderAlipayClient(payOrder.getMchPaySettingJson()).execute(retryRequest); ThirdPayOrderEntity update = new ThirdPayOrderEntity(); update.setRefundRes(retryResponse == null ? null : retryResponse.getBody()); thirdPayOrderService.update(update, new LambdaUpdateWrapper() @@ -1241,7 +1317,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService stage.set("outRequestNo", outRequestNo); stage.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(model))); try { - AlipayTradeSettleConfirmResponse response = getAlipayClient().execute(request); + AlipayTradeSettleConfirmResponse response = getOrderAlipayClient(payOrder.getMchPaySettingJson()).execute(request); stage.set("response", response == null ? null : JSONUtil.parseObj(JSONUtil.toJsonStr(response))); stage.set("externalStatus", response == null ? "EMPTY_RESPONSE" : response.getSubCode()); stage.set("status", resolveSettleConfirmStatus(response)); @@ -1323,7 +1399,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService stage.set("outRequestNo", outRequestNo); stage.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(model))); try { - AlipayTradeOrderSettleResponse response = getAlipayClient().execute(request); + AlipayTradeOrderSettleResponse response = getOrderAlipayClient(payOrder.getMchPaySettingJson()).execute(request); stage.set("response", response == null ? null : JSONUtil.parseObj(JSONUtil.toJsonStr(response))); stage.set("externalStatus", response == null ? "EMPTY_RESPONSE" : response.getSubCode()); stage.set("status", resolveRoyaltySubmissionStatus(response)); @@ -1447,7 +1523,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService putAppAuthToken(request::putOtherTextParam, aliPaySetting); stage.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(model))); try { - AlipayTradeOrderSettleQueryResponse response = getAlipayClient().execute(request); + AlipayTradeOrderSettleQueryResponse response = getOrderAlipayClient(payOrder.getMchPaySettingJson()).execute(request); stage.set("response", response == null ? null : JSONUtil.parseObj(JSONUtil.toJsonStr(response))); stage.set("externalStatus", resolveRoyaltyExternalStatus(response)); String status = resolveRoyaltyQueryStatus(response); @@ -1708,16 +1784,16 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 校验支付宝回调应用与平台统一支付宝账号一致。 + * 校验支付宝回调应用与原支付单固定应用一致;历史无 AppID 快照使用旧应用。 * * @param callbackAppId 回调中的支付宝应用 ID * @param payOrder 回调对应的支付订单 - * @return 回调应用与平台统一账号完全一致时返回 {@code true} + * @return 回调应用与支付单固定应用完全一致且商户快照有效时返回 {@code true} */ private boolean validateCallbackAccount(String callbackAppId, ThirdPayOrderEntity payOrder) { try { parseAliPaySettingSnapshot(payOrder.getMchPaySettingJson()); - AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig(); + AppPlusPayConfig.AliPayConfig config = resolveOrderAliPayConfig(payOrder.getMchPaySettingJson()); return config != null && ObjectUtil.equals(config.getAppId(), callbackAppId); } catch (Exception exception) { return false; @@ -1730,7 +1806,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService @Override public ThirdALiPayCallbackVo miniSubscribeCallback(Map params, String body, Map headers) { try { - boolean flag = AlipaySignature.rsaCheckV1(params, appPlusPayConfig.getAliPayConfig().getAlipayPublicKey(), "UTF-8", "RSA2"); + AppPlusPayConfig.AliPayConfig config = resolveCallbackAliPayConfig(params.get("app_id")); + boolean flag = config != null && AlipaySignature.rsaCheckV1(params, config.getAlipayPublicKey(), "UTF-8", "RSA2"); if (!flag) return null; String bizContent = params.get("biz_content"); String msgMethod = params.get("msg_method"); @@ -1779,7 +1856,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService payOrder.setIsBizCallback(com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode()); payOrder.setPayNo(dto.getOutTradeNo()); payOrder.setMchId(dto.getMchId()); - payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + payOrder.setMchPaySettingJson(buildApplicationSnapshot(setting)); payOrder.setFzRelationId(dto.getFzRelationId()); payOrder.setEnableFz(dto.getFzRelationId() != null ? com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode() @@ -1894,17 +1971,13 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 按回调 app_id 核对平台统一支付宝账号,避免使用错误公钥验签。 + * 按回调 app_id 选取已登记应用的支付宝公钥;验签后仍须匹配订单固定 AppID。 * * @param appId 支付宝回调中的应用 ID - * @return 应用 ID 与平台账号一致时返回配置,否则返回 {@code null} + * @return 应用 ID 唯一匹配时返回对应配置,未知应用返回 {@code null} */ private AppPlusPayConfig.AliPayConfig resolveCallbackAliPayConfig(String appId) { - AppPlusPayConfig.AliPayConfig defaults = appPlusPayConfig.getAliPayConfig(); - if (defaults == null || StrUtil.isBlank(appId)) { - return null; - } - return ObjectUtil.equals(defaults.getAppId(), appId) ? defaults : null; + return appPlusPayConfig.findAliPayConfig(appId); } /** @@ -1916,15 +1989,16 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService */ private boolean validatePayCallbackOrder(Map params, ThirdPayOrderEntity payOrder) { String payNo = payOrder.getPayNo(); - MchPaySettingEntity.AliPaySetting aliPaySetting; + AppPlusPayConfig.AliPayConfig expectedConfig; try { - aliPaySetting = StrUtil.isBlank(payOrder.getMchPaySettingJson()) - ? null : parseAliPaySettingSnapshot(payOrder.getMchPaySettingJson()); + if (StrUtil.isNotBlank(payOrder.getMchPaySettingJson())) { + parseAliPaySettingSnapshot(payOrder.getMchPaySettingJson()); + } + expectedConfig = resolveOrderAliPayConfig(payOrder.getMchPaySettingJson()); } catch (Exception exception) { log.warn("|{}|支付宝支付回调配置快照无效,payNo:{}", TAG, payNo); return false; } - AppPlusPayConfig.AliPayConfig expectedConfig = appPlusPayConfig.getAliPayConfig(); if (expectedConfig == null || ObjectUtil.notEqual(params.get("app_id"), expectedConfig.getAppId())) { log.warn("|{}|支付宝支付回调appId不匹配,payNo:{}", TAG, payNo); return false; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java index 27a0bd97..234264e2 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java @@ -82,19 +82,26 @@ class ThirdAggregateAliPayServiceImplTest { */ private MchPaySettingVo paySetting; + /** 相互隔离的网页/移动与小程序应用配置,用于验证不发生凭据回退。 */ + private AppPlusPayConfig appPlusPayConfig; + /** * 初始化应用配置、支付上下文和商户授权 Token。 */ @BeforeEach void setUp() { - AppPlusPayConfig appPlusPayConfig = new AppPlusPayConfig(); + appPlusPayConfig = new AppPlusPayConfig(); AppPlusPayConfig.AliPayConfig aliPayConfig = new AppPlusPayConfig.AliPayConfig(); - aliPayConfig.setAppId("2026000000000001"); + aliPayConfig.setAppId("2026000000000002"); aliPayConfig.setAppPrivateKey("test-private-key"); aliPayConfig.setAlipayPublicKey("test-public-key"); - aliPayConfig.setBaseCallbackUrl("https://pay.example.com"); + aliPayConfig.setBaseCallbackUrl("https://mini-pay.example.com"); aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback"); - appPlusPayConfig.setAliPayConfig(aliPayConfig); + appPlusPayConfig.setAliPayMiniConfig(aliPayConfig); + AppPlusPayConfig.AliPayConfig webApplication = new AppPlusPayConfig.AliPayConfig(); + webApplication.setAppId("2026000000000001"); + webApplication.setBaseCallbackUrl("https://pay.example.com"); + appPlusPayConfig.setAliPayConfig(webApplication); service = new TestableThirdAggregateAliPayService(appPlusPayConfig, thirdPayOrderService, thirdAppPlusAliPayService, alipayClient); ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService); @@ -164,6 +171,7 @@ class ThirdAggregateAliPayServiceImplTest { Assertions.assertEquals("alipay-open-id", model.getBuyerOpenId()); Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token")); Assertions.assertEquals("2026000000000002", result.getPayParams().get("tradeNO")); + Assertions.assertEquals("https://mini-pay.example.com/third-app-pay/app/pay-callback", request.getNotifyUrl()); assertDirectPaySettlement(request, "12.34"); ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); @@ -172,6 +180,21 @@ class ThirdAggregateAliPayServiceImplTest { Assertions.assertEquals(context.getExpireTime(), orderCaptor.getValue().getExpireTime()); Assertions.assertFalse(orderCaptor.getValue().getPayRep().contains("merchant-auth-token")); Assertions.assertFalse(orderCaptor.getValue().getPayRep().contains("alipay-open-id")); + JSONObject snapshot = JSONUtil.parseObj(orderCaptor.getValue().getMchPaySettingJson()); + Assertions.assertEquals("2026000000000002", snapshot.getStr(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY)); + Assertions.assertFalse(snapshot.containsKey("appPrivateKey")); + } + + /** 缺少小程序凭据时禁止使用已配置的网页/移动应用替代 JSAPI 下单。 */ + @Test + void createPayShouldRejectMissingMiniApplication() throws Exception { + appPlusPayConfig.setAliPayMiniConfig(null); + + Assertions.assertThrows(IllegalStateException.class, + () -> service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity("payer", null))); + + verify(alipayClient, never()).execute(any(AlipayTradeCreateRequest.class)); + verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class)); } /** @@ -291,7 +314,10 @@ class ThirdAggregateAliPayServiceImplTest { Assertions.assertThrows(RuntimeException.class, () -> service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null))); - verify(thirdAppPlusAliPayService).cancel(eq(context.getPayNo()), any(String.class)); + ArgumentCaptor snapshotCaptor = ArgumentCaptor.forClass(String.class); + verify(thirdAppPlusAliPayService).cancel(eq(context.getPayNo()), snapshotCaptor.capture()); + Assertions.assertEquals("2026000000000002", JSONUtil.parseObj(snapshotCaptor.getValue()) + .getStr(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY)); } /** 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 c9a17899..3d6b94c9 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 @@ -42,10 +42,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -255,11 +258,51 @@ class ThirdAggregateOrderPayServiceImplTest { merchant.setPaySettingList(List.of(settings.get(1))); config.setWechatConfig(wechat); + config.setAliPayMiniConfig(null); config.setAliPayConfig(null); Assertions.assertNotNull(service.create(request)); verify(aggregatePaySessionService, times(2)).save(any(ThirdAggregatePaySessionEntity.class)); } + /** 旧键与新增两类配置必须通过真实 Spring 属性绑定分别落到对应字段。 */ + @Test + void alipayConfigurationShouldBindThreeApplicationTypes() { + Binder binder = new Binder(new MapConfigurationPropertySource(Map.of( + "app-plus.pay.ali-pay-config.app-id", "web-mobile-app", + "app-plus.pay.ali-pay-mini-config.app-id", "mini-app", + "app-plus.pay.ali-pay-third-party-config.app-id", "third-party-app"))); + + AppPlusPayConfig config = binder.bind("app-plus.pay", AppPlusPayConfig.class).get(); + + Assertions.assertEquals("web-mobile-app", config.getAliPayConfig().getAppId()); + Assertions.assertEquals("mini-app", config.getAliPayMiniConfig().getAppId()); + Assertions.assertEquals("third-party-app", config.getAliPayThirdPartyConfig().getAppId()); + } + + /** 应用组 AppID 重复会导致验签歧义,必须在进入小程序收银前拒绝。 */ + @Test + void duplicatedApplicationIdShouldRejectMiniCashier() { + AppPlusPayConfig config = completeApplicationConfig(); + config.getAliPayMiniConfig().setAppId(config.getAliPayConfig().getAppId()); + + Assertions.assertThrows(IllegalStateException.class, config::requireAliPayMiniConfig); + } + + /** 即使网页/移动应用配置完整,未配置小程序应用也不能创建支付宝聚合会话。 */ + @Test + void createShouldRejectMissingMiniApplicationWithoutFallback() { + AppPlusPayConfig config = (AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig"); + config.setAliPayMiniConfig(null); + 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)); + + IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, () -> service.create(request)); + + Assertions.assertTrue(exception.getMessage().contains("ali-pay-mini-config")); + verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class)); + } + /** 两个渠道都停用时拒绝创建,避免产生没有付款入口的会话。 */ @Test void createShouldRejectMerchantWithoutEnabledPlatform() { @@ -277,19 +320,20 @@ class ThirdAggregateOrderPayServiceImplTest { MchPaySettingVo ali = merchant.getPaySettingList().get(0); MchPaySettingVo wechat = merchant.getPaySettingList().get(1); ali.setEnable(YesNoEnum.NO.getCode()); - ((AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig")).setAliPayConfig(null); + ((AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig")).setAliPayMiniConfig(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); + AppPlusPayConfig config = (AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig"); + config.setWechatConfig(null); ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity(); when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); when(mchFzRelationService.findBoundDefault(ali)).thenReturn(relation); @@ -321,7 +365,9 @@ class ThirdAggregateOrderPayServiceImplTest { String oauthUrl = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10.6.80"); - Assertions.assertTrue(oauthUrl.contains("redirect_uri=https%3A%2F%2Fpay.example.com%2Fthird%2Faggregate-order-pay%2Foauth%2Falipay")); + Assertions.assertTrue(oauthUrl.contains("app_id=2026000000000002")); + Assertions.assertFalse(oauthUrl.contains("app_id=2026000000000001")); + Assertions.assertTrue(oauthUrl.contains("redirect_uri=https%3A%2F%2Fmini-pay.example.com%2Fthird%2Faggregate-order-pay%2Foauth%2Falipay")); Assertions.assertFalse(oauthUrl.contains("oauth%2Falipay%2Ftenant-code")); Assertions.assertTrue(oauthUrl.contains("state=dGVuYW50LWNvZGU.")); } @@ -429,6 +475,13 @@ class ThirdAggregateOrderPayServiceImplTest { ali.setBaseCallbackUrl("https://pay.example.com"); ali.setPayCallbackUrl("/third-app-pay/app/pay-callback"); config.setAliPayConfig(ali); + AppPlusPayConfig.AliPayConfig mini = new AppPlusPayConfig.AliPayConfig(); + mini.setAppId("2026000000000002"); + mini.setAppPrivateKey("mini-test-private-key"); + mini.setAlipayPublicKey("mini-test-public-key"); + mini.setBaseCallbackUrl("https://mini-pay.example.com"); + mini.setPayCallbackUrl("/third-app-pay/app/pay-callback"); + config.setAliPayMiniConfig(mini); AppPlusPayConfig.WechatConfig wechat = new AppPlusPayConfig.WechatConfig(); wechat.setGzhAppId("wx-service-jsapi-appid"); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java index bdf18cea..77090d58 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java @@ -13,18 +13,23 @@ import com.alipay.api.domain.AlipayTradeSettleConfirmModel; import com.alipay.api.domain.OpenApiRoyaltyDetailInfoPojo; import com.alipay.api.domain.RefundRoyaltyResult; import com.alipay.api.domain.RoyaltyDetail; +import com.alipay.api.internal.util.AlipaySignature; import com.alipay.api.request.AlipayTradeAppPayRequest; +import com.alipay.api.request.AlipayTradeCancelRequest; import com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest; import com.alipay.api.request.AlipayTradeOrderSettleRequest; import com.alipay.api.request.AlipayTradeOrderSettleQueryRequest; import com.alipay.api.request.AlipayTradePrecreateRequest; +import com.alipay.api.request.AlipayTradeQueryRequest; import com.alipay.api.request.AlipayTradeRefundRequest; import com.alipay.api.request.AlipayTradeSettleConfirmRequest; import com.alipay.api.response.AlipayTradeAppPayResponse; +import com.alipay.api.response.AlipayTradeCancelResponse; import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse; import com.alipay.api.response.AlipayTradeOrderSettleResponse; import com.alipay.api.response.AlipayTradeOrderSettleQueryResponse; import com.alipay.api.response.AlipayTradePrecreateResponse; +import com.alipay.api.response.AlipayTradeQueryResponse; import com.alipay.api.response.AlipayTradeRefundResponse; import com.alipay.api.response.AlipayTradeSettleConfirmResponse; import com.baomidou.mybatisplus.core.conditions.Wrapper; @@ -60,7 +65,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import java.security.KeyPair; +import java.security.KeyPairGenerator; import java.time.LocalDateTime; +import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -84,6 +92,10 @@ class ThirdAppPlusAliPayServiceImplTest { @Mock private AlipayClient alipayClient; + /** 小程序应用客户端,与网页/移动客户端分开,防止测试掩盖应用路由错误。 */ + @Mock + private AlipayClient miniAlipayClient; + /** * 分账订单服务 Mock,用于保存支付宝确认结算、平台抽佣和退分账快照。 */ @@ -108,6 +120,7 @@ class ThirdAppPlusAliPayServiceImplTest { private TestableThirdAppPlusAliPayService service; + /** 初始化两组独立应用凭据、SDK 客户端和原网页/移动应用的商户关系。 */ @BeforeEach void setUp() { appPlusPayConfig = new AppPlusPayConfig(); @@ -119,6 +132,14 @@ class ThirdAppPlusAliPayServiceImplTest { aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback"); aliPayConfig.setRefundCallbackUrl("/third-app-pay/app/refund-callback"); appPlusPayConfig.setAliPayConfig(aliPayConfig); + AppPlusPayConfig.AliPayConfig mini = new AppPlusPayConfig.AliPayConfig(); + mini.setAppId("2026000000000002"); + mini.setAppPrivateKey("mini-test-private-key"); + mini.setAlipayPublicKey("mini-test-public-key"); + mini.setBaseCallbackUrl("https://mini-pay.example.com"); + mini.setPayCallbackUrl("/third-app-pay/app/pay-callback"); + mini.setRefundCallbackUrl("/third-app-pay/app/refund-callback"); + appPlusPayConfig.setAliPayMiniConfig(mini); MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); aliPaySetting.setAppAuthToken("merchant-auth-token"); @@ -137,7 +158,7 @@ class ThirdAppPlusAliPayServiceImplTest { payDto.setAmount(1234); payDto.setMchId(88L); payDto.setFzRelationId(9001L); - service = new TestableThirdAppPlusAliPayService(appPlusPayConfig, thirdPayOrderService, alipayClient); + service = new TestableThirdAppPlusAliPayService(appPlusPayConfig, thirdPayOrderService, alipayClient, miniAlipayClient); ReflectionTestUtils.setField(service, "aggregatePaySessionService", aggregatePaySessionService); ReflectionTestUtils.setField(service, "thirdFzOrderService", thirdFzOrderService); ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService); @@ -160,6 +181,8 @@ class ThirdAppPlusAliPayServiceImplTest { ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeAppPayRequest.class); verify(alipayClient).sdkExecute(requestCaptor.capture()); AlipayTradeAppPayRequest request = requestCaptor.getValue(); + Assertions.assertEquals("https://pay.example.com/third-app-pay/app/pay-callback", request.getNotifyUrl()); + verify(miniAlipayClient, never()).sdkExecute(any(AlipayTradeAppPayRequest.class)); AlipayTradeAppPayModel model = (AlipayTradeAppPayModel) request.getBizModel(); Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId()); Assertions.assertEquals("12.34", model.getTotalAmount()); @@ -175,6 +198,112 @@ class ThirdAppPlusAliPayServiceImplTest { Assertions.assertEquals(88L, savedOrder.getMchId()); Assertions.assertEquals(YesNoEnum.YES.getCode(), savedOrder.getEnableFz()); Assertions.assertNull(savedOrder.getPayTime()); + Assertions.assertEquals(appPlusPayConfig.getAliPayConfig().getAppId(), JSONUtil.parseObj(savedOrder.getMchPaySettingJson()) + .getStr(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY)); + } + + /** 查单和撤销必须使用支付单实际应用;未记录 AppID 的历史交易继续使用原应用。 */ + @Test + void queryAndCancelShouldKeepOriginalApplication() throws Exception { + String legacySnapshot = JSONUtil.toJsonStr(paySetting); + String miniSnapshot = miniApplicationSnapshot(); + AlipayTradeQueryResponse webQuery = new AlipayTradeQueryResponse(); + AlipayTradeQueryResponse miniQuery = new AlipayTradeQueryResponse(); + AlipayTradeCancelResponse webCancel = new AlipayTradeCancelResponse(); + AlipayTradeCancelResponse miniCancel = new AlipayTradeCancelResponse(); + when(alipayClient.execute(any(AlipayTradeQueryRequest.class))).thenReturn(webQuery); + when(miniAlipayClient.execute(any(AlipayTradeQueryRequest.class))).thenReturn(miniQuery); + when(alipayClient.execute(any(AlipayTradeCancelRequest.class))).thenReturn(webCancel); + when(miniAlipayClient.execute(any(AlipayTradeCancelRequest.class))).thenReturn(miniCancel); + + Assertions.assertSame(miniQuery, service.query(payDto.getOutTradeNo(), miniSnapshot)); + Assertions.assertSame(miniCancel, service.cancel(payDto.getOutTradeNo(), miniSnapshot)); + Assertions.assertSame(webQuery, service.query(payDto.getOutTradeNo(), legacySnapshot)); + Assertions.assertSame(webCancel, service.cancel(payDto.getOutTradeNo(), legacySnapshot)); + } + + /** 支付单明确保存未知应用时不能回退旧应用查单或撤销,避免误判交易不存在。 */ + @Test + void unknownApplicationShouldNeverFallBackToDefault() throws Exception { + String snapshot = JSONUtil.parseObj(miniApplicationSnapshot()) + .set(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY, "unconfigured-app-id").toString(); + + Assertions.assertNull(service.query(payDto.getOutTradeNo(), snapshot)); + Assertions.assertNull(service.cancel(payDto.getOutTradeNo(), snapshot)); + + verify(alipayClient, never()).execute(any(AlipayTradeQueryRequest.class)); + verify(alipayClient, never()).execute(any(AlipayTradeCancelRequest.class)); + verify(miniAlipayClient, never()).execute(any(AlipayTradeQueryRequest.class)); + verify(miniAlipayClient, never()).execute(any(AlipayTradeCancelRequest.class)); + } + + /** 小程序原交易的退款、退款查询和通知地址必须成组使用小程序配置。 */ + @Test + void miniRefundShouldUseMiniApplicationAndNotifyUrl() throws Exception { + ThirdPayOrderEntity order = paidDirectPayOrder(); + order.setMchPaySettingJson(miniApplicationSnapshot()); + order.setRefundNo("refund_77"); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + AlipayTradeRefundResponse accepted = new AlipayTradeRefundResponse(); + accepted.setCode("10000"); + when(miniAlipayClient.execute(any(AlipayTradeRefundRequest.class))).thenReturn(accepted); + AlipayTradeFastpayRefundQueryResponse refunded = new AlipayTradeFastpayRefundQueryResponse(); + refunded.setCode("10000"); + refunded.setRefundStatus("REFUND_SUCCESS"); + when(miniAlipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(refunded); + ThirdMiniWechatRefundDto dto = new ThirdMiniWechatRefundDto(); + dto.setAfterSaleId(77L); + dto.setRefundPrice(100); + + Assertions.assertFalse(service.refund(order, dto)); + Assertions.assertSame(refunded, service.queryRefund(order)); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeRefundRequest.class); + verify(miniAlipayClient).execute(requestCaptor.capture()); + Assertions.assertEquals("https://mini-pay.example.com/third-app-pay/app/refund-callback", requestCaptor.getValue().getNotifyUrl()); + verify(alipayClient, never()).execute(any(AlipayTradeRefundRequest.class)); + verify(alipayClient, never()).execute(any(AlipayTradeFastpayRefundQueryRequest.class)); + } + + /** 小程序原交易确认结算不能使用网页/移动应用客户端。 */ + @Test + void miniSettlementShouldKeepMiniApplication() throws Exception { + ThirdPayOrderEntity order = paidDirectPayOrder(); + order.setMchPaySettingJson(miniApplicationSnapshot()); + order.setEnableFz(YesNoEnum.NO.getCode()); + ThirdFzOrderEntity fzOrder = pendingFzOrder(order); + when(miniAlipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))).thenReturn(successSettleConfirmResponse()); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + Assertions.assertEquals("SUCCESS", service.confirmDirectPaySettlementStage(fzOrder, order)); + + verify(miniAlipayClient).execute(any(AlipayTradeSettleConfirmRequest.class)); + verify(alipayClient, never()).execute(any(AlipayTradeSettleConfirmRequest.class)); + } + + /** 使用两组真实 RSA 测试密钥核对公钥选择,未知应用和跨应用签名均须被拒绝。 */ + @Test + void callbackSignatureShouldUseRegisteredApplicationPublicKey() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair miniKeys = generator.generateKeyPair(); + KeyPair webKeys = generator.generateKeyPair(); + appPlusPayConfig.getAliPayMiniConfig().setAlipayPublicKey(Base64.getEncoder().encodeToString(miniKeys.getPublic().getEncoded())); + appPlusPayConfig.getAliPayConfig().setAlipayPublicKey(Base64.getEncoder().encodeToString(webKeys.getPublic().getEncoded())); + String privateKey = Base64.getEncoder().encodeToString(miniKeys.getPrivate().getEncoded()); + ThirdAppPlusAliPayServiceImpl realService = new ThirdAppPlusAliPayServiceImpl(appPlusPayConfig, thirdPayOrderService); + Map params = successCallbackParams(); + params.put("app_id", appPlusPayConfig.getAliPayMiniConfig().getAppId()); + params.put("sign_type", "RSA2"); + params.put("sign", AlipaySignature.rsaSign(AlipaySignature.getSignCheckContentV1(new HashMap<>(params)), privateKey, "UTF-8", "RSA2")); + Assertions.assertTrue(realService.verifyPayCallbackSignature(params)); + + params.remove("sign"); + params.put("app_id", appPlusPayConfig.getAliPayConfig().getAppId()); + params.put("sign", AlipaySignature.rsaSign(AlipaySignature.getSignCheckContentV1(new HashMap<>(params)), privateKey, "UTF-8", "RSA2")); + Assertions.assertFalse(realService.verifyPayCallbackSignature(params)); + params.put("app_id", "unknown-app-id"); + Assertions.assertFalse(realService.verifyPayCallbackSignature(params)); } /** @@ -789,6 +918,7 @@ class ThirdAppPlusAliPayServiceImplTest { @Test void payCallbackShouldAcceptBoundAggregateJsapiOrder() { ThirdPayOrderEntity order = unpaidOrder(); + order.setMchPaySettingJson(miniApplicationSnapshot()); order.setPayRep(JSONUtil.createObj().set("aggregatePay", true) .set("productCode", "JSAPI_PAY").toString()); ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity(); @@ -805,13 +935,37 @@ class ThirdAppPlusAliPayServiceImplTest { paidOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); when(thirdPayOrderService.getById(order.getId())).thenReturn(paidOrder); - ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(successCallbackParams()); + Map params = successCallbackParams(); + params.put("app_id", appPlusPayConfig.getAliPayMiniConfig().getAppId()); + ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(params); Assertions.assertNotNull(result); Assertions.assertTrue(result.isPaySuccess()); Assertions.assertTrue(result.isStateChanged()); } + /** 已登记的另一个应用即使验签通过,也不能处理不属于该应用的支付单。 */ + @Test + void payCallbackShouldRejectAnotherRegisteredApplication() { + ThirdPayOrderEntity order = unpaidOrder(); + order.setMchPaySettingJson(miniApplicationSnapshot()); + when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order); + + Assertions.assertNull(service.payCallback(successCallbackParams())); + + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 使用已有商户快照固定小程序 AppID,模拟聚合 JSAPI 下单后的数据库记录。 + * + * @return 不含应用密钥、包含小程序应用标识的订单级商户 JSON + */ + private String miniApplicationSnapshot() { + return JSONUtil.parseObj(JSONUtil.toJsonStr(paySetting)).set(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY, + appPlusPayConfig.getAliPayMiniConfig().getAppId()).toString(); + } + private AlipayTradePrecreateResponse successPrecreateResponse(String qrCode) { AlipayTradePrecreateResponse response = new AlipayTradePrecreateResponse(); response.setCode("10000"); @@ -963,12 +1117,21 @@ class ThirdAppPlusAliPayServiceImplTest { private final AlipayClient testAlipayClient; + /** 小程序场景专用客户端 Mock,用于确认操作没有误用旧应用客户端。 */ + private final AlipayClient testMiniAlipayClient; + + /** 被测小程序 AppID,仅用于为已解析的应用选择对应 Mock。 */ + private final String testMiniAppId; + private boolean signatureValid = true; + /** 创建保持原 App 客户端扩展点、同时区分小程序客户端的测试服务。 */ private TestableThirdAppPlusAliPayService(AppPlusPayConfig appPlusPayConfig, ThirdPayOrderService thirdPayOrderService, - AlipayClient testAlipayClient) { + AlipayClient testAlipayClient, AlipayClient testMiniAlipayClient) { super(appPlusPayConfig, thirdPayOrderService); this.testAlipayClient = testAlipayClient; + this.testMiniAlipayClient = testMiniAlipayClient; + this.testMiniAppId = appPlusPayConfig.getAliPayMiniConfig().getAppId(); } @Override @@ -976,6 +1139,12 @@ class ThirdAppPlusAliPayServiceImplTest { return testAlipayClient; } + /** 按生产解析方法选出的 AppID 返回独立 Mock,保留对支付单应用路由的真实校验。 */ + @Override + protected AlipayClient getAlipayClient(AppPlusPayConfig.AliPayConfig config) { + return testMiniAppId.equals(config.getAppId()) ? testMiniAlipayClient : super.getAlipayClient(config); + } + @Override protected boolean verifyPayCallbackSignature(Map params) { return signatureValid; From cf794ce6b69aa7312c1eafbbc95c38a66a217bab Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Wed, 28 Oct 2026 12:07:11 +0800 Subject: [PATCH 2/2] 11 --- pjcloud-thirdparty/docs/aggregate-h5.md | 34 ++++-- .../thirdparty/config/AppPlusPayConfig.java | 21 +++- .../ThirdAggregateAlipayOauthIdentity.java | 25 +++- .../ThirdAggregateAliPayServiceImpl.java | 97 +++++++++++---- .../ThirdAggregateOrderPayServiceImpl.java | 14 +-- .../ThirdAppPlusAliPayServiceImpl.java | 10 +- .../ThirdAggregateAliPayServiceImplTest.java | 114 +++++++++++++++--- ...ThirdAggregateOrderPayServiceImplTest.java | 33 +++-- .../ThirdAppPlusAliPayServiceImplTest.java | 33 ++++- 9 files changed, 304 insertions(+), 77 deletions(-) diff --git a/pjcloud-thirdparty/docs/aggregate-h5.md b/pjcloud-thirdparty/docs/aggregate-h5.md index 89b4d66c..219f6031 100644 --- a/pjcloud-thirdparty/docs/aggregate-h5.md +++ b/pjcloud-thirdparty/docs/aggregate-h5.md @@ -7,7 +7,7 @@ ```yaml app-plus: pay: - # 原配置键保留:网页/移动应用,供既有 App 支付、当面付及历史交易使用。 + # 网页/移动应用:H5 网页授权与授权码交换使用此组;原 App 支付继续使用此组。 ali-pay-config: app-id: ${ALIPAY_WEB_APP_ID} app-private-key: ${ALIPAY_WEB_APP_PRIVATE_KEY} @@ -15,7 +15,7 @@ app-plus: base-callback-url: ${THIRD_PUBLIC_BASE_URL} # 网关公网地址,包含实际 /api 子路径 pay-callback-url: /third/third-app-pay/app/pay-callback refund-callback-url: /third/third-app-pay/app/refund-callback - # 小程序应用:聚合 OAuth、身份交换和 JSAPI 下单必须使用同一组凭据。 + # 小程序支付应用:聚合交易创建、付款通知和后续交易处理使用此组。 ali-pay-mini-config: app-id: ${ALIPAY_MINI_APP_ID} app-private-key: ${ALIPAY_MINI_APP_PRIVATE_KEY} @@ -43,9 +43,9 @@ app-plus: `h5-base-url` 只接受 HTTPS 基础地址,不能带查询参数、Hash 或用户信息。可以包含实际部署子路径;H5 的路由 base 需对应配置。 -支付宝三类应用各自使用配套的 AppID、应用私钥和支付宝公钥;不同组不能填写重复 AppID,也不能只更换 AppID 而沿用另一应用的密钥。聚合支付缺少小程序组时明确拒绝,不回退到旧 `ali-pay-config`;只启用微信的商户不要求配置支付宝小程序组。应用客户端在服务进程中缓存,更新应用凭据后需重启 third 的全部实例。 +支付宝按网页/移动应用、小程序、第三方应用分类保存配套 AppID、应用私钥和支付宝公钥。H5 不新增应用类型或配置组:授权链接及 `alipay.system.oauth.token` 使用原 `ali-pay-config`,聚合 `alipay.trade.create` 使用 `ali-pay-mini-config`,两类客户端分别缓存。配置重复 AppID 会造成凭据选择歧义,使用前会明确拒绝。只启用微信的商户不要求支付宝配置;支付宝聚合入口则要求这两组均完整。更新凭据后需重启 third 的全部实例。 -新支付单在已有 `mch_pay_setting_json` 中保存非敏感 `alipayAppId`,查单、撤销、退款、退款查询及结算/分账按这个 AppID 选择原应用;支付与退款回调先按已登记 AppID 验签,再校验是否与支付单一致。旧快照没有该标识时继续使用原网页/移动应用,不能把所有历史聚合订单直接切换到新小程序应用。本次不增加数据库字段。 +新支付单在已有 `mch_pay_setting_json` 中保存非敏感 `alipayAppId`,查单、撤销、退款、退款查询及结算/分账按这个 AppID 选择原应用;支付与退款回调先按已登记 AppID 验签,再校验是否与支付单一致。旧快照没有该标识时继续使用原网页/移动应用,不能把历史网页/移动应用订单直接切换到小程序支付应用。本次不增加数据库字段。 仅在网关/反向代理**覆盖客户端传入的 X-Real-IP**时,将其直连地址加入 `trusted-proxy-addresses`。默认使用直连 IP 限流。Redis 业务键继续由项目基础设施附加租户前缀,不手工拼接租户编号。 @@ -57,14 +57,28 @@ H5 只使用项目根目录一个 `.env`,变量命名沿用参考 App:开发 - 微信沿用服务商统一公众号 OAuth 与 JSAPI 下单配置,确认公众号网页授权域名对应现有后端 OAuth 回调域名。 - 将新 H5 的实际页面目录核对加入微信支付 JSAPI 支付授权目录;Hash 部分不作为目录填写。当前根目录部署对应 `https://baac9a029ea84f36ba28c023fed4fc5f.hd.takin.cc/`。 -- 支付宝聚合入口使用 `ali-pay-mini-config` 的 AppID 与回调基础地址,固定 OAuth 回调仍为 `/third/aggregate-order-pay/oauth/alipay`,租户通过一次性 state 恢复;需在对应小程序应用核对授权回调配置。不要把独立 H5 地址替换为后端回调地址。 +- 支付宝网页授权入口读取 `ali-pay-config` 的 AppID 与回调基础地址。固定 OAuth 回调为 `/third/aggregate-order-pay/oauth/alipay`,租户通过一次性 state 恢复;在这个网页/移动应用下配置“授权回调地址”,与该组 `base-callback-url + public-route-prefix + /aggregate-order-pay/oauth/alipay` 一致。交易创建的异步支付及退款通知地址则读取小程序支付组,通知验签后还需匹配支付单保存的实际下单 AppID。 - 两种渠道继续使用商户既有支付配置、子商户和默认分佣关系快照,配置需已具备相应 OAuth/JSAPI 权限。仅域名能访问不代表上述平台配置已完成。 支付宝 H5 通过 `alipay.trade.create` 创建交易并返回 `tradeNO`,前端使用 `AlipayJSBridge.tradePay` 调起支付。直付通结算参数沿用现有 `ThirdAppPlusAliPayServiceImpl.appPay`:始终携带 `sub_merchant.merchant_id`,以及 `settle_info.settle_period_time=1d` 和一条 `defaultSettle` 结算明细;明细金额为交易全额,单位为元,`trans_in` 留空。关闭平台抽佣仍保留这组结算参数,不在创建交易时扣减平台分佣。 -产品码保持当前 JSAPI 场景的 `JSAPI_PAY`,商户授权 Token 继续按已有关系快照和抽佣规则传递。`1d` 账期只在商户签约账期结算模式时有效;补齐结算参数后仍需验证应用及商户权限,不能据此认定 `40006 / isv.insufficient-isv-permissions` 已解除。 +聚合交易创建使用小程序支付组的 `JSAPI_PAY`;历史聚合快照中的 `FACE_TO_FACE_PAYMENT` 仍参与严格会话绑定及回调校验,不改写历史流水。原 App 的产品码、SDK 签名下单及默认配置保持不变。支付宝应用类型与产品权限分别管理,不能仅按分类名称认定已获得 API、直付通或当前客户端场景的支付权限。 -此次拆分只改变应用凭据选择,不创建支付宝小程序页面,也不把 H5 转换为小程序。仍需由对应应用具备实际运行场景的 OAuth、`alipay.trade.create` 和支付调起能力,并在客户端联调确认。直付通关系管理仍归原应用;复用二级商户前需确认两应用属于同一服务商且渠道允许使用该 smid。已有 `app_auth_token` 也不能视为自动适用于新应用,开启代调用时应核对其应用授权范围;配置凭据不代表新增渠道授权。 +### 授权应用与支付应用的用户身份 + +网页/移动应用用自己的密钥兑换自己发起的授权码,服务端身份对象记录这个来源 AppID;小程序支付不能用其密钥直接兑换另一应用的网页授权码。 + +跨应用下单只使用 OAuth 实际返回的、2088 开头且共 16 位的 `user_id`,传入 `buyer_id`,并要求支付应用当前仍支持该标识模式。如果只返回网页应用的 `open_id`,后端会拒绝直接向另一个应用传入 `buyer_open_id`,日志标记为 `聚合付款缺少跨应用user_id`。仅在身份来源 AppID 与下单 AppID 相同的情况下,才允许直接使用其 `open_id`。兼容身份构造器未记录来源应用时,也不能据此跨应用使用 `open_id`。 + +这意味着两组密钥均已配置仍不等于身份可以互通:若网页授权仅返回 `open_id`,需核对应用的用户标识模式,或完成支付宝官方支持的应用关联及身份打通;本版本不伪造、推算或擅自转换用户标识,也不依靠前端提供付款人 ID。 + +### 直付通关系及渠道联调 + +直付通关系管理仍归原网页/移动应用;跨应用复用二级商户前,确认两应用属于相同服务商且渠道允许该小程序支付应用使用此 smid。关系中的 `app_auth_token` 也不能视为自动适用于另一个应用,开启代调用时应核对授权范围。本版本保留既有关系快照及抽佣规则,配置分工本身不会新增渠道权限。 + +前端仍在支付宝客户端的独立 H5 内通过 `AlipayJSBridge.tradePay` 调起交易;需以实际支付应用、产品签约、用户标识模式与客户端场景联合验证,不能把当前实现当作任意小程序产品与 H5 容器通用的证明。 + +参考:[支付宝网页授权与授权码交换](https://developer.alibaba.com/docs/doc.htm?articleId=105656&docType=1&treeId=346)、[支付宝官方聚合 H5/小程序流程对照](https://developer.alibaba.com/docs/doc.htm?articleId=109734&docType=1&treeId=598)。当前 SDK `AlipayTradeCreateModel.buyerId` 文档说明用户号格式及授权来源,身份模式与跨应用能力仍以当前应用实际权限为准。 ## 发布顺序与兼容 @@ -82,6 +96,12 @@ H5 只使用项目根目录一个 `.env`,变量命名沿用参考 App:开发 ## 接口与故障恢复 +### 支付宝 E004 授权回调白名单排查 + +出现 `AUTH_REDIRECT_URL_NOT_IN_WHITE` 时,先查看 third 的 `聚合收银支付宝网页授权` 日志。该日志只输出本次实际使用的配置组、`appId` 和静态 `redirectUri`,不输出一次性 state、授权码、付款人标识、Token 或密钥。按这两个实际值与同一应用的“授权回调地址”逐项核对,避免拿 Nacos 编辑页面或另一个 AppID 的白名单代替实际请求证据。 + +若 `base-callback-url` 为 `https://api.example.com/api`,微服务前缀为 `/third`,完整 OAuth 回调就是 `https://api.example.com/api/third/aggregate-order-pay/oauth/alipay`;不要把网关前缀重复写入基础地址,也不要将 H5 Hash 页面或支付异步通知路径填在授权回调处。确认线上所有 third 实例均已更新配置,并在开放平台修改生效后重新扫码。 + - 匿名查询:`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;已确认终态的旧请求直接恢复结果页,不再下单。 diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java index 7792d9d4..fbc3dfe5 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java @@ -22,14 +22,27 @@ public class AppPlusPayConfig { /** 网页/移动应用凭据;保留 ali-pay-config 配置键及原访问方法,兼容既有 App 和历史订单。 */ private AliPayConfig AliPayConfig; - /** 小程序应用凭据,配置键为 ali-pay-mini-config;聚合 JSAPI 不得回退到网页/移动应用。 */ + /** 小程序应用凭据,配置键为 ali-pay-mini-config;用于聚合交易下单,不用于 H5 网页授权。 */ private AliPayConfig aliPayMiniConfig; /** 第三方应用凭据,配置键为 ali-pay-third-party-config;独立预留,不自动替代支付应用。 */ private AliPayConfig aliPayThirdPartyConfig; /** - * 读取聚合 OAuth 和 JSAPI 共用的小程序应用,缺少任一支付必需项时拒绝发起收银。 + * 读取现有网页/移动应用供 H5 网页授权使用;授权码必须由发起授权的同一应用换取身份。 + * + * @return 默认网页/移动应用凭据及 OAuth 回调基础地址,不新增配置组 + */ + public AliPayConfig requireAliPayWebConfig() { + if (AliPayConfig == null || StrUtil.hasBlank(AliPayConfig.getAppId(), AliPayConfig.getAppPrivateKey(), + AliPayConfig.getAlipayPublicKey(), AliPayConfig.getBaseCallbackUrl())) { + throw new IllegalStateException("支付宝网页授权配置不完整,请配置网页/移动应用 app-plus.pay.ali-pay-config"); + } + return findAliPayConfig(AliPayConfig.getAppId()); + } + + /** + * 读取聚合下单使用的小程序应用完整凭据;不能代替网页/移动应用完成网页授权。 * * @return 完整的小程序应用凭据与支付回调地址 */ @@ -38,12 +51,12 @@ public class AppPlusPayConfig { aliPayMiniConfig.getAlipayPublicKey(), aliPayMiniConfig.getBaseCallbackUrl(), aliPayMiniConfig.getPayCallbackUrl())) { throw new IllegalStateException("支付宝小程序支付配置不完整,请配置 app-plus.pay.ali-pay-mini-config"); } - // 同一 AppID 出现在多类配置中会导致回调无法唯一选取公钥,创建支付前即拒绝该配置。 + // 同一 AppID 出现在多组配置中会导致回调无法唯一选取公钥,使用前即拒绝该配置。 return findAliPayConfig(aliPayMiniConfig.getAppId()); } /** - * 在服务端已登记的三类应用中按 AppID 精确选取凭据;未知或空 AppID 不使用默认应用。 + * 在服务端已登记的各组应用中按 AppID 精确选取凭据;未知或空 AppID 不使用默认应用。 * * @param appId 支付单快照或待验签通知中的应用标识;通知仍须验签并核对订单 * @return 唯一匹配的应用;未知时返回 null,重复 AppID 配置时拒绝歧义 diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java index 043dbcf2..6ee0e371 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java @@ -5,10 +5,14 @@ import cn.hutool.core.util.StrUtil; /** * 支付宝用户授权换取的付款人身份。 * - *

新应用优先使用 open_id,兼容尚未返回 open_id 的存量应用 user_id。

+ *

记录授权来源应用,避免将网页应用的 open_id 直接用于另一个支付应用。 + * 跨应用下单只使用平台实际返回且支付应用支持的 user_id。

*/ public class ThirdAggregateAlipayOauthIdentity { + /** 换取此身份的服务端应用 AppID;兼容构造器未传入时为空,不能据此跨应用使用 open_id。 */ + private final String oauthAppId; + /** * 支付宝开放平台用户 open_id;接口未返回时为空。 */ @@ -20,16 +24,33 @@ public class ThirdAggregateAlipayOauthIdentity { private final String userId; /** - * 创建支付宝付款人身份值对象。 + * 兼容未记录授权来源应用的内部调用;仅有 open_id 时不能用于跨应用下单。 * * @param openId 支付宝 open_id,允许为空 * @param userId 支付宝 user_id,允许为空 */ public ThirdAggregateAlipayOauthIdentity(String openId, String userId) { + this(null, openId, userId); + } + + /** + * 创建带可信授权来源的身份值对象,来源 AppID 必须由服务端 OAuth 客户端决定。 + * + * @param oauthAppId 实际换取身份的支付宝应用 AppID + * @param openId 该应用下的 open_id,接口未返回时为空 + * @param userId 平台返回的 2088 开头用户号,接口未返回时为空 + */ + public ThirdAggregateAlipayOauthIdentity(String oauthAppId, String openId, String userId) { + this.oauthAppId = oauthAppId; this.openId = openId; this.userId = userId; } + /** 返回实际执行授权码交换的应用标识;为空表示身份来源应用未记录。 */ + public String getOauthAppId() { + return oauthAppId; + } + /** * 获取支付宝 open_id。 * 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 3f85c6bd..9ca72c22 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 @@ -48,7 +48,7 @@ import java.util.List; import java.util.Map; /** - * 支付宝聚合订单码 OAuth 与 JSAPI 支付实现。 + * 支付宝聚合收银:网页/移动应用负责 OAuth,小程序应用负责交易创建及付款通知。 */ @Service @Validated @@ -62,7 +62,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ private static final String TAG = "支付宝聚合JSAPI支付"; /** - * 支付宝 JSAPI 支付固定产品码。 + * 聚合支付下单使用小程序应用签约的 JSAPI 产品码,不改变原 App 支付产品。 */ private static final String PRODUCT_CODE = "JSAPI_PAY"; @@ -111,10 +111,13 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ private ThirdMchFzRelationService mchFzRelationService; /** - * 当前应用配置对应的支付宝客户端,延迟初始化并在请求间复用。 + * 小程序支付应用的客户端,延迟初始化并在聚合下单请求间复用。 */ private volatile AlipayClient alipayClient; + /** 网页/移动应用的 OAuth 客户端,独立缓存,不能用支付应用密钥兑换网页授权码。 */ + private volatile AlipayClient oauthAlipayClient; + /** * 使用一次性 OAuth 授权码换取支付宝付款人身份,不保存授权令牌。 * @@ -129,14 +132,15 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ AlipaySystemOauthTokenRequest request = new AlipaySystemOauthTokenRequest(); request.setGrantType(OAUTH_GRANT_TYPE); request.setCode(authCode); + String oauthAppId = appPlusPayConfig.requireAliPayWebConfig().getAppId(); try { - AlipaySystemOauthTokenResponse response = getAlipayClient().execute(request); + AlipaySystemOauthTokenResponse response = getOauthAlipayClient().execute(request); if (response == null || !response.isSuccess()) { String errorCode = response == null ? "EMPTY_RESPONSE" : StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "UNKNOWN"); log.warn("|{}|支付宝用户授权失败,errorCode:{}", TAG, errorCode); throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_OAUTH_FAIL); } - ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity(response.getOpenId(), response.getUserId()); + ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity(oauthAppId, response.getOpenId(), response.getUserId()); if (StrUtil.isBlank(identity.getStableIdentity())) { throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_OAUTH_NO_PAYER); } @@ -161,10 +165,12 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ public ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting, ThirdAggregateAlipayOauthIdentity identity) { MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(paySetting); + AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.requireAliPayMiniConfig(); + AlipayTradeCreateModel model = new AlipayTradeCreateModel(); + applyBuyerIdentity(model, identity, aliPayConfig.getAppId()); validateFzRelation(context, paySetting); ThirdPayOrderEntity payOrder = buildPayOrder(context, paySetting); - AlipayTradeCreateModel model = new AlipayTradeCreateModel(); String totalAmount = fenToYuan(context.getAmount()); model.setOutTradeNo(context.getPayNo()); model.setTotalAmount(totalAmount); @@ -184,14 +190,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ directPaySettleInfo.setSettlePeriodTime(DIRECT_PAY_SETTLE_PERIOD_TIME); directPaySettleInfo.setSettleDetailInfos(List.of(defaultSettleDetailInfo)); model.setSettleInfo(directPaySettleInfo); - if (StrUtil.isNotBlank(identity.getOpenId())) { - model.setBuyerOpenId(identity.getOpenId()); - } else { - model.setBuyerId(identity.getUserId()); - } - AlipayTradeCreateRequest request = new AlipayTradeCreateRequest(); - AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.requireAliPayMiniConfig(); request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl()); request.setBizModel(model); // 关闭分账不携带平台抽佣专属授权;开启分账且配置了代调用授权时才发送 token。 @@ -260,21 +259,12 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ /** * 获取延迟初始化的支付宝客户端。 * - * @return 使用小程序应用的 AppID、应用私钥和支付宝公钥初始化的客户端 + * @return 使用小程序支付应用 AppID 与配套密钥初始化的客户端 */ protected synchronized AlipayClient getAlipayClient() { if (alipayClient == null) { try { - AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.requireAliPayMiniConfig(); - AlipayConfig sdkConfig = new AlipayConfig(); - sdkConfig.setServerUrl("https://openapi.alipay.com/gateway.do"); - sdkConfig.setAppId(config.getAppId()); - sdkConfig.setPrivateKey(config.getAppPrivateKey()); - sdkConfig.setFormat("json"); - sdkConfig.setAlipayPublicKey(config.getAlipayPublicKey()); - sdkConfig.setCharset("UTF-8"); - sdkConfig.setSignType("RSA2"); - alipayClient = new DefaultAlipayClient(sdkConfig); + alipayClient = createAlipayClient(appPlusPayConfig.requireAliPayMiniConfig()); } catch (Exception exception) { throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_CLIENT_INIT_FAIL); } @@ -282,6 +272,65 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ return alipayClient; } + /** 获取网页/移动应用的 OAuth 客户端,始终与授权链接使用同一组凭据。 */ + protected synchronized AlipayClient getOauthAlipayClient() { + if (oauthAlipayClient == null) { + try { + oauthAlipayClient = createAlipayClient(appPlusPayConfig.requireAliPayWebConfig()); + } catch (Exception exception) { + throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_CLIENT_INIT_FAIL); + } + } + return oauthAlipayClient; + } + + /** + * 仅以调用方明确指定的服务端配置创建 SDK 客户端,不在两类应用之间自动降级。 + * + * @param config 当前调用使用的完整应用凭据 + * @return 对应应用的客户端 + * @throws Exception SDK 初始化失败时由上层转换为统一业务错误 + */ + private AlipayClient createAlipayClient(AppPlusPayConfig.AliPayConfig config) throws Exception { + AlipayConfig sdkConfig = new AlipayConfig(); + sdkConfig.setServerUrl("https://openapi.alipay.com/gateway.do"); + sdkConfig.setAppId(config.getAppId()); + sdkConfig.setPrivateKey(config.getAppPrivateKey()); + sdkConfig.setFormat("json"); + sdkConfig.setAlipayPublicKey(config.getAlipayPublicKey()); + sdkConfig.setCharset("UTF-8"); + sdkConfig.setSignType("RSA2"); + return new DefaultAlipayClient(sdkConfig); + } + + /** + * 绑定付款人:跨应用只使用平台真实返回的全局用户号,同应用才允许直接使用 open_id。 + * + * @param model 即将发送的交易创建模型 + * @param identity 服务端 OAuth 换取的付款人身份,不能由 H5 自行提供 + * @param payAppId 本次下单的小程序应用 AppID + */ + private void applyBuyerIdentity(AlipayTradeCreateModel model, ThirdAggregateAlipayOauthIdentity identity, String payAppId) { + if (identity == null) { + throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_OAUTH_NO_PAYER); + } + if (StrUtil.isNotBlank(identity.getUserId())) { + if (!identity.getUserId().matches("2088[0-9]{12}")) { + throw new IllegalStateException("支付宝授权返回的user_id格式无效,不能作为buyer_id下单"); + } + model.setBuyerId(identity.getUserId()); + return; + } + if (StrUtil.isNotBlank(identity.getOpenId()) && payAppId.equals(identity.getOauthAppId())) { + model.setBuyerOpenId(identity.getOpenId()); + return; + } + // 不记录用户标识,仅说明是哪两个应用及身份模式不匹配,避免错误跨应用传入 buyer_open_id。 + log.warn("|{}|聚合付款缺少跨应用user_id,oauthAppId:{},payAppId:{},hasOpenId:{}", TAG, + identity.getOauthAppId(), payAppId, StrUtil.isNotBlank(identity.getOpenId())); + throw new IllegalStateException("支付宝网页授权仅返回应用级open_id,跨应用支付需要可用user_id或完成官方支持的身份打通"); + } + /** * 构建不含付款人标识和商户授权令牌的请求快照。 * 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 5d38a661..9e6aec51 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 @@ -391,7 +391,10 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay + "&response_type=code&scope=" + WECHAT_OAUTH_SCOPE + "&state=" + encode(state) + "#wechat_redirect"; } - return ALIPAY_OAUTH_URL + "?app_id=" + encode(appPlusPayConfig.requireAliPayMiniConfig().getAppId()) + AppPlusPayConfig.AliPayConfig alipay = appPlusPayConfig.requireAliPayWebConfig(); + // 只记录本次实际使用的应用和静态回调地址,不记录包含一次性 state 的完整授权链接。 + log.info("聚合收银支付宝网页授权,configKey:ali-pay-config,appId:[{}],redirectUri:[{}]", alipay.getAppId(), callbackUrl); + return ALIPAY_OAUTH_URL + "?app_id=" + encode(alipay.getAppId()) + "&scope=" + ALIPAY_OAUTH_SCOPE + "&redirect_uri=" + encode(callbackUrl) + "&state=" + encode(state); @@ -1212,6 +1215,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay */ private void validateApplicationConfig(AppPlusPlatformTypeEnum platform) { if (platform == AppPlusPlatformTypeEnum.ALI_PAY) { + appPlusPayConfig.requireAliPayWebConfig(); appPlusPayConfig.requireAliPayMiniConfig(); } AppPlusPayConfig.WechatConfig wechat = appPlusPayConfig.getWechatConfig(); @@ -1353,17 +1357,13 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay /** * 获取聚合二维码和状态查询共用的公网基础地址。 * - * @return 优先保持旧入口基础地址;未配置旧应用时使用已配置的小程序或微信回调基础地址 + * @return 优先保持原网页/移动应用的入口地址;仅微信场景可使用微信回调基础地址 */ private String getPublicBaseUrl() { AppPlusPayConfig.AliPayConfig original = appPlusPayConfig.getAliPayConfig(); if (original != null && StrUtil.isNotBlank(original.getBaseCallbackUrl())) { return normalizeBaseUrl(original.getBaseCallbackUrl()); } - AppPlusPayConfig.AliPayConfig mini = appPlusPayConfig.getAliPayMiniConfig(); - if (mini != null && StrUtil.isNotBlank(mini.getBaseCallbackUrl())) { - return normalizeBaseUrl(mini.getBaseCallbackUrl()); - } return normalizeBaseUrl(appPlusPayConfig.getWechatConfig() == null ? null : appPlusPayConfig.getWechatConfig().getBaseCallbackUrl()); } @@ -1377,7 +1377,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.WECHAT)) { return normalizeBaseUrl(appPlusPayConfig.getWechatConfig().getBaseCallbackUrl()); } - return normalizeBaseUrl(appPlusPayConfig.requireAliPayMiniConfig().getBaseCallbackUrl()); + return normalizeBaseUrl(appPlusPayConfig.requireAliPayWebConfig().getBaseCallbackUrl()); } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java index 46b8eb90..56dc2636 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java @@ -107,6 +107,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService */ private static final String ORDER_CODE_PRODUCT_CODE = "QR_CODE_OFFLINE"; + /** 历史聚合单保存的当面付产品码,回调兼容时不得与原订单码产品码混用。 */ + private static final String LEGACY_AGGREGATE_FACE_PRODUCT_CODE = "FACE_TO_FACE_PAYMENT"; + /** * 订单码交易标题,固定安全文案避免把业务隐私传给第三方平台。 */ @@ -2048,10 +2051,10 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 判断内部订单请求快照是否明确标记为支付宝聚合 JSAPI 子订单。 + * 识别独立 H5 当面付及历史聚合 JSAPI 请求快照,后续仍须严格校验会话与订单绑定。 * * @param payOrder 回调商户订单号对应的内部支付订单 - * @return 请求快照同时包含聚合标记和 {@code JSAPI_PAY} 产品码时返回 {@code true} + * @return 请求快照包含聚合标记,且产品码为当面付或历史 {@code JSAPI_PAY} 时返回 {@code true} */ private boolean isAggregatePayRequest(ThirdPayOrderEntity payOrder) { if (payOrder == null || StrUtil.isBlank(payOrder.getPayRep())) { @@ -2060,7 +2063,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService try { JSONObject request = JSONUtil.parseObj(payOrder.getPayRep()); return Boolean.TRUE.equals(request.getBool("aggregatePay")) - && ObjectUtil.equals(request.getStr("productCode"), "JSAPI_PAY"); + && (ObjectUtil.equals(request.getStr("productCode"), LEGACY_AGGREGATE_FACE_PRODUCT_CODE) + || ObjectUtil.equals(request.getStr("productCode"), "JSAPI_PAY")); } catch (Exception exception) { return false; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java index 234264e2..9cccb43f 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java @@ -46,11 +46,15 @@ import static org.mockito.Mockito.when; class ThirdAggregateAliPayServiceImplTest { /** - * 支付宝 SDK 客户端 Mock,用于捕获 OAuth 和交易创建请求。 + * 小程序支付 SDK 客户端 Mock,仅用于捕获聚合交易创建请求。 */ @Mock private AlipayClient alipayClient; + /** 网页/移动应用 OAuth 客户端,必须与支付 SDK Mock 隔离以检查凭据选择。 */ + @Mock + private AlipayClient oauthAlipayClient; + /** * 第三方支付订单服务 Mock,用于验证子订单快照和保存失败补偿。 */ @@ -101,9 +105,12 @@ class ThirdAggregateAliPayServiceImplTest { AppPlusPayConfig.AliPayConfig webApplication = new AppPlusPayConfig.AliPayConfig(); webApplication.setAppId("2026000000000001"); webApplication.setBaseCallbackUrl("https://pay.example.com"); + webApplication.setAppPrivateKey("web-test-private-key"); + webApplication.setAlipayPublicKey("web-test-public-key"); + webApplication.setPayCallbackUrl("/third-app-pay/app/pay-callback"); appPlusPayConfig.setAliPayConfig(webApplication); service = new TestableThirdAggregateAliPayService(appPlusPayConfig, thirdPayOrderService, - thirdAppPlusAliPayService, alipayClient); + thirdAppPlusAliPayService, alipayClient, oauthAlipayClient); ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService); context = new ThirdAggregatePayCreateContext(); @@ -132,20 +139,22 @@ class ThirdAggregateAliPayServiceImplTest { AlipaySystemOauthTokenResponse response = new AlipaySystemOauthTokenResponse(); response.setCode("10000"); response.setOpenId("alipay-open-id"); - when(alipayClient.execute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(response); + when(oauthAlipayClient.execute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(response); ThirdAggregateAlipayOauthIdentity identity = service.exchangeOauthCode("one-time-auth-code"); Assertions.assertEquals("alipay-open-id", identity.getStableIdentity()); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipaySystemOauthTokenRequest.class); - verify(alipayClient).execute(requestCaptor.capture()); + verify(oauthAlipayClient).execute(requestCaptor.capture()); + verify(alipayClient, never()).execute(any(AlipaySystemOauthTokenRequest.class)); + Assertions.assertEquals("2026000000000001", identity.getOauthAppId()); Assertions.assertEquals("authorization_code", requestCaptor.getValue().getGrantType()); Assertions.assertEquals("one-time-auth-code", requestCaptor.getValue().getCode()); } /** - * 验证 trade.create 使用 JSAPI_PAY、全额直付通结算、付款人 open_id 和授权 Token。 + * 验证 trade.create 使用 JSAPI_PAY、全额直付通结算、网页授权 user_id 和授权 Token。 * * @throws Exception Mock 支付宝客户端声明的受检异常 */ @@ -159,7 +168,7 @@ class ThirdAggregateAliPayServiceImplTest { when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true); ThirdAggregateCashierLaunch result = service.createPay(context, paySetting, - new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null)); + webIdentity()); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class); verify(alipayClient).execute(requestCaptor.capture()); @@ -168,7 +177,8 @@ class ThirdAggregateAliPayServiceImplTest { Assertions.assertEquals("JSAPI_PAY", model.getProductCode()); Assertions.assertEquals("12.34", model.getTotalAmount()); Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId()); - Assertions.assertEquals("alipay-open-id", model.getBuyerOpenId()); + Assertions.assertEquals("2088000000000001", model.getBuyerId()); + Assertions.assertNull(model.getBuyerOpenId()); Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token")); Assertions.assertEquals("2026000000000002", result.getPayParams().get("tradeNO")); Assertions.assertEquals("https://mini-pay.example.com/third-app-pay/app/pay-callback", request.getNotifyUrl()); @@ -185,7 +195,7 @@ class ThirdAggregateAliPayServiceImplTest { Assertions.assertFalse(snapshot.containsKey("appPrivateKey")); } - /** 缺少小程序凭据时禁止使用已配置的网页/移动应用替代 JSAPI 下单。 */ + /** 缺少小程序支付配置时禁止使用网页/移动应用代替聚合下单。 */ @Test void createPayShouldRejectMissingMiniApplication() throws Exception { appPlusPayConfig.setAliPayMiniConfig(null); @@ -197,6 +207,63 @@ class ThirdAggregateAliPayServiceImplTest { verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class)); } + /** 网页应用只有 open_id 时,不允许将其直接交给另一小程序应用下单。 */ + @Test + void createPayShouldRejectCrossApplicationOpenIdWithoutUserId() throws Exception { + ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity( + appPlusPayConfig.getAliPayConfig().getAppId(), "web-app-open-id", null); + + IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, + () -> service.createPay(context, paySetting, identity)); + + Assertions.assertTrue(exception.getMessage().contains("user_id")); + verify(alipayClient, never()).execute(any(AlipayTradeCreateRequest.class)); + verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class)); + } + + /** 仅在身份确实来自本次下单应用时,才允许使用其 open_id 创建交易。 */ + @Test + void createPayShouldAllowOpenIdFromThePaymentApplication() throws Exception { + AlipayTradeCreateResponse response = new AlipayTradeCreateResponse(); + response.setCode("10000"); + response.setTradeNo("2026000000000088"); + when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response); + when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true); + ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity( + appPlusPayConfig.getAliPayMiniConfig().getAppId(), "mini-app-open-id", null); + + service.createPay(context, paySetting, identity); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class); + verify(alipayClient).execute(captor.capture()); + AlipayTradeCreateModel model = (AlipayTradeCreateModel) captor.getValue().getBizModel(); + Assertions.assertEquals("mini-app-open-id", model.getBuyerOpenId()); + Assertions.assertNull(model.getBuyerId()); + } + + /** 非 2088 开头的有效用户号不能伪装成全局 user_id,不能以错误身份发起真实下单。 */ + @Test + void createPayShouldRejectMalformedGlobalUserId() throws Exception { + ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity( + appPlusPayConfig.getAliPayConfig().getAppId(), "web-app-open-id", "not-a-user-id"); + + Assertions.assertThrows(IllegalStateException.class, () -> service.createPay(context, paySetting, identity)); + + verify(alipayClient, never()).execute(any(AlipayTradeCreateRequest.class)); + verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class)); + } + + /** 网页授权配置缺失时不尝试以小程序密钥兑换授权码。 */ + @Test + void exchangeOauthCodeShouldNeverFallBackToMiniCredentials() throws Exception { + appPlusPayConfig.setAliPayConfig(null); + + Assertions.assertThrows(IllegalStateException.class, () -> service.exchangeOauthCode("one-time-auth-code")); + + verify(oauthAlipayClient, never()).execute(any(AlipaySystemOauthTokenRequest.class)); + verify(alipayClient, never()).execute(any(AlipaySystemOauthTokenRequest.class)); + } + /** * 验证商户没有配置授权 Token 时请求中完全不发送 app_auth_token,且不降级为另一套支付流程。 * @@ -213,7 +280,7 @@ class ThirdAggregateAliPayServiceImplTest { when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response); when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true); - service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null)); + service.createPay(context, paySetting, webIdentity()); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class); verify(alipayClient).execute(requestCaptor.capture()); @@ -241,14 +308,14 @@ class ThirdAggregateAliPayServiceImplTest { when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response); when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true); - service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity(null, "alipay-user-id")); + service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity(null, "2088000000000001")); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class); verify(alipayClient).execute(requestCaptor.capture()); AlipayTradeCreateRequest request = requestCaptor.getValue(); AlipayTradeCreateModel model = (AlipayTradeCreateModel) request.getBizModel(); Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId()); - Assertions.assertEquals("alipay-user-id", model.getBuyerId()); + Assertions.assertEquals("2088000000000001", model.getBuyerId()); Assertions.assertFalse(request.getTextParams().containsKey("app_auth_token")); assertDirectPaySettlement(request, "0.01"); @@ -291,7 +358,7 @@ class ThirdAggregateAliPayServiceImplTest { RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> service.createPay(context, paySetting, - new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null))); + webIdentity())); Assertions.assertTrue(exception.getMessage().contains("分佣关系尚未绑定")); verify(alipayClient, never()).execute(any(AlipayTradeCreateRequest.class)); @@ -312,7 +379,7 @@ class ThirdAggregateAliPayServiceImplTest { when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(false); Assertions.assertThrows(RuntimeException.class, () -> service.createPay(context, paySetting, - new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null))); + webIdentity())); ArgumentCaptor snapshotCaptor = ArgumentCaptor.forClass(String.class); verify(thirdAppPlusAliPayService).cancel(eq(context.getPayNo()), snapshotCaptor.capture()); @@ -320,6 +387,12 @@ class ThirdAggregateAliPayServiceImplTest { .getStr(AppPlusPayConfig.ALIPAY_APP_ID_SNAPSHOT_KEY)); } + /** 网页授权与小程序下单跨应用时只携带平台真实返回的全局 user_id。 */ + private ThirdAggregateAlipayOauthIdentity webIdentity() { + return new ThirdAggregateAlipayOauthIdentity(appPlusPayConfig.getAliPayConfig().getAppId(), + "alipay-open-id", "2088000000000001"); + } + /** * 固定使用 Mock 支付宝客户端的可测试服务实现。 */ @@ -330,20 +403,25 @@ class ThirdAggregateAliPayServiceImplTest { */ private final AlipayClient testAlipayClient; + /** 网页授权专用客户端 Mock,与聚合下单客户端分离。 */ + private final AlipayClient testOauthAlipayClient; + /** * 创建可替换 SDK 客户端的支付宝聚合服务。 * * @param appPlusPayConfig 测试应用配置 * @param thirdPayOrderService 支付订单服务 Mock * @param thirdAppPlusAliPayService 旧支付宝查询撤销服务 Mock - * @param testAlipayClient 支付宝 SDK 客户端 Mock + * @param testAlipayClient 小程序支付客户端 Mock + * @param testOauthAlipayClient 网页/移动应用 OAuth 客户端 Mock */ private TestableThirdAggregateAliPayService(AppPlusPayConfig appPlusPayConfig, ThirdPayOrderService thirdPayOrderService, ThirdAppPlusAliPayService thirdAppPlusAliPayService, - AlipayClient testAlipayClient) { + AlipayClient testAlipayClient, AlipayClient testOauthAlipayClient) { super(appPlusPayConfig, thirdPayOrderService, thirdAppPlusAliPayService); this.testAlipayClient = testAlipayClient; + this.testOauthAlipayClient = testOauthAlipayClient; } /** @@ -355,5 +433,11 @@ class ThirdAggregateAliPayServiceImplTest { protected AlipayClient getAlipayClient() { return testAlipayClient; } + + /** 返回网页授权专用客户端,不允许由支付客户端兑换授权码。 */ + @Override + protected AlipayClient getOauthAlipayClient() { + return testOauthAlipayClient; + } } } 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 3d6b94c9..22b60c42 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 @@ -264,9 +264,9 @@ class ThirdAggregateOrderPayServiceImplTest { verify(aggregatePaySessionService, times(2)).save(any(ThirdAggregatePaySessionEntity.class)); } - /** 旧键与新增两类配置必须通过真实 Spring 属性绑定分别落到对应字段。 */ + /** 网页/移动应用、小程序和三方应用必须通过真实 Spring 属性绑定相互隔离。 */ @Test - void alipayConfigurationShouldBindThreeApplicationTypes() { + void alipayConfigurationShouldBindExistingApplicationTypes() { Binder binder = new Binder(new MapConfigurationPropertySource(Map.of( "app-plus.pay.ali-pay-config.app-id", "web-mobile-app", "app-plus.pay.ali-pay-mini-config.app-id", "mini-app", @@ -279,16 +279,16 @@ class ThirdAggregateOrderPayServiceImplTest { Assertions.assertEquals("third-party-app", config.getAliPayThirdPartyConfig().getAppId()); } - /** 应用组 AppID 重复会导致验签歧义,必须在进入小程序收银前拒绝。 */ + /** 应用组 AppID 重复会导致验签歧义,必须在进入 H5 收银前拒绝。 */ @Test - void duplicatedApplicationIdShouldRejectMiniCashier() { + void duplicatedApplicationIdShouldRejectCashier() { AppPlusPayConfig config = completeApplicationConfig(); config.getAliPayMiniConfig().setAppId(config.getAliPayConfig().getAppId()); Assertions.assertThrows(IllegalStateException.class, config::requireAliPayMiniConfig); } - /** 即使网页/移动应用配置完整,未配置小程序应用也不能创建支付宝聚合会话。 */ + /** 缺少小程序支付应用时不得使用网页/移动应用代替创建聚合会话。 */ @Test void createShouldRejectMissingMiniApplicationWithoutFallback() { AppPlusPayConfig config = (AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig"); @@ -303,6 +303,21 @@ class ThirdAggregateOrderPayServiceImplTest { verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class)); } + /** 小程序支付配置完整也不能代替缺失的网页/移动应用进行网页授权。 */ + @Test + void createShouldRejectMissingWebApplicationWithoutFallback() { + AppPlusPayConfig config = (AppPlusPayConfig) ReflectionTestUtils.getField(service, "appPlusPayConfig"); + config.setAliPayConfig(null); + 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)); + + IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, () -> service.create(request)); + + Assertions.assertTrue(exception.getMessage().contains("app-plus.pay.ali-pay-config")); + verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class)); + } + /** 两个渠道都停用时拒绝创建,避免产生没有付款入口的会话。 */ @Test void createShouldRejectMerchantWithoutEnabledPlatform() { @@ -327,7 +342,7 @@ class ThirdAggregateOrderPayServiceImplTest { verify(mchFzRelationService, never()).findBoundDefault(ali); } - /** 仅支付宝启用时校验小程序支付应用及既有直付通关系,微信应用缺省不影响签发。 */ + /** 仅支付宝启用时校验网页授权、小程序支付应用及既有直付通关系,微信应用缺省不影响签发。 */ @Test void merchantCodeShouldValidateOnlyEnabledAlipay() { MchPaySettingVo ali = merchant.getPaySettingList().get(0); @@ -365,9 +380,9 @@ class ThirdAggregateOrderPayServiceImplTest { String oauthUrl = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10.6.80"); - Assertions.assertTrue(oauthUrl.contains("app_id=2026000000000002")); - Assertions.assertFalse(oauthUrl.contains("app_id=2026000000000001")); - Assertions.assertTrue(oauthUrl.contains("redirect_uri=https%3A%2F%2Fmini-pay.example.com%2Fthird%2Faggregate-order-pay%2Foauth%2Falipay")); + Assertions.assertTrue(oauthUrl.contains("app_id=2026000000000001")); + Assertions.assertFalse(oauthUrl.contains("app_id=2026000000000002")); + Assertions.assertTrue(oauthUrl.contains("redirect_uri=https%3A%2F%2Fpay.example.com%2Fthird%2Faggregate-order-pay%2Foauth%2Falipay")); Assertions.assertFalse(oauthUrl.contains("oauth%2Falipay%2Ftenant-code")); Assertions.assertTrue(oauthUrl.contains("state=dGVuYW50LWNvZGU.")); } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java index 77090d58..d35da86e 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java @@ -120,7 +120,7 @@ class ThirdAppPlusAliPayServiceImplTest { private TestableThirdAppPlusAliPayService service; - /** 初始化两组独立应用凭据、SDK 客户端和原网页/移动应用的商户关系。 */ + /** 初始化网页/移动应用与小程序凭据及客户端,以及原 App 的直付通关系。 */ @BeforeEach void setUp() { appPlusPayConfig = new AppPlusPayConfig(); @@ -170,6 +170,8 @@ class ThirdAppPlusAliPayServiceImplTest { */ @Test void appPayShouldCreateDirectPayOrderWithoutMarkingPaymentSuccess() throws Exception { + // 原 App 下单不应依赖是否配置了小程序支付。 + appPlusPayConfig.setAliPayMiniConfig(null); AlipayTradeAppPayResponse response = new AlipayTradeAppPayResponse(); response.setBody("signed-alipay-order"); when(alipayClient.sdkExecute(any(AlipayTradeAppPayRequest.class))).thenReturn(response); @@ -281,7 +283,7 @@ class ThirdAppPlusAliPayServiceImplTest { verify(alipayClient, never()).execute(any(AlipayTradeSettleConfirmRequest.class)); } - /** 使用两组真实 RSA 测试密钥核对公钥选择,未知应用和跨应用签名均须被拒绝。 */ + /** 使用各应用真实 RSA 测试密钥核对公钥选择,未知应用和跨应用签名均须被拒绝。 */ @Test void callbackSignatureShouldUseRegisteredApplicationPublicKey() throws Exception { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); @@ -917,10 +919,27 @@ class ThirdAppPlusAliPayServiceImplTest { */ @Test void payCallbackShouldAcceptBoundAggregateJsapiOrder() { + assertBoundAggregateCallback("JSAPI_PAY", miniApplicationSnapshot(), appPlusPayConfig.getAliPayMiniConfig().getAppId()); + } + + /** 历史当面付聚合子单仍需匹配应用、会话、业务订单及金额后才能确认成功。 */ + @Test + void payCallbackShouldAcceptLegacyFaceToFaceOrder() { + assertBoundAggregateCallback("FACE_TO_FACE_PAYMENT", miniApplicationSnapshot(), appPlusPayConfig.getAliPayMiniConfig().getAppId()); + } + + /** + * 对新旧聚合产品码复用完整支付回调约束,避免切换产品码后被错误拒绝或放宽会话校验。 + * + * @param productCode 支付创建时保存的当面付或历史 JSAPI 产品码 + * @param settingJson 含原应用 AppID 的支付单商户配置快照 + * @param appId 已验签通知中的应用标识,必须与支付单一致 + */ + private void assertBoundAggregateCallback(String productCode, String settingJson, String appId) { ThirdPayOrderEntity order = unpaidOrder(); - order.setMchPaySettingJson(miniApplicationSnapshot()); + order.setMchPaySettingJson(settingJson); order.setPayRep(JSONUtil.createObj().set("aggregatePay", true) - .set("productCode", "JSAPI_PAY").toString()); + .set("productCode", productCode).toString()); ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity(); session.setPayNo(order.getPayNo()); session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType()); @@ -936,7 +955,7 @@ class ThirdAppPlusAliPayServiceImplTest { when(thirdPayOrderService.getById(order.getId())).thenReturn(paidOrder); Map params = successCallbackParams(); - params.put("app_id", appPlusPayConfig.getAliPayMiniConfig().getAppId()); + params.put("app_id", appId); ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(params); Assertions.assertNotNull(result); @@ -966,6 +985,7 @@ class ThirdAppPlusAliPayServiceImplTest { appPlusPayConfig.getAliPayMiniConfig().getAppId()).toString(); } + private AlipayTradePrecreateResponse successPrecreateResponse(String qrCode) { AlipayTradePrecreateResponse response = new AlipayTradePrecreateResponse(); response.setCode("10000"); @@ -1123,9 +1143,10 @@ class ThirdAppPlusAliPayServiceImplTest { /** 被测小程序 AppID,仅用于为已解析的应用选择对应 Mock。 */ private final String testMiniAppId; + private boolean signatureValid = true; - /** 创建保持原 App 客户端扩展点、同时区分小程序客户端的测试服务。 */ + /** 创建保持原 App 扩展点、同时区分网页/移动与小程序客户端的测试服务。 */ private TestableThirdAppPlusAliPayService(AppPlusPayConfig appPlusPayConfig, ThirdPayOrderService thirdPayOrderService, AlipayClient testAlipayClient, AlipayClient testMiniAlipayClient) { super(appPlusPayConfig, thirdPayOrderService);