正式开放契约 · v1.3 · 2026-07-25

话费充值 API 接入文档

本页是话费充值服务端接入的唯一对外契约。下游必须按本文字段、路径、签名和状态处理;未在本文列出的兼容字段不作为新接入依据。本文为 v1.3,发布日期 2026-07-25,替代此前所有版本。

PrepaidRefill服务端签名异步通知HMAC-SHA256

1. 快速开始

前提条件:商户必须先完成资质审核,开通话费权益并启用 OpenAPI 应用,获取 app_id 和 app_secret;同时在商户后台配置固定公网出口 IP 白名单及 SDK 通知密钥(apiSecret)。

接入步骤总览

  1. 获取商户开放应用 app_id 与 app_secret(用于 OpenAPI 签名,详见第 5 节),以及 SDK 通知密钥 apiSecret(用于异步通知验签,详见第 9 节)。
  2. 下游服务端调用创建订单接口,传入手机号、面额、异步通知地址等参数,获得来福 orderNo。
  3. 调用查询接口取得本单锁价(totalAmount/payAmount),按 totalAmount 在自有收银台向用户收款。
  4. 下游服务端确认用户真实付款成功后,调用支付成功通知接口,传入 totalAmount 触发来福履约。
  5. 接收来福异步通知,按 X-LYP-Sign 验签,以 orderStatus=SUCCESS 为充值成功最终口径。
  6. 任何时候不确定订单状态,优先查单而非重复创建。
安全红线:app_secret 和 apiSecret 严禁暴露到客户端、前端或 H5;支付成功通知必须由下游服务端调用,不得从前端直接请求。

2. 标准业务时序

① POST /openapi/v1/order/create 创建 WAIT_PAY 订单,保存 orderNo
② GET  /openapi/v1/order/query  查询 totalAmount 与 payAmount
③ 下游按 totalAmount 在自有收银台向用户收款
④ 下游服务端验真自己的支付结果
⑤ POST /openapi/v1/sdk/payment/notify 通知来福已收款
⑥ 来福校验订单、金额、签名和幂等,按 payAmount 扣商户账户并请求上游
⑦ 来福向下游 notifyUrl 发送异步通知(含 X-LYP-* 签名头)
⑧ 下游验签后仅以 SUCCESS 作为充值成功;PROCESSING 继续等待
callbackUrl 只是浏览器同步回跳地址,不能证明用户已付款,也不能证明上游已充值成功。下游应验签接收异步通知,并在状态不确定时主动查单。
⚠ 唯一契约边界:本文只覆盖话费 PrepaidRefill 产品的创建订单、查询锁价与状态、支付成功通知、异步通知四个接口。SDK 演示接口、其他权益产品(积分兑换、卡券直充等)的接入规范不在本文。任何本文之外的字段、路径、参数不做保证,也不作为新接入依据。

3. 公共响应结构

所有 OpenAPI 接口统一使用以下 JSON 响应结构。下游必须同时检查 HTTP 状态码(应为 200)和业务码(code 应为 200)以判定成功。

字段中文名称类型必填/出现条件示例详细说明
code业务状态码Integer始终出现200200 表示业务受理成功;非 200 表示业务拒绝,见第 11 节错误排查。
msg提示消息String始终出现"操作成功"供调试参考。生产环境下游不应依赖消息文本做逻辑判断。
data业务数据Object成功时出现{"orderNo":"UP..."}具体业务字段见各接口说明。失败时可能为 null。

4. 产品、面额与价格

字段正式值/规则
typeCodePrepaidRefill。不要传不存在的 PHONE_RECHARGE;其他历史编码不属于本文契约。
carrierCode可选;明确知道时可传 01 移动、02 电信、03 联通。未传时平台向智信提交空 cp,由智信识别;平台不按手机号段猜测运营商。
faceValue运行接口允许 100、200、300、500;能否销售还取决于该商户、产品、运营商、面额是否存在唯一有效 SKU。
totalAmount用户应付金额,由商户有效 SKU 的 userPayAmount 锁定。
payAmount来福扣商户账户金额,由有效 SKU 的 merchantSettleAmount 锁定。
价格边界:下游不能通过创建订单的 amount 修改价格。用户售价与平台扣商户金额可以不同;下游必须先查单取得本单锁价,再向用户收款。

