research.md 11 KB

Research: 蓝新金流(NewebPay)线上支付接入

Feature: specs/011-newebpay-payment/spec.md Date: 2026-06-22

本文记录实现蓝新 MPG 幕前支付所需的关键技术决策、理由与备选。所有决策已基于现有系统(ezPay 发票实现、PayController 回调链路、PosOrder 字段)与蓝新官方手册 NDNF-1.2.2。


D1. 加密工具:新建独立 NewebPayEncryptUtil,不复用 EzPayEncryptUtil

Decision: 新建 ruoyi-admin/.../app/utils/newebpay/NewebPayEncryptUtil.java + NewebPay.java(HTTP 客户端)+ NewebPayConfig.java,与 ezPay 工具类并列,不抽取公共加密套件。

Rationale:

  • 蓝新加密是标准 AES-256-CBC + PKCS7Padding(块 16 字节),输出 hex;ezPay 是 AES/CBC/NoPadding + 手动 PKCS7(块 32 字节,ezPay 特殊)。两者填充块大小不同,加密实现无法共用。
  • 蓝新的检查码语义:TradeSha = SHA256("HashKey={key}&{tradeInfoHex}&HashIV={iv}") 大写(请求时带);CheckCode = SHA256("HashIV={iv}&{Amt=&MerchantID=&MerchantOrderNo=&TradeNo= 排序}&HashKey={key}") 大写(校验响应);CheckValue = SHA256("IV={iv}&{Amt=&MerchantID=&MerchantOrderNo= 排序}&Key={key}") 大写(查询请求)。与 ezPay 的 CheckValue/CheckCode 形似但字段与前缀不同。
  • 第一期使用 EncryptType=0(默认 AES/CBC/PKCS7Padding),不上 AES/GCM,降低复杂度。
  • 不抽取公共套件遵循「简单优先」——两者细节差异大,强行抽象会引入间接层且只有两个使用点。

Alternatives:

  • 抽取 PaymentCryptoUtil 公共类:被否,因填充块大小与检查码字段差异使抽象收益低、误用风险高。
  • 复用 EzPayEncryptUtil:被否,块大小不同会导致蓝新解密失败。

D2. MPG 幕前跳转:后端生成加密参数,前端 Form Post 跳转蓝新

Decision: 新增「发起支付」接口返回 { gatewayUrl, MerchantID, TradeInfo, TradeSha, Version, EncryptType };前端用隐藏 form 自动 submit 跳转至 https://(c)core.newebpay.com/MPG/mpg_gateway

Rationale:

  • MPG 是幕前支付,必须由用户浏览器跳转到蓝新付款页(NDNF §3.1、§4.1.3 Form Post)。后端不能直接 HTTP 调用完成付款。
  • 与现有 VNPay 的 payUrl(单个跳转 URL)模式不同——蓝新需 POST 多个加密字段。故不复用 PosOrder.payUrl 存 URL,而是接口返回 form 参数;payUrl 字段保留存 gatewayUrl 供前端兜底/记录。
  • 后端生成参数时机:用户下单后、点击「去支付」时调用发起接口。

Alternatives:

  • 后端直接返回自动 submit 的 HTML 页面(蓝新 PHP 官方示例做法):被否,前端是 Vue SPA,返回 HTML 与现有交互不一致;改为接口返回 form 字段由前端构建更贴合。
  • 前端自行加密:被否,HashKey/HashIV 是敏感凭证,不能下发前端。

D3. 凭证存储:新建 pos_store_newebpay 表(与 pos_store 1:1),独立于发票凭证

Decision: 新建 pos_store_newebpay,与 pos_store 一对一,结构与状态机复用 009 pos_store_ezpay 模式,但独立表、独立字段。

Rationale:

  • 支付与发票是两条独立业务线,凭证、开通流程、启用开关各自独立;混表会造成字段语义混乱、停用相互影响。
  • 复用 009 的「未申请 0 / 申请中 1 / 已开通 2 + is_enabled」状态机与 Controller/Service 分层(Service 纯 DB,联网验证在 Controller 层,避免 ruoyi-system 反向依赖 ruoyi-admin 工具类)。
  • 额外字段 enabled_payments:记录该门店启用的支付方式组合(信用卡/LINE Pay/Apple Pay),发起 MPG 时据此设置 CREDIT/LINEPAY/APPLEPAY 开关。

Alternatives:

  • 复用 pos_store_ezpay 加列:被否,语义混淆、发票停用会误伤支付。
  • 全局 application.yml 单组凭证:被否,用户已选定门店级。

D4. 凭证联网验证:用单笔查询 QueryTradeInfo 探测

Decision: 录入凭证后调蓝新 QueryTradeInfo(查一个不存在的订单号),根据返回判断凭证是否有效。

Rationale:

  • 蓝新无等价的「纯凭证校验」接口。QueryTradeInfo 用 CheckValue(Amt/MerchantID/MerchantOrderNo)签名,若 HashKey/HashIV/MerchantID 错误,蓝新返回商店/金钥相关错误码(如 MPG01001 系列、Status≠SUCCESS 且 Message 提示金钥);若凭证正确则返回「交易不存在」类结果(Status≠SUCCESS 但 Message 为查询无资料)。
  • 与 009 ezPay 用只读 invoice_search 验证金钥的思路一致(ezPay 靠响应含 KEY1 判定金钥错误)。
  • 验证逻辑放 Controller 层(同 009),Service 仅持久化结果。

Alternatives:

  • MPG 测试环境发起真实小额交易再取消:被否,流程重、产生垃圾交易。
  • 不验证直接保存:被否,错误凭证会导致所有交易失败,需录入即校验。

D5. 支付流水表:新建 pos_order_payment 承载幂等与对账

Decision: 新建 pos_order_payment,每笔蓝新交易一条,回调按 trade_no(蓝新交易序号)幂等。

Rationale:

  • PosOrder.payStatus 只能存最终状态,无法承载 TradeNo、支付方式、授权码、原始回调、多次发起。蓝新要求按 TradeNo 幂等(NotifyURL 可能重试),必须有专门表。
  • 字段:dd_id、merchant_order_no、trade_no(蓝新交易序号,幂等键)、store_id、merchant_id、pay_type(CREDIT/LINEPAY/APPLEPAY)、amount、pay_status、auth_code、pay_time、callback_raw、create_time、update_time。
  • 同一订单可有多条(多次发起支付,每次新 merchant_order_no),但成功回调只处理一次(按 trade_no)。

Alternatives:

  • 用 PosOrder 字段 + Redis 幂等标记:被否,无法留存对账明细与多次发起记录。
  • 用现有 IpnLog:被否,IpnLog 是通用日志无结构化字段、无 trade_no 索引。

D6. 商店订单号 MerchantOrderNo 生成规则

Decision: merchant_order_no = "NB" + ddId(ddId 为系统订单号),保证门店内唯一、格式合法(英数+下划线)、可由 ddId 反查订单。

Rationale:

  • 蓝新要求同 MerchantID 下 MerchantOrderNo 不重复、限英数+下划线、≤30 字元。
  • ddId 全局唯一 ⇒ 加固定前缀 "NB" 后门店内必然唯一;前缀便于识别蓝新订单、避免与其他渠道订单号冲突。
  • 实现时需确认 ddId 不含 -// 等非法字符;若含则清洗(替换为 _)。
  • 重新发起支付时生成新 merchant_order_no(追加时间戳后缀),旧记录作废。

Alternatives:

  • 直接用 ddId:可行但缺渠道标识,调试时难辨来源;加前缀更稳。
  • 独立序列号:被否,需额外维护映射,ddId 已满足唯一性。

D7. NotifyURL 回调处理链路:复用 PayController.payipn 业务链路

Decision: 新建 NewebpayPayController/pay/newebpay/*),回调接口 /pay/newebpay/notify@Anonymous;处理流程复用现有 PayController.payipn 的业务链路(IPN 日志 → 解密验签 → 金额校验 → 幂等 → 更新 payStatus → 订单日志 → 推送),仅替换加解密/字段层。

Rationale:

  • PayController.payipn 已实现完整的「支付成功后业务动作」:orderLogHelper.logSync 记日志、userBillingService 记账、push.apppush/shpush + pushEventService.PublisherEvent 推送用户/商家/骑手、sendAcceptRiderPush 推送可接单骑手。蓝新回调成功后必须触发相同动作,否则订单虽已支付但商家/骑手收不到通知。
  • @Anonymous 注解经 PermitAllUrlProperties 自动加入 SecurityConfig 匿名白名单(蓝新服务器无登录态)。
  • 新建独立 Controller 而非塞入已 440 行的 PayController,保持单一职责、便于维护。
  • 订单状态更新语义沿用 payipn:state 保持不变(0 待处理,支付不改业务状态)、payStatus=1(已支付)。

Alternatives:

  • 抽取公共「支付成功处理」方法供 VNPay/蓝新共用:理想但 VNPay 代码耦合度高、改造范围大,第一期不做(surgical changes);仅复用其调用的组件(orderLogHelper/pushEventService 等)。
  • 在 PayController 加 /pay/newebpay/notify 方法:被否,Controller 已臃肿。

D8. payType 取值与支付方式承载

Decision: PosOrder.payType 发起时设固定值表示「蓝新在线支付」(取 "6"),具体支付方式(CREDIT/LINEPAY/APPLEPAY) 由 pos_order_payment.pay_type 在回调后承载;订单列表展示支付方式时联查 pos_order_payment。

Rationale:

  • MPG 幕前:发起交易时用户尚未选择支付方式(在蓝新付款页才选),故发起瞬间只能知道「走蓝新」,无法预知信用卡还是 LINE Pay。
  • payType 历史取值 1-5(越南到付/vnpay/zalopay/银行卡),台湾已不用;新增 "6" 表示蓝新,避免语义冲突。
  • 回调到达后明确方式,写入 pos_order_payment;订单列表/详情需展示具体方式时由该表提供。

Alternatives:

  • 回调后把 payType 更新为细分(7/8/9):增加枚举且与「发起时未知方式」矛盾,被否。

D9. 环境配置:application.yml 新增 newebpay 段

Decision: 仿 ezpay.base-url 模式新增:

newebpay:
  base-url: https://ccore.newebpay.com   # 测试 ccore / 正式 core
  notify-url: https://<公网域名>/pay/newebpay/notify
  return-url: https://<公网域名>/pay/newebpay/return
  mpg-version: "2.3"

Rationale:

  • 与 ezpay.base-url 一致的注入风格(@Value),便于环境切换。
  • notify-url/return-url 必须是公网可访问的 80/443(蓝新要求),配置化便于不同环境部署。
  • 第一期固定 EncryptType=0(CBC),不入配置。

D10. 测试环境与端到端自测

Decision: 使用蓝新测试环境 ccore.newebpay.com + 文档附录1测试卡号验证;本地开发用内网穿透(ngrok/frp)暴露 NotifyURL。

Rationale:

  • 蓝新测试区提供测试商店凭证与测试卡(附录1),不产生真实扣款。
  • NotifyURL 须被蓝新服务器访问,本地 8082 端口需穿透到公网。
  • 自测链路:门店录入测试凭证 → 下单 → 发起蓝新支付 → 测试卡付款 → 回调到达 → 订单已支付 + 推送。

Alternatives:

  • 直接用正式环境:被否,风险高、产生真实交易。

D11. 数据库变更管理

Decision: 所有 DDL 写入 updatesql/sql.md(标注日期与用途),不直接执行,由开发者统一手动执行(遵循项目规范)。

涉及:pos_store_newebpay 建表、pos_order_payment 建表。(pos_order 无需改结构,payType 取值扩展不改表。)