一卡通 Android 对接协议

项目 内容
文档版本 1.0
整理日期 2026-08-28
适用项目 ZhctTrayBindingMachine 及复用其一卡通代码的 Android Java 项目
Android 包根目录 com.zhct.traybinding.onecard
读卡器 New-CAP 0005 / HID RF Picc Reader
USB 标识 VID 0x0610,PID 0x2010
服务端 新开普 Newcapec.eCard.ProxyWebService.asmx SOAP WebService
金额单位 分,所有卡余额和消费金额均使用整数 long
时间格式 卡交易输入使用毫秒值;上传使用 yyyy-MM-dd HH:mm:ss
文档依据 对话确认结果、Windows Bus Hound 抓包、Android 实现、OnecardDemo 测试记录和当前目标项目源码

1. 对话结论摘要

本次一卡通适配从 Windows Demo 和读卡器抓包开始,最终形成 Android Java 实现。已经确认的关键结论如下:

  1. Windows Demo 的业务顺序是“连接读卡器 → 寻卡 → 读卡”,读卡前需要获得当前卡片 UID。
  2. Android 的 readCard() 内部会自行寻卡;已经显式执行 findCard() 时,应调用 readCard(expectedUid) 复用 UID,避免重复完整寻卡。
  3. 读卡结果包含账号、卡号、持卡序号、卡状态、卡类型、现金余额、补贴余额及各钱包消费计数,金额单位均为分。
  4. 本地消费只修改卡片:优先扣补贴钱包,不足部分再扣主钱包;一次跨钱包消费可能产生两笔独立卡交易。
  5. 消费交易使用当前毫秒值,上传服务端时转换为 yyyy-MM-dd HH:mm:ss
  6. 自动寻卡模式在读卡器连接后持续轮询;检测到新卡时触发一次交易,卡片拿走并连续确认无卡后才恢复等待下一张卡。
  7. 长时间连接后重新打开可能出现 HID 写入 -1。F700-HW 已实现只在连接初始化阶段触发一次 xHCI 重绑恢复;该能力依赖 root 和固定硬件拓扑,不能视为通用 Android 能力。
  8. 一卡通 WebService 的“连接”并非保持长连接,而是使用 URL 和 AppID 调用 GetCustomerCount 做连通性及认证验证;成功后客户端保存配置供后续请求使用。
  9. AppID 必须先通过 Windows 注册流程获得并在一卡通服务器完成认证配置。Android 不复制 Windows 的 systemint.dat,只保存和使用最终数字 AppID。
  10. 全量人员按账号游标分段获取,当前默认每批 1000 条;每批解析后写入 SQLite,完整成功后才清理上次快照中的过期人员。
  11. 日志记录读卡器连接、USB收发、寻卡、读卡、消费、人员同步和异常,时间精确到毫秒。

2. 能力与验证状态

能力 代码状态 当前证据 交付状态
USB设备发现、授权、连接、断开 已实现 OnecardDemo 真机和目标代码编译 目标设备待硬件验证
32/64字节 HID Report 选择 已实现 两种端点拓扑已编码 新型号待硬件验证
寻卡、读取 UID 已实现 抓包与 Android 真机 目标设备待硬件验证
完整读卡 已实现 OnecardDemo 真机成功 目标设备待硬件验证
主钱包本地消费 已实现 成功消费样本 目标业务流程待联调
补贴优先、现金补足 已实现 代码和分账测试 有补贴余额卡待硬件验证
消费前读卡、扣款、消费后复读 已实现 当前目标项目源码 目标设备待硬件验证
服务器连接与人数查询 已实现 Windows/Android Demo 测试 目标网络环境待验证
单人、部分人、全量人员 已实现 XML解析及同步测试 目标数据规模待验证
黑名单、部门接口 已实现 代码实现 待服务端联调
消费记录上传 已实现 SOAP DTO及请求生成测试 待服务端端到端联调
SQLite人员快照 已实现 编译和单元逻辑 目标数据规模待验证
本地文件日志 已实现 代码与日志测试 可用