5. 创建待支付订单

由下游服务端调用,创建一笔话费充值待支付订单。请求必须携带第 6 节 OpenAPI 签名头。

POST https://api.laifuagi.cn/prod-api/openapi/v1/order/create
Content-Type: application/json
app_id: {appId}
timestamp: {unix-seconds}
nonce: {unique-random-string}
sign: {base64url-hmac-sha256}

{"typeCode":"PrepaidRefill","merchantRequestNo":"M202607250001","bizParams":{"mobile":"13800138000","faceValue":"100","notifyUrl":"https://merchant.example.com/laifu/notify","callbackUrl":"https://merchant.example.com/order/result"}}

请求头字段

字段中文名称类型必填/出现条件示例详细说明
Content-Type内容类型String必填application/json固定为 application/json。
app_id商户应用 IDString必填laifu_app_abc123商户后台已启用 OpenAPI 应用的 AppID。
timestamp时间戳String必填1721827200Unix 秒级整数,与平台时间差不超过 300 秒。
nonce随机串String必填n9k8m7j6每次请求唯一;5 分钟内不可重复。建议 UUID 或安全随机。
sign签名String必填aBcDeF...Z0HMAC-SHA256 后做 Base64URL 编码,去掉 = 补位。签名算法见第 6 节。

请求体字段

字段中文名称类型必填/出现条件示例详细说明
typeCode产品编码String必填"PrepaidRefill"固定传 PrepaidRefill。其他编码不属于本文契约。
merchantRequestNo商户请求号String必填"M202607250001"下游唯一业务请求号,建议全局唯一并持久化。用于幂等和查单。
bizParams业务参数Object必填{...}JSON 子对象,包含具体充值参数。
bizParams.mobile充值手机号String必填"13800138000"目标充值手机号。新接入只使用该字段,不重复传 phoneNumber。
bizParams.faceValue充值面额String必填"100"面额字符串,必须命中唯一有效 SKU。允许值:100、200、300、500。
bizParams.carrierCode运营商编码String否"01"明确运营商时可传 01 移动、02 电信、03 联通。不确定时省略,平台交由上游智能识别,禁止下游按手机号段自行猜测。
bizParams.notifyUrl异步通知地址String否"https://merchant.example.com/laifu/notify"接收来福订单状态异步通知的公网 HTTPS 地址;未传时以商户后台配置为准。见第 8 节。
bizParams.callbackUrl回跳地址String否"https://merchant.example.com/order/result"浏览器同步回跳地址,不参与支付和终态判断,不建议作为异步通知替代。

成功响应体字段

{"code":200,"msg":"操作成功","data":{"orderNo":"UP20260725123456789","requestId":"req_a1b2c3d4"}}
字段中文名称类型必填/出现条件示例详细说明
code业务状态码Integer始终出现200200 表示创建成功。
msg提示消息String始终出现"操作成功"调试参考。
data.orderNo来福订单号String成功时出现"UP20260725123456789"来福系统唯一订单号,以 UP 开头。下游必须持久化,用于后续查单和支付通知。
data.requestId请求流水 IDString成功时出现"req_a1b2c3d4"本次请求内部流水号,排查问题时提供。
创建成功时订单状态为 WAIT_PAY,不会立即向上游充值。必须先查单取得锁价,再收款并调用支付通知。

6. 查询订单锁价与状态

创建订单后,下游必须主动查询以获取本单锁价(totalAmount/payAmount),并按 totalAmount 向用户收款。请求携带 OpenAPI 签名头。

GET https://api.laifuagi.cn/prod-api/openapi/v1/order/query?orderNo={orderNo}
app_id: {appId}
timestamp: {unix-seconds}
nonce: {unique-random-string}
sign: {base64url-hmac-sha256}
字段中文名称类型必填/出现条件示例详细说明
orderNo查询订单号StringQuery 必填UP20260725123456789创建接口返回的来福 orderNo。GET QueryString 传递,不参与签名原文。

