PAYATHON 2026

如何实现 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 APIPIPA v2
发起路径/v1/institutions/{senderrtn}/environments/{envname}/products/paycenter/networks/rtp/credit-transfers/v2/paycenter/institutions/{senderrtn}/environments/{envname}/products/pipa/credit-transfers
响应主标识CreditTransfer.IdPmtHubTrakId
响应状态字段CreditTransfer.StatusStatus
调用方参考号迁移指南列为 OriginatorReferenceId,可选、UUIDX-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、相同 bodyPIPA 文档规定返回重复错误,未承诺重放首次响应读取原交易信息,必要时查询原交易
相同 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请求恢复、重复检测及辅助事件关联
PmtHubTrakIdPayCenter 交易查询及主要事件关联
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 PendingPROCESSING否
出站 DrPmt/AcptSUCCEEDED是,付款结算成功
出站 DrPmt/RejREJECTED是,付款被拒绝;退款入账另行核对
查询 FailedFAILED付款处理失败,不等于已验证所有账务恢复
入站 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/AcptCT 已发送至 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 的处理方式、事件标识是否必填及重投保证,以及当前部署的终态和冲正流程。

备注:内容仅供参考。