“代码已实现”不等同于目标硬件已经验收。涉及读卡器、PSAM、补贴卡、F700 root恢复的结论均应以目标样机实测为最终依据。

3. 代码结构与复制边界

com.zhct.traybinding.onecard
├── config/      一卡通URL、AppID默认值及输入校验
├── reader/      USB HID、寻卡、读卡、本地消费、自动巡卡
├── server/      SOAP请求、响应解析、人员/黑名单/部门/上传接口
├── customer/    全量人员分页、SQLite快照、业务人员同步扩展
└── log/         毫秒级文件日志

3.1 可作为通用一卡通代码复制

3.2 复制到其他项目时需要调整

4. Android 接入前置条件

4.1 最低代码条件

4.2 Manifest

<uses-permission android:name="android.permission.INTERNET" />

<uses-feature
    android:name="android.hardware.usb.host"
    android:required="true" />

当前服务地址是 HTTP。若目标项目仍使用 HTTP,需要允许明文流量,例如:

<application
    android:networkSecurityConfig="@xml/network_security_config"
    ... />
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true" />
</network-security-config>

当前目标项目已经具备 INTERNET 和明文网络配置,但尚未声明 android.hardware.usb.host;正式硬件交付前应补充该声明。

4.3 USB权限与自动启动

ConsumptionCardSearchManager 会动态监听:

因此正常页面内自动连接不要求静态 device_filter.xml。只有需要“插入读卡器自动拉起应用”时,才需给 Activity 配置 USB_DEVICE_ATTACHED 和设备过滤器:

<resources>
    <usb-device vendor-id="1552" product-id="8208" />
</resources>

其中十进制 1552:8208 对应十六进制 0610:2010

Android 13及更高 targetSdk 的项目复制动态广播代码时,需要按目标SDK要求为 registerReceiver 增加导出标志,建议使用 RECEIVER_NOT_EXPORTED

5. 服务器配置与 AppID

当前默认值定义在 OnecardSettings

String serverUrl = OnecardSettings.DEFAULT_SERVER_URL;
long appId = OnecardSettings.DEFAULT_APP_ID;

当前默认值:

这些只是当前部署默认值,正式项目必须允许配置。URL校验规则为:

AppID校验规则为正整数。

5.1 AppID注册边界

  1. 在可连接一卡通服务端的 Windows 环境执行厂商注册流程。
  2. 使用厂商注册码完成注册,并由服务端加载对应 AppID 认证配置。
  3. 获取最终数字 AppID,例如当前配置为 6
  4. Android 只保存 URL 和数字 AppID,不读取或模拟 Windows 的 systemint.dat
  5. 如果服务端没有加载该 AppID 的认证配置,Android 即使网络可达也会连接验证失败。

注册码属于敏感部署材料,不应写入源码、普通日志或本文档。

6. 读卡器高层接口

6.1 基础接口

CardReader 提供:

void connect() throws ReaderException;
CardUid findCard() throws ReaderException;
CardInfo readCard() throws ReaderException;
CardInfo readCard(CardUid expectedUid) throws ReaderException;
ConsumptionResult consume(ConsumptionRequest request) throws ReaderException;
ConsumptionBatchResult consumeSubsidyFirst(long customerId, long amount,
                                            long timeMillis) throws ReaderException;
ConsumptionBatchResult consumeSubsidyFirst(CardInfo card, long amount,
                                            long timeMillis) throws ReaderException;
void disconnect();
boolean isConnected();

具体实现:

NewcapecUsbCardReader reader = new NewcapecUsbCardReader(context);

设备检查扩展:

UsbDevice device = reader.findSupportedDevice();
boolean supported = NewcapecUsbCardReader.isSupported(device);
boolean permission = reader.hasPermission(device);
boolean physicalPresent = reader.isConnectedDevicePresent();

6.2 手动连接、寻卡、读卡

以下代码必须运行在后台串行线程:

