Flutter 集成 Bank Alfalah Payment Gateway 成功后仍抛出异常
结论
异常和 "success": true 没有直接关系。真正的问题是,代码在 try/catch 结束后无条件执行了:
throw Exception();
response.statusCode == 200 分支中又没有 return,所以即使响应成功,代码仍会继续向下执行,最后抛出异常。
处理响应时,需要同时检查 HTTP 状态码和响应中的业务状态。确认两者都成功后,应立即返回 AuthToken 或其他结果。只有失败时才抛出异常。
HS_RequestHash 也不应该和银行提供的 static hash key 完全一致。static hash key 是生成 HMAC 的密钥,HS_RequestHash 是使用该密钥和请求字符串计算出的摘要。两者用途不同,正常情况下不会相等。
为什么成功后仍会抛出异常
当前代码的控制流相当于:
if (response.statusCode == 200) {
// 什么也没有做
} else {
throw Exception(...);
}
// try/catch 执行结束
throw Exception();
状态码为 200 时,程序只会进入空分支,随后继续执行函数末尾的 throw Exception()。
只检查 statusCode == 200 也不够。部分支付接口会通过 HTTP 200 返回业务错误,因此还要检查:
jsonResponse['success']
只有 HTTP 状态和业务状态都表示成功时,才应把结果返回给调用方。
推荐的响应处理方式
可以让方法返回支付初始化结果:
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<Map<String, dynamic>> initiatePayment() async {
try {
final response = await http.post(
Uri.parse(url),
body: {
'HS_ChannelId': channelId,
'HS_IsRedirectionRequest': '0',
'HS_MerchantId': merchantId,
'HS_StoreId': storeId,
'HS_ReturnURL': returnUrl,
'HS_MerchantHash': merchantHash,
'HS_MerchantUsername': username,
'HS_MerchantPassword': password,
'HS_TransactionReferenceNumber': transactionReference,
'HS_RequestHash': requestHash,
},
);
dynamic decoded;
try {
decoded = jsonDecode(response.body);
} on FormatException catch (error) {
throw Exception(
'Payment gateway returned invalid JSON: $error',
);
}
if (decoded is! Map<String, dynamic>) {
throw Exception('Unexpected payment gateway response format');
}
final jsonResponse = decoded;
// 某些接口可能返回 bool,也可能返回字符串。
final successValue = jsonResponse['success'];
final isSuccess =
successValue == true ||
successValue?.toString().toLowerCase() == 'true';
if (response.statusCode < 200 || response.statusCode >= 300) {
final message =
jsonResponse['ErrorMessage'] ??
jsonResponse['message'] ??
'HTTP ${response.statusCode}';
throw Exception('Payment initiation failed: $message');
}
if (!isSuccess) {
final message =
jsonResponse['ErrorMessage'] ??
jsonResponse['message'] ??
'Gateway rejected the request';
throw Exception('Payment initiation failed: $message');
}
final authToken = jsonResponse['AuthToken'];
if (authToken == null || authToken.toString().isEmpty) {
throw Exception(
'Payment gateway reported success but AuthToken is missing',
);
}
// 成功后必须 return,不能继续执行到异常逻辑。
return jsonResponse;
} catch (error) {
// 避免在此处记录密码、Hash Key、AuthToken 等敏感信息。
throw Exception('Error initiating payment: $error');
}
}
如果调用方只需要 AuthToken,也可以直接返回字符串:
Future<String> initiatePayment() async {
// 发送请求并解析 jsonResponse ...
if (response.statusCode >= 200 &&
response.statusCode < 300 &&
jsonResponse['success'] == true) {
final authToken = jsonResponse['AuthToken'];
if (authToken is String && authToken.isNotEmpty) {
return authToken;
}
throw Exception('AuthToken is missing from successful response');
}
throw Exception(
'Payment initiation failed: '
'${jsonResponse['ErrorMessage'] ?? 'Unknown gateway error'}',
);
}
重点不在于删除某个 throw,而在于为成功路径添加明确的 return。
正确理解 HS_RequestHash
HMAC SHA-256 的计算关系如下:
HS_RequestHash = Base64(HMAC-SHA256(staticHashKey, canonicalRequestString))
其中:
staticHashKey是银行或商户后台提供的密钥。canonicalRequestString是按照支付网关要求拼接的请求字段。HS_RequestHash是最终生成的摘要。- static hash key 不会被“加密成自身”,也不应该拿来和摘要比较是否相等。
Dart 中可以这样生成:
import 'dart:convert';
import 'package:crypto/crypto.dart';
String generateRequestHash({
required String data,
required String staticHashKey,
}) {
final keyBytes = utf8.encode(staticHashKey);
final dataBytes = utf8.encode(data);
final digest = Hmac(sha256, keyBytes).convert(dataBytes);
return base64Encode(digest.bytes);
}
调用示例:
final dataString = [
'HS_ChannelId=$channelId',
'HS_IsRedirectionRequest=0',
'HS_MerchantId=$merchantId',
'HS_StoreId=$storeId',
'HS_ReturnURL=$returnUrl',
'HS_MerchantUsername=$username',
'HS_MerchantPassword=$password',
'HS_TransactionReferenceNumber=$transactionReference',
].join('&');
final requestHash = generateRequestHash(
data: dataString,
staticHashKey: staticHashKey,
);
请求中发送的字段必须与参与签名的字段完全一致:
final body = <String, String>{
'HS_ChannelId': channelId,
'HS_IsRedirectionRequest': '0',
'HS_MerchantId': merchantId,
'HS_StoreId': storeId,
'HS_ReturnURL': returnUrl,
'HS_MerchantHash': merchantHash,
'HS_MerchantUsername': username,
'HS_MerchantPassword': password,
'HS_TransactionReferenceNumber': transactionReference,
'HS_RequestHash': requestHash,
};
原代码计算 hash 时使用的是变量值,实际请求中发送的却是 "12345"、"xyz" 等硬编码值。这会造成签名内容和实际请求不一致。即使 HMAC 算法本身没有问题,服务端验证仍可能失败。
排查 hash 不一致时需要检查什么
HMAC 对输入中的每个字符都很敏感。以下任何一处差异都会产生完全不同的结果:
- 字段顺序不同。
- 多了或少了字段。
HS_IsRedirectionRequest参与了签名,却没有随请求发送。- 签名使用动态交易号,请求发送的却是固定值
"12345"。 - 参数名称的大小写不同。
&、=、空格或换行不同。- URL 编码的处理方式不同。
- 密钥前后存在不可见空格。
- 使用了错误环境的密钥,例如混用了测试环境和生产环境。
- 服务端要求十六进制字符串,客户端却使用了 Base64,或情况正好相反。
标准 HMAC SHA-256 的原始输出固定为 32 字节。直接进行 Base64 编码后,通常会得到包括末尾 = 在内的 44 个字符。问题中展示的 HS_RequestHash 明显更长,因此不像是“将 32 字节 HMAC-SHA256 结果直接进行 Base64 编码”的形式。它可能还经过了其他加密或封装步骤,也可能来自另一个网关示例。
具体实现应以 Bank Alfalah 当前接入文档中的以下要求为准:
- 参与签名的字段。
- 字段的固定顺序。
- static hash key 是否直接用作 HMAC key。
- 输出格式是 Base64 还是十六进制。
- 是否需要先进行 URL 编码。
- 是否还需要 RSA、AES 或其他处理。
没有对应接口规范时,不能只根据一个静态示例 hash 判断实现是否正确。
其他注意事项
正式环境中不要打印以下内容:
HS_MerchantPassword
HS_MerchantHash
staticHashKey
HS_RequestHash 的原始签名字符串
AuthToken
也不建议把商户密码和 static hash key 保存在 Flutter 客户端中。移动应用可以被反编译,内置密钥很难真正保密。更安全的做法是由自己的后端生成 HS_RequestHash、调用银行接口,再把必要的支付结果返回给 Flutter 客户端。
随机数也不适合作为可靠的订单号:
Random().nextInt(1786612)
它可能产生重复值。交易参考号应由后端或数据库生成,并保证符合银行要求,同时在规定范围内保持唯一。
备注:内容仅供参考。
