PAYATHON 2026

如何将 web 系统与 card payment processing terminals 集成

支付老李

明确结论

这类集成无法通过一个通用的 PHP 或 Python library 完成。首先需要确认收单机构、支付网关或终端厂商是否提供以下接入方式之一:

  • Cloud Terminal API:Web 后端通过 HTTPS API 向指定终端发起交易。
  • Semi-integrated POS:Web 系统将金额发送给本地终端或厂商提供的客户端,银行卡数据由终端独立处理。
  • ECR/POS protocol:通过局域网、USB 或串口,按照终端厂商的专有协议发送金额和交易类型。

建议优先采用 Cloud Terminal API 或厂商支持的 semi-integrated 方案。不要让 PHP、Python 或浏览器直接读取卡号、磁条、PIN、CVV 等敏感信息,也不要自行实现 EMV 或银行卡授权流程。

推荐架构

常见流程如下:

浏览器
  │ 创建订单
  ▼
PHP Web 后端
  │ 调用支付服务商 API
  ▼
支付服务商 / 本地 Connector
  │ 指示指定终端收款
  ▼
实体支付终端
  │ 读卡、PIN、EMV、授权
  ▼
支付服务商
  │ Webhook / 状态查询
  ▼
PHP Web 后端更新订单

Web 系统只需提交:

  • 订单号
  • 金额和币种
  • 终端 ID
  • 允许的支付方式
  • 幂等键
  • 必要的收银台或门店信息

终端和支付服务商负责:

  • 读卡或非接触式支付
  • PIN 输入
  • EMV 流程
  • 银行卡授权
  • debit/credit 的实际识别
  • 返回授权结果和交易标识

需要注意的是,debit/credit 不一定要由收银员提前指定。许多系统会根据银行卡类型、持卡人在终端上的选择和收单机构规则确定支付类型。你的系统可以记录客户表达的付款意向,但最终类型应以支付服务商返回的数据为准。

第一步:联系收单机构或终端供应商

准备好以下问题,向供应商逐项确认:

  1. 终端是否支持 unattended、semi-integrated 或 ECR 模式。
  2. 是否提供 REST API、SDK、本地 Connector 或 ECR 协议文档。
  3. Web 系统如何指定某台实体终端,例如使用 terminal_id 或 device_id。
  4. 如何发起 sale、cancel、refund、void 和 status query。
  5. 支付结果通过 webhook、轮询还是本地回调返回。
  6. 是否提供 sandbox 和用于认证的测试终端。
  7. SDK 是否支持 Linux、Windows,以及 PHP 或 Python。
  8. 该方案是否允许用于你的业务模式和所在地区。
  9. 集成后是否需要认证、审核或现场部署。

终端只是硬件入口,实际接口通常由收单银行、支付处理商或终端管理平台提供。即使两台终端的外观和型号相同,只要安装了不同的支付应用,接口也可能完全不同。

常见接入方式

Cloud Terminal API

对 Web 系统来说,这是通常最容易维护的方式。PHP 后端调用支付服务商的 HTTPS API,再由服务商将交易推送到指定终端。

优点:

  • 不依赖收银机的串口或 USB。
  • PHP 后端容易调用。
  • 终端与 Web 服务器不必位于同一个局域网。
  • 通常提供 webhook、重试和交易查询功能。

限制:

  • 终端和支付服务必须属于同一个受支持的生态。
  • 网络中断时,需要妥善处理超时和交易状态不确定的问题。
  • 接口、字段和认证方式完全由供应商决定。

本地 Connector

有些服务商会在收银电脑上安装本地程序。浏览器或后端向该程序发送请求,再由它通过 USB、串口或局域网控制终端。

Web 页面 → 本地 Connector → USB/LAN → Payment Terminal

不要假定浏览器可以直接连接终端。浏览器访问设备的权限有限,还会受到 HTTPS、CORS、操作系统权限和浏览器兼容性的限制。本地 Connector 通常由厂商提供,也可以根据厂商发布的正式协议开发。