ReaderLog.initialize(context);
ReaderLog.beginSession("ONECARD_MANUAL");

NewcapecUsbCardReader reader = new NewcapecUsbCardReader(context);
try {
    reader.connect();
    CardUid uid = reader.findCard();
    CardInfo card = reader.readCard(uid);
    // 使用 card
} catch (ReaderException error) {
    ReaderError code = error.getError();
    // 分类处理
} finally {
    reader.disconnect();
}

调用约束:

6.3 推荐的自动连接与巡卡接口

业务页面优先使用 ConsumptionCardSearchManager,它已经封装:

生命周期接法:

private ConsumptionCardSearchManager manager;

@Override
protected void onCreate(Bundle state) {
    super.onCreate(state);
    ReaderLog.initialize(this);
    ReaderLog.beginSession("CONSUMPTION_PAGE");
    manager = new ConsumptionCardSearchManager(this, listener);
}

@Override
protected void onStart() {
    super.onStart();
    manager.start();
}

@Override
protected void onStop() {
    if (!manager.isTransactionInFlight()) {
        manager.stop();
    }
    super.onStop();
}

@Override
protected void onDestroy() {
    manager.close();
    super.onDestroy();
}

准备一笔订单:

boolean armed = manager.armConsumption(orderNo, amountInCents);

取消尚未开始的订单:

manager.cancelPendingConsumption();

6.4 自动巡卡回调

ConsumptionCardSearchManager.Listener

回调 含义
onDeviceNotFound() 未枚举到 0610:2010
onPermissionRequest() 已发起系统USB授权
onPermissionDenied() 用户拒绝或授权失败
onConnecting() 正在打开并初始化读卡器
onSearching() 已连接,等待新卡
onCardFound(uid) 检测到一张新卡
onWaitingForRemoval() 卡仍在读卡区,等待移除
onTransactionStarted(...) 订单卡交易开始
onTransactionStage(...) BEFORE_READDEBITAFTER_READ
onTransactionFinished(...) 返回各阶段独立结果
onDisconnected() 读卡器拔出或断开
onError(exception) 不可继续巡卡的错误

7. 卡片与消费数据模型

7.1 CardInfo

Java字段 类型 含义 单位/格式
uid CardUid 射频卡UID 十六进制显示
cardClass int 钱包卡类别/交易卡类别 数字
applicationSerialNumber String 用户卡应用序列号 字符串
customerId long 账号 数字
cardNumber long 卡号 数字
cardSerialNumber int 持卡序号 数字
status int 卡状态 数字
cardType int 卡类型 数字
totalAmount long 卡总额字段
balance long 主钱包/现金余额
operationCount int 主钱包消费计数
subsidyBalance long 补贴余额
subsidyOperationCount int 补贴钱包消费计数
rawResponse String 完整读卡原始APDU响应 调试使用

7.2 钱包

枚举 code 卡文件 含义
WalletType.MAIN 0 EC11 主钱包/现金钱包
WalletType.SUBSIDY 1 EC12 补助/补贴钱包

7.3 单钱包消费

ConsumptionRequest request = new ConsumptionRequest(
        card.getCustomerId(),
        amountInCents,
        WalletType.MAIN,
        System.currentTimeMillis());

ConsumptionResult result = reader.consume(request);

注意:consume(request) 内部会重新读卡。已经有完整 CardInfo 且要求补贴优先时,应调用 consumeSubsidyFirst(card, ...)

7.4 补贴优先消费

ConsumptionBatchResult result = reader.consumeSubsidyFirst(
        card, amountInCents, System.currentTimeMillis());

分账原则:

  1. 优先使用补贴余额;
  2. 补贴不足时,剩余金额使用主钱包;
  3. 总余额不足返回 INSUFFICIENT_BALANCE
  4. 每个钱包产生独立的 ConsumptionResult、PSAM流水号和TAC;
  5. 如果第一钱包成功、第二钱包失败,返回部分成功结果,禁止直接按原总金额重试。