签名规则补充

GET 请求签名原文:METHOD + "\n" + URI + "\n" + timestamp + "\n" + nonce + "\n"。URI 固定为 /openapi/v1/order/query,不包含 ?orderNo=... QueryString,也不包含公网 /prod-api 前缀。

成功响应体字段

{"code":200,"msg":"操作成功","data":{"orderNo":"UP20260725123456789","status":"WAIT_PAY","totalAmount":100.00,"payAmount":101.00,"createTime":"2026-07-25T10:00:00","requestId":"req_b2c3d4e5"}}
字段中文名称类型必填/出现条件示例详细说明
data.orderNo来福订单号String成功时出现"UP20260725123456789"与创建返回一致。
data.status订单状态String成功时出现"WAIT_PAY"来福订单当前状态,枚举值见第 10 节状态码表。
data.totalAmount用户应付金额Number成功时出现100.00下游应收/用户应付金额,也是支付成功通知的 amount 字段。固定两位小数。
data.payAmount来福扣款金额Number成功时出现101.00来福最终扣商户账户金额。该值可与 totalAmount 不同,下游不应向用户据此收款。
data.createTime创建时间String成功时出现"2026-07-25T10:00:00"订单创建时间,当前接口按 Java LocalDateTime.toString() 输出,日期与时间之间为 T。
data.requestId请求流水 IDString成功时出现"req_b2c3d4e5"本次查询内部流水号。
只能查询当前 App 所属商户的订单;跨商户查询统一返回 code=200 data=null 或订单不存在。

7. 下游支付成功通知

安全关键:仅在下游服务端确认用户真实付款成功后调用。该入口不使用 OpenAPI 签名头,而使用本节独立 SHA-256 body 签名。前端/H5 不得直接调用此接口。
POST https://api.laifuagi.cn/prod-api/openapi/v1/sdk/payment/notify
Content-Type: application/json

{"merchantId":44,"orderNo":"UP20260725123456789","quoteId":"","amount":"100.00","timestamp":"1784532897689","nonce":"f40ff48166e5e663","sign":"{sha256-hex}"}

请求体字段

字段中文名称类型必填/出现条件示例详细说明
merchantId商户 IDInteger必填44订单所属商户 ID,由来福分配。通过正文传递即可。
orderNo来福订单号String必填"UP20260725123456789"创建接口返回的 orderNo。
quoteId报价 IDString必填(可空串)""无 quoteId 时传空字符串 ""。签名原文仍保留 quoteId=。
amount支付金额String必填"100.00"查单返回的 totalAmount,固定两位小数字符串。不是 payAmount。
timestamp时间戳String/Number必填"1784532897689"当前毫秒时间戳,字符串或 JSON 数字均可,签名时使用其文本值。
nonce随机串String必填"f40ff48166e5e663"本次请求唯一随机串。字段名是 nonce 而非 nonceStr。
sign签名String必填"a1b2c3d4e5f6..."64 位 SHA-256 十六进制小写串,不是 HMAC 也不是 Base64URL。

签名算法

payload = merchantId={merchantId}&orderNo={orderNo}&quoteId={quoteId}&amount={amount两位小数}&timestamp={timestamp}&nonce={nonce}
signText = payload + "&key=" + app_secret + "&sandbox=false"
sign = SHA256(signText).hexLowercase()

签名使用该商户启用的 OpenAPI 应用 app_secret。参数按固定顺序拼接待签字符串,不排序。

成功响应

{"code":200,"msg":"操作成功","data":true}
成功响应 code=200 只代表支付通知已被来福受理,不代表充值成功。充值终态通过异步通知送达。

8. 来福异步通知

来福在订单状态变更时向下游配置的通知地址发送异步通知。通知可能重复送达,下游须按 orderNo + event + orderStatus 幂等处理。通知地址优先级以订单/商户实际配置解析结果为准,接入前请在各商户后台的「通知配置」页面确认实际生效地址。

