// e2e_merchant 商户进件端到端测试脚本 // // 测试链路:本脚本 → pay-bridge 本地服务 → Heepay 沙盒 // // 前置条件: // 1. 启动 pay-bridge 服务:go run ./cmd/server // 2. 数据库中已有测试 app(appID + 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") // 最小合法 JPEG(22字节) 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-4(Heepay 沙盒): 需对照官方文档确认字段名\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 }