ECR/POS protocol

传统终端可能提供 ECR 协议,通过 TCP socket、RS-232 或 USB virtual COM port 通信。消息可能包含:

SALE | amount=1250 | currency=CNY | reference=ORDER-20260901-001

这只是概念示例,不能直接发送给真实终端。实际设备可能要求二进制帧、长度字段、校验码、ACK/NAK、特定字符编码,以及严格的超时和重试规则。具体实现必须以厂商协议文档为准。

一般不建议直接对接 ISO 8583。该协议主要用于支付机构之间交换交易消息,普通商户系统通常不具备直接使用它所需的权限、密钥体系和认证条件。

PHP 后端示例

下面的代码只展示与厂商无关的基本结构,不代表任何真实支付服务商的 URL 或字段。正式接入时,必须替换为供应商文档规定的接口。

<?php

function startTerminalPayment(
    string $orderId,
    int $amountMinor,
    string $currency,
    string $terminalId
): array {
    if ($amountMinor <= 0) {
        throw new InvalidArgumentException('Amount must be positive');
    }

    $payload = [
        'merchant_reference' => $orderId,
        'terminal_id'        => $terminalId,
        'amount'             => [
            'value'    => $amountMinor,
            'currency' => $currency,
        ],
        'operation' => 'sale',
    ];

    $ch = curl_init('https://payment-provider.example/terminal/payments');

    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . getenv('PAYMENT_API_TOKEN'),
            'Content-Type: application/json',
            'Idempotency-Key: terminal-payment-' . $orderId,
        ],
        CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
        CURLOPT_TIMEOUT    => 15,
    ]);

    $body = curl_exec($ch);

    if ($body === false) {
        throw new RuntimeException(curl_error($ch));
    }

    $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    $response = json_decode($body, true, flags: JSON_THROW_ON_ERROR);

    if ($statusCode < 200 || $statusCode >= 300) {
        throw new RuntimeException(
            'Payment request failed: HTTP ' . $statusCode
        );
    }

    return $response;
}

金额应以最小货币单位的整数表示。例如,对于保留两位小数的货币,12.50 应表示为 1250。不要用二进制浮点数处理金额。

请求发出后,不要立即将订单标记为已支付。此时应保存类似下面的信息:

{
  "order_id": "ORDER-20260901-001",
  "provider_payment_id": "provider-generated-id",
  "terminal_id": "TERM-01",
  "amount_minor": 1250,
  "currency": "CNY",
  "status": "pending"
}

Webhook 处理示例

支付通常需要客户在终端上完成操作,因此 HTTP 请求返回的往往只是 pending 或“已发送到终端”。最终状态需要通过 webhook 或交易查询确认。

<?php

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_PAYMENT_SIGNATURE'] ?? '';

if (!verifyProviderSignature($rawBody, $signature)) {
    http_response_code(401);
    exit;
}

$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);

$eventId = $event['event_id'];
$paymentId = $event['payment']['id'];
$status = $event['payment']['status'];

if (eventAlreadyProcessed($eventId)) {
    http_response_code(200);
    exit;
}

beginTransaction();

try {
    lockPaymentByProviderId($paymentId);

    switch ($status) {
        case 'approved':
            markPaymentApproved(
                $paymentId,
                $event['payment']['authorized_amount'],
                $event['payment']['payment_method'] ?? null
            );
            break;

        case 'declined':
            markPaymentDeclined($paymentId);
            break;

        case 'cancelled':
            markPaymentCancelled($paymentId);
            break;

        default:
            updatePaymentStatus($paymentId, $status);
    }

    recordProcessedEvent($eventId);
    commitTransaction();
    http_response_code(200);
} catch (Throwable $e) {
    rollbackTransaction();
    http_response_code(500);
}

verifyProviderSignature()、数据库函数和事件字段都需要根据实际服务商的要求实现。Webhook 必须验证签名,并根据 event_id 或类似字段进行幂等处理,因为同一条通知可能会重复发送。