POST {notifyUrl}
Content-Type: application/json
X-LYP-Timestamp: {unix-milliseconds}
X-LYP-Nonce: {unique-random-string}
X-LYP-Sign: {hmac-sha256-hex}

{"event":"ORDER_SUCCESS","orderNo":"UP20260725123456789","merchantId":44,"typeCode":"PrepaidRefill","orderStatus":"SUCCESS","totalAmount":100.00,"payAmount":101.00,"userPayAmount":100.00,"merchantSettleAmount":101.00,"merchantProfitAmount":-1.00,"upstreamOrderNo":"UPSTREAM20260725001","upstreamStatus":"SUCCESS","mobile":"13800138000","faceValue":"100","timestamp":1784532897689,"nonce":"abc123def456","sign":"{hmac-sha256-hex}"}

通知请求头

字段中文名称类型必填/出现条件示例详细说明
Content-Type内容类型String始终出现application/json固定 application/json。
X-LYP-Timestamp通知时间戳String始终出现1784532897689Unix 毫秒时间戳,用于防重放和时间窗口校验。
X-LYP-Nonce通知随机串String始终出现abc123def456本次通知唯一随机串,与正文中 nonce 对应。
X-LYP-Sign通知签名String始终出现a1b2c3d4e5f6...64 位 HMAC-SHA256 十六进制小写串,签名算法见下文。

通知体字段

字段中文名称类型必填/出现条件示例详细说明
event事件类型String业务通知出现"ORDER_SUCCESS"事件枚举值:ORDER_PROCESSING、ORDER_SUCCESS、ORDER_FAIL、ORDER_REFUND。现行协议以正文该字段为准。
orderNo来福订单号String始终出现"UP20260725123456789"来福系统唯一订单号。
merchantId商户 IDInteger始终出现44订单归属商户 ID。
typeCode产品编码String始终出现"PrepaidRefill"产品类型编码。
interfaceTypeCode接口类型编码String出现时"PREPAID_REFILL"接口类型编码,部分场景出现。
orderStatus订单状态String始终出现"SUCCESS"订单最新状态,枚举值见第 10 节。
totalAmount用户应付金额Number始终出现100.00用户应付总金额。
payAmount来福扣款金额Number始终出现101.00来福从商户账户扣款金额。
userPayAmount用户支付金额Number始终出现100.00用户实际支付金额。
merchantSettleAmount商户结算金额Number始终出现101.00来福与商户的结算金额。
merchantProfitAmount商户利润Number出现时-1.00商户毛利金额(可为负数表示亏损),部分场景可能不出现。
upstreamOrderNo上游订单号String出现时"UPSTREAM20260725001"上游(智信等)返回的订单号;上游未响应时可能缺失。
upstreamStatus上游状态String出现时"SUCCESS"上游返回的状态文本;上游未响应时可能缺失。
upstreamMessage上游消息String出现时"充值成功"上游返回的消息文本;上游未响应时可能缺失。
upstreamRaw上游原始响应String出现时{"code":0,...}上游返回的原始响应数据,调试用;上游未响应时可能缺失。
refundAmount退款金额Number出现时(退款场景)101.00发生退款时的退款金额。
refundStatus退款状态String出现时(退款场景)"SUCCESS"退款当前状态。
refundTime退款时间String出现时(退款场景)"2026-07-25 12:00:00"退款完成时间。
refundReason退款原因String出现时(退款场景)"上游充值失败"触发退款的原因描述。
refundRemark退款备注String出现时(退款场景)"人工处理"退款相关补充说明。
refundRequestNo退款请求号String出现时(退款场景)"REF20260725001"退款请求流水号。
refundExternalNo退款外部编号String出现时(退款场景)"EXTREF001"退款外部参考编号。
notifyUrl通知地址String出现时"https://..."本次通知目标地址,部分场景出现。
callbackUrl回跳地址String出现时"https://..."浏览器同步回跳地址,部分场景出现。
mobile充值手机号String出现时"13800138000"目标充值手机号。部分通知场景中不包含该字段,以创建订单时传入值为准。
faceValue充值面额String出现时"100"充值面额字符串,部分通知场景中不包含该字段。
carrierCode运营商编码String出现时"01"运营商编码:01 移动、02 电信、03 联通;创建时未传则可能不出现。
timestamp通知时间戳Number始终出现1784532897689Unix 毫秒时间戳,与请求头 X-LYP-Timestamp 对应。签名层强制添加。
nonce通知随机串String始终出现"abc123def456"本次通知唯一随机串,与请求头 X-LYP-Nonce 对应。签名层强制添加。
sign签名String始终出现"a1b2c3d4..."HMAC-SHA256 十六进制小写串。该字段不参与自身签名原文。签名层强制添加。