ConsumptionResult 关键字段:

字段 含义
amount 本钱包实际扣款金额,分
balanceBefore / balanceAfter 本钱包扣款前后余额,分
operationCountBefore / operationCountAfter 扣款前后消费计数
psamIdHex / psamIdDecimal PSAM卡号
psamTransactionNumber PSAM脱机交易序号
tac 交易验证码
timeMillis 交易时间毫秒值
psamConfirmed PSAM确认是否完成
rawResponse 本钱包交易各阶段原始响应

8. 交易一致性与禁止自动重试

推荐订单流程:

检测新卡UID
  → 消费前完整读卡
  → 校验账号、卡状态和余额
  → 按补贴优先执行一笔或两笔卡交易
  → 消费后复读同一UID
  → 核对余额和计数
  → 本地落库
  → 异步上传服务端

处理原则:

9. 读卡器错误码

错误码 含义 建议处理
DEVICE_NOT_FOUND 未找到读卡器 等待插入或提示检查USB
PERMISSION_REQUIRED 缺少USB权限 发起系统授权
OPEN_FAILED Android无法打开设备 关闭旧连接后重试,必要时插拔
INTERFACE_NOT_FOUND 未找到HID接口 记录描述符,待硬件确认
ENDPOINT_NOT_FOUND 未找到Interrupt IN 记录端点,待硬件确认
CLAIM_FAILED HID接口占用/声明失败 释放其他连接后重试
UNSUPPORTED_HID_REPORT 未知端点拓扑或报文长度 禁止试探写卡,待适配
NOT_CONNECTED 未连接即调用业务接口 先连接
IO_WRITE_FAILED HID写入失败 断开重连;F700可在限定条件恢复
DEVICE_RECOVERY_FAILED F700自动恢复失败 物理插拔,保留日志
IO_TIMEOUT 等待响应超时 交易中不得盲目重试
SHORT_REPORT 返回报文长度不完整 断开并记录原始信息
PROTOCOL_ERROR 私有协议或状态字异常 保留原始响应,停止本次流程
CRC_ERROR 卡片APDU CRC错误 停止本次流程,可重新放卡
NO_CARD 当前无卡 自动巡卡中的正常状态
CARD_REMOVED 操作中移卡 中止,扣款阶段需核对结果
CARD_CHANGED 操作中换卡 立即中止
UNSUPPORTED_CARD 卡片类型不支持 提示换卡
INVALID_INPUT 参数错误 修正业务参数
ACCOUNT_MISMATCH 输入账号与卡账号不一致 禁止消费
CARD_STATUS_INVALID 卡状态不可消费 禁止消费
INSUFFICIENT_BALANCE 总余额不足 提示余额不足
TRANSACTION_OUTCOME_UNKNOWN 已进入写卡但结果未知 禁止自动重扣,人工核对
PSAM_ERROR PSAM初始化、计算或确认失败 停止消费并检查PSAM

10. 一卡通服务端接口

10.1 客户端与连接语义

OnecardWebServiceClient client = new OnecardWebServiceClient();
int customerCount = client.connect(serverUrl, appId);

connect() 实际执行 GetCustomerCount

10.2 SOAP公共约定

项目
HTTP方法 POST
Content-Type text/xml; charset=utf-8
Accept text/xml
SOAP命名空间 http://NewCap.com/NewCapecWebService/
SOAPAction 命名空间 + 操作名
成功HTTP状态 2xx
错误 HTTP错误、空响应、SOAP Fault或XML解析错误均抛出 ServerException

10.3 接口清单

Java方法 SOAP操作 请求参数 返回处理
connect(url, appId) GetCustomerCount appId 返回人员总数并保存连接配置
getCustomerCount() GetCustomerCount appId 人员总数
getCustomer(customerId) GetInfoByCustID appId, CustomerID 单个人员 SoapResponse
getCustomers(start, count) GetCustomerByNum appId, custid, num 从账号游标开始的人员列表
getBlacklistCount() GetBLLTCount appId 黑名单数量
getBlacklist() GetBLLTAll appId 黑名单XML
getDepartmentCount() GetCustDeptCount appId 部门数量
getDepartments() GetCustDeptInfo appId 部门XML
upload(record) UploadBusinessRec dto + AppID 上传结果 SoapResponse

