PMP / SUPPLIER INTEGRATION REPORT

和川代收对接研判报告(昱鼎站点)

针对“昱鼎”站点接入“和川”代收支付通道进行的协议匹配、兼容性评估以及 Go 驱动与配置生成方案。

未对接 Integration Status

供应商是 HC/和川昱鼎 只是部署站点;不得把站点名误判为模板或映射到 yudian

GET Notify Method

回调通知为罕见的 GET 请求,需定制专门的 GET 验签拦截器。

Sign2 Sign Helper

下单与回调密钥拼接无 &key=,直接追加在参数尾部,契合 utils.Sign2

0 风险 Compatibility Risk

通信为 RESTful JSON POST,无国密或双向 mTLS 等硬件阻碍。

配置与改造决策

在后台配置“HC”模板;“昱鼎”仅作为站点信息保留。

原因一

文档标题应拆成供应商与站点两层:HC(和川) 是通道供应商,昱鼎 是部署站点/客户环境,不参与模板匹配。

原因二

回调通知方式不同。和川是 GET 请求回调。此外,验签**仅限 3 个核心参数**(out_trade_no, total_amount, trade_status),须在中间件中剔除其余参数后校验。

Interaction Flow

支付与回调交互时序图

描述 PMP 平台、用户浏览器与和川 API 通信中的参数拼装、签名、Unescape 还原与 GET 回调验签机制。

User / Browser PMP Gateway Common/hechuan Hechuan API 1. 提交订单 2. Sign2 签名 & amount分化 3. POST /Payment/Pay 4. 返回 urlencoded Data 5. QueryUnescape 还原 & 跳转支付 6. GET 异步通知 (仅签名3要素) 7. 拦截器验签上分,回复 SUCCESS

对接参数要素提取

接口分类 参数名 必选 签名 类型 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

新开发通道部署至预发布或生产前的测试自检清单。

1

本地运行 go test ./common/channels/hechuan,确保签名样例断言 100% 通过。

2

在测试商户发起支付,验证下单响应 payResult.Data 能成功 unescape 还原并拉起。

3

模拟 GET 异步回调,传入 SUCCESS,验证网关能成功仅提取三要素签名验证并上分。

4

在管理后台(pmp_admin)将该供应商模板下拉值指定为 HC,并录入 mchCode 等参数。