draft
This commit is contained in:
355
backend/internal/channel/heepay/sandbox_test.go
Normal file
355
backend/internal/channel/heepay/sandbox_test.go
Normal file
@@ -0,0 +1,355 @@
|
||||
//go:build sandbox
|
||||
|
||||
// 沙盒集成测试:直连汇元沙盒环境,验证真实 API 调用。
|
||||
// 需要设置以下环境变量后运行:
|
||||
//
|
||||
// export HEEPAY_MERCHANT_ID=your_merchant_id
|
||||
// export HEEPAY_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||
// export HEEPAY_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||||
//
|
||||
// go test -tags sandbox ./internal/channel/heepay/ -v -timeout 60s
|
||||
package heepay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"pay-bridge/internal/channel"
|
||||
"pay-bridge/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
sandboxPayURL = "http://openapi.heepaydev.com/gateway"
|
||||
sandboxMerchantURL = "http://openapi.heepaydev.com/v1/customer/gateway"
|
||||
)
|
||||
|
||||
// newSandboxAdapter 从环境变量读取凭证,构造沙盒 Adapter
|
||||
func newSandboxAdapter(t *testing.T) *Adapter {
|
||||
t.Helper()
|
||||
merchantID := requireEnv(t, "HEEPAY_MERCHANT_ID")
|
||||
privateKey := requireEnv(t, "HEEPAY_PRIVATE_KEY")
|
||||
publicKey := requireEnv(t, "HEEPAY_PUBLIC_KEY")
|
||||
|
||||
// 支持 \n 转义(shell 传入时换行符可能被转义)
|
||||
privateKey = strings.ReplaceAll(privateKey, `\n`, "\n")
|
||||
publicKey = strings.ReplaceAll(publicKey, `\n`, "\n")
|
||||
|
||||
cfg := &model.ChannelConfig{
|
||||
MerchantID: merchantID,
|
||||
PrivateKey: privateKey,
|
||||
PublicKey: publicKey,
|
||||
Sandbox: 1,
|
||||
}
|
||||
return &Adapter{
|
||||
config: cfg,
|
||||
payURL: sandboxPayURL,
|
||||
merchantURL: sandboxMerchantURL,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func requireEnv(t *testing.T, key string) string {
|
||||
t.Helper()
|
||||
v := os.Getenv(key)
|
||||
require.NotEmpty(t, v, "环境变量 %s 未设置,无法运行沙盒测试", key)
|
||||
return v
|
||||
}
|
||||
|
||||
// uniqueOrderNo 生成测试用唯一订单号(避免重复)
|
||||
func uniqueOrderNo(prefix string) string {
|
||||
return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// --- 下单 ---
|
||||
|
||||
func TestSandbox_CreateOrder_JSAPI(t *testing.T) {
|
||||
a := newSandboxAdapter(t)
|
||||
ctx := context.Background()
|
||||
|
||||
resp, err := a.CreateOrder(ctx, &channel.CreateOrderReq{
|
||||
AppID: a.config.MerchantID,
|
||||
TradeNo: uniqueOrderNo("TEST"),
|
||||
MerchantOrderNo: uniqueOrderNo("ORD"),
|
||||
PayMethod: model.PayMethodWechatJSAPI,
|
||||
Amount: 1, // 1 分
|
||||
Subject: "沙盒测试-JSAPI",
|
||||
NotifyURL: "https://example.com/notify",
|
||||
ExpireTime: time.Now().Add(10 * time.Minute),
|
||||
Extra: map[string]any{"openid": "oBk9Y5YMoAb2UG0L1OWQ_xNoBnE0"}, // 沙盒 openid
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, resp.PayCredential, "应返回支付凭证")
|
||||
t.Logf("pay_credential: %+v", resp.PayCredential)
|
||||
}
|
||||
|
||||
func TestSandbox_CreateOrder_Native(t *testing.T) {
|
||||
a := newSandboxAdapter(t)
|
||||
ctx := context.Background()
|
||||
|
||||
resp, err := a.CreateOrder(ctx, &channel.CreateOrderReq{
|
||||
AppID: a.config.MerchantID,
|
||||
TradeNo: uniqueOrderNo("TEST"),
|
||||
MerchantOrderNo: uniqueOrderNo("ORD"),
|
||||
PayMethod: model.PayMethodWechatNative,
|
||||
Amount: 1,
|
||||
Subject: "沙盒测试-Native",
|
||||
NotifyURL: "https://example.com/notify",
|
||||
ExpireTime: time.Now().Add(10 * time.Minute),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, resp.PayCredential)
|
||||
t.Logf("pay_credential: %+v", resp.PayCredential)
|
||||
}
|
||||
|
||||
// --- 查询订单 ---
|
||||
|
||||
func TestSandbox_QueryOrder_NotExist(t *testing.T) {
|
||||
a := newSandboxAdapter(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 查一个不存在的订单,预期渠道返回错误
|
||||
_, err := a.QueryOrder(ctx, &channel.QueryOrderReq{
|
||||
TradeNo: "NOT_EXIST_" + uniqueOrderNo(""),
|
||||
})
|
||||
|
||||
// 沙盒对不存在订单会返回错误,确认我们能正确解析
|
||||
assert.Error(t, err)
|
||||
t.Logf("expected error: %v", err)
|
||||
}
|
||||
|
||||
// --- 完整流程:下单 → 查询 → 关闭 ---
|
||||
|
||||
func TestSandbox_OrderLifecycle(t *testing.T) {
|
||||
a := newSandboxAdapter(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tradeNo := uniqueOrderNo("LIFE")
|
||||
|
||||
// 1. 下单
|
||||
createResp, err := a.CreateOrder(ctx, &channel.CreateOrderReq{
|
||||
AppID: a.config.MerchantID,
|
||||
TradeNo: tradeNo,
|
||||
MerchantOrderNo: uniqueOrderNo("ORD"),
|
||||
PayMethod: model.PayMethodWechatNative,
|
||||
Amount: 1,
|
||||
Subject: "沙盒-生命周期测试",
|
||||
NotifyURL: "https://example.com/notify",
|
||||
ExpireTime: time.Now().Add(10 * time.Minute),
|
||||
})
|
||||
require.NoError(t, err, "下单失败")
|
||||
t.Logf("下单成功,channel_trade_no: %s", createResp.ChannelTradeNo)
|
||||
|
||||
// 2. 查询(刚下单,应为 PAYING 状态)
|
||||
queryResp, err := a.QueryOrder(ctx, &channel.QueryOrderReq{
|
||||
TradeNo: tradeNo,
|
||||
ChannelTradeNo: createResp.ChannelTradeNo,
|
||||
})
|
||||
require.NoError(t, err, "查询订单失败")
|
||||
assert.Equal(t, model.TradeStatusPaying, queryResp.Status)
|
||||
t.Logf("订单状态: %s", queryResp.Status)
|
||||
|
||||
// 3. 关闭
|
||||
err = a.CloseOrder(ctx, &channel.CloseOrderReq{
|
||||
TradeNo: tradeNo,
|
||||
ChannelTradeNo: createResp.ChannelTradeNo,
|
||||
})
|
||||
require.NoError(t, err, "关闭订单失败")
|
||||
t.Log("关闭订单成功")
|
||||
|
||||
// 4. 再次查询,应为 CLOSED
|
||||
queryResp2, err := a.QueryOrder(ctx, &channel.QueryOrderReq{
|
||||
TradeNo: tradeNo,
|
||||
ChannelTradeNo: createResp.ChannelTradeNo,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.TradeStatusClosed, queryResp2.Status)
|
||||
}
|
||||
|
||||
// --- 商户进件 ---
|
||||
|
||||
// TestSandbox_UploadFile 上传一张最小 JPEG 到沙盒,验证返回 file_id
|
||||
func TestSandbox_UploadFile(t *testing.T) {
|
||||
a := newSandboxAdapter(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 最小合法 JPEG(几十字节)
|
||||
minJPEG := []byte{
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
|
||||
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9,
|
||||
}
|
||||
|
||||
resp, err := a.UploadFile(ctx, &channel.UploadFileReq{
|
||||
FileContent: minJPEG,
|
||||
FileName: "test_license.jpg",
|
||||
FileMediaType: "image/jpeg",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, resp.FileID, "应返回 file_id")
|
||||
t.Logf("file_id: %s", resp.FileID)
|
||||
}
|
||||
|
||||
// TestSandbox_MerchantApply 提交企业入网申请,验证返回 request_no
|
||||
// 沙盒环境审核不会真正处理,但接口应正常响应
|
||||
func TestSandbox_MerchantApply(t *testing.T) {
|
||||
a := newSandboxAdapter(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 沙盒测试用企业信息(使用固定测试数据)
|
||||
// 字段名以汇元 customer.enter.enterprise.apply 文档为准
|
||||
bizContent := map[string]any{
|
||||
"merch_name": "测试科技有限公司",
|
||||
"merch_short_name": "测试科技",
|
||||
"merch_type": "ENTERPRISE",
|
||||
"contact_name": "张三",
|
||||
"contact_phone": "13800138000",
|
||||
"contact_email": "test@example.com",
|
||||
"license_no": "91110000123456789X",
|
||||
"legal_name": "李四",
|
||||
"legal_id": "110101199001011234",
|
||||
"province": "北京市",
|
||||
"city": "北京市",
|
||||
"district": "朝阳区",
|
||||
"address": "朝阳区测试路1号",
|
||||
"bank_acct_name": "测试科技有限公司",
|
||||
"bank_acct_no": "6222021234567890123",
|
||||
"bank_name": "中国工商银行",
|
||||
}
|
||||
|
||||
resp, err := a.MerchantApply(ctx, &channel.MerchantApplyReq{
|
||||
MerchantID: uniqueOrderNo("M"),
|
||||
BizContent: bizContent,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, resp.RequestNo, "应返回 request_no")
|
||||
t.Logf("request_no: %s", resp.RequestNo)
|
||||
}
|
||||
|
||||
// TestSandbox_QueryMerchantStatus 用已有的 request_no 查询进件状态
|
||||
// 若无真实 request_no,跳过(防止误报失败)
|
||||
func TestSandbox_QueryMerchantStatus(t *testing.T) {
|
||||
requestNo := os.Getenv("HEEPAY_TEST_REQUEST_NO")
|
||||
if requestNo == "" {
|
||||
t.Skip("未设置 HEEPAY_TEST_REQUEST_NO,跳过进件状态查询测试")
|
||||
}
|
||||
|
||||
a := newSandboxAdapter(t)
|
||||
ctx := context.Background()
|
||||
|
||||
resp, err := a.QueryMerchantStatus(ctx, requestNo)
|
||||
require.NoError(t, err)
|
||||
t.Logf("audit_state: %s, merch_id: %s, fail_reason: %s",
|
||||
resp.Status, resp.ChannelMerchantID, resp.FailReason)
|
||||
}
|
||||
|
||||
// TestSandbox_MerchantOnboardingFlow 完整进件流程:上传文件 → 提交申请 → 查询状态
|
||||
func TestSandbox_MerchantOnboardingFlow(t *testing.T) {
|
||||
a := newSandboxAdapter(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. 上传营业执照
|
||||
minJPEG := []byte{
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
|
||||
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9,
|
||||
}
|
||||
uploadResp, err := a.UploadFile(ctx, &channel.UploadFileReq{
|
||||
FileContent: minJPEG,
|
||||
FileName: "license.jpg",
|
||||
FileMediaType: "image/jpeg",
|
||||
})
|
||||
require.NoError(t, err, "上传营业执照失败")
|
||||
t.Logf("上传成功,file_id: %s", uploadResp.FileID)
|
||||
|
||||
// 2. 提交进件申请(带上文件 ID)
|
||||
bizContent := map[string]any{
|
||||
"merch_name": "沙盒流程测试公司",
|
||||
"merch_short_name": "沙盒测试",
|
||||
"merch_type": "ENTERPRISE",
|
||||
"contact_name": "王五",
|
||||
"contact_phone": "13900139000",
|
||||
"contact_email": "flow@example.com",
|
||||
"license_no": "91110000FLOW00001X",
|
||||
"license_img": uploadResp.FileID,
|
||||
"legal_name": "赵六",
|
||||
"legal_id": "110101199001019999",
|
||||
"province": "北京市",
|
||||
"city": "北京市",
|
||||
"district": "海淀区",
|
||||
"address": "海淀区测试路2号",
|
||||
"bank_acct_name": "沙盒流程测试公司",
|
||||
"bank_acct_no": "6222029876543210987",
|
||||
"bank_name": "中国建设银行",
|
||||
}
|
||||
applyResp, err := a.MerchantApply(ctx, &channel.MerchantApplyReq{
|
||||
MerchantID: uniqueOrderNo("FLOW"),
|
||||
BizContent: bizContent,
|
||||
})
|
||||
require.NoError(t, err, "提交进件申请失败")
|
||||
t.Logf("申请成功,request_no: %s", applyResp.RequestNo)
|
||||
|
||||
// 3. 查询进件状态(沙盒可能立即返回状态)
|
||||
statusResp, err := a.QueryMerchantStatus(ctx, applyResp.RequestNo)
|
||||
require.NoError(t, err, "查询进件状态失败")
|
||||
t.Logf("进件状态: audit_state=%s, merch_id=%s", statusResp.Status, statusResp.ChannelMerchantID)
|
||||
}
|
||||
|
||||
// --- 签名验证(本地,不发网络请求)---
|
||||
|
||||
// TestSign_And_Verify 本地签名/验签往返测试(不发网络请求)
|
||||
// 注意汇元双密钥体系:
|
||||
// - 请求签名:商户私钥签 → 汇元用商户公钥验
|
||||
// - 响应验签:汇元私钥签 → 商户用汇元公钥验(即 a.config.PublicKey)
|
||||
//
|
||||
// 本测试只验证商户私钥签名正确,使用从私钥派生的公钥做自验,
|
||||
// 不混用汇元公钥(两者非同一密钥对)。
|
||||
func TestSign_And_Verify(t *testing.T) {
|
||||
a := newSandboxAdapter(t)
|
||||
|
||||
params := map[string]string{
|
||||
"app_id": a.config.MerchantID,
|
||||
"method": "pay.heepay.trade.create",
|
||||
"format": "JSON",
|
||||
"charset": "utf-8",
|
||||
"sign_type": SignTypeRSA2,
|
||||
"timestamp": "2026-02-28 10:00:00",
|
||||
"version": "1.0",
|
||||
"biz_content": `{"out_trade_no":"TEST001"}`,
|
||||
}
|
||||
|
||||
sign, err := Sign(params, a.config.PrivateKey)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, sign)
|
||||
t.Logf("sign (first 32 chars): %s...", sign[:32])
|
||||
|
||||
// 从商户私钥提取对应公钥,用于验证我们自己签出来的签名
|
||||
merchantPubKeyPEM, err := extractPublicKeyFromPrivate(a.config.PrivateKey)
|
||||
require.NoError(t, err, "从私钥提取公钥失败")
|
||||
err = VerifyResponse(params, sign, merchantPubKeyPEM)
|
||||
assert.NoError(t, err, "用商户公钥验证商户私钥签名应通过")
|
||||
}
|
||||
|
||||
// extractPublicKeyFromPrivate 从商户私钥中派生出对应的公钥(DER base64 格式)
|
||||
func extractPublicKeyFromPrivate(privKeyStr string) (string, error) {
|
||||
privKey, err := parsePrivateKey(privKeyStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
derBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(derBytes), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user