Skip to content

fix(core): 修复 appendUrlQuery 的取值编码与 fragment 位置两处缺陷 - #157

Open
rwzdk wants to merge 1 commit into
jeequan:masterfrom
rwzdk:fix/appendurlquery-value-encoding
Open

rwzdk wants to merge 1 commit into
jeequan:masterfrom
rwzdk:fix/appendurlquery-value-encoding

Conversation

@rwzdk

@rwzdk rwzdk commented Oct 27, 2026 •

Copy link
Copy Markdown

现象

商户配置的异步通知地址 / 同步跳转地址,在特定订单内容或特定回调地址形态下会被拼成错误的 URL。两个独立缺陷,都在同一个函数里。

StringKit#appendUrlQuery 是拼接这类地址的唯一入口:

  • PayMchNotifyService#createNotifyUrl(支付 / 退款 / 转账通知)
  • PayMchNotifyService#createReturnUrl(支付完成同步跳转,ChannelNoticeController、QrCashierController 调用)
  • ChannelUserIdController#channelUserIdCallback、MchTransferController(收银台跳转)

缺陷一:用 encodeQuery 编码单个参数值

jeepay-core/src/main/java/com/jeequan/jeepay/core/utils/StringKit.java:65

sb.append(k).append("=").append(URLUtil.encodeQuery(map.get(k).toString()));

URLUtil.encodeQuery() 的语义是编码整条 query string,因此它刻意保留 query 语法里的
分隔符 & = + ? /。这里却把它用在单个参数值上:

值里的字符 后果
& 被当成参数分隔符 → 一个参数拆成两个,商户只收到前半段
= 被当成 key/value 分隔符 → 取值被截断
+ 服务端按 application/x-www-form-urlencoded 解码成空格

实测(Hutool 5.8.26):

ampersand   input=[A & B]   encodeQuery=[A%20&%20B]   encodeAll=[A%20%26%20B]
equals      input=[a=b]     encodeQuery=[a=b]         encodeAll=[a%3Db]
plus        input=[a+b]     encodeQuery=[a+b]         encodeAll=[a%2Bb]

这些取值完全合法:UnifiedOrderRQ#subject / #body 只有 @NotBlank 约束。商户正常填写
"Salt & Pepper Shaker" 这样的商品标题,通知就会错乱。

修复:改用 URLUtil.encodeAll()(编码全部保留字符,语义正确对应"单个参数值")。


缺陷二:query 被拼到了 fragment 之后

原实现把参数直接追加到整个 url 末尾。当回调地址带 fragment 时:

输入   https://mch.example.com/cb#/result
输出   https://mch.example.com/cb#/result?payOrderId=P123

? 之后的内容属于 fragment,根本不会发给服务端,商户永远收不到参数。

值得注意的是,旁边 appendUrlQueryWithHutool 上的 @Deprecated 注释写的正是:

