如何在 C# Windows 应用中启用 POS 支付并读取银行卡数据?
明确结论
KeyPress 不是 POS 支付的通用接口。刷卡后会不会触发 KeyPress,取决于读卡设备是否处于 USB HID Keyboard(键盘模拟、Keyboard Wedge)模式:
- 设备模拟键盘时,会把磁条内容转换成一连串“按键”,Windows 随后产生相应的键盘事件。
- 设备通过串口、虚拟 COM、厂商驱动或专用 SDK 工作时,不会自动触发
KeyPress。应用需要通过对应的接口读取数据。 - EMV 芯片卡、非接触式银行卡和 PIN Pad 通常要使用支付终端 SDK 或支付网关提供的集成方案,不能依靠
KeyPress完成交易。
在正式支付系统中,也不应把完整卡号或磁道数据直接发送到 supervisor application。更合适的做法是使用支付服务商提供的 半集成终端、端到端加密和令牌化方案。业务应用只接收交易结果、token、卡品牌和掩码卡号,例如 ************1234。
为什么刷卡会触发 KeyPress
在 Windows 看来,键盘模拟型磁条读卡器就是一只普通键盘。刷卡时,设备可能依次输入:
%...?...;
...?
输入结束后,设备还会发送 Enter、Tab 或其他结束字符。由于这些内容以键盘扫描码的形式输入,当前拥有焦点的窗口会收到 KeyDown、KeyPress 和 KeyUp 等事件。
这不是支付网关或驱动主动调用了应用的 KeyPress() 方法,而是 Windows 将设备输入识别为键盘输入,再分发给窗口。
这种方式通常要满足以下条件:
- 读卡器支持并已启用 HID Keyboard 模式。
- Windows 已正确识别该设备。
- POS 窗口或输入控件拥有键盘焦点。
- 读卡器配置了应用可以识别的结束符,例如
Enter。 - 应用订阅了正确窗口或控件的键盘事件。
设备模式、前缀、后缀和结束符一般通过厂商配置工具设置。不同型号提供的选项可能不同,需要查阅对应设备的手册。
先确定设备属于哪一种类型
键盘模拟型读卡器
可以先打开记事本,再刷一张测试卡。如果记事本中立刻出现一串字符,设备很可能正在使用键盘模拟模式。
这种模式容易接入,但限制也很明显:
- 数据会被输入到当前拥有焦点的窗口。
- 用户的键盘输入可能和刷卡数据混在一起。
- 其他程序也可能收到这些数据。
- 应用很难可靠地区分实体键盘和读卡器。
- 原始磁道数据会进入应用进程,带来较高的合规和安全风险。
串口或虚拟 COM 设备
如果设备管理器中出现 COM3、COM4 等端口,应用通常需要使用 System.IO.Ports.SerialPort,并按照厂商协议设置波特率、数据位、校验位和停止位。
厂商 SDK 或支付终端
EMV、NFC、PIN 输入和加密读卡设备通常通过厂商 SDK、OPOS、JavaPOS、Windows 服务、HTTP 本地代理或支付处理商 API 通信。
应用从这类设备收到的可能是加密块、token 或交易结果,而不是可以直接显示的卡号字符串。具体如何实现,应以终端厂商和收单机构提供的接口文档为准。
WinForms 中接收键盘模拟输入
下面的示例会缓冲设备模拟的按键,并在收到 Enter 后处理一条完整输入。它只适用于 HID Keyboard 模式,不是完整的支付实现。
using System;
using System.Text;
using System.Windows.Forms;
public partial class PosForm : Form
{
private readonly StringBuilder _readerBuffer = new();
public PosForm()
{
InitializeComponent();
// 让窗体在子控件之前接收键盘事件。
KeyPreview = true;
KeyPress += PosForm_KeyPress;
}
private void PosForm_KeyPress(object? sender, KeyPressEventArgs e)
{
if (e.KeyChar == '\r')
{
string deviceInput = _readerBuffer.ToString();
_readerBuffer.Clear();
e.Handled = true;
if (deviceInput.Length > 0)
{
ProcessReaderInput(deviceInput);
}
return;
}
if (!char.IsControl(e.KeyChar))
{
_readerBuffer.Append(e.KeyChar);
e.Handled = true;
}
}
private void ProcessReaderInput(string deviceInput)
{
try
{
// 不要写入日志、剪贴板或普通数据库。
// 正式系统应把数据交给经过认证的支付组件处理。
paymentStatusLabel.Text = "已收到读卡设备输入";
}
finally
{
// string 在 .NET 中不可变,无法保证从内存中立即擦除。
// 因此应尽量避免让原始卡数据进入业务应用。
deviceInput = string.Empty;
}
}
}
这个示例仍有一个实际限制:KeyPreview 只能接收发送到当前窗体的键盘消息。如果应用不在前台、焦点落在其他程序中,或者某些控件截获了按键,事件就可能无法按预期触发。
也不能只凭“字符输入速度很快”判断输入来自读卡器。如果需要可靠地区分多个 HID 设备,可以考虑使用 Windows Raw Input API,根据设备句柄识别输入来源,也可以直接使用厂商 SDK。
读取串口设备的示例
如果读卡器提供虚拟 COM 端口,可以使用 SerialPort。端口参数必须与设备配置一致,示例中的值不能直接照搬。
using System;
using System.IO.Ports;
using System.Text;
using System.Windows.Forms;
public partial class PosForm : Form
{
private readonly StringBuilder _serialBuffer = new();
private SerialPort? _cardReaderPort;
public PosForm()
{
InitializeComponent();
}
private void OpenCardReader()
{
_cardReaderPort = new SerialPort
{
PortName = "COM3",
BaudRate = 9600,
DataBits = 8,
Parity = Parity.None,
StopBits = StopBits.One,
Handshake = Handshake.None,
Encoding = Encoding.ASCII,
NewLine = "\r"
};
_cardReaderPort.DataReceived += CardReaderPort_DataReceived;
_cardReaderPort.Open();
}
private void CardReaderPort_DataReceived(
object sender,
SerialDataReceivedEventArgs e)
{
if (_cardReaderPort is null)
{
return;
}
string chunk = _cardReaderPort.ReadExisting();
lock (_serialBuffer)
{
_serialBuffer.Append(chunk);
int terminatorIndex;
while ((terminatorIndex =
_serialBuffer.ToString().IndexOf('\r')) >= 0)
{
string message = _serialBuffer
.ToString(0, terminatorIndex);
_serialBuffer.Remove(0, terminatorIndex + 1);
BeginInvoke(new Action(() =>
{
ProcessSerialMessage(message);
}));
}
}
}
private void ProcessSerialMessage(string message)
{
paymentStatusLabel.Text = "已收到串口设备数据";
// 不要在这里显示或记录完整磁道数据。
// 应按照设备厂商协议将数据提交给安全支付组件。
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
if (_cardReaderPort is not null)
{
_cardReaderPort.DataReceived -= CardReaderPort_DataReceived;
if (_cardReaderPort.IsOpen)
{
_cardReaderPort.Close();
}
_cardReaderPort.Dispose();
}
base.OnFormClosed(e);
}
}
DataReceived 在后台线程中执行,所以更新 WinForms 控件时,需要通过 BeginInvoke 切回 UI 线程。实际协议还可能采用 STX/ETX、校验和或二进制数据,不能简单假定每条消息都以回车结束。
正式 POS 支付的推荐流程
完整的支付实现通常不是先读取字符串,再调用支付网关。常见流程如下:
- 选择支付处理商及其支持的终端型号。
- 由支付处理商或收单机构完成商户和终端配置。
- 安装终端驱动、服务或 SDK。
- POS 应用通过 SDK 发起交易,并传入金额、币种和订单号。
- 顾客在支付终端上插卡、挥卡或刷卡。
- 终端完成读卡、PIN、加密和必要的持卡人验证。
- 终端或支付服务与收单系统通信。
- POS 应用接收批准、拒绝、取消或超时结果。
- 应用保存交易 ID、授权码、token 和掩码卡号,不保存磁道数据。
伪代码通常类似:
var request = new PaymentRequest
{
Amount = 125.50m,
Currency = "CNY",
OrderId = "ORDER-20260901-001"
};
PaymentResult result =
await paymentTerminal.StartSaleAsync(request);
if (result.Approved)
{
SavePaymentResult(
result.TransactionId,
result.AuthorizationCode,
result.CardBrand,
result.MaskedPan,
result.PaymentToken);
}
else
{
ShowPaymentFailure(result.ResponseMessage);
}
PaymentRequest、PaymentResult 和 StartSaleAsync 只是用来说明集成结构的占位名称,并不是通用的 .NET API。实际使用的名称由支付终端 SDK 决定。
关于卡号和持卡人姓名
并不是所有银行卡或交易方式都会提供持卡人姓名:
- 磁条 Track 1 可能包含姓名,但该字段不一定存在,也不一定可靠。
- Track 2 通常包含主账号、有效期和服务代码等数据,不包含姓名。
- EMV 芯片交易能否返回姓名,取决于卡片数据、终端配置和 SDK。
- 非接触式钱包交易可能只返回与网络 token 对应的信息。
- 出于安全或合规要求,支付处理商可能只返回掩码卡号。
因此,不能把“读取持卡人姓名”设为支付成功的必要条件,也不应使用卡片上的姓名完成身份认证。
安全与合规注意事项
原始磁道数据是高度敏感的支付数据。生产环境至少要遵守以下原则:
- 不记录完整 PAN、Track 1、Track 2、PIN、PIN block 或验证码。
- 不把原始卡数据写入日志、异常信息、剪贴板、消息队列或分析系统。
- 不使用普通 HTTP、共享文件或自定义明文协议传输卡数据。
- 不在 supervisor 屏幕上显示完整卡号,只显示允许的掩码形式。
- 不保存刷卡后的完整磁道数据,即使交易失败也不能保存。
- 优先选择支持点到点加密或端到端加密的终端。
- 使用支付处理商返回的 token 完成退款、撤销或后续业务。
- 根据实际业务范围评估并满足适用的 PCI DSS 要求。
一旦业务应用直接接触明文磁道数据,合规范围和安全责任都会明显扩大。除非设备厂商和支付处理商明确要求,否则不要自行解析磁道字符串。
KeyPress 只是旧式读卡器的一种输入方式,并不是启用 POS 支付的核心接口。应先确认设备接口和工作模式,再按照厂商 SDK 或支付处理商的认证流程完成集成,这样才能得到可靠并可上线的支付实现。
备注:内容仅供参考。