通知验签算法

1. 解析 JSON 通知体,取得全部一级字段
2. 排除 sign 字段(该字段不参与自身签名原文)
3. 排除值为 null 的字段
4. 剩余一级字段放入按字段名字典序排列的对象(Java TreeMap 语义)
5. 将整个排序后对象序列化为紧凑 JSON;字符串仍带 JSON 双引号,数字和布尔值保持 JSON 类型,嵌套值按原 JSON 值参与
6. 将该完整紧凑 JSON 字符串作为 HMAC 的 data 输入
7. expectedSign = HMAC-SHA256(apiSecret, jsonString).hexLowercase()
8. 使用常量时间比较 expectedSign 与请求头 X-LYP-Sign

注意:header 中的 sign 与 body 中的 sign 值相同。验签密钥使用商户后台 SDK 安全配置中的通知密钥(apiSecret),不是 OpenAPI 的 app_secret。两者不同。
通知密钥:使用商户后台 SDK 安全配置中的通知密钥(apiSecret),不是 OpenAPI 的 app_secret。两者不同。

成功应答要求

应答条件详细规则
HTTP 状态码 204直接视为成功。
HTTP 状态码 2xx同时要求 body 去除首尾空白后为纯文本 success 或 ok(不区分大小写)。
JSON 响应body 解析为 JSON 后,code 字段字符串值为 "200",或 success 字段布尔值为 true,视为成功。
不符合上述条件来福视为投递失败并进入重试。
重试策略:最多投递 5 次(含首次)。失败后重试延迟按 2、4、8、16 分钟退避(如仍失败最后停止自动重试)。来福人工补发仍发送订单当前标准事件,不发送内部操作事件。

9. 签名算法汇总

本文档涉及两套签名算法,其用途和密钥各不相同,请严格区分。

签名类型接口密钥算法输出格式
OpenAPI 签名创建订单、查单app_secretHMAC-SHA256Base64URL(去掉 = 补位)
支付通知签名支付成功通知app_secretSHA-256(非 HMAC)十六进制小写(64 位)
异步通知验签来福通知回调apiSecret(通知密钥)HMAC-SHA256十六进制小写(64 位)

OpenAPI 签名(创建/查单)

// POST 请求
signText = METHOD + "\n" + URI + "\n" + timestamp + "\n" + nonce + "\n" + rawBody
// GET 请求
signText = METHOD + "\n" + URI + "\n" + timestamp + "\n" + nonce + "\n"
sign = base64url(HMAC-SHA256(app_secret, signText))
// 去掉 base64url 结果中的 '=' 补位符
  • URI 使用内部路径,不含公网 /prod-api 前缀,不含 QueryString:创建为 /openapi/v1/order/create,查询为 /openapi/v1/order/query。
  • rawBody 必须与实际发送的 HTTP 请求体字节一致。签名后不要重新格式化 JSON。
  • 不使用 MD5、字段排序签名或 nonceStr。app_secret 只能保存在下游服务端。

支付通知签名

payload = merchantId={merchantId}&orderNo={orderNo}&quoteId={quoteId}&amount={amount}&timestamp={timestamp}&nonce={nonce}
signText = payload + "&key=" + app_secret + "&sandbox=false"
sign = SHA256(signText).hexLowercase()

异步通知验签

