如何将 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 不一定要由收银员提前指定。许多系统会根据银行卡类型、持卡人在终端上的选择和收单机构规则确定支付类型。你的系统可以记录客户表达的付款意向,但最终类型应以支付服务商返回的数据为准。
第一步:联系收单机构或终端供应商
准备好以下问题,向供应商逐项确认:
- 终端是否支持 unattended、semi-integrated 或 ECR 模式。
- 是否提供 REST API、SDK、本地 Connector 或 ECR 协议文档。
- Web 系统如何指定某台实体终端,例如使用
terminal_id或device_id。 - 如何发起 sale、cancel、refund、void 和 status query。
- 支付结果通过 webhook、轮询还是本地回调返回。
- 是否提供 sandbox 和用于认证的测试终端。
- SDK 是否支持 Linux、Windows,以及 PHP 或 Python。
- 该方案是否允许用于你的业务模式和所在地区。
- 集成后是否需要认证、审核或现场部署。
终端只是硬件入口,实际接口通常由收单银行、支付处理商或终端管理平台提供。即使两台终端的外观和型号相同,只要安装了不同的支付应用,接口也可能完全不同。
常见接入方式
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 仓库或浏览器请求。支付日志同样需要脱敏,不应记录完整请求头、密钥或银行卡数据。
实际落地顺序
- 确认当前终端的型号、支付应用、收单机构和终端供应商。
- 获取 terminal integration、ECR 或 Cloud Terminal API 文档。
- 申请 sandbox、测试商户和认证测试终端。
- 优先实现“发起支付、状态查询、取消、退款”四项能力。
- 建立支付状态机、幂等控制和 webhook 验签。
- 测试断网、重复请求、终端忙、客户取消和响应超时等情况。
- 完成服务商要求的认证或验收。
- 上线后建立对账、监控和异常交易处理流程。
如果现有终端没有任何受支持的 ECR、SDK 或云端接口,通常更稳妥的做法是更换为支付服务商明确支持 POS 集成的终端,而不是继续寻找所谓的通用刷卡机 library。
备注:内容仅供参考。
