This commit is contained in:
2026-03-13 15:51:59 +08:00
parent 4db2386bbf
commit 4e91f4cede
133 changed files with 19502 additions and 37 deletions

View 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
}