单人信息可使用:

SoapResponse response = client.getCustomer(customerId);
String display = CustomerInfoFormatter.format(response);

当前格式化器识别:CUSTOMERIDNAMEOUTIDCARDNOCARDSNSCARDSNRSTATUSCARDTYPECUSTDEPTODDFARESUBODDFAREOPCOUNTOPENDTNOUSEDATE

11. 全量人员同步与SQLite

OnecardWebServiceClient client = new OnecardWebServiceClient();
client.connect(serverUrl, appId);

SQLiteCustomerRepository repository =
        new SQLiteCustomerRepository(context);
CustomerSyncService syncService =
        new CustomerSyncService(client, repository);

CustomerSyncResult result = syncService.sync(
        (batch, batchSaved, totalSaved, expected) -> {
            // 更新进度
        });

同步规则:

  1. 先获取总人数;
  2. startingCustomerId = 0 开始;
  3. 默认每批1000条;
  4. 解析 PROXY_BASE_CUSTOMERS
  5. 每条必须有合法 CUSTOMERID
  6. 同批和跨批 CUSTOMERID 不得重复;
  7. 下一批游标为本批最大账号加1;
  8. 每批使用SQLite事务保存;
  9. 同一账号使用主键更新,保证唯一性;
  10. 只有完整同步成功后才删除上次快照中不存在的人员;
  11. 中途失败保留已经保存的批次和失败记录,但不会把失败快照标为成功。

数据库:onecard.db

用途
customers 账号主表,customer_id 唯一
customer_fields 按原始字段名保存全部人员字段
customer_sync_runs 同步批次、数量、状态和错误

CustomerSyncResult 返回同步ID、预期数量、保存数量、成功批次数、开始/完成时间、耗时和数据库人数。

12. 消费记录上传

12.1 字段协议

UploadRecord 构造器的16个字段均必须提供;其中 CardSNOPDTSAMCardNO 还有显式格式校验。

SOAP字段 Java来源建议 含义 格式
TermID 终端配置 终端编号 整数
CustomerID CardInfo.customerId 账号 整数
CardNO CardInfo.cardNumber 卡号 整数
Cardsnr CardInfo.cardSerialNumber 持卡序号 整数
TradeCardType CardInfo.cardClass 钱包卡类型/交易卡类别 整数
CardSN CardInfo.applicationSerialNumber 用户卡应用序列号 非空字符串
SumFare CardInfo.totalAmount 总额
MngFare 业务规则 管理费 分,无管理费传0
PurseSector WalletType.code 钱包类型 主钱包0,补贴1
OpFare ConsumptionResult.amount 本笔消费额
OddFare ConsumptionResult.balanceAfter 本钱包消费后余额
Opcount ConsumptionResult.operationCountAfter 消费后计数 整数
OPDT ConsumptionResult.timeMillis 消费时间 yyyy-MM-dd HH:mm:ss
SAMCardNO ConsumptionResult.psamIdDecimal PSAM卡号 十进制数字字符串
SAMTradeNO ConsumptionResult.psamTransactionNumber PSAM脱机交易序号 整数
CardMac ConsumptionResult.tac 交易验证码 整数

AppID 不在 UploadRecord 构造参数内,由 SoapRequestFactory.createUpload() 自动加入 dto

12.2 时间转换

SimpleDateFormat format = new SimpleDateFormat(
        "yyyy-MM-dd HH:mm:ss", Locale.CHINA);
String consumptionTime = format.format(
        new Date(result.getTimeMillis()));

应使用设备业务时区,并保证设备时间已经校准。

12.3 上传示例

