| 项目 | 内容 |
|---|---|
| 文档版本 | 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 测试记录和当前目标项目源码 |
本次一卡通适配从 Windows Demo 和读卡器抓包开始,最终形成 Android Java 实现。已经确认的关键结论如下:
readCard() 内部会自行寻卡;已经显式执行 findCard() 时,应调用 readCard(expectedUid) 复用 UID,避免重复完整寻卡。yyyy-MM-dd HH:mm:ss。-1。F700-HW 已实现只在连接初始化阶段触发一次 xHCI 重绑恢复;该能力依赖 root 和固定硬件拓扑,不能视为通用 Android 能力。GetCustomerCount 做连通性及认证验证;成功后客户端保存配置供后续请求使用。systemint.dat,只保存和使用最终数字 AppID。| 能力 | 代码状态 | 当前证据 | 交付状态 |
|---|---|---|---|
| USB设备发现、授权、连接、断开 | 已实现 | OnecardDemo 真机和目标代码编译 | 目标设备待硬件验证 |
| 32/64字节 HID Report 选择 | 已实现 | 两种端点拓扑已编码 | 新型号待硬件验证 |
| 寻卡、读取 UID | 已实现 | 抓包与 Android 真机 | 目标设备待硬件验证 |
| 完整读卡 | 已实现 | OnecardDemo 真机成功 | 目标设备待硬件验证 |
| 主钱包本地消费 | 已实现 | 成功消费样本 | 目标业务流程待联调 |
| 补贴优先、现金补足 | 已实现 | 代码和分账测试 | 有补贴余额卡待硬件验证 |
| 消费前读卡、扣款、消费后复读 | 已实现 | 当前目标项目源码 | 目标设备待硬件验证 |
| 服务器连接与人数查询 | 已实现 | Windows/Android Demo 测试 | 目标网络环境待验证 |
| 单人、部分人、全量人员 | 已实现 | XML解析及同步测试 | 目标数据规模待验证 |
| 黑名单、部门接口 | 已实现 | 代码实现 | 待服务端联调 |
| 消费记录上传 | 已实现 | SOAP DTO及请求生成测试 | 待服务端端到端联调 |
| SQLite人员快照 | 已实现 | 编译和单元逻辑 | 目标数据规模待验证 |
| 本地文件日志 | 已实现 | 代码与日志测试 | 可用 |
“代码已实现”不等同于目标硬件已经验收。涉及读卡器、PSAM、补贴卡、F700 root恢复的结论均应以目标样机实测为最终依据。
com.zhct.traybinding.onecard
├── config/ 一卡通URL、AppID默认值及输入校验
├── reader/ USB HID、寻卡、读卡、本地消费、自动巡卡
├── server/ SOAP请求、响应解析、人员/黑名单/部门/上传接口
├── customer/ 全量人员分页、SQLite快照、业务人员同步扩展
└── log/ 毫秒级文件日志
config/OnecardSettingsreader 包中的读卡器协议、数据模型和交易执行器server 全包log 全包customer 中的 CustomerBatchParser、CustomerPageSource、CustomerRecord、CustomerRepository、CustomerSyncResult、CustomerSyncService、SQLiteCustomerRepository、SyncLogger、ReaderLogSyncLoggerConsumptionCardSearchManager 引用了当前应用的 BuildConfig.APPLICATION_ID 生成 USB 权限 Action。复制后必须改为目标项目的 BuildConfig,或把 applicationId 作为构造参数传入。NetworkBusinessPersonGateway、BusinessPersonMapper、BusinessPersonSyncService、ConsumptionPersonSyncCoordinator 依赖 ZhctTrayBindingMachine 自有网络接口和人员 DTO,属于目标业务扩展,不是新开普通用协议。/system/xbin/su、USB 节点 5-1 和 xhci-hcd.1.auto,复制到其他硬件前必须重新确认拓扑。ConsumptionCardSearchManager,目标项目需要 Android Handler、单线程 ExecutorService 和可用的 BuildConfig.APPLICATION_ID。<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;正式硬件交付前应补充该声明。
ConsumptionCardSearchManager 会动态监听:
UsbManager.ACTION_USB_DEVICE_ATTACHED;UsbManager.ACTION_USB_DEVICE_DETACHED。因此正常页面内自动连接不要求静态 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。
当前默认值定义在 OnecardSettings:
String serverUrl = OnecardSettings.DEFAULT_SERVER_URL;
long appId = OnecardSettings.DEFAULT_APP_ID;
当前默认值:
http://10.128.1.19/WebService/Newcapec.eCard.ProxyWebService.asmx6这些只是当前部署默认值,正式项目必须允许配置。URL校验规则为:
http 或 https;.asmx 结尾。AppID校验规则为正整数。
6。systemint.dat。注册码属于敏感部署材料,不应写入源码、普通日志或本文档。
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();
以下代码必须运行在后台串行线程:
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();
}
调用约束:
connect() 前必须已经获得 Android USB 权限。findCard() 只负责获取 UID,不返回余额。readCard() 会先自行完整寻卡。findCard() 时使用 readCard(uid),避免重复寻卡。CARD_REMOVED、CARD_CHANGED。CardReader 实例上的硬件命令必须串行执行。业务页面优先使用 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);
amountInCents == 0:只读卡,不发送扣款指令。amountInCents > 0:执行消费前读卡、补贴优先消费、消费后复读。取消尚未开始的订单:
manager.cancelPendingConsumption();
ConsumptionCardSearchManager.Listener:
| 回调 | 含义 |
|---|---|
onDeviceNotFound() |
未枚举到 0610:2010 |
onPermissionRequest() |
已发起系统USB授权 |
onPermissionDenied() |
用户拒绝或授权失败 |
onConnecting() |
正在打开并初始化读卡器 |
onSearching() |
已连接,等待新卡 |
onCardFound(uid) |
检测到一张新卡 |
onWaitingForRemoval() |
卡仍在读卡区,等待移除 |
onTransactionStarted(...) |
订单卡交易开始 |
onTransactionStage(...) |
BEFORE_READ、DEBIT、AFTER_READ |
onTransactionFinished(...) |
返回各阶段独立结果 |
onDisconnected() |
读卡器拔出或断开 |
onError(exception) |
不可继续巡卡的错误 |
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响应 | 调试使用 |
| 枚举 | code | 卡文件 | 含义 |
|---|---|---|---|
WalletType.MAIN |
0 | EC11 |
主钱包/现金钱包 |
WalletType.SUBSIDY |
1 | EC12 |
补助/补贴钱包 |
ConsumptionRequest request = new ConsumptionRequest(
card.getCustomerId(),
amountInCents,
WalletType.MAIN,
System.currentTimeMillis());
ConsumptionResult result = reader.consume(request);
注意:consume(request) 内部会重新读卡。已经有完整 CardInfo 且要求补贴优先时,应调用 consumeSubsidyFirst(card, ...)。
ConsumptionBatchResult result = reader.consumeSubsidyFirst(
card, amountInCents, System.currentTimeMillis());
分账原则:
INSUFFICIENT_BALANCE;ConsumptionResult、PSAM流水号和TAC;ConsumptionResult 关键字段:
| 字段 | 含义 |
|---|---|
amount |
本钱包实际扣款金额,分 |
balanceBefore / balanceAfter |
本钱包扣款前后余额,分 |
operationCountBefore / operationCountAfter |
扣款前后消费计数 |
psamIdHex / psamIdDecimal |
PSAM卡号 |
psamTransactionNumber |
PSAM脱机交易序号 |
tac |
交易验证码 |
timeMillis |
交易时间毫秒值 |
psamConfirmed |
PSAM确认是否完成 |
rawResponse |
本钱包交易各阶段原始响应 |
推荐订单流程:
检测新卡UID
→ 消费前完整读卡
→ 校验账号、卡状态和余额
→ 按补贴优先执行一笔或两笔卡交易
→ 消费后复读同一UID
→ 核对余额和计数
→ 本地落库
→ 异步上传服务端
处理原则:
TRANSACTION_OUTCOME_UNKNOWN、USB超时、卡片在扣款中移除或消费后复读失败:不得自动再次扣款,应保存原始响应并进入人工核对或补偿流程。ConsumptionBatchResult.isComplete() 为 false 时必须查看 getCompletedTransactions() 和 getFailure(),不能只按失败处理,否则会丢失已成功的部分扣款。| 错误码 | 含义 | 建议处理 |
|---|---|---|
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 |
OnecardWebServiceClient client = new OnecardWebServiceClient();
int customerCount = client.connect(serverUrl, appId);
connect() 实际执行 GetCustomerCount:
endpoint 和 appId;disconnect() 只是清除本地 URL 和 AppID,不存在需要保持的长连接或Socket;| 项目 | 值 |
|---|---|
| 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 |
| 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);
当前格式化器识别:CUSTOMERID、NAME、OUTID、CARDNO、CARDSN、SCARDSNR、STATUS、CARDTYPE、CUSTDEPT、ODDFARE、SUBODDFARE、OPCOUNT、OPENDT、NOUSEDATE。
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) -> {
// 更新进度
});
同步规则:
startingCustomerId = 0 开始;PROXY_BASE_CUSTOMERS;CUSTOMERID;CUSTOMERID 不得重复;数据库:onecard.db
| 表 | 用途 |
|---|---|
customers |
账号主表,customer_id 唯一 |
customer_fields |
按原始字段名保存全部人员字段 |
customer_sync_runs |
同步批次、数量、状态和错误 |
CustomerSyncResult 返回同步ID、预期数量、保存数量、成功批次数、开始/完成时间、耗时和数据库人数。
UploadRecord 构造器的16个字段均必须提供;其中 CardSN、OPDT、SAMCardNO 还有显式格式校验。
| 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。
SimpleDateFormat format = new SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss", Locale.CHINA);
String consumptionTime = format.format(
new Date(result.getTimeMillis()));
应使用设备业务时区,并保证设备时间已经校准。
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都不同。
上传失败不得回滚卡片扣款。应先把完整交易信息和原始响应持久化,再通过唯一业务流水进行幂等补传;服务端幂等键需要由业务接口另行约定。
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。
初始化:
ReaderLog.initialize(context);
ReaderLog.beginSession("业务场景名称");
日志位置:
<应用内部 filesDir>/reader_logs/
规则:
reader-yyyy-MM-dd.log;yyyy-MM-dd HH:mm:ss.SSS;读取:
List<File> files = ReaderLog.listLogFiles();
String text = ReaderLog.readLog(files.get(0));
禁止在普通日志中记录注册码、密码、完整身份证号等敏感信息。卡号、账号、PSAM号的日志脱敏规则应由业务项目补充。
完整底层指令、抓包样本和字段偏移见:
新开普 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,低字节在前。
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.* 不是新开普协议的一部分,其他项目复制时应替换为自己的数据目标。
.asmx 结尾。0610:2010。OnecardWebServiceClient 超时目前是固定值,尚未开放构造配置。