拼接url参数: 旧版采用Hutool方式(当回调地址是 http://abc.com/#/abc 时存在位置问题)

也就是说这个位置问题当初就是为了修它才废弃 Hutool 版本的 —— 但替代实现同样有这个问题。
对照实测:

input : https://mch.example.com/cb#/result
  hutool: https://mch.example.com/cb?payOrderId=P123#/result   <- 正确
  manual: https://mch.example.com/cb#/result?payOrderId=P123   <- 错误

SPA 应用用 hash 路由做支付回跳页(https://mch.example.com/#/payResult)非常常见。

修复:先切出 fragment,query 拼在 base 上,最后把 fragment 接回去。


签名兼容性

不受影响。JeepayKit#getSign 是对未编码的原始参数计算的(k + "=" + value),
接收方把 query 解码后拿到的仍是同一个原始值,因此签名校验结果不变。

测试

新增 jeepay-core/src/test/java/com/jeequan/jeepay/core/utils/StringKitAppendUrlQueryTest.java,
7 个用例,按标准 query string 规则反解生成的 URL 并断言能原样还原:

  1. 值含 & 时不得被拆成两个参数;
  2. 值含 = 时不得截断;
  3. 值含 + 时不得被服务端解码成空格;
  4. 回调地址已带 query 时,原参数保留、新参数追加;
  5. null / 空 url / 空参数原样返回;
  6. 回调地址带 fragment 时,query 必须在 # 之前且 fragment 原样保留;
  7. query + fragment 同时存在时两者都保留。

缺陷一 修复前(真实输出)

[ERROR] Tests run: 5, Failures: 2, Errors: 0, Skipped: 0 <<< FAILURE!
[ERROR]   StringKitAppendUrlQueryTest.ampersandInValueMustNotSplitParameters:67
  参数个数被改变,实际 URL: https://mch.example.com/notify?subject=Salt%20&%20Pepper%20Shaker&amount=100
  ==> expected: <2> but was: <3>
[ERROR]   StringKitAppendUrlQueryTest.plusInValueMustBeEncoded:97
  sign 中的 + 未编码,服务端会解码成空格,实际 URL: https://mch.example.com/notify?sign=ab+cd==
  ==> expected: <ab+cd==> but was: <ab cd==>

缺陷二 修复前(单独回退 fragment 逻辑,保留编码修复)

[ERROR] Tests run: 7, Failures: 2, Errors: 0, Skipped: 0 <<< FAILURE!
[ERROR]   StringKitAppendUrlQueryTest.queryMustBePlacedBeforeFragment:140
  query 必须出现在 fragment 之前,否则服务端收不到参数,
  实际 URL: https://mch.example.com/cb#/result?payOrderId=P123 ==> expected: <true> but was: <false>
[ERROR]   StringKitAppendUrlQueryTest.existingQueryAndFragmentAreBothPreserved:163
  fragment 被破坏,实际 URL: https://mch.example.com/cb?tenant=t1#/result&payOrderId=P123
  ==> expected: <true> but was: <false>

修复后(真实输出,jeepay-core 全量)

[INFO] Building jeepay-core 3.2.0
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in ...FastJsonCompatSmokeTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 -- in ...StringKitAppendUrlQueryTest
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

执行的命令:

mvn -o -q -pl jeepay-core -am -DskipTests install
mvn -o -pl jeepay-core -Dtest=StringKitAppendUrlQueryTest -DfailIfNoTests=false test
mvn -o -pl jeepay-core test

未执行的部分(如实说明):没有启动完整应用做端到端通知投递验证(需要 MySQL / Redis / MQ,
本地不具备)。验证停留在单元层面:证明了生成的 URL 能被标准规则原样反解、且 fragment 位置正确。
Maven 全程以 -o(离线)运行,未下载新的第三方依赖。

备注

  • 与 fix(core): 修复 genUrlParams 参数值未完整编码导致渠道请求参数错乱 #148 无关:那个 PR 修的是 JeepayKit#genUrlParams(渠道请求参数),本 PR 修的是
    StringKit#appendUrlQuery(发给商户的回调地址),是两处独立的编码缺陷。
  • 两个缺陷都在同一个函数内、同一次改动中修复,因为它们共享同一段拼接逻辑,拆开会产生冲突。
  • 只影响含保留字符的取值和带 fragment 的回调地址;其余情况编码结果与之前完全一致,
    对绝大多数存量商户无行为变化。

Prepared with an AI coding agent.

StringKit#appendUrlQuery 负责拼装发给商户的异步通知/同步跳转地址
(PayMchNotifyService#createNotifyUrl / createReturnUrl), 以及收银台跳转地址
(ChannelUserIdController, MchTransferController)。存在两个独立缺陷:

1) 用 encodeQuery 编码单个参数值
   URLUtil.encodeQuery() 的语义是编码整条 query string, 会刻意保留 & = + ? / 等
   分隔符, 用在单个参数值上会导致:

     subject="Salt & Pepper Shaker" -> subject=Salt%20&%20Pepper%20Shaker
                                       (一个参数被拆成两个, 商户收到 subject="Salt ")
     extParam="a=b"                 -> extParam=a=b   (取值被截断)
     sign="ab+cd=="                 -> sign=ab+cd==   (服务端按 form 解码成 "ab cd==")

   subject/body 只有 @notblank 约束, 上述取值完全合法, 缺陷可被真实触发。
   修复: 改用 URLUtil.encodeAll()。

2) query 被拼到了 fragment 之后
   原实现直接把参数追加到整个 url 末尾。当回调地址带 fragment 时
   (如 SPA 的 https://mch.example.com/cb#/result), 生成的是

     https://mch.example.com/cb#/result?payOrderId=P123

   ? 之后的内容属于 fragment, 不会发给服务端, 商户永远收不到参数。
   讽刺的是 appendUrlQueryWithHutool 上的 @deprecated 注释写的正是
   "当回调地址是 http://abc.com/#/abc 时存在位置问题", 但替代实现同样有这个问题。
   修复: 先切出 fragment, query 拼在 base 上, 最后再把 fragment 接回去。

签名不受影响: 签名是对原始(未编码)参数计算的, 接收方解码后得到同样的原始值。

回归测试: 新增 StringKitAppendUrlQueryTest (7 个用例), 修复前分别有 2 个
(编码) 和 2 个 (fragment) 用例失败, 修复后全部通过。
@rwzdk
rwzdk force-pushed the fix/appendurlquery-value-encoding branch from c22b72d to 974c7b7 Compare October 27, 2026 16:57
@rwzdk rwzdk changed the title fix(core): 修复 appendUrlQuery 用 encodeQuery 编码单个参数值导致通知报文错乱 fix(core): 修复 appendUrlQuery 的取值编码与 fragment 位置两处缺陷 Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant