如何实现 PayCenter RTP CreditTransfer 幂等性与 EES 事件关联?
结论
使用 PayCenter Instant Payments API(PIPA v2)时,应通过必填的 UUID 请求头 X-Request-ID 检测重复请求,保存 PmtHubTrakId 作为 PayCenter 侧的主要交易参考,并通过 EES event 800 跟踪最终结果。同一个 X-Request-ID 再次提交会触发重复交易错误,客户端不能预期服务端原样返回首次成功响应。官方 Payment API 说明
使用旧版 /v1/.../networks/rtp/credit-transfers 时,必须遵循对应的 RTP 接口约定。旧版的 CreditTransfer.Id、调用方参考号和事件状态均与 PIPA v2 有差异。官方 RTP 迁移指南
两套接口中的 Pending 都不能作为付款成功的依据。请求超时后,先查明原交易状态。本地防重、服务端重复检测和 EES 消费去重需要分别实现。
先确认接口版本
题目中的响应结构与 PIPA 文档一致,CreditTransfer.Id 的说明却来自旧版 RTP 文档。接入前应核对实际请求 URL、机构接入配置和对应的 API schema。
| 项目 | 旧版 RTP API | PIPA v2 |
|---|---|---|
| 发起路径 | /v1/institutions/{senderrtn}/environments/{envname}/products/paycenter/networks/rtp/credit-transfers | /v2/paycenter/institutions/{senderrtn}/environments/{envname}/products/pipa/credit-transfers |
| 响应主标识 | CreditTransfer.Id | PmtHubTrakId |
| 响应状态字段 | CreditTransfer.Status | Status |
| 调用方参考号 | 迁移指南列为 OriginatorReferenceId,可选、UUID | X-Request-ID,必填、UUID |
这些差异见官方迁移指南。即使旧版服务接受 X-Request-ID,也需确认网关是否映射了该字段,以及它是否具有 PIPA 的重复检测语义。
CreditTransfer 如何防止重复付款?
PIPA 使用 X-Request-ID
PIPA Payment API 规定:
- 请求必须包含 UUID 格式的
X-Request-ID。 - 再次提交同一值时,返回重复请求错误。
- 重复错误可携带
OriginalTransactionDetails,提供原交易标识和状态。官方请求与响应说明
错误码表将 70080 列在 HTTP 409 Conflict 下,表示收到了使用既有 X-Request-ID 的重复交易。官方错误码表
PayCenter Payment API 文档没有列出 Idempotency-Key 请求头,不应依赖这一名称,也无法据此确定它的格式或是否必填。其他 Jack Henry 产品对同名请求头的约定,不能直接套用到 PayCenter。
相同 key 的两种情况怎么处理?
| 情况 | 已确认的服务端约定 | 客户端应如何处理 |
|---|---|---|
相同 X-Request-ID、相同 body | PIPA 文档规定返回重复错误,未承诺重放首次响应 | 读取原交易信息,必要时查询原交易 |
相同 X-Request-ID、不同 body | 文档将其描述为重复 ID 错误,没有单独说明 body 比较及其优先级 | 在本地直接拒绝,不提交修改后的请求 |
409/70080 只表示 ID 重复,不能据此认定服务端已经比较 body 并确认内容不同。幂等键的有效期、作用域、并发请求的处理方式,以及校验失败后是否占用该键,都需要向接入方确认。
本地应为每笔业务付款固定一个请求 UUID,并保存付款内容的稳定摘要。相同业务号且内容一致时,返回已有记录;内容不同时,报业务冲突。不要生成新 UUID 来绕过重复错误。
CreditTransfer.Id 是响应标识,不能据此推断调用方可以在请求中设置它来防重。没有接口约定时,金额、收款账号、备注或 EndToEndId 也不能替代正式的重复检测机制。
超时和 HTTP 错误如何重试?
PIPA 对 HTTP 504 的说明是:交易可能已经被网络处理,应先用原 X-Request-ID 查询状态,再决定是否重试。官方超时处理说明
客户端可按以下方式处理:
| 结果 | 处理方式 |
|---|---|
返回 Pending | 保存标识,等待事件或查询结果,不重新发起付款 |
客户端读超时、连接中断、504 | 将提交结果记为待核实,先查询原交易 |
409/70080 | 转入原交易恢复流程 |
400、422 | 按业务错误修正,不循环发送原请求 |
401、403 | 修复认证或权限问题,不按一般临时故障处理 |
其他 5xx | 结合业务错误码和查询结果判断,不能仅凭 HTTP 类别重新付款 |
官方错误码 70714 要求重试前核实状态;70715 提示稍后再试,但未规定固定等待时长。官方错误码表
客户端可以采用指数退避、随机抖动和重试上限,这些策略不属于 PayCenter 的官方规定。一次查询返回“未找到”也不足以自动发起新付款,还需考虑交易可见性、查询范围和接口保留期。
应保存哪些标识用于 EES 对账?
对于题目所示的 PIPA 响应,建议将 PmtHubTrakId 作为 PayCenter 侧的主要参考,并按用途保留其他标识:
| 标识 | 建议用途 |
|---|---|
内部 transfer_id 或业务订单号 | 本地付款主键及业务防重 |
X-Request-ID | 请求恢复、重复检测及辅助事件关联 |
PmtHubTrakId | PayCenter 交易查询及主要事件关联 |
CreditTransfer.Id | 保存旧版 RTP 返回的标识 |
EndtoEndId、NetworkPrimaryReferenceId | 网络侧追踪及辅助对账 |
TrnRcptId、冲正凭证标识 | 核心账务核对 |
EventUniqueId | 事件去重 |
PIPA 支持按 PmtHubTrakId 或原 X-Request-ID 查询交易。查询结果还包含 EndtoEndId、NetworkPrimaryReferenceId、TransactionReceiptId 和 ReversalTransactionReceiptId。官方查询字段说明
EndtoEndId 的大小写应遵循实际 schema,不要自行更改拼法。
event 800 使用哪个字段关联?
PIPA event 800 的字段中列有 PmtHubTrakId,示例也携带了初始请求的 X-Request-ID,可以通过这两项标识关联交易。官方 Payment event 800 说明
事件业务字段位于 EventDataInfoArray 的 Name/Val 对中,例如:
<EventDataInfo>
<Name>PmtHubTrakId</Name>
<Val>20260504TTN6Z095328736</Val>
</EventDataInfo>
匹配时应限定机构、环境和支付轨道,优先使用 PmtHubTrakId。缺少该值时,再使用已确认会回传的请求参考号恢复关联。如果两种标识匹配到不同交易,应保留事件并告警,不能任选一笔更新。
公开文档将 PmtHubTrakId 用作交易跟踪标识,但未完整保证它在所有失败分支中都存在,也未保证它在跨重试及所有生命周期活动中始终不变。请求尚未成功建单时,本地记录应允许该字段为空。
event 800 的通用字段定义也未保证携带 CreditTransfer.Id 或独立的 EndToEndId 字段。因此,EndToEndId 不适合作为唯一关联入口;仅凭“end to end tracking identifier”的描述,也无法认定它与 ISO 20022 EndToEndId 相等。官方 event 800 字段定义
EES 应如何接收和去重?
event 800 能覆盖到什么程度?
PIPA Payment 文档规定,通过 event 800 的 DrPmt/Acpt 和 DrPmt/Rej 通知出站付款的最终结果。旧版 RTP 事件表还描述了送达、网络重试和冲正相关活动。PIPA 事件说明、旧版 RTP 事件代码表
正常付款结果应订阅 event 800,完整对账还需结合交易查询和核心账务凭证。这些文档不足以保证 event 800 覆盖所有退款、退回及异常处理流程,也不足以支持另行指定某个事件编号。
推送还是拉取?
EESEventAdd 用于推送事件,不负责创建订阅。官方接入说明要求消费者提供 XML/SOAP 监听端点,并及时返回规定格式的响应。接收确认失败可能阻塞后续投递。官方 EES 接入说明
EESEventSrch 支持游标分页,用于检索订阅中等待处理的事件。不能据此认定它支持查询任意历史事件,或重新拉取所有已成功推送的事件。官方 Enterprise Event Search 说明
实现时可优先采用推送,对长时间未完成的交易主动查询。是否同时开放拉取、能否补拉已投递事件,以及事件保留多久,都应按实际订阅配置确认,无需默认同时运行两种消费方式。
EventUniqueId 是否适合去重?
官方将 EventUniqueId 定义为 EES 在事件首次发布时分配的唯一 ID,并建议消费者确保每个 ID 只处理一次,以应对重试造成的重复投递。字段定义、重复事件处理建议
本地可采用以下唯一约束:
UNIQUE (
consumer_scope,
InstId,
ProdEnv,
EventProd,
EventUniqueId
)
consumer_scope 是自行定义的业务消费范围。同一消费流程通过多个订阅收到同一事件时,应共享去重范围。
还需检查实际 WSDL/schema 是否要求 EventUniqueId 必填,以及人工重新发布事件时是否生成新 ID。缺少该字段时,应持久化异常报文并进入恢复流程,不能统一使用空字符串作为去重键。
同一交易的多个合法状态事件会使用相同的 PmtHubTrakId,因此不能只按交易标识去重。状态更新和记账副作用也需要在本地保证幂等。
Payment Hub 状态如何映射?
判定状态时,至少需要结合:
接口版本 + PmtRailType + 交易方向 + PmtHubActType + PmtHubActStat
状态机应依据正式代码,PmtHubActStatDesc 或 Description 可用于记录诊断信息。遇到未知组合或相互冲突的结果,应保留原值并查询核实,避免根据描述字符串猜测状态。
PIPA v2
| 输入 | 建议内部状态 | 是否终态 |
|---|---|---|
REST Pending | PROCESSING | 否 |
出站 DrPmt/Acpt | SUCCEEDED | 是,付款结算成功 |
出站 DrPmt/Rej | REJECTED | 是,付款被拒绝;退款入账另行核对 |
查询 Failed | FAILED | 付款处理失败,不等于已验证所有账务恢复 |
入站 CrPmt/Acpt | 入站付款成功 | 应更新对应入站交易 |
PIPA 查询状态区分 Pending、Accepted、Rejected 和 Failed;事件文档将出站 DrPmt/Acpt、DrPmt/Rej 定义为最终结果。查询状态定义、事件状态定义
旧版 RTP 的 CT 活动
| 活动组合 | 官方描述的含义 | 建议处理 |
|---|---|---|
CrPmt/Dlvr | 收到 pacs.002 ACTC | 处理中,不据此确认最终结算 |
CrPmt/Retry | 网络侧正在重新尝试 | 继续等待,不由客户端另发付款 |
CrPmt/Rej | 收到 RJCT,资金稍后退回 | 付款拒绝,跟踪资金退回 |
CrPmt/Fail | 可能涉及延迟/孤立响应,也可能表示已发生冲正 | 核对具体原因和账务结果 |
DrPmt/Acpt | CT 已发送至 TCH | 不能直接套用 PIPA 的最终结算语义 |
DrPmt/Fail | 已扣款但未能发送至 TCH,资金稍后退回 | 记录失败并跟踪退款 |
这些解释见旧版 RTP 事件代码表。该表未给出完整、统一的终态矩阵,不能仅凭 Acpt 或 Fail 一个值,判定整笔付款及资金恢复都已结束。
建议分别保存“付款结果”和“资金恢复状态”,避免将“已拒绝、等待退回”提前显示为“退款完成”。
推荐实现流程与示例
发起付款前先落库,建立业务唯一约束,并通过事务或发送任务防止并发重复发起。固定的请求 UUID、规范化后的付款内容摘要和待发送任务都应保存下来。
以下为本地控制逻辑的 Python 风格伪代码,其中的函数名不是 PayCenter SDK 方法:
def submit_transfer(scope, business_id, payload):
digest = hash_canonical_payment(payload)
# 原子创建;数据库唯一约束:(scope, business_id)
transfer, created = create_transfer_and_outbox_atomically(
scope=scope,
business_id=business_id,
payload=payload,
payload_hash=digest,
request_id=new_uuid(),
)
if transfer.payload_hash != digest:
raise BusinessConflict("同一业务付款标识对应了不同内容")
# 已有记录由查询/恢复流程处理,不在此再次发起付款
return transfer
def dispatch_transfer(transfer):
# 发送任务必须具备并发领取保护;使用已保存的请求内容和 UUID
try:
response = post_credit_transfer(
payload=transfer.saved_payload,
headers={"X-Request-ID": transfer.request_id},
)
except TransportError:
mark_submission_unknown_and_schedule_lookup(transfer.id)
return
if is_duplicate_error(response):
save_original_details_if_present(transfer.id, response)
schedule_lookup(transfer.id)
elif response.http_status == 504:
mark_submission_unknown_and_schedule_lookup(transfer.id)
else:
apply_version_specific_response(transfer.id, response)
超时任务应先进入查询与恢复流程,不能直接放回普通发送队列。确认使用 PIPA 后,可按原请求号查询:
GET /v2/paycenter/institutions/{senderrtn}/environments/{envname}/products/pipa/credit-transfers?X-Request-ID={original_uuid}
该路径见官方 Get Payment 迁移说明。PIPA 文档规定,交易详情最多可查询至创建后 30 天;这一查询期限不能视为幂等键的有效期。官方查询范围说明
事件接收端建议只做必要校验和持久化,随后立即返回符合 EES 契约的接收确认。后台消费者逐个处理 EESEventInfoArray 中的事件,在同一数据库事务内完成去重记录、交易关联和状态更新。需要通知其他系统时,通过本地 outbox 投递。
事件也可能早于 REST 响应落库。暂时无法关联交易的事件应进入待匹配队列,后续重新关联。较晚到达的 Pending 响应不能覆盖已经确认的最终结果。
上线前,应向 PayCenter 接入团队确认尚未明确的接口约定:实际接口与字段映射、重复检测的有效期和作用域、同 key 不同 body 的处理方式、事件标识是否必填及重投保证,以及当前部署的终态和冲正流程。
备注:内容仅供参考。
