Spring Boot 集成 LINE Pay API 时 sandbox 支付失败
结论
Reserve Payment API 已返回 transactionId、webUrl 和 appUrl,说明服务端认证和预留请求很可能已经通过。“Unable to process your request”通常出现在 LINE Pay 支付页面,建议优先检查以下问题:
- Reserve API 与支付 URL 是否属于同一环境,sandbox 和 production 不能混用。
- 返回的
webUrl是否被转义、截断、重复编码,或被网关改写。 - 预留交易是否已经过期、取消,或支付 URL 是否被重复使用。
confirmUrl、币种、金额和商户 sandbox 配置是否符合当前测试环境的要求。- 是否在不支持的设备或浏览器中打开了
appUrl。 - sandbox 服务是否暂时异常。
仅凭支付页面上的提示,无法断定问题出在 Spring Boot 集成。先保存完整的 Reserve 响应和 LINE Pay 返回码,再用原始 webUrl 做一次最小化测试。如果请求参数没有变化,同一个请求有时成功、有时失败,才更可能是 sandbox 环境问题。
首先检查支付 URL
可以把 Reserve 响应中的 URL 直接返回给浏览器,或通过 HTTP 302 跳转。不要手工拼接查询参数,也不要再次编码整个 URL。
下面的写法会破坏 URL:
String redirectUrl = URLEncoder.encode(webUrl, StandardCharsets.UTF_8);
return "redirect:" + redirectUrl;
直接重定向更稳妥:
@GetMapping("/line-pay/start")
public ResponseEntity<Void> startPayment() {
LinePayReserveResponse response = linePayService.reservePayment();
URI paymentUri = URI.create(response.getWebUrl());
return ResponseEntity.status(HttpStatus.FOUND)
.location(paymentUri)
.build();
}
如果使用 Spring MVC 的 redirect:,也不要重新编码完整 URL:
@GetMapping("/line-pay/start")
public String startPayment() {
LinePayReserveResponse response = linePayService.reservePayment();
return "redirect:" + response.getWebUrl();
}
同时检查 JSON 字段映射。不同 API 版本或 SDK 的响应结构可能不同,应以实际响应和当前版本的文档为准。例如:
@JsonIgnoreProperties(ignoreUnknown = true)
public class LinePayReserveResponse {
private String transactionId;
private PaymentUrl paymentUrl;
// getters and setters
public static class PaymentUrl {
private String web;
private String app;
// getters and setters
}
}
不要假定 v2 和 v3 的字段层级完全相同,也不要把某个非官方 SDK 的字段名直接用于其他版本。
确认没有混用 sandbox 与正式环境
Reserve、支付页面和 Confirm 必须使用同一环境和同一套商户凭证。
需要核对以下配置:
linepay.base-url=<sandbox API base URL>
linepay.channel-id=<sandbox channel id>
linepay.channel-secret=<sandbox channel secret>
支付地址必须直接使用 sandbox Reserve 响应中返回的地址。以下情况都可能导致系统无法识别交易:
- 使用 sandbox 凭证调用 production API。
- Reserve 请求发往 sandbox,Confirm 请求却发往 production。
- 使用以前保存的 production
webUrl测试 sandbox 交易。 - 更换商户账号或凭证后,仍然打开旧交易的支付 URL。
具体域名和路径应以当前 LINE Pay 官方文档及商户后台的信息为准。不要照搬旧博客中的配置,因为接口地址和测试环境规则可能发生调整。
每次测试都创建新的交易
支付 URL 通常绑定一笔具体的预留交易。旧 URL 可能因超时、取消、已经支付或重复访问而失效。
每次测试可以按以下步骤进行:
- 生成新的商户订单号。
- 重新调用 Reserve Payment API。
- 记录完整响应,并对密钥和签名进行脱敏。
- 立即在浏览器中打开新返回的
webUrl。 - 支付返回后,使用对应的
transactionId调用 Confirm API。
不要重复使用订单号:
String orderId = "LINE-" + UUID.randomUUID();
还需要保存订单号与 LINE Pay 交易号的对应关系:
paymentRepository.save(
new PaymentRecord(orderId, reserveResponse.getTransactionId())
);
调用 Confirm 时,必须使用该次 Reserve 返回的 transactionId,金额和币种也要与 Reserve 请求一致。
检查回调地址
Reserve 请求中的确认跳转地址必须能被浏览器访问。下面的地址通常只能在开发者自己的电脑上访问:
http://localhost:8080/payments/line/return
如果 LINE Pay 页面运行在远程环境或移动设备上,它无法访问你电脑上的 localhost。本地联调时,可以使用受控的 HTTPS 隧道,或把服务部署到测试服务器。例如:
https://test.example.com/payments/line/return
Spring Boot 可以分别提供成功和取消入口:
@GetMapping("/payments/line/return")
public ResponseEntity<String> paymentReturn(
@RequestParam(required = false) String transactionId) {
// 不应只凭浏览器参数判定付款成功。
// 应从服务端订单记录取得可信的 transactionId,
// 再调用 Confirm API 或查询接口核验交易。
return ResponseEntity.ok("Payment authorization returned");
}
@GetMapping("/payments/line/cancel")
public ResponseEntity<String> paymentCancel() {
return ResponseEntity.ok("Payment cancelled");
}
即使页面成功跳回 confirmUrl,也不能直接把订单标记为已支付。最终状态应以 Confirm API 或官方提供的交易查询结果为准。
正确选择 webUrl 和 appUrl
使用桌面浏览器联调时,一般优先选择 webUrl。appUrl 可能依赖移动设备、LINE 应用、系统的深链接处理能力和用户登录状态。
可以根据设备选择 URL,但不要修改 URL 本身:
public String selectPaymentUrl(
LinePayReserveResponse response,
boolean mobileDevice) {
if (mobileDevice
&& response.getAppUrl() != null
&& !response.getAppUrl().isBlank()) {
return response.getAppUrl();
}
return response.getWebUrl();
}
排查时可以先绕过前端代码。从服务端日志中复制脱敏后的原始 webUrl,立即粘贴到无痕浏览器中测试。如果原始地址能够打开,而经过前端跳转后失败,通常需要检查 URL 编码、代理跳转或前端路由处理。
检查 v3 请求签名
如果使用 v3 API,必须按照对应文档生成签名。签名通常涉及以下内容:
- Channel Secret
- API URI
- 请求体
- Nonce
签名字符串的拼接顺序、请求体字节和实际发送的 HTTP 内容必须一致。下面只展示代码结构,具体拼接规则应以当前 v3 官方文档为准:
public String createHmacSignature(String secret, String message) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
));
byte[] digest = mac.doFinal(
message.getBytes(StandardCharsets.UTF_8)
);
return Base64.getEncoder().encodeToString(digest);
} catch (GeneralSecurityException ex) {
throw new IllegalStateException("Unable to create LINE Pay signature", ex);
}
}
不要先对一个 JSON 字符串签名,再让序列化框架生成字段顺序或内容不同的请求体。可以先生成最终请求体,然后用同一个字符串完成签名和发送:
String requestBody = objectMapper.writeValueAsString(request);
String nonce = UUID.randomUUID().toString();
String signatureSource = buildSignatureSource(
channelSecret,
apiUri,
requestBody,
nonce
);
String signature = createHmacSignature(channelSecret, signatureSource);
不过,Reserve 既然已经成功,签名通常不是支付页面报错的首要排查对象。除非程序在解析返回码时,把业务失败误判成了成功。
不要只检查 HTTP 状态码
LINE Pay API 返回 HTTP 200,并不一定表示业务预留成功。还要检查响应中的业务返回码和消息:
LinePayReserveResponse response = linePayClient.reserve(request);
if (!response.isSuccessful()) {
throw new IllegalStateException(
"LINE Pay reserve failed: code="
+ response.getReturnCode()
+ ", message="
+ response.getReturnMessage()
);
}
if (response.getTransactionId() == null
|| response.getWebUrl() == null) {
throw new IllegalStateException(
"LINE Pay reserve response does not contain payment URL"
);
}
isSuccessful() 的判断条件应按照当前 API 版本的官方返回码定义实现,不能假定所有版本都使用完全相同的成功码。
建议保留的排查信息
出现错误时,可以记录:
- Reserve 请求时间。
- API 版本和完整请求路径。
- HTTP 状态码。
- LINE Pay 业务返回码及返回消息。
- 商户订单号。
transactionId。- 金额和币种。
confirmUrl、cancelUrl。- 返回支付 URL 的域名和路径。
- 打开 URL 的设备、浏览器及时间。
- Confirm 或交易查询接口的结果。
不要记录:
- Channel Secret。
- 完整认证签名。
- 用户支付凭据。
- 未脱敏的个人信息。
如果创建新交易、直接打开原始 webUrl,并确认环境和参数一致后,仍然稳定出现相同错误,可以把上述信息脱敏后提交给 LINE Pay 技术支持。当支付页面只显示通用错误时,服务端交易状态和平台日志通常比浏览器页面更适合用来定位问题。
备注:内容仅供参考。