// 1. 取 JSON 体所有一级字段
// 2. 排除 sign 字段和 null 值字段
// 3. ASCII 升序排列字段名
// 4. 将排序后的字段值序列化为紧凑 JSON 字符串(无空格、无换行)
//    - 对象/Map → 紧凑 JSON(如 {"a":1})
//    - 数组/List → 紧凑 JSON(如 [1,2,3])
//    - 其他值按 JSON 语义使用文本值
// 5. expected = HMAC-SHA256(apiSecret, jsonString).hexLowercase()
// 6. 使用常量时间比较 expected 与 X-LYP-Sign

10. 状态码、订单状态与事件

公共业务状态码

code含义建议处理
200业务受理成功正常处理 data。
400请求参数错误检查请求体字段格式、必填项。
401签名验证失败核对 app_id、密钥、URI、rawBody、时间戳单位、nonce 字段名。
403无权限(IP 白名单/商户未授权)联系来福确认 IP 白名单和产品授权。
404订单不存在检查 orderNo 所属 App 是否正确的商户。
409订单状态冲突订单状态不允许当前操作,查单确认最新状态。
429请求频率超限降低调用频率,稍后重试。
500平台内部错误等待后重试,持续失败则联系来福技术支持。

订单状态枚举

状态含义下游处理
WAIT_PAY待下游收款确认不要触发充值。查询锁价并在自有渠道收款。
CREATED已创建订单已创建但尚未进入支付确认流程。
PAID支付确认已受理来福已收到支付通知,准备履约。展示处理中。
PROCESSING上游处理中等待终态异步通知。不重复下单。
SUCCESS充值成功完成订单并向用户展示成功。
FAILED充值失败等待资金冲正/退款结果及终态通知。
REFUNDED平台冲正完成按合同和自身支付渠道处理用户侧退款。
CLOSED未支付或异常关闭需要时重新创建订单。

异步通知事件枚举

事件触发条件对应 orderStatus
ORDER_PROCESSING订单进入上游处理中PROCESSING
ORDER_SUCCESS充值成功终态SUCCESS
ORDER_FAIL充值失败FAILED
ORDER_REFUND已冲正/退款完成REFUNDED

11. 成功判定与错误排查

成功判定原则

  • 同时检查 HTTP 状态码(应为 200)与 JSON code(应为 200),两者均满足才是接口受理成功。
  • 支付成功通知的 code=200 只代表来福受理,不代表充值成功。充值成功以异步通知 orderStatus=SUCCESS 为准。
  • 网络超时/连接中断不等于失败,先使用原 orderNo 查单,禁止直接新建订单。

常见错误与排查

错误现象可能原因排查步骤
签名验证失败密钥错误、URI 路径不对、rawBody 被重新格式化、时间戳超差核对内部 URI(不含 /prod-api)、原始 JSON 字节、时间戳单位及 nonce 字段名;确认使用了 app_secret 而非 apiSecret。
当前商户该话费 SKU 未配置有效价格运营商/面额未授权或 SKU 不唯一确认商户已开通该运营商+面额的 SKU,且价格有效。
支付金额与订单锁价不一致支付通知传了错误金额支付通知必须传查单返回的 totalAmount,不能随意填写。
订单状态不允许支付确认订单已在 PAID 或终态先查单确认当前状态,不得再次触发真实履约。
订单不存在orderNo 不存在或跨商户查询确认 orderNo 属于当前 App 对应商户。
异步通知验签不通过使用了错误密钥或 canonical 算法有误确认使用 apiSecret 而非 app_secret;确认已排除 sign 和 null 字段;确认 ASCII 升序排列;确认 timestamp/nonce 使用请求头值。

12. 幂等机制与重试策略

创建订单防重

merchantRequestNo 是下游业务请求号。下游必须自行保证唯一并持久化它与来福 orderNo 的映射;网络结果不确定时先查已保存的 orderNo,不要假设重复创建必然返回同一订单。

支付通知幂等

相同 orderNo + sign 重复请求被幂等处理。每次须复用完全相同的数据,平台按订单和签名自动去重。

异步通知幂等

下游收到重复通知时,按 orderNo + event + orderStatus 去重。终态通知(SUCCESS/FAILED/REFUNDED)到达后不应再触发用户侧重复处理。

