Files
2026-03-13 15:51:59 +08:00

327 lines
9.4 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.
// e2e_merchant 商户进件端到端测试脚本
//
// 测试链路:本脚本 → pay-bridge 本地服务 → Heepay 沙盒
//
// 前置条件:
// 1. 启动 pay-bridge 服务go run ./cmd/server
// 2. 数据库中已有测试 appappID + appSecret或通过 Admin API 创建
// 3. 设置环境变量(见下方)
//
// 环境变量:
//
// PAY_BRIDGE_URL=http://localhost:8080 服务地址(默认 http://localhost:8080
// APP_ID=your_app_id 测试用 appID
// APP_SECRET=your_app_secret 对应的明文 appSecret
//
// 运行:
//
// APP_ID=app_test APP_SECRET=secret123 go run ./scripts/e2e_merchant/
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// ---- 配置 ----
func baseURL() string {
if v := os.Getenv("PAY_BRIDGE_URL"); v != "" {
return strings.TrimRight(v, "/")
}
return "http://localhost:8080"
}
func mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
fatalf("环境变量 %s 未设置", key)
}
return v
}
// ---- HTTP 客户端(带 HMAC 签名) ----
type client struct {
appID string
appSecret string
base string
http *http.Client
}
func newClient() *client {
return &client{
appID: mustEnv("APP_ID"),
appSecret: mustEnv("APP_SECRET"),
base: baseURL(),
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *client) do(method, path string, body any) (map[string]any, error) {
var bodyBytes []byte
if body != nil {
var err error
bodyBytes, err = json.Marshal(body)
if err != nil {
return nil, err
}
}
ts := strconv.FormatInt(time.Now().Unix(), 10)
sign := hmacSign(c.appID+ts+string(bodyBytes), c.appSecret)
req, err := http.NewRequest(method, c.base+path, bytes.NewReader(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-App-Id", c.appID)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Sign", sign)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(resp.Body)
var result map[string]any
if err := json.Unmarshal(respBytes, &result); err != nil {
return nil, fmt.Errorf("parse response [%d]: %s", resp.StatusCode, string(respBytes))
}
return result, nil
}
func (c *client) post(path string, body any) (map[string]any, error) {
return c.do("POST", path, body)
}
func (c *client) get(path string) (map[string]any, error) {
return c.do("GET", path, nil)
}
func hmacSign(payload, secret string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(payload))
return hex.EncodeToString(h.Sum(nil))
}
// ---- 辅助函数 ----
func printStep(n int, name string) {
fmt.Printf("\n[步骤 %d] %s\n%s\n", n, name, strings.Repeat("-", 40))
}
func printResult(resp map[string]any) {
b, _ := json.MarshalIndent(resp, " ", " ")
fmt.Printf(" 响应: %s\n", string(b))
}
func checkOK(resp map[string]any, step string) {
code, _ := resp["code"].(string)
if code != "0" {
fatalf("[%s] 接口返回错误: code=%s message=%v", step, code, resp["message"])
}
}
func dataStr(resp map[string]any, key string) string {
data, _ := resp["data"].(map[string]any)
if data == nil {
return ""
}
v, _ := data[key].(string)
return v
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "❌ FAIL: "+format+"\n", args...)
os.Exit(1)
}
// ---- 测试流程 ----
func main() {
c := newClient()
merchantID := fmt.Sprintf("E2E_M_%d", time.Now().UnixNano()%1e9)
fmt.Printf("=== 商户进件 E2E 测试 ===\n")
fmt.Printf("服务地址: %s\n", c.base)
fmt.Printf("App ID: %s\n", c.appID)
fmt.Printf("商户ID: %s\n", merchantID)
// ---- 步骤 1创建商户 ----
printStep(1, "创建商户")
resp, err := c.post("/api/v1/merchant", map[string]any{
"merchant_id": merchantID,
"merchant_name": "E2E测试企业" + merchantID[len(merchantID)-6:],
"license_no": "91110000E2ETEST01X",
"legal_person": "测试法人",
})
if err != nil {
fatalf("创建商户请求失败: %v", err)
}
printResult(resp)
checkOK(resp, "创建商户")
fmt.Printf(" ✓ 商户创建成功: merchant_id=%s\n", dataStr(resp, "merchant_id"))
// ---- 步骤 2查询商户详情 ----
printStep(2, "查询商户详情")
resp, err = c.get("/api/v1/merchant/" + merchantID)
if err != nil {
fatalf("查询商户失败: %v", err)
}
printResult(resp)
checkOK(resp, "查询商户")
fmt.Printf(" ✓ 商户查询成功\n")
// ---- 步骤 3上传营业执照调用 Heepay 沙盒) ----
printStep(3, "上传营业执照到 Heepay 沙盒")
fmt.Printf(" → 调用 POST /api/v1/merchant/upload-file\n")
fmt.Printf(" → 内部转调 Heepay customer.file.upload\n")
// 最小合法 JPEG22字节
minJPEG := []byte{
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9,
}
// 用 multipart 上传
fileID, uploadErr := uploadFile(c, minJPEG, "license.jpg", "image/jpeg")
if uploadErr != nil {
fmt.Printf(" ⚠ 文件上传失败Heepay 沙盒可能返回业务错误): %v\n", uploadErr)
fmt.Printf(" → 继续后续步骤submit_data 不含 file_id\n")
fileID = ""
} else {
fmt.Printf(" ✓ 文件上传成功: file_id=%s\n", fileID)
}
// ---- 步骤 4提交进件申请调用 Heepay 沙盒) ----
printStep(4, "提交进件申请到 Heepay 沙盒")
fmt.Printf(" → 调用 POST /api/v1/merchant/%s/apply\n", merchantID)
fmt.Printf(" → 内部转调 Heepay customer.enter.enterprise.apply\n")
submitData := map[string]any{
"merch_name": "E2E测试企业",
"merch_short_name": "E2E测试",
"merch_type": "ENTERPRISE",
"contact_name": "测试联系人",
"contact_phone": "13800138000",
"contact_email": "e2e@example.com",
"province": "北京市",
"city": "北京市",
"district": "朝阳区",
"address": "朝阳区测试路1号",
}
if fileID != "" {
submitData["license_img"] = fileID
}
resp, err = c.post("/api/v1/merchant/"+merchantID+"/apply", map[string]any{
"channel_code": "HEEPAY",
"submit_data": submitData,
})
if err != nil {
fatalf("提交进件失败: %v", err)
}
printResult(resp)
applicationID := dataStr(resp, "application_id")
if resp["code"] != "0" {
fmt.Printf(" ⚠ 进件接口返回错误Heepay 沙盒字段验证): code=%v message=%v\n",
resp["code"], resp["message"])
fmt.Printf(" → 进件字段名需对照 Heepay 官方文档确认\n")
} else {
fmt.Printf(" ✓ 进件申请提交成功: application_id=%s\n", applicationID)
}
// ---- 步骤 5查询进件审核状态 ----
printStep(5, "查询进件审核状态")
resp, err = c.get("/api/v1/merchant/" + merchantID + "/audit")
if err != nil {
fatalf("查询审核状态失败: %v", err)
}
printResult(resp)
if resp["code"] == "0" {
fmt.Printf(" ✓ 审核状态查询成功\n")
} else {
fmt.Printf(" ⚠ 审核状态查询异常(可能进件未提交成功)\n")
}
// ---- 步骤 6验证商户列表隔离 ----
printStep(6, "验证 appID 隔离:列表只返回本 app 的商户")
resp, err = c.get("/api/v1/merchant?limit=10&offset=0")
if err != nil {
fatalf("查询列表失败: %v", err)
}
checkOK(resp, "商户列表")
data, _ := resp["data"].(map[string]any)
list, _ := data["list"].([]any)
fmt.Printf(" ✓ 返回 %d 个商户(均属于 app_id=%s\n", len(list), c.appID)
fmt.Printf("\n=== 测试完成 ===\n")
fmt.Printf("步骤 1-2本地 DB: ✓ 正常\n")
fmt.Printf("步骤 3-4Heepay 沙盒): 需对照官方文档确认字段名\n")
fmt.Printf("步骤 6数据隔离: ✓ 正常\n")
}
// uploadFile 发送 multipart 文件上传请求
func uploadFile(c *client, content []byte, filename, mediaType string) (string, error) {
var buf bytes.Buffer
boundary := "----PayBridgeE2EBoundary"
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString(fmt.Sprintf(`Content-Disposition: form-data; name="file"; filename="%s"`, filename) + "\r\n")
buf.WriteString("Content-Type: " + mediaType + "\r\n\r\n")
buf.Write(content)
buf.WriteString("\r\n--" + boundary + "\r\n")
buf.WriteString(`Content-Disposition: form-data; name="channel_code"` + "\r\n\r\n")
buf.WriteString("HEEPAY")
buf.WriteString("\r\n--" + boundary + "\r\n")
buf.WriteString(`Content-Disposition: form-data; name="file_media_type"` + "\r\n\r\n")
buf.WriteString(mediaType)
buf.WriteString("\r\n--" + boundary + "--\r\n")
ts := strconv.FormatInt(time.Now().Unix(), 10)
// multipart 请求 body 不参与签名,签名 body 部分为空
sign := hmacSign(c.appID+ts+"", c.appSecret)
req, err := http.NewRequest("POST", c.base+"/api/v1/merchant/upload-file", &buf)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
req.Header.Set("X-App-Id", c.appID)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Sign", sign)
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(resp.Body)
var result map[string]any
if err := json.Unmarshal(respBytes, &result); err != nil {
return "", fmt.Errorf("parse upload response: %s", string(respBytes))
}
if result["code"] != "0" {
return "", fmt.Errorf("code=%v message=%v", result["code"], result["message"])
}
data, _ := result["data"].(map[string]any)
fileID, _ := data["file_id"].(string)
return fileID, nil
}