Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
f59e03ec02
11 files changed
+1372
-104
No files matched your search
@@ -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节,正常情况下应没有剩余待修复行。
|
||||
-- 尚未审批扣款的订单只修创建时间;后续扣款由更新后的后端沿用订单时间并写入离线备注。
|
||||
@@ -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相同,再次运行不会重复扣款或累加。
|
||||
+52
-2
@@ -7,6 +7,30 @@
|
||||
```yaml
|
||||
app-plus:
|
||||
pay:
|
||||
# 网页/移动应用:H5 网页授权与授权码交换使用此组;原 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
|
||||
# 小程序支付应用:聚合交易创建、付款通知和后续交易处理使用此组。
|
||||
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、应用私钥和支付宝公钥。H5 不新增应用类型或配置组:授权链接及 `alipay.system.oauth.token` 使用原 `ali-pay-config`,聚合 `alipay.trade.create` 使用 `ali-pay-mini-config`,两类客户端分别缓存。配置重复 AppID 会造成凭据选择歧义,使用前会明确拒绝。只启用微信的商户不要求支付宝配置;支付宝聚合入口则要求这两组均完整。更新凭据后需重启 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,12 +57,28 @@ 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-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、直付通或当前客户端场景的支付权限。
|
||||
|
||||
### 授权应用与支付应用的用户身份
|
||||
|
||||
网页/移动应用用自己的密钥兑换自己发起的授权码,服务端身份对象记录这个来源 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` 文档说明用户号格式及授权来源,身份模式与跨应用能力仍以当前应用实际权限为准。
|
||||
|
||||
## 发布顺序与兼容
|
||||
|
||||
@@ -52,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;已确认终态的旧请求直接恢复结果页,不再下单。
|
||||
|
||||
+62
-1
@@ -1,22 +1,81 @@
|
||||
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;用于聚合交易下单,不用于 H5 网页授权。 */
|
||||
private AliPayConfig aliPayMiniConfig;
|
||||
|
||||
/** 第三方应用凭据,配置键为 ali-pay-third-party-config;独立预留,不自动替代支付应用。 */
|
||||
private AliPayConfig aliPayThirdPartyConfig;
|
||||
|
||||
/**
|
||||
* 读取现有网页/移动应用供 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 完整的小程序应用凭据与支付回调地址
|
||||
*/
|
||||
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 +177,11 @@ public class AppPlusPayConfig {
|
||||
|
||||
}
|
||||
|
||||
/** 同一支付宝应用的完整凭据与回调配置,不允许只替换 AppID 而混用其他应用密钥。 */
|
||||
@Data
|
||||
public static class AliPayConfig {
|
||||
|
||||
/** 支付宝开放平台的应用 ID,必须与当前组的应用私钥及支付宝公钥配套。 */
|
||||
private String appId;
|
||||
|
||||
/**
|
||||
|
||||
+23
-2
@@ -5,10 +5,14 @@ import cn.hutool.core.util.StrUtil;
|
||||
/**
|
||||
* 支付宝用户授权换取的付款人身份。
|
||||
*
|
||||
* <p>新应用优先使用 open_id,兼容尚未返回 open_id 的存量应用 user_id。</p>
|
||||
* <p>记录授权来源应用,避免将网页应用的 open_id 直接用于另一个支付应用。
|
||||
* 跨应用下单只使用平台实际返回且支付应用支持的 user_id。</p>
|
||||
*/
|
||||
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。
|
||||
*
|
||||
|
||||
+77
-26
@@ -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.getAliPayConfig();
|
||||
request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl());
|
||||
request.setBizModel(model);
|
||||
// 关闭分账不携带平台抽佣专属授权;开启分账且配置了代调用授权时才发送 token。
|
||||
@@ -226,7 +225,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,21 +259,12 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
|
||||
/**
|
||||
* 获取延迟初始化的支付宝客户端。
|
||||
*
|
||||
* @return 使用现有应用密钥和支付宝公钥初始化的客户端
|
||||
* @return 使用小程序支付应用 AppID 与配套密钥初始化的客户端
|
||||
*/
|
||||
protected synchronized AlipayClient getAlipayClient() {
|
||||
if (alipayClient == null) {
|
||||
try {
|
||||
AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig();
|
||||
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或完成官方支持的身份打通");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建不含付款人标识和商户授权令牌的请求快照。
|
||||
*
|
||||
@@ -319,7 +368,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;
|
||||
}
|
||||
|
||||
|
||||
+14
-8
@@ -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.getAliPayConfig().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);
|
||||
@@ -1211,10 +1214,9 @@ 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.requireAliPayWebConfig();
|
||||
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 +1357,14 @@ 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());
|
||||
}
|
||||
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.requireAliPayWebConfig().getBaseCallbackUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+117
-39
@@ -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
|
||||
@@ -106,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";
|
||||
|
||||
/**
|
||||
* 订单码交易标题,固定安全文案避免把业务隐私传给第三方平台。
|
||||
*/
|
||||
@@ -136,6 +140,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
|
||||
*/
|
||||
private volatile AlipayClient alipayClient;
|
||||
|
||||
/** 非默认支付宝应用的 SDK 客户端,按已登记 AppID 缓存;仅在同步方法内读写。 */
|
||||
private final Map<String, AlipayClient> applicationClients = new HashMap<>();
|
||||
|
||||
/**
|
||||
* 支付宝应用级配置,来源于现有 app-plus.pay 配置中心。
|
||||
*/
|
||||
@@ -181,10 +188,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 +216,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 +225,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 +357,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 +398,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 +491,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 +525,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 +630,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 +694,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 +737,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 +793,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<ThirdPayOrderEntity>()
|
||||
@@ -1241,7 +1320,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 +1402,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 +1526,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 +1787,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 +1809,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
|
||||
@Override
|
||||
public ThirdALiPayCallbackVo miniSubscribeCallback(Map<String, String> params, String body, Map<String, String> 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 +1859,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 +1974,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 +1992,16 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
|
||||
*/
|
||||
private boolean validatePayCallbackOrder(Map<String, String> 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;
|
||||
@@ -1974,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())) {
|
||||
@@ -1986,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;
|
||||
}
|
||||
|
||||
+129
-19
@@ -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,用于验证子订单快照和保存失败补偿。
|
||||
*/
|
||||
@@ -82,21 +86,31 @@ 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");
|
||||
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();
|
||||
@@ -125,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<AlipaySystemOauthTokenRequest> 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 支付宝客户端声明的受检异常
|
||||
*/
|
||||
@@ -152,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<AlipayTradeCreateRequest> requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class);
|
||||
verify(alipayClient).execute(requestCaptor.capture());
|
||||
@@ -161,9 +177,11 @@ 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());
|
||||
assertDirectPaySettlement(request, "12.34");
|
||||
|
||||
ArgumentCaptor<ThirdPayOrderEntity> orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
|
||||
@@ -172,6 +190,78 @@ 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"));
|
||||
}
|
||||
|
||||
/** 缺少小程序支付配置时禁止使用网页/移动应用代替聚合下单。 */
|
||||
@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));
|
||||
}
|
||||
|
||||
/** 网页应用只有 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<AlipayTradeCreateRequest> 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));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,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<AlipayTradeCreateRequest> requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class);
|
||||
verify(alipayClient).execute(requestCaptor.capture());
|
||||
@@ -218,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<AlipayTradeCreateRequest> 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");
|
||||
|
||||
@@ -268,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));
|
||||
@@ -289,9 +379,18 @@ 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()));
|
||||
|
||||
verify(thirdAppPlusAliPayService).cancel(eq(context.getPayNo()), any(String.class));
|
||||
ArgumentCaptor<String> 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));
|
||||
}
|
||||
|
||||
/** 网页授权与小程序下单跨应用时只携带平台真实返回的全局 user_id。 */
|
||||
private ThirdAggregateAlipayOauthIdentity webIdentity() {
|
||||
return new ThirdAggregateAlipayOauthIdentity(appPlusPayConfig.getAliPayConfig().getAppId(),
|
||||
"alipay-open-id", "2088000000000001");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,5 +433,11 @@ class ThirdAggregateAliPayServiceImplTest {
|
||||
protected AlipayClient getAlipayClient() {
|
||||
return testAlipayClient;
|
||||
}
|
||||
|
||||
/** 返回网页授权专用客户端,不允许由支付客户端兑换授权码。 */
|
||||
@Override
|
||||
protected AlipayClient getOauthAlipayClient() {
|
||||
return testOauthAlipayClient;
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
-3
@@ -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,66 @@ 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 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",
|
||||
"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 重复会导致验签歧义,必须在进入 H5 收银前拒绝。 */
|
||||
@Test
|
||||
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");
|
||||
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 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() {
|
||||
@@ -277,19 +335,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,6 +380,8 @@ class ThirdAggregateOrderPayServiceImplTest {
|
||||
|
||||
String oauthUrl = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10.6.80");
|
||||
|
||||
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."));
|
||||
@@ -429,6 +490,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");
|
||||
|
||||
+194
-4
@@ -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;
|
||||
|
||||
/** 初始化网页/移动应用与小程序凭据及客户端,以及原 App 的直付通关系。 */
|
||||
@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);
|
||||
@@ -149,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);
|
||||
@@ -160,6 +183,8 @@ class ThirdAppPlusAliPayServiceImplTest {
|
||||
ArgumentCaptor<AlipayTradeAppPayRequest> 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 +200,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<AlipayTradeRefundRequest> 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<String, String> 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));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -788,9 +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(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());
|
||||
@@ -805,13 +954,38 @@ class ThirdAppPlusAliPayServiceImplTest {
|
||||
paidOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
|
||||
when(thirdPayOrderService.getById(order.getId())).thenReturn(paidOrder);
|
||||
|
||||
ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(successCallbackParams());
|
||||
Map<String, String> params = successCallbackParams();
|
||||
params.put("app_id", appId);
|
||||
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 +1137,22 @@ 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 +1160,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<String, String> params) {
|
||||
return signatureValid;
|
||||
|
||||
Reference in new issue
Block a user