diff --git a/db/20261026_fix_mengkanuo01_offline_order_time_121500.sql b/db/20261026_fix_mengkanuo01_offline_order_time_121500.sql new file mode 100644 index 00000000..9cbcea03 --- /dev/null +++ b/db/20261026_fix_mengkanuo01_offline_order_time_121500.sql @@ -0,0 +1,130 @@ +-- 梦卡诺01离线订单历史创建时间修复(2026-10-26 生成,仅生成,尚未执行)。 +-- 日期口径已经用户确认:使用 hard_order.appt_use_date 当天的 12:15:00。 +-- 此为用户指定的统一时刻,不代表已从梦卡诺01本地数据库恢复原始时分秒。 +-- 不使用云端已错误的 create_time/pay_time 推断日期;appt_use_date 为空时跳过。 +-- 范围:租户1、梦卡诺餐厅、梦卡诺01、is_offline_order='1'、未删除,消费日期为10月19日至26日。 +-- 仅处理没有匹配支付流水、或匹配支付流水 remark 为 NULL/空串/空格的剩余订单及流水。 +-- 已有非空备注的付款流水不更新;尚无付款流水时仅修订单,不创建流水、不补扣款。 +-- 不限制订单状态和审批状态,已审批扣款的离线订单同样按付款流水关联。 +-- 仅修改 hard_order.create_time、wallet_transaction.create_time 和缺失的 wallet_transaction.remark。 +-- 付款流水限定 biz_type='4',并核对订单号、租户、用户、钱包及删除标记;退款流水不进入修复。 +-- 例:订单2114557001439100930 -> 2026-10-22 12:15:00;2114556468313702401 -> 2026-10-21 12:15:00。 +-- 沿用仓库库名 pjcloudx_hardware / pjcloudx_wallet,要求同实例且目标表支持事务。 +-- 在同一连接先执行参数和第1节预览,确认目标后执行第2节;未改变任何表结构。 + +SET NAMES utf8mb4; +SET @repair_tenant_id = 1; +SET @repair_hardware_id = 2048955151738671106; +SET @repair_merchant_id = 2030260730023755777; +SET @repair_start_date = '2026-10-19'; +SET @repair_end_date = '2026-10-27'; + +-- 1. 更新前预览:按消费日期汇总本次需要修复的订单及支付流水数量。 +SELECT + o.appt_use_date AS consumption_date, + TIMESTAMP(o.appt_use_date, '12:15:00') AS target_create_time, + COUNT(DISTINCT o.id) AS orders_to_update, + COUNT(DISTINCT w.id) AS payment_transactions_to_update, + COUNT(DISTINCT CASE WHEN w.id IS NULL THEN o.id END) AS orders_without_payment_transaction +FROM pjcloudx_hardware.hard_order o +LEFT JOIN pjcloudx_wallet.wallet_transaction w + ON w.biz_id = CAST(o.id AS CHAR) + AND w.biz_type = '4' + AND w.tenant_id = o.tenant_id + AND w.user_id = o.user_id + AND w.wallet_id = o.wallet_id + AND w.del_flag = '0' +WHERE o.tenant_id = @repair_tenant_id + AND o.merchant_id = @repair_merchant_id + AND o.hard_id = @repair_hardware_id + AND o.is_offline_order = '1' + AND o.del_flag = '0' + AND o.appt_use_date >= @repair_start_date + AND o.appt_use_date < @repair_end_date + -- 只处理剩余未备注付款;条件放在 WHERE,避免已备注付款被 LEFT JOIN 误当作不存在。 + AND (w.id IS NULL OR NULLIF(TRIM(w.remark), '') IS NULL) + AND ( + NOT (o.create_time <=> TIMESTAMP(o.appt_use_date, '12:15:00')) + OR (w.id IS NOT NULL AND ( + NOT (w.create_time <=> TIMESTAMP(o.appt_use_date, '12:15:00')) + OR NULLIF(TRIM(w.remark), '') IS NULL + )) + ) +GROUP BY o.appt_use_date +ORDER BY o.appt_use_date; + +-- 预览明细:可核对旧订单时间、旧流水时间、目标日期和待补备注;只读查询。 +SELECT + o.id AS order_id, + o.android_order_code, + o.appt_use_date, + o.create_time AS old_order_create_time, + TIMESTAMP(o.appt_use_date, '12:15:00') AS target_create_time, + w.id AS wallet_transaction_id, + w.create_time AS old_transaction_create_time, + w.remark AS old_transaction_remark +FROM pjcloudx_hardware.hard_order o +LEFT JOIN pjcloudx_wallet.wallet_transaction w + ON w.biz_id = CAST(o.id AS CHAR) + AND w.biz_type = '4' + AND w.tenant_id = o.tenant_id + AND w.user_id = o.user_id + AND w.wallet_id = o.wallet_id + AND w.del_flag = '0' +WHERE o.tenant_id = @repair_tenant_id + AND o.merchant_id = @repair_merchant_id + AND o.hard_id = @repair_hardware_id + AND o.is_offline_order = '1' + AND o.del_flag = '0' + AND o.appt_use_date >= @repair_start_date + AND o.appt_use_date < @repair_end_date + -- 只处理剩余未备注付款;条件放在 WHERE,避免已备注付款被 LEFT JOIN 误当作不存在。 + AND (w.id IS NULL OR NULLIF(TRIM(w.remark), '') IS NULL) + AND ( + NOT (o.create_time <=> TIMESTAMP(o.appt_use_date, '12:15:00')) + OR (w.id IS NOT NULL AND ( + NOT (w.create_time <=> TIMESTAMP(o.appt_use_date, '12:15:00')) + OR NULLIF(TRIM(w.remark), '') IS NULL + )) + ) +ORDER BY o.appt_use_date, o.id, w.id; + +-- 2. 修复:同一条多表 UPDATE 原子修改订单和已存在的付款流水。 +-- 已有备注付款由 WHERE 排除;重复执行会跳过已补齐备注的付款及无需再改时间的无流水订单。 +START TRANSACTION; + +UPDATE pjcloudx_hardware.hard_order o +LEFT JOIN pjcloudx_wallet.wallet_transaction w + ON w.biz_id = CAST(o.id AS CHAR) + AND w.biz_type = '4' + AND w.tenant_id = o.tenant_id + AND w.user_id = o.user_id + AND w.wallet_id = o.wallet_id + AND w.del_flag = '0' +SET o.create_time = TIMESTAMP(o.appt_use_date, '12:15:00'), + w.create_time = TIMESTAMP(o.appt_use_date, '12:15:00'), + w.remark = '离线订单扣除' +WHERE o.tenant_id = @repair_tenant_id + AND o.merchant_id = @repair_merchant_id + AND o.hard_id = @repair_hardware_id + AND o.is_offline_order = '1' + AND o.del_flag = '0' + AND o.appt_use_date >= @repair_start_date + AND o.appt_use_date < @repair_end_date + -- 只处理剩余未备注付款;条件放在 WHERE,避免已备注付款被 LEFT JOIN 误当作不存在。 + AND (w.id IS NULL OR NULLIF(TRIM(w.remark), '') IS NULL) + AND ( + NOT (o.create_time <=> TIMESTAMP(o.appt_use_date, '12:15:00')) + OR (w.id IS NOT NULL AND ( + NOT (w.create_time <=> TIMESTAMP(o.appt_use_date, '12:15:00')) + OR NULLIF(TRIM(w.remark), '') IS NULL + )) + ); + +-- 订单和钱包流水合计受影响记录数,不等于订单数,且受客户端 affected/found-rows 选项影响。 +SELECT ROW_COUNT() AS affected_target_rows; + +COMMIT; + +-- 3. 执行后可重跑第1节,正常情况下应没有剩余待修复行。 +-- 尚未审批扣款的订单只修创建时间;后续扣款由更新后的后端沿用订单时间并写入离线备注。 diff --git a/db/20261028_recalculate_offline_wallet_transaction_balance_20261019_20261026.sql b/db/20261028_recalculate_offline_wallet_transaction_balance_20261019_20261026.sql new file mode 100644 index 00000000..de82d681 --- /dev/null +++ b/db/20261028_recalculate_offline_wallet_transaction_balance_20261019_20261026.sql @@ -0,0 +1,503 @@ +-- 2026-10-28:修复离线订单流水时间调整后,历史余额快照与新时间顺序不一致的问题。 +-- 仅生成 SQL,未连接或执行业务数据库;MySQL 8 / 支持窗口函数的 OceanBase MySQL 模式。 +-- 目标区间:[2026-10-19 00:00:00, 2026-10-27 00:00:00),完整包含10月26日。 +-- 按“人”选范围:该区间任一有效流水 remark='离线订单扣除',即选中该租户下的用户。 +-- 再处理该用户各钱包在区间内的全部有效流水,不限制设备、商户、业务类型或流水备注。 +-- 同一用户的钱包分别计算,禁止把不同钱包的资金混在一起;金额单位均为分。 +-- 只更新 wallet_transaction.balance,不修改真实钱包余额、price、create_time、remark或订单。 +-- +-- 重算公式(price本身已带正负号,充值/消费/退款/转账/调账/清零等全部参与): +-- 19日期初 = 当前 wallet_manage.balance - SUM(19日起至当前的全部有效流水price) +-- 26日期末 = 当前 wallet_manage.balance - SUM(27日起至当前的全部有效流水price) +-- 每笔新balance = 19日期初 + 按(create_time ASC,id ASC)累计的区间price。 +-- 因此27日后的流水只参与回退资金,不更新其balance;不假设期初为0,不用已乱序的首笔balance作基准。 +-- 同秒流水按id稳定排序;不改price来“强制清零”,RESET流水也按其真实增减金额参与。 +-- +-- 安全校验(整钱包跳过,预览会标明原因): +-- INVALID_WALLET:钱包已删除或真实余额为空。 +-- OWNER_MISMATCH:钱包当前所有者不在本次冻结用户集合。 +-- INVALID_HISTORY:存在未知时间,或19日起存在删除流水/空金额/流水用户归属冲突。 +-- 删除流水不会自动退回资金,不能直接忽略其资金影响后强行计算,须先核对。 +-- OPENING_MISMATCH:回推期初与19日前最后有效流水balance不一致。 +-- CLOSING_MISMATCH:回推期末与27日起第一有效流水(balance-price)不一致。 +-- wallet_has_invalid_new_balance=1:任一新balance为空或超出INT范围,整钱包跳过。 +-- CURRENT_BALANCE_ONLY:区间外没有余额快照辅助校验,仍以真实当前余额回推,不把缺样本当作余额为0。 +-- 缺少wallet_manage的钱包单独列出,无法取得真实余额时不更新。 +-- +-- 执行条件:当前真实钱包余额正确、资金账本完整。钱包直接改余额/手工删改流水可能造成不一致, +-- 必须先处理预览中的异常钱包;本脚本不会把异常钱包强行改成“看起来连续”。 +-- 请在短维护窗口暂停钱包开户、资金写入、离线补传和历史流水编辑/日期修复。 +-- 同一连接顺序执行;事务内先冻结用户和钱包集合,再锁钱包行,再读取最新快照计算。 +-- 普通资金写入会被钱包行锁阻挡,但行锁挡不住后台直接删改流水;不要并发执行此类操作。 +-- 如只执行预览不更新,立即 ROLLBACK 释放行锁;任何语句报错都应 ROLLBACK,不继续 COMMIT。 +-- 出错后可用 SET SESSION group_concat_max_len=@repair_saved_group_concat_max_len 恢复会话参数。 +-- 不创建临时表,不改变表结构;窗口派生表强制物化,用于避免UPDATE自读目标表的1093限制。 + +SET NAMES utf8mb4; +SET @repair_tenant_id = 1; +SET @repair_start_time = '2026-10-19 00:00:00'; +SET @repair_end_time = '2026-10-27 00:00:00'; +SET @repair_remark = '离线订单扣除'; +SET @repair_saved_group_concat_max_len = @@SESSION.group_concat_max_len; +SET SESSION group_concat_max_len = 16777216; +-- 防止同一连接重跑时,失败语句残留上一次的用户或钱包集合。 +SET @repair_user_ids = NULL, @repair_wallet_ids = NULL, + @repair_user_expected_bytes = NULL, @repair_wallet_expected_bytes = NULL, + @repair_users_complete = 0, @repair_wallets_complete = 0, @repair_lists_complete = 0; + +-- 1. 使用新的已提交快照;仅设置本次事务的隔离级别,不改变会话后续事务默认值。 +SET TRANSACTION ISOLATION LEVEL READ COMMITTED; +START TRANSACTION; + +-- 2. 同一条SELECT同时冻结用户列表、人数和完整长度,避免分次查询集合漂移。 +SELECT GROUP_CONCAT(CAST(u.user_id AS CHAR) ORDER BY u.user_id SEPARATOR ','), + COUNT(*), + COALESCE(SUM(OCTET_LENGTH(CAST(u.user_id AS CHAR))), 0) + GREATEST(COUNT(*) - 1, 0) +INTO @repair_user_ids, @repair_user_count, @repair_user_expected_bytes +FROM ( + SELECT DISTINCT t.user_id + FROM pjcloudx_wallet.wallet_transaction t + WHERE t.tenant_id = @repair_tenant_id AND t.del_flag = '0' + AND t.create_time >= @repair_start_time AND t.create_time < @repair_end_time + AND t.remark = @repair_remark AND t.user_id IS NOT NULL +) u; +SET @repair_users_complete = (COALESCE(OCTET_LENGTH(@repair_user_ids), 0) = @repair_user_expected_bytes); + +-- 只冻结这些用户区间内有流水且账户已存在的钱包;后续不动态扩大钱包范围。 +SELECT GROUP_CONCAT(CAST(k.wallet_id AS CHAR) ORDER BY k.wallet_id SEPARATOR ','), + COUNT(*), + COALESCE(SUM(OCTET_LENGTH(CAST(k.wallet_id AS CHAR))), 0) + GREATEST(COUNT(*) - 1, 0) +INTO @repair_wallet_ids, @repair_wallet_count, @repair_wallet_expected_bytes +FROM ( + SELECT DISTINCT m.id AS wallet_id + FROM pjcloudx_wallet.wallet_manage m + JOIN pjcloudx_wallet.wallet_transaction t + ON t.tenant_id = m.tenant_id AND t.wallet_id = m.id + WHERE m.tenant_id = @repair_tenant_id AND t.del_flag = '0' + AND t.create_time >= @repair_start_time AND t.create_time < @repair_end_time + AND @repair_users_complete = 1 + AND (FIND_IN_SET(CAST(m.user_id AS CHAR), @repair_user_ids) > 0 + OR FIND_IN_SET(CAST(t.user_id AS CHAR), @repair_user_ids) > 0) +) k; +SET @repair_wallets_complete = (COALESCE(OCTET_LENGTH(@repair_wallet_ids), 0) = @repair_wallet_expected_bytes); +SET @repair_lists_complete = (@repair_users_complete = 1 AND @repair_wallets_complete = 1); + +-- lists_complete必须为1;任一列表被截断时整批UPDATE自然不命中,不允许部分钱包误修。 +SELECT @repair_user_count AS affected_user_count, + @repair_wallet_count AS frozen_wallet_count, + @repair_lists_complete AS lists_complete; + +-- 3. 按固定主键顺序锁定全部目标钱包;等待已有资金事务完成后,再进行后续计算。 +SELECT m.tenant_id, m.id AS wallet_id, m.user_id, m.balance AS current_wallet_balance +FROM pjcloudx_wallet.wallet_manage m +WHERE m.tenant_id = @repair_tenant_id AND @repair_lists_complete = 1 + AND FIND_IN_SET(CAST(m.id AS CHAR), @repair_wallet_ids) > 0 +ORDER BY m.id +FOR UPDATE; + +-- 4. 缺少真实钱包账户的区间流水,仅诊断,不更新。 +SELECT t.tenant_id, t.user_id, t.wallet_id, COUNT(*) AS period_transaction_count, + 'MISSING_WALLET' AS repair_status +FROM pjcloudx_wallet.wallet_transaction t +LEFT JOIN pjcloudx_wallet.wallet_manage m + ON m.tenant_id = t.tenant_id AND m.id = t.wallet_id +WHERE t.tenant_id = @repair_tenant_id AND t.del_flag = '0' + AND t.create_time >= @repair_start_time AND t.create_time < @repair_end_time + AND @repair_lists_complete = 1 + AND FIND_IN_SET(CAST(t.user_id AS CHAR), @repair_user_ids) > 0 + AND m.id IS NULL +GROUP BY t.tenant_id, t.user_id, t.wallet_id; + +-- 5. 钱包级双端校验:缺少某端快照不会自动判错;有快照但对不上则整钱包不更新。 +SELECT c.* +FROM ( +SELECT b.*, + CASE + WHEN b.current_wallet_balance IS NULL OR NOT (b.wallet_del_flag <=> '0') + THEN 'INVALID_WALLET' + WHEN COALESCE(FIND_IN_SET(CAST(b.user_id AS CHAR), @repair_user_ids), 0) = 0 + THEN 'OWNER_MISMATCH' + WHEN b.invalid_history_count > 0 THEN 'INVALID_HISTORY' + WHEN b.before_anchor_id IS NOT NULL AND NOT (b.before_anchor_balance <=> b.opening_balance) + THEN 'OPENING_MISMATCH' + WHEN b.after_anchor_id IS NOT NULL AND NOT (b.after_anchor_opening_balance <=> b.closing_balance) + THEN 'CLOSING_MISMATCH' + ELSE 'READY' + END AS repair_status, + CASE WHEN b.before_anchor_id IS NULL AND b.after_anchor_id IS NULL + THEN 'CURRENT_BALANCE_ONLY' ELSE 'BOUNDARY_CHECKED' END AS anchor_mode +FROM ( +SELECT + m.tenant_id, + m.id AS wallet_id, + m.user_id, + m.balance AS current_wallet_balance, + m.del_flag AS wallet_del_flag, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_start_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS opening_balance, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_end_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS closing_balance, + SUM(CASE WHEN h.create_time >= @repair_start_time AND h.create_time < @repair_end_time + AND h.del_flag = '0' THEN 1 ELSE 0 END) AS period_transaction_count, + SUM(CASE WHEN h.create_time IS NULL OR ( + h.create_time >= @repair_start_time AND ( + h.price IS NULL OR NOT (h.del_flag <=> '0') OR NOT (h.user_id <=> m.user_id) + )) THEN 1 ELSE 0 END) AS invalid_history_count, + (SELECT p0.id FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_id, + (SELECT CAST(p0.balance AS DECIMAL(65, 0)) FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_balance, + (SELECT n0.id FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_id, + (SELECT CAST(n0.balance AS DECIMAL(65, 0)) - CAST(n0.price AS DECIMAL(65, 0)) + FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_opening_balance +FROM pjcloudx_wallet.wallet_manage m +JOIN pjcloudx_wallet.wallet_transaction h + ON h.tenant_id = m.tenant_id AND h.wallet_id = m.id +WHERE m.tenant_id = @repair_tenant_id + AND @repair_lists_complete = 1 + AND FIND_IN_SET(CAST(m.id AS CHAR), @repair_wallet_ids) > 0 +GROUP BY m.tenant_id, m.id, m.user_id, m.balance, m.del_flag +) b +) c +ORDER BY c.repair_status, c.user_id, c.wallet_id; + +-- 6. 更新前明细:列出需要变化或存在异常的流水,old_balance可导出保存作为本次修复前快照。 +SELECT c.tenant_id, c.user_id, c.wallet_id, c.id AS transaction_id, c.create_time, + c.price, c.old_balance, c.new_balance, c.repair_status, c.anchor_mode, + c.wallet_has_invalid_new_balance, + CASE WHEN c.repair_status = 'READY' AND c.wallet_has_invalid_new_balance = 0 + AND NOT (c.old_balance <=> c.new_balance) THEN 1 ELSE 0 END AS will_update +FROM ( +SELECT r.*, + MAX(CASE WHEN r.new_balance IS NULL OR r.new_balance < -2147483648 + OR r.new_balance > 2147483647 THEN 1 ELSE 0 END) OVER ( + PARTITION BY r.tenant_id, r.wallet_id + ) AS wallet_has_invalid_new_balance +FROM ( +SELECT + t.id, + t.tenant_id, + t.user_id, + t.wallet_id, + t.create_time, + t.price, + t.balance AS old_balance, + a.current_wallet_balance, + a.opening_balance, + a.closing_balance, + a.repair_status, + a.anchor_mode, + a.opening_balance + SUM(CAST(t.price AS DECIMAL(65, 0))) OVER ( + PARTITION BY t.tenant_id, t.wallet_id + ORDER BY t.create_time ASC, t.id ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS new_balance +FROM ( +SELECT b.*, + CASE + WHEN b.current_wallet_balance IS NULL OR NOT (b.wallet_del_flag <=> '0') + THEN 'INVALID_WALLET' + WHEN COALESCE(FIND_IN_SET(CAST(b.user_id AS CHAR), @repair_user_ids), 0) = 0 + THEN 'OWNER_MISMATCH' + WHEN b.invalid_history_count > 0 THEN 'INVALID_HISTORY' + WHEN b.before_anchor_id IS NOT NULL AND NOT (b.before_anchor_balance <=> b.opening_balance) + THEN 'OPENING_MISMATCH' + WHEN b.after_anchor_id IS NOT NULL AND NOT (b.after_anchor_opening_balance <=> b.closing_balance) + THEN 'CLOSING_MISMATCH' + ELSE 'READY' + END AS repair_status, + CASE WHEN b.before_anchor_id IS NULL AND b.after_anchor_id IS NULL + THEN 'CURRENT_BALANCE_ONLY' ELSE 'BOUNDARY_CHECKED' END AS anchor_mode +FROM ( +SELECT + m.tenant_id, + m.id AS wallet_id, + m.user_id, + m.balance AS current_wallet_balance, + m.del_flag AS wallet_del_flag, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_start_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS opening_balance, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_end_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS closing_balance, + SUM(CASE WHEN h.create_time >= @repair_start_time AND h.create_time < @repair_end_time + AND h.del_flag = '0' THEN 1 ELSE 0 END) AS period_transaction_count, + SUM(CASE WHEN h.create_time IS NULL OR ( + h.create_time >= @repair_start_time AND ( + h.price IS NULL OR NOT (h.del_flag <=> '0') OR NOT (h.user_id <=> m.user_id) + )) THEN 1 ELSE 0 END) AS invalid_history_count, + (SELECT p0.id FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_id, + (SELECT CAST(p0.balance AS DECIMAL(65, 0)) FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_balance, + (SELECT n0.id FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_id, + (SELECT CAST(n0.balance AS DECIMAL(65, 0)) - CAST(n0.price AS DECIMAL(65, 0)) + FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_opening_balance +FROM pjcloudx_wallet.wallet_manage m +JOIN pjcloudx_wallet.wallet_transaction h + ON h.tenant_id = m.tenant_id AND h.wallet_id = m.id +WHERE m.tenant_id = @repair_tenant_id + AND @repair_lists_complete = 1 + AND FIND_IN_SET(CAST(m.id AS CHAR), @repair_wallet_ids) > 0 +GROUP BY m.tenant_id, m.id, m.user_id, m.balance, m.del_flag +) b +) a +JOIN pjcloudx_wallet.wallet_transaction t + ON t.tenant_id = a.tenant_id AND t.wallet_id = a.wallet_id +WHERE t.del_flag = '0' + AND t.create_time >= @repair_start_time + AND t.create_time < @repair_end_time +) r +) c +WHERE NOT (c.old_balance <=> c.new_balance) + OR c.repair_status <> 'READY' OR c.wallet_has_invalid_new_balance = 1 +ORDER BY c.user_id, c.wallet_id, c.create_time, c.id; + +-- 7. 只修改通过钱包校验且新余额未溢出的区间流水balance。 +-- 计算源涵盖该用户各钱包的全部区间业务,不按离线备注筛选待更新明细。 +UPDATE pjcloudx_wallet.wallet_transaction target +JOIN ( +SELECT r.*, + MAX(CASE WHEN r.new_balance IS NULL OR r.new_balance < -2147483648 + OR r.new_balance > 2147483647 THEN 1 ELSE 0 END) OVER ( + PARTITION BY r.tenant_id, r.wallet_id + ) AS wallet_has_invalid_new_balance +FROM ( +SELECT + t.id, + t.tenant_id, + t.user_id, + t.wallet_id, + t.create_time, + t.price, + t.balance AS old_balance, + a.current_wallet_balance, + a.opening_balance, + a.closing_balance, + a.repair_status, + a.anchor_mode, + a.opening_balance + SUM(CAST(t.price AS DECIMAL(65, 0))) OVER ( + PARTITION BY t.tenant_id, t.wallet_id + ORDER BY t.create_time ASC, t.id ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS new_balance +FROM ( +SELECT b.*, + CASE + WHEN b.current_wallet_balance IS NULL OR NOT (b.wallet_del_flag <=> '0') + THEN 'INVALID_WALLET' + WHEN COALESCE(FIND_IN_SET(CAST(b.user_id AS CHAR), @repair_user_ids), 0) = 0 + THEN 'OWNER_MISMATCH' + WHEN b.invalid_history_count > 0 THEN 'INVALID_HISTORY' + WHEN b.before_anchor_id IS NOT NULL AND NOT (b.before_anchor_balance <=> b.opening_balance) + THEN 'OPENING_MISMATCH' + WHEN b.after_anchor_id IS NOT NULL AND NOT (b.after_anchor_opening_balance <=> b.closing_balance) + THEN 'CLOSING_MISMATCH' + ELSE 'READY' + END AS repair_status, + CASE WHEN b.before_anchor_id IS NULL AND b.after_anchor_id IS NULL + THEN 'CURRENT_BALANCE_ONLY' ELSE 'BOUNDARY_CHECKED' END AS anchor_mode +FROM ( +SELECT + m.tenant_id, + m.id AS wallet_id, + m.user_id, + m.balance AS current_wallet_balance, + m.del_flag AS wallet_del_flag, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_start_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS opening_balance, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_end_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS closing_balance, + SUM(CASE WHEN h.create_time >= @repair_start_time AND h.create_time < @repair_end_time + AND h.del_flag = '0' THEN 1 ELSE 0 END) AS period_transaction_count, + SUM(CASE WHEN h.create_time IS NULL OR ( + h.create_time >= @repair_start_time AND ( + h.price IS NULL OR NOT (h.del_flag <=> '0') OR NOT (h.user_id <=> m.user_id) + )) THEN 1 ELSE 0 END) AS invalid_history_count, + (SELECT p0.id FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_id, + (SELECT CAST(p0.balance AS DECIMAL(65, 0)) FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_balance, + (SELECT n0.id FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_id, + (SELECT CAST(n0.balance AS DECIMAL(65, 0)) - CAST(n0.price AS DECIMAL(65, 0)) + FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_opening_balance +FROM pjcloudx_wallet.wallet_manage m +JOIN pjcloudx_wallet.wallet_transaction h + ON h.tenant_id = m.tenant_id AND h.wallet_id = m.id +WHERE m.tenant_id = @repair_tenant_id + AND @repair_lists_complete = 1 + AND FIND_IN_SET(CAST(m.id AS CHAR), @repair_wallet_ids) > 0 +GROUP BY m.tenant_id, m.id, m.user_id, m.balance, m.del_flag +) b +) a +JOIN pjcloudx_wallet.wallet_transaction t + ON t.tenant_id = a.tenant_id AND t.wallet_id = a.wallet_id +WHERE t.del_flag = '0' + AND t.create_time >= @repair_start_time + AND t.create_time < @repair_end_time +) r +) c ON c.id = target.id AND c.tenant_id = target.tenant_id +SET target.balance = CAST(c.new_balance AS SIGNED) +WHERE target.tenant_id = @repair_tenant_id AND target.del_flag = '0' + AND target.create_time >= @repair_start_time AND target.create_time < @repair_end_time + AND c.repair_status = 'READY' AND c.wallet_has_invalid_new_balance = 0 + AND target.wallet_id = c.wallet_id AND target.user_id = c.user_id + AND target.price = c.price AND target.create_time = c.create_time + AND target.balance <=> c.old_balance + AND NOT (target.balance <=> c.new_balance); +SELECT ROW_COUNT() AS updated_transaction_rows; + +-- 8. 提交前复核:remaining_repairable_rows应为0;异常钱包单独统计,未被强行改写。 +SET @repair_checked_wallets = NULL, @repair_remaining_rows = NULL, @repair_skipped_wallets = NULL; +SELECT + COUNT(DISTINCT CASE WHEN c.repair_status = 'READY' + AND c.wallet_has_invalid_new_balance = 0 THEN c.wallet_id END) AS checked_wallets, + COALESCE(SUM(CASE WHEN c.repair_status = 'READY' AND c.wallet_has_invalid_new_balance = 0 + AND NOT (c.old_balance <=> c.new_balance) THEN 1 ELSE 0 END), 0) AS remaining_repairable_rows, + COUNT(DISTINCT CASE WHEN c.repair_status <> 'READY' + OR c.wallet_has_invalid_new_balance = 1 THEN c.wallet_id END) AS skipped_wallets +INTO @repair_checked_wallets, @repair_remaining_rows, @repair_skipped_wallets +FROM ( +SELECT r.*, + MAX(CASE WHEN r.new_balance IS NULL OR r.new_balance < -2147483648 + OR r.new_balance > 2147483647 THEN 1 ELSE 0 END) OVER ( + PARTITION BY r.tenant_id, r.wallet_id + ) AS wallet_has_invalid_new_balance +FROM ( +SELECT + t.id, + t.tenant_id, + t.user_id, + t.wallet_id, + t.create_time, + t.price, + t.balance AS old_balance, + a.current_wallet_balance, + a.opening_balance, + a.closing_balance, + a.repair_status, + a.anchor_mode, + a.opening_balance + SUM(CAST(t.price AS DECIMAL(65, 0))) OVER ( + PARTITION BY t.tenant_id, t.wallet_id + ORDER BY t.create_time ASC, t.id ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS new_balance +FROM ( +SELECT b.*, + CASE + WHEN b.current_wallet_balance IS NULL OR NOT (b.wallet_del_flag <=> '0') + THEN 'INVALID_WALLET' + WHEN COALESCE(FIND_IN_SET(CAST(b.user_id AS CHAR), @repair_user_ids), 0) = 0 + THEN 'OWNER_MISMATCH' + WHEN b.invalid_history_count > 0 THEN 'INVALID_HISTORY' + WHEN b.before_anchor_id IS NOT NULL AND NOT (b.before_anchor_balance <=> b.opening_balance) + THEN 'OPENING_MISMATCH' + WHEN b.after_anchor_id IS NOT NULL AND NOT (b.after_anchor_opening_balance <=> b.closing_balance) + THEN 'CLOSING_MISMATCH' + ELSE 'READY' + END AS repair_status, + CASE WHEN b.before_anchor_id IS NULL AND b.after_anchor_id IS NULL + THEN 'CURRENT_BALANCE_ONLY' ELSE 'BOUNDARY_CHECKED' END AS anchor_mode +FROM ( +SELECT + m.tenant_id, + m.id AS wallet_id, + m.user_id, + m.balance AS current_wallet_balance, + m.del_flag AS wallet_del_flag, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_start_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS opening_balance, + CAST(m.balance AS DECIMAL(65, 0)) - COALESCE(SUM(CASE + WHEN h.create_time >= @repair_end_time AND h.del_flag = '0' + THEN CAST(h.price AS DECIMAL(65, 0)) ELSE 0 END), 0) AS closing_balance, + SUM(CASE WHEN h.create_time >= @repair_start_time AND h.create_time < @repair_end_time + AND h.del_flag = '0' THEN 1 ELSE 0 END) AS period_transaction_count, + SUM(CASE WHEN h.create_time IS NULL OR ( + h.create_time >= @repair_start_time AND ( + h.price IS NULL OR NOT (h.del_flag <=> '0') OR NOT (h.user_id <=> m.user_id) + )) THEN 1 ELSE 0 END) AS invalid_history_count, + (SELECT p0.id FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_id, + (SELECT CAST(p0.balance AS DECIMAL(65, 0)) FROM pjcloudx_wallet.wallet_transaction p0 + WHERE p0.tenant_id = m.tenant_id AND p0.wallet_id = m.id AND p0.del_flag = '0' + AND p0.create_time < @repair_start_time + ORDER BY p0.create_time DESC, p0.id DESC LIMIT 1) AS before_anchor_balance, + (SELECT n0.id FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_id, + (SELECT CAST(n0.balance AS DECIMAL(65, 0)) - CAST(n0.price AS DECIMAL(65, 0)) + FROM pjcloudx_wallet.wallet_transaction n0 + WHERE n0.tenant_id = m.tenant_id AND n0.wallet_id = m.id AND n0.del_flag = '0' + AND n0.create_time >= @repair_end_time + ORDER BY n0.create_time ASC, n0.id ASC LIMIT 1) AS after_anchor_opening_balance +FROM pjcloudx_wallet.wallet_manage m +JOIN pjcloudx_wallet.wallet_transaction h + ON h.tenant_id = m.tenant_id AND h.wallet_id = m.id +WHERE m.tenant_id = @repair_tenant_id + AND @repair_lists_complete = 1 + AND FIND_IN_SET(CAST(m.id AS CHAR), @repair_wallet_ids) > 0 +GROUP BY m.tenant_id, m.id, m.user_id, m.balance, m.del_flag +) b +) a +JOIN pjcloudx_wallet.wallet_transaction t + ON t.tenant_id = a.tenant_id AND t.wallet_id = a.wallet_id +WHERE t.del_flag = '0' + AND t.create_time >= @repair_start_time + AND t.create_time < @repair_end_time +) r +) c; + +SELECT @repair_checked_wallets AS checked_wallets, + @repair_remaining_rows AS remaining_repairable_rows, + @repair_skipped_wallets AS skipped_wallets; + +-- 只有列表完整且可修复差异全部清零才提交,否则整批回滚;检查本身失败时NULL也会走回滚。 +SET @repair_transaction_action = IF(@repair_lists_complete = 1 AND @repair_remaining_rows = 0, + 'COMMIT', 'ROLLBACK'); +PREPARE repair_transaction_stmt FROM @repair_transaction_action; +EXECUTE repair_transaction_stmt; +DEALLOCATE PREPARE repair_transaction_stmt; +SELECT @repair_transaction_action AS transaction_result; +SET SESSION group_concat_max_len = @repair_saved_group_concat_max_len; + +-- 仅生成此脚本不代表已修复线上数据;执行后保留人数、钱包校验、更新前明细、更新行数及提交前复核结果。 +-- 可重复执行:计算只依赖真实余额和price,已修复行的新balance相同,再次运行不会重复扣款或累加。 diff --git a/pjcloud-thirdparty/docs/aggregate-h5.md b/pjcloud-thirdparty/docs/aggregate-h5.md index 35019255..219f6031 100644 --- a/pjcloud-thirdparty/docs/aggregate-h5.md +++ b/pjcloud-thirdparty/docs/aggregate-h5.md @@ -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;已确认终态的旧请求直接恢复结果页,不再下单。 diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java index 5c01d96c..fbc3dfe5 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java @@ -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; /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java index 043dbcf2..6ee0e371 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java @@ -5,10 +5,14 @@ import cn.hutool.core.util.StrUtil; /** * 支付宝用户授权换取的付款人身份。 * - *
新应用优先使用 open_id,兼容尚未返回 open_id 的存量应用 user_id。
+ *记录授权来源应用,避免将网页应用的 open_id 直接用于另一个支付应用。 + * 跨应用下单只使用平台实际返回且支付应用支持的 user_id。
*/ public class ThirdAggregateAlipayOauthIdentity { + /** 换取此身份的服务端应用 AppID;兼容构造器未传入时为空,不能据此跨应用使用 open_id。 */ + private final String oauthAppId; + /** * 支付宝开放平台用户 open_id;接口未返回时为空。 */ @@ -20,16 +24,33 @@ public class ThirdAggregateAlipayOauthIdentity { private final String userId; /** - * 创建支付宝付款人身份值对象。 + * 兼容未记录授权来源应用的内部调用;仅有 open_id 时不能用于跨应用下单。 * * @param openId 支付宝 open_id,允许为空 * @param userId 支付宝 user_id,允许为空 */ public ThirdAggregateAlipayOauthIdentity(String openId, String userId) { + this(null, openId, userId); + } + + /** + * 创建带可信授权来源的身份值对象,来源 AppID 必须由服务端 OAuth 客户端决定。 + * + * @param oauthAppId 实际换取身份的支付宝应用 AppID + * @param openId 该应用下的 open_id,接口未返回时为空 + * @param userId 平台返回的 2088 开头用户号,接口未返回时为空 + */ + public ThirdAggregateAlipayOauthIdentity(String oauthAppId, String openId, String userId) { + this.oauthAppId = oauthAppId; this.openId = openId; this.userId = userId; } + /** 返回实际执行授权码交换的应用标识;为空表示身份来源应用未记录。 */ + public String getOauthAppId() { + return oauthAppId; + } + /** * 获取支付宝 open_id。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java index 0cd63dcc..9ca72c22 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java @@ -48,7 +48,7 @@ import java.util.List; import java.util.Map; /** - * 支付宝聚合订单码 OAuth 与 JSAPI 支付实现。 + * 支付宝聚合收银:网页/移动应用负责 OAuth,小程序应用负责交易创建及付款通知。 */ @Service @Validated @@ -62,7 +62,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ private static final String TAG = "支付宝聚合JSAPI支付"; /** - * 支付宝 JSAPI 支付固定产品码。 + * 聚合支付下单使用小程序应用签约的 JSAPI 产品码,不改变原 App 支付产品。 */ private static final String PRODUCT_CODE = "JSAPI_PAY"; @@ -111,10 +111,13 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ private ThirdMchFzRelationService mchFzRelationService; /** - * 当前应用配置对应的支付宝客户端,延迟初始化并在请求间复用。 + * 小程序支付应用的客户端,延迟初始化并在聚合下单请求间复用。 */ private volatile AlipayClient alipayClient; + /** 网页/移动应用的 OAuth 客户端,独立缓存,不能用支付应用密钥兑换网页授权码。 */ + private volatile AlipayClient oauthAlipayClient; + /** * 使用一次性 OAuth 授权码换取支付宝付款人身份,不保存授权令牌。 * @@ -129,14 +132,15 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ AlipaySystemOauthTokenRequest request = new AlipaySystemOauthTokenRequest(); request.setGrantType(OAUTH_GRANT_TYPE); request.setCode(authCode); + String oauthAppId = appPlusPayConfig.requireAliPayWebConfig().getAppId(); try { - AlipaySystemOauthTokenResponse response = getAlipayClient().execute(request); + AlipaySystemOauthTokenResponse response = getOauthAlipayClient().execute(request); if (response == null || !response.isSuccess()) { String errorCode = response == null ? "EMPTY_RESPONSE" : StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "UNKNOWN"); log.warn("|{}|支付宝用户授权失败,errorCode:{}", TAG, errorCode); throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_OAUTH_FAIL); } - ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity(response.getOpenId(), response.getUserId()); + ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity(oauthAppId, response.getOpenId(), response.getUserId()); if (StrUtil.isBlank(identity.getStableIdentity())) { throw new I18nBizException(ErrorCodes.THIRD_ALIPAY_OAUTH_NO_PAYER); } @@ -161,10 +165,12 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ public ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting, ThirdAggregateAlipayOauthIdentity identity) { MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(paySetting); + AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.requireAliPayMiniConfig(); + AlipayTradeCreateModel model = new AlipayTradeCreateModel(); + applyBuyerIdentity(model, identity, aliPayConfig.getAppId()); validateFzRelation(context, paySetting); ThirdPayOrderEntity payOrder = buildPayOrder(context, paySetting); - AlipayTradeCreateModel model = new AlipayTradeCreateModel(); String totalAmount = fenToYuan(context.getAmount()); model.setOutTradeNo(context.getPayNo()); model.setTotalAmount(totalAmount); @@ -184,14 +190,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ directPaySettleInfo.setSettlePeriodTime(DIRECT_PAY_SETTLE_PERIOD_TIME); directPaySettleInfo.setSettleDetailInfos(List.of(defaultSettleDetailInfo)); model.setSettleInfo(directPaySettleInfo); - if (StrUtil.isNotBlank(identity.getOpenId())) { - model.setBuyerOpenId(identity.getOpenId()); - } else { - model.setBuyerId(identity.getUserId()); - } - AlipayTradeCreateRequest request = new AlipayTradeCreateRequest(); - AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.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; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java index 17438105..9e6aec51 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java @@ -391,7 +391,10 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay + "&response_type=code&scope=" + WECHAT_OAUTH_SCOPE + "&state=" + encode(state) + "#wechat_redirect"; } - return ALIPAY_OAUTH_URL + "?app_id=" + encode(appPlusPayConfig.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()); } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java index 20312eaa..56dc2636 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java @@ -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