如果必须使用 Python 本地桥接程序

只有终端厂商提供正式的串口、USB 或 TCP 协议时,才适合开发本地桥接程序。可以将厂商协议封装成一个只监听本机或受信任局域网的服务:

from flask import Flask, request, jsonify

app = Flask(__name__)

@app.post("/terminal/payments")
def create_payment():
    data = request.get_json()

    amount_minor = int(data["amount_minor"])
    reference = str(data["reference"])

    if amount_minor <= 0:
        return jsonify({"error": "invalid_amount"}), 400

    # 此函数必须严格按照终端厂商的正式协议实现。
    result = terminal_client.sale(
        amount_minor=amount_minor,
        reference=reference,
    )

    return jsonify({
        "transaction_id": result.transaction_id,
        "status": result.status,
    }), 202

桥接程序还要处理:

  • 身份认证和请求签名
  • 仅允许受信任来源访问
  • 为终端加占用锁,防止两笔交易同时发送
  • ACK/NAK 和消息序列号
  • 超时、断线和自动重连
  • 交易状态查询
  • 日志脱敏
  • 服务重启后的交易恢复

不要在缺少厂商文档时猜测串口命令,也不要通过抓包逆向生产环境中的支付协议。这类做法容易造成重复扣款,还可能违反终端、收单机构或安全认证的要求。

支付状态必须设计成状态机

一次超时不能直接判定为失败。发生请求超时时,终端或银行可能已经完成授权,只是结果没有及时返回。

建议至少区分以下状态:

created
  → pending
  → customer_action_required
  → approved
  → declined
  → cancelled
  → unknown

unknown 表示当前无法确定交易结果。遇到这种状态时,应使用原交易号查询,不要直接重新发起扣款。

处理时应遵循以下原则:

  • 每笔业务订单使用稳定的幂等键。
  • 重试前先查询原交易的状态。
  • 只有后端确认 approved 后,才能完成订单。
  • 核对授权金额、币种、商户和订单引用。
  • 收银员取消交易时,要区分终端取消和授权后的 void。
  • 退款应使用原支付交易 ID,不要创建金额为负数的 sale。
  • 每天核对业务订单、支付服务商记录和结算报表。

安全与合规注意事项

半集成方案的主要作用,是让银行卡敏感数据始终留在经过认证的终端和支付服务商环境中。Web 系统一般只需保存:

  • 支付服务商交易 ID
  • 授权状态
  • 金额和币种
  • 卡品牌
  • 脱敏卡号,例如末四位
  • 授权码或服务商允许保存的交易信息

不要保存:

  • 完整卡号
  • CVV/CVC
  • PIN 或 PIN block
  • 磁条数据
  • EMV 芯片原始数据
  • 支付终端密钥

即使系统不直接处理卡号,也仍可能处于 PCI DSS 的部分适用范围内。具体合规范围取决于数据流、网络结构、服务商方案和当地要求,需要由收单机构或合规人员确认。

API 密钥应存放在服务器端的密钥管理系统或受保护的环境配置中,不能写入 JavaScript、Git 仓库或浏览器请求。支付日志同样需要脱敏,不应记录完整请求头、密钥或银行卡数据。

实际落地顺序

  1. 确认当前终端的型号、支付应用、收单机构和终端供应商。
  2. 获取 terminal integration、ECR 或 Cloud Terminal API 文档。
  3. 申请 sandbox、测试商户和认证测试终端。
  4. 优先实现“发起支付、状态查询、取消、退款”四项能力。
  5. 建立支付状态机、幂等控制和 webhook 验签。
  6. 测试断网、重复请求、终端忙、客户取消和响应超时等情况。
  7. 完成服务商要求的认证或验收。
  8. 上线后建立对账、监控和异常交易处理流程。

如果现有终端没有任何受支持的 ECR、SDK 或云端接口,通常更稳妥的做法是更换为支付服务商明确支持 POS 集成的终端,而不是继续寻找所谓的通用刷卡机 library。

备注:内容仅供参考。