下游重试指南

场景推荐策略
创建订单超时先按本地保存的映射核查是否已取得 orderNo;无法确认时联系来福排查,不直接生成新请求号重复下单。
查单超时使用原 orderNo 重试。
支付通知超时或非 200使用完全相同的数据重试,幂等处理。
异步通知接收失败来福最多投递 5 次(含首次),失败后按 2/4/8/16 分钟退避。下游可查来福订单,但不要绕过来福直接查上游。

13. 联调与安全上线门禁

以下为上线前的必检项目,逐项确认后方可切换真实流量。

  1. 基本信息确认:商户已审核、权益已开通、应用已启用、产品已授权、固定出口 IP 已加白名单。
  2. SKU 价格确认:传 carrierCode 时确认 PrepaidRefill + carrierCode + faceValue 存在唯一有效 SKU;不传时确认该面额三网 SKU 完整且商户结算价、用户支付价一致。
  3. 数据持久化:下游保存 merchantRequestNo / orderNo / totalAmount / payAmount 并可对账。
  4. 服务端安全:支付回调必须由下游服务端验真;前端/H5 不得直接调用支付成功通知。密钥仅保存在服务端。
  5. 边界测试:验证金额不一致、错误签名、重复通知、超时查单和异步通知幂等场景。
  6. 不可逆操作:真实话费充值不可逆。未经双方明确授权不得使用真实号码执行测试。联调应使用来福提供的测试手机号。
  7. 异步通知验签确认:已按第 9 节算法实现 X-LYP-Sign 验签,并确认使用了 apiSecret 而非 app_secret。
  8. 响应处理确认:异步通知收到后返回 HTTP 2xx 且 body 为 ok 或 success。
协议冻结:本页版本为 v1.3 / 2026-07-25。后续若无正式版本升级通知,下游继续按本版本实现,不接受聊天记录、历史样例或内部兼容字段覆盖本文。

14. Java 与 Node.js 签名示例

以下示例供参考,生产环境请将密钥配置在安全配置中心或环境变量中。

Java OpenAPI 签名示例

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;

public class OpenApiSigner {

    public static String sign(String method, String uri, long timestamp,
                              String nonce, String rawBody, String secret) throws Exception {
        String signText = method + "\n" + uri + "\n" + timestamp + "\n" + nonce + "\n";
        if (rawBody != null && !rawBody.isEmpty()) {
            signText += rawBody;
        }
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"));
        byte[] hmac = mac.doFinal(signText.getBytes("UTF-8"));
        String base64 = Base64.getUrlEncoder().withoutPadding().encodeToString(hmac);
        return base64;
    }

    // 使用示例
    public static void main(String[] args) throws Exception {
        String secret = "your-app-secret";
        long timestamp = System.currentTimeMillis() / 1000;
        String nonce = java.util.UUID.randomUUID().toString();
        String body = "{\"typeCode\":\"PrepaidRefill\",\"merchantRequestNo\":\"M202607250001\",\"bizParams\":{\"mobile\":\"13800138000\",\"faceValue\":\"100\",\"notifyUrl\":\"https://merchant.example.com/laifu/notify\"}}";

        String sign = sign("POST", "/openapi/v1/order/create",
                           timestamp, nonce, body, secret);
        System.out.println("sign: " + sign);
    }
}

Java 支付通知签名示例

import java.security.MessageDigest;

public class PaymentNotifySigner {

    public static String sign(long merchantId, String orderNo, String quoteId,
                              String amount, String timestamp, String nonce,
                              String secret) throws Exception {
        String payload = "merchantId=" + merchantId
                       + "&orderNo=" + orderNo
                       + "&quoteId=" + (quoteId == null ? "" : quoteId)
                       + "&amount=" + amount
                       + "&timestamp=" + timestamp
                       + "&nonce=" + nonce;
        String signText = payload + "&key=" + secret + "&sandbox=false";

        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] hash = md.digest(signText.getBytes("UTF-8"));
        StringBuilder hex = new StringBuilder();
        for (byte b : hash) {
            hex.append(String.format("%02x", b & 0xFF));
        }
        return hex.toString();
    }
}