UploadRecord record = new UploadRecord(
        terminalId,
        card.getCustomerId(),
        card.getCardNumber(),
        card.getCardSerialNumber(),
        card.getCardClass(),
        card.getApplicationSerialNumber(),
        card.getTotalAmount(),
        0L,
        transaction.getWallet().getCode(),
        transaction.getAmount(),
        transaction.getBalanceAfter(),
        transaction.getOperationCountAfter(),
        consumptionTime,
        transaction.getPsamIdDecimal(),
        transaction.getPsamTransactionNumber(),
        transaction.getTac());

SoapResponse response = client.upload(record);

跨钱包消费必须遍历 ConsumptionBatchResult.getCompletedTransactions(),每个 ConsumptionResult 分别上传一条记录,因为钱包类型、余额、消费计数、PSAM交易序号和TAC都不同。

上传失败不得回滚卡片扣款。应先把完整交易信息和原始响应持久化,再通过唯一业务流水进行幂等补传;服务端幂等键需要由业务接口另行约定。

13. SOAP响应解析

SoapResponse 提供:

boolean isFault();
String getMessage();
String getResultText();
String getRawXml();
String getOperation();
int requireCount();
List<Long> getLongValues(String elementName);
Map<String, String> getFirstValues(String... elementNames);
List<Map<String, String>> getRecords(String recordElementName);

解析器兼容带命名空间和不带命名空间的XML元素,并启用了安全XML配置,拒绝外部实体。业务代码不应通过字符串截取解析SOAP XML。

14. 日志协议

初始化:

ReaderLog.initialize(context);
ReaderLog.beginSession("业务场景名称");

日志位置:

<应用内部 filesDir>/reader_logs/

规则:

读取:

List<File> files = ReaderLog.listLogFiles();
String text = ReaderLog.readLog(files.get(0));

禁止在普通日志中记录注册码、密码、完整身份证号等敏感信息。卡号、账号、PSAM号的日志脱敏规则应由业务项目补充。

15. USB HID底层摘要

完整底层指令、抓包样本和字段偏移见:

新开普 HID RF Picc Reader 通讯协议(逆向整理)

Android实现的核心约定:

项目 当前实现
VID/PID 0610:2010
USB类 HID,Interface Class 0x03
写入 HID Class Control Transfer SET_REPORT
bmRequestType 0x21
bRequest 0x09
wValue 0x0200
读取 Interrupt IN
I/O超时 3000ms
1个IN、0个OUT端点 32字节输入/输出 Report
1个IN、1个OUT端点 64字节输入/输出 Report
未知端点拓扑 返回 UNSUPPORTED_HID_REPORT,禁止试探交易

私有操作码摘要:

opcode 用途
0x50 获取长响应续包
0x62 激活卡片
0x63 关闭卡片/控制通道
0x6B 读卡器或卡片私有控制
0x6F 透传卡片APDU

通道 0x01 用于卡片,0x02 用于读卡器初始化。APDU使用 ISO/IEC 14443 Type A CRC-A,初值 0x6363,低字节在前。

16. 目标项目业务扩展

ZhctTrayBindingMachine 在通用一卡通能力之上增加了人员上传到自身业务系统的流程:

连接新开普服务
  → 全量人员按1000条拉取
  → 保存SQLite成功快照
  → 按业务接口上限分批转换
  → POST /api/oneCardTerminal/syncPersons

业务人员字段:

新开普字段 业务字段 规则
BM bm 必填,最长200
XH xh 必填,最长60
NAME name 必填,最长40
SEX sex 必须为0或1
CUSTOMERID customerid 必填,且与记录主键一致
CARDNO cardno 必填整数
STATUS status 必填整数
CARDSN cardsn 必填整数

该业务接口和 com.zhct.traybinding.network.* 不是新开普协议的一部分,其他项目复制时应替换为自己的数据目标。

17. 接入验收清单

17.1 配置

17.2 USB与读卡

17.3 消费

17.4 服务端与数据

17.5 稳定性

18. 已知边界