Files
pay-bridge/backend/internal/channel/heepay/sandbox_test.go
2026-03-13 15:51:59 +08:00

356 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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
}