Java 异步通知验签示例

import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.*;

public class NotifyVerifier {

    public static boolean verify(String jsonBody,
                                 String apiSecret) throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        Map<String, Object> body = mapper.readValue(jsonBody, Map.class);

        // 1. 排除 sign 和 null 值字段
        TreeMap<String, Object> sorted = new TreeMap<>();
        for (Map.Entry<String, Object> e : body.entrySet()) {
            if ("sign".equals(e.getKey()) || e.getValue() == null) continue;
            sorted.put(e.getKey(), e.getValue());
        }

        // 2. 按 ASCII 排序后的键值对,序列化为紧凑 JSON 字符串
        //    TreeMap 保证字典序;Jackson writeValueAsString 输出紧凑 JSON
        String jsonString = mapper.writeValueAsString(sorted);

        // 3. HMAC-SHA256
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(apiSecret.getBytes("UTF-8"), "HmacSHA256"));
        byte[] hmac = mac.doFinal(jsonString.getBytes("UTF-8"));
        StringBuilder expectedHex = new StringBuilder();
        for (byte b : hmac) {
            expectedHex.append(String.format("%02x", b & 0xFF));
        }

        // 4. 从原始 JSON 体中提取 sign 值做常量时间比较
        String actualSign = (String) body.get("sign");
        return MessageDigest.isEqual(
            expectedHex.toString().getBytes("UTF-8"),
            actualSign.getBytes("UTF-8"));
    }
}

Node.js OpenAPI 签名示例

const crypto = require('crypto');

function sign(method, uri, timestamp, nonce, rawBody, secret) {
  let signText = method + '\n' + uri + '\n' + timestamp + '\n' + nonce + '\n';
  if (rawBody) signText += rawBody;

  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(signText, 'utf8');
  return hmac.digest('base64url'); // Node.js 15.7+ 支持 base64url
}

// 使用示例
const secret = 'your-app-secret';
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomUUID();
const body = JSON.stringify({
  typeCode: 'PrepaidRefill',
  merchantRequestNo: 'M202607250001',
  bizParams: {
    mobile: '13800138000',
    faceValue: '100',
    notifyUrl: 'https://merchant.example.com/laifu/notify'
  }
});

const s = sign('POST', '/openapi/v1/order/create',
               timestamp, nonce, body, secret);
console.log('sign:', s);

Node.js 支付通知签名示例

const crypto = require('crypto');

function paymentSign(merchantId, orderNo, quoteId, amount,
                     timestamp, nonce, secret) {
  const payload = `merchantId=${merchantId}&orderNo=${orderNo}` +
    `&quoteId=${quoteId || ''}&amount=${amount}` +
    `&timestamp=${timestamp}&nonce=${nonce}`;
  const signText = payload + `&key=${secret}&sandbox=false`;
  return crypto.createHash('sha256')
    .update(signText, 'utf8')
    .digest('hex');
}

Node.js 异步通知验签示例

const crypto = require('crypto');

function verifyNotify(jsonBody, apiSecret) {
  const body = JSON.parse(jsonBody);

  // 1. 排除 sign 和 null,ASCII 排序后再序列化为紧凑 JSON
  const sorted = {};
  Object.keys(body)
    .filter(k => k !== 'sign' && body[k] !== null)
    .sort()
    .forEach(k => { sorted[k] = body[k]; });

  // 2. 紧凑 JSON 字符串即为签名原文
  const jsonString = JSON.stringify(sorted);

  // 3. HMAC-SHA256 hex
  const hmac = crypto.createHmac('sha256', apiSecret);
  hmac.update(jsonString, 'utf8');
  const expected = hmac.digest('hex');

  // 4. 常量时间比较
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'utf8'),
    Buffer.from(body.sign, 'utf8')
  );
}
生产注意事项:密钥应通过环境变量或安全配置中心读取,不硬编码。HMAC 算法的 key 和 data 均使用 UTF-8 编码。确保 rawBody 与实际的 HTTP 请求体字节一致。