供应商是 HC/和川,昱鼎 只是部署站点;不得把站点名误判为模板或映射到 yudian。
回调通知为罕见的 GET 请求,需定制专门的 GET 验签拦截器。
下单与回调密钥拼接无 &key=,直接追加在参数尾部,契合 utils.Sign2。
通信为 RESTful JSON POST,无国密或双向 mTLS 等硬件阻碍。
配置与改造决策
在后台配置“HC”模板;“昱鼎”仅作为站点信息保留。
原因一
文档标题应拆成供应商与站点两层:HC(和川) 是通道供应商,昱鼎 是部署站点/客户环境,不参与模板匹配。
原因二
回调通知方式不同。和川是 GET 请求回调。此外,验签**仅限 3 个核心参数**(out_trade_no, total_amount, trade_status),须在中间件中剔除其余参数后校验。
Interaction Flow
支付与回调交互时序图
描述 PMP 平台、用户浏览器与和川 API 通信中的参数拼装、签名、Unescape 还原与 GET 回调验签机制。
对接参数要素提取
| 接口分类 | 参数名 | 必选 | 签名 | 类型 | PMP 对应映射与开发提示 |
|---|---|---|---|---|---|
| 下单接口 POST JSON |
product | 是 | 是 | string | 对应通道后台设置的通道编码 order.ChannelWayCode |
| mchCode | 是 | 是 | string | 商户编码,对应 channel.ChannelMchID |
|
| mchOrderNo | 是 | 是 | string | 平台生成的唯一充值订单号 order.TradeNum |
|
| amount | 是 | 是 | int | **分**单位。decimal.NewFromFloat(order.OrderAmount).Mul(100).IntPart() |
|
| sign | 是 | 否 | string | 小写 MD5 签名。通过 utils.Sign2(params, apiKey, false) 生成 |
|
| 异步回调 GET Query |
out_trade_no | 是 | 是 | string | 平台订单号,在验签时用来查缓存获取对应的商户 apiKey |
| total_amount | 是 | 是 | string | 回调充值金额(分单位)。需除以100还原为元进行上分记录 | |
| trade_status | 是 | 是 | string | 支付成功状态值固定为 SUCCESS |
|
| sign | 是 | 否 | string | **只对 out_trade_no, total_amount, trade_status 三参数进行签名!** |
代码与配置修改草案
common/channels/hechuan/hechuan.go (新建通道支付逻辑)
package hechuan
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/shopspring/decimal"
"github.com/spf13/cast"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.uber.org/zap"
"pmp_backend/common/logger"
"pmp_backend/common/models"
"pmp_backend/common/request"
"pmp_backend/common/response"
"pmp_backend/common/utils"
)
type payResult struct {
Count int `json:"count"`
Data string `json:"data"`
State string `json:"state"`
Message string `json:"message"`
}
func Pay(ctx context.Context, order *models.PayOrders, channel *models.PayChannels, extra map[string]interface{}) (res *response.PayResponse, params map[string]interface{}, err error) {
ctx, span := otel.Tracer("pay").Start(ctx, "HechuanPay")
defer span.End()
res = &response.PayResponse{}
header := make(map[string]string)
header["Content-Type"] = "application/json; charset=utf-8"
params = make(map[string]interface{})
params["product"] = order.ChannelWayCode
params["mchCode"] = channel.ChannelMchID
params["mchOrderNo"] = order.TradeNum
amountCents := decimal.NewFromFloat(order.OrderAmount).Mul(decimal.NewFromInt(100)).IntPart()
params["amount"] = amountCents
params["nonce"] = utils.RandomDigits(10)
params["timestamp"] = cast.ToString(time.Now().UnixMilli())
params["notifyUrl"] = channel.NotifyURL
if order.UIp != "" {
params["userIp"] = order.UIp
}
apiKey := strings.Trim(channel.ChannelAPIKey, " ")
if apiKey == "" {
return res, params, errors.New("channel api key is empty")
}
sign, err := utils.Sign2(params, apiKey, false)
if err != nil {
return
}
params["sign"] = sign
resBt, err := request.Post(channel.Gateway, header, params)
if err != nil {
return
}
payResult := &payResult{}
err = json.Unmarshal(resBt, payResult)
if err != nil {
return
}
if payResult.State != "success" {
return res, params, errors.New(payResult.Message)
}
payUrl, err := url.QueryUnescape(payResult.Data)
if err != nil {
payUrl = payResult.Data
}
res = &response.PayResponse{
Code: 200,
Msg: payResult.Message,
Data: struct {
OrderSn string `json:"order_sn"`
TradeNum string `json:"trade_num"`
PayUrl string `json:"pay_url"`
}{
OrderSn: cast.ToString(order.TradeNum),
TradeNum: "",
PayUrl: payUrl,
},
}
return
}
common/channels/hechuan/notify_handler.go (新建异步GET通知逻辑)
package hechuan
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/labstack/echo"
"github.com/shopspring/decimal"
"github.com/spf13/cast"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.uber.org/zap"
"pmp_backend/common/bm"
"pmp_backend/common/cache"
"pmp_backend/common/dao"
"pmp_backend/common/logger"
"pmp_backend/common/mysqlToRedis"
"pmp_backend/common/response"
"pmp_backend/common/service"
"pmp_backend/common/utils"
"pmp_backend/pmp_gateway/middle"
"pmp_backend/pmp_merchant_order/proto/pb"
)
type HechuanNotifyRequest struct {
OutTradeNo interface{} `json:"out_trade_no"`
TotalAmount interface{} `json:"total_amount"`
TradeStatus interface{} `json:"trade_status"`
TradeNo interface{} `json:"trade_no"`
}
// HechuanNotifyVerifySign 和川专用GET验签拦截器
func HechuanNotifyVerifySign(orderSnKey string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, span := otel.Tracer("VerifySign").Start(c.Request().Context(), "HechuanNotifyVerifySign")
defer span.End()
traceId := span.SpanContext().TraceID().String()
// 1. 从 Query String 中读取 GET 传入参数
params := make(map[string]interface{})
for k, v := range c.Request().URL.Query() {
if len(v) > 0 {
params[k] = v[0]
}
}
sign, ok := params["sign"].(string)
if !ok || sign == "" {
return response.ToJsonFailed(c, traceId, http.StatusBadRequest, "缺少签名参数")
}
delete(params, "sign")
tradeNum, ok := params[orderSnKey].(string)
if !ok || tradeNum == "" {
return response.ToJsonFailed(c, traceId, http.StatusBadRequest, "缺少订单号参数")
}
order, err2 := cache.GetPayOrderByTradeNum(ctx, tradeNum)
if err2 != nil {
_, order, _ = dao.NewPayOrderDao(ctx).GetBeanByTradeNum(tradeNum)
}
if order == nil || order.Id == 0 {
return response.ToJsonFailed(c, traceId, http.StatusUnauthorized, "订单不存在")
}
apiKey, _ := GetChannelApiKey(ctx, order.ChannelId)
// 2. 核心验签:和川回调签名仅对 3 个要素进行签名
signParams := make(map[string]interface{})
signParams["out_trade_no"] = params["out_trade_no"]
signParams["total_amount"] = params["total_amount"]
signParams["trade_status"] = params["trade_status"]
calculatedSign, _ := utils.Sign2(signParams, apiKey, false)
if calculatedSign != sign {
return response.ToJsonFailed(c, traceId, http.StatusUnauthorized, "签名验证失败")
}
if !middle.CheckChannelIpWhiteList(ctx, c.RealIP(), order.ChannelId) {
return response.ToJsonFailed(c, traceId, http.StatusUnauthorized, "IP不在白名单")
}
params["channel_trade_num"] = order.ChannelTradeNum
c.Set("channel_res", params)
return next(c)
}
}
}
func HechuanNotifyHandler(c echo.Context) error {
ctx, span := otel.Tracer("pmp-gateway").Start(c.Request().Context(), "HechuanNotifyHandler")
defer span.End()
traceId := span.SpanContext().TraceID().String()
req := new(HechuanNotifyRequest)
channelResMap, ok := c.Get("channel_res").(map[string]interface{})
if !ok {
logger.PmpLog.Error("hechuan notify: channel_res not found in context", zap.String("traceId", traceId))
span.SetStatus(codes.Error, "获取回调参数失败")
return response.ToJsonFailed(c, traceId, http.StatusBadRequest, "获取回调参数失败")
}
channelResBytes, err := json.Marshal(channelResMap)
if err != nil {
logger.PmpLog.Error("hechuan notify: marshal channel_res err", zap.Error(err), zap.String("traceId", traceId))
span.RecordError(err)
span.SetStatus(codes.Error, "序列化回调参数失败")
return response.ToJsonFailed(c, traceId, http.StatusBadRequest, "序列化回调参数失败")
}
if err = json.Unmarshal(channelResBytes, req); err != nil {
logger.PmpLog.Error("hechuan notify: unmarshal channel_res err", zap.Error(err), zap.String("traceId", traceId))
span.RecordError(err)
span.SetStatus(codes.Error, "解析回调参数失败")
return response.ToJsonFailed(c, traceId, http.StatusBadRequest, "解析回调参数失败")
}
totalAmountCents := cast.ToFloat64(req.TotalAmount)
payAmount := decimal.NewFromFloat(totalAmountCents).Div(decimal.NewFromInt(100))
merchantOrderService := service.GetMerchantOrderService()
if merchantOrderService == nil {
return response.ToJsonFailed(c, traceId, http.StatusBadRequest, "init merchant orders service failed")
}
if cast.ToString(req.TradeStatus) != "SUCCESS" {
return response.ToJsonFailed(c, traceId, http.StatusBadRequest, "订单状态错误")
}
orderStatus := bm.OrderStatusSuccess
res, err := merchantOrderService.PayNotify(ctx, &pb.PayNotifyRequest{
OrderSn: cast.ToString(req.OutTradeNo),
OrderAmount: cast.ToFloat32(payAmount),
OrderStatus: cast.ToString(orderStatus),
TradeNum: cast.ToString(req.TradeNo),
Retry: true,
})
if err != nil || res.Code != 0 {
return response.ToJsonFailed(c, traceId, http.StatusBadRequest, "上分失败")
}
return c.String(http.StatusOK, "SUCCESS")
}
common/channels/hechuan/hechuan_test.go (签名和回调单元测试)
package hechuan
import (
"testing"
"github.com/stretchr/testify/assert"
"pmp_backend/common/utils"
)
func TestSignatureSample(t *testing.T) {
// 1. 下单接口签名校验 (测试文档提供的真实签名样例)
payParams := map[string]interface{}{
"product": "810",
"mchOrderNo": "mchno202400001",
"amount": 50000,
"nonce": "2238notic",
"timestamp": "1722263925",
"notifyUrl": "http://www.xxx.com/Notify",
"returnUrl": "http://www.xxx.com/returnUrl",
"mchCode": "XS240723000001",
}
paySecret := "ISdsie980wsxI"
expectedPaySign := "f65729ba07964818dc679332c8e1a864"
paySign, err := utils.Sign2(payParams, paySecret, false)
assert.NoError(t, err)
assert.Equal(t, expectedPaySign, paySign)
// 2. 异步回调签名校验
notifyParams := map[string]interface{}{
"out_trade_no": "mchno2024080002",
"total_amount": "50000",
"trade_status": "SUCCESS",
}
notifySecret := "a9d9c9fsdffff"
expectedNotifySign := "03c41a44522a69af4a61d6fa284d3a8e"
notifySign, err = utils.Sign2(notifyParams, notifySecret, false)
assert.NoError(t, err)
assert.Equal(t, expectedNotifySign, notifySign)
}
common/channels/handler.go (分发器修改 Diff)
- "pmp_backend/common/channels/zunyun"
+ "pmp_backend/common/channels/zunyun"
+ "pmp_backend/common/channels/hechuan"
)
func SelectPay(...) {
- case "zy1":
- return zunyun.Pay(ctx, order, channel, extra)
+ case "zy1":
+ return zunyun.Pay(ctx, order, channel, extra)
+ case "HC":
+ return hechuan.Pay(ctx, order, channel, extra)
}
pmp_gateway/router/http.go (网关路由修改 Diff)
- "pmp_backend/common/channels/zunyun"
+ "pmp_backend/common/channels/zunyun"
+ "pmp_backend/common/channels/hechuan"
)
func RegisterHttpRouter(e *echo.Echo) {
- notify.POST("/zunxing", handler.ZunxingNotifyHandler, middle.NotifyVerifySign("outTradeNo", "key", true))
+ notify.POST("/zunxing", handler.ZunxingNotifyHandler, middle.NotifyVerifySign("outTradeNo", "key", true))
+ notify.GET("/hechuan", hechuan.HechuanNotifyHandler, hechuan.HechuanNotifyVerifySign("out_trade_no")) // GET 回调
}
pmp_admin/service/out/channel_template.go (模板字典添加 Diff)
var DefaultChannelTemplates = []struct {
Template string
NotifyUrl string
}{
- {"尊信", "/notify/zunxing"},
+ {"尊信", "/notify/zunxing"},
+ {"HC", "/notify/hechuan"},
}
Verification Checklist
上线与验证 Checklist
新开发通道部署至预发布或生产前的测试自检清单。
本地运行 go test ./common/channels/hechuan,确保签名样例断言 100% 通过。
在测试商户发起支付,验证下单响应 payResult.Data 能成功 unescape 还原并拉起。
模拟 GET 异步回调,传入 SUCCESS,验证网关能成功仅提取三要素签名验证并上分。
在管理后台(pmp_admin)将该供应商模板下拉值指定为 HC,并录入 mchCode 等参数。