PAYATHON 2026

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)

它可能产生重复值。交易参考号应由后端或数据库生成,并保证符合银行要求,同时在规定范围内保持唯一。

备注:内容仅供参考。