package v5pay import ( "bytes" "context" "crypto/md5" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "sort" "strconv" "strings" "time" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/config" "hyapp/services/wallet-service/internal/domain/ledger" walletservice "hyapp/services/wallet-service/internal/service/wallet" ) type Client struct { cfg config.V5PayConfig httpClient *http.Client } func New(cfg config.V5PayConfig) *Client { if cfg.HTTPTimeout <= 0 { cfg.HTTPTimeout = 10 * time.Second } return &Client{ cfg: cfg, httpClient: &http.Client{Timeout: cfg.HTTPTimeout}, } } // CreateOrder 调 V5Pay 收银台接口并返回 checkoutUrl;productType 由后台支付方式配置决定。 func (c *Client) CreateOrder(ctx context.Context, req walletservice.V5PayCreateOrderRequest) (walletservice.V5PayCreateOrderResponse, error) { if c == nil || !c.ready() { return walletservice.V5PayCreateOrderResponse{}, xerr.New(xerr.Unavailable, "v5pay client is not configured") } body := map[string]any{ "merchantNo": c.cfg.MerchantNo, "appKey": c.cfg.AppKey, "sysCountryCode": strings.ToUpper(strings.TrimSpace(req.CountryCode)), "currency": strings.ToUpper(strings.TrimSpace(req.CurrencyCode)), "orderNo": req.OrderID, "amount": formatV5PayAmount(req.ProviderAmountMinor), "productType": strings.TrimSpace(req.ProductType), "callbackUrl": strings.TrimSpace(req.NotifyURL), "redirectUrl": strings.TrimSpace(req.ReturnURL), "merchantParam": req.OrderID, "language": normalizeV5PayLanguage(req.Language), "tradeSummary": productInfo(req), "email": payerEmail(req), "merchantCustomerId": strconv.FormatInt(req.TargetUserID, 10), } body["sign"] = signFields(body, c.cfg.SecretKey) raw, err := json.Marshal(body) if err != nil { return walletservice.V5PayCreateOrderResponse{}, err } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.APIBaseURL+"/cgi/cashier/v2/payin", bytes.NewReader(raw)) if err != nil { return walletservice.V5PayCreateOrderResponse{}, err } httpReq.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(httpReq) if err != nil { return walletservice.V5PayCreateOrderResponse{}, err } defer resp.Body.Close() rawResp, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) if err != nil { return walletservice.V5PayCreateOrderResponse{}, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return walletservice.V5PayCreateOrderResponse{}, xerr.New(xerr.Unavailable, "v5pay order request failed") } fields, err := decodeObject(rawResp) if err != nil { return walletservice.V5PayCreateOrderResponse{}, err } if code := fields["code"]; code != "" && code != "1000" { return walletservice.V5PayCreateOrderResponse{}, v5payGatewayRejectionError{ cause: xerr.New(xerr.Unavailable, "v5pay order rejected: "+code+": "+fields["message"]), rawJSON: string(rawResp), } } if !verifyFields(fields, c.cfg.SecretKey) { return walletservice.V5PayCreateOrderResponse{}, xerr.New(xerr.PermissionDenied, "v5pay signature is invalid") } if strings.TrimSpace(fields["checkoutUrl"]) == "" { return walletservice.V5PayCreateOrderResponse{}, xerr.New(xerr.Unavailable, "v5pay order response is incomplete") } return walletservice.V5PayCreateOrderResponse{ OrderID: req.OrderID, ProviderOrderID: req.OrderID, PayURL: fields["checkoutUrl"], RawJSON: string(rawResp), }, nil } // QueryOrder 主动查询 V5Pay 订单状态;H5 返回页依赖它补偿异步回调延迟或回调丢失的窗口期。 func (c *Client) QueryOrder(ctx context.Context, req walletservice.V5PayQueryOrderRequest) (walletservice.V5PayQueryOrderResponse, error) { if c == nil || !c.ready() { return walletservice.V5PayQueryOrderResponse{}, xerr.New(xerr.Unavailable, "v5pay client is not configured") } body := map[string]any{ "merchantNo": c.cfg.MerchantNo, "appKey": c.cfg.AppKey, "sysCountryCode": strings.ToUpper(strings.TrimSpace(req.CountryCode)), "currency": strings.ToUpper(strings.TrimSpace(req.CurrencyCode)), "orderNo": req.OrderID, } body["sign"] = signFields(body, c.cfg.SecretKey) raw, err := json.Marshal(body) if err != nil { return walletservice.V5PayQueryOrderResponse{}, err } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.APIBaseURL+"/cgi/payment/v1/payin/query", bytes.NewReader(raw)) if err != nil { return walletservice.V5PayQueryOrderResponse{}, err } httpReq.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(httpReq) if err != nil { return walletservice.V5PayQueryOrderResponse{}, err } defer resp.Body.Close() rawResp, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) if err != nil { return walletservice.V5PayQueryOrderResponse{}, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return walletservice.V5PayQueryOrderResponse{}, xerr.New(xerr.Unavailable, "v5pay order query request failed") } fields, err := decodeObject(rawResp) if err != nil { return walletservice.V5PayQueryOrderResponse{}, err } if code := fields["code"]; code != "" && code != "1000" { return walletservice.V5PayQueryOrderResponse{}, v5payGatewayRejectionError{ cause: xerr.New(xerr.Unavailable, "v5pay order query rejected: "+code+": "+fields["message"]), rawJSON: string(rawResp), } } if !verifyFields(fields, c.cfg.SecretKey) { return walletservice.V5PayQueryOrderResponse{}, xerr.New(xerr.PermissionDenied, "v5pay signature is invalid") } return walletservice.V5PayQueryOrderResponse{ OrderID: fields["orderNo"], ProviderOrderID: fields["transactionId"], Status: fields["status"], Amount: fields["amount"], Currency: fields["currency"], ProductType: fields["productType"], RawJSON: string(rawResp), }, nil } // ListProductTypes 查询当前商户应用在指定国家和币种下实际开通的 V5Pay 产品;H5 展示前用它过滤静态配置。 func (c *Client) ListProductTypes(ctx context.Context, req walletservice.V5PayProductTypesRequest) (walletservice.V5PayProductTypesResponse, error) { if c == nil || !c.ready() { return walletservice.V5PayProductTypesResponse{}, xerr.New(xerr.Unavailable, "v5pay client is not configured") } query := map[string]any{ "merchantNo": c.cfg.MerchantNo, "appKey": c.cfg.AppKey, "sysCountryCode": strings.ToUpper(strings.TrimSpace(req.CountryCode)), "currency": strings.ToUpper(strings.TrimSpace(req.CurrencyCode)), "nonce": time.Now().UnixMilli(), } query["sign"] = signFields(query, c.cfg.SecretKey) httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.APIBaseURL+"/cgi/payment/v1/productTypes", nil) if err != nil { return walletservice.V5PayProductTypesResponse{}, err } values := httpReq.URL.Query() for key, value := range query { values.Set(key, valueToString(value)) } httpReq.URL.RawQuery = values.Encode() resp, err := c.httpClient.Do(httpReq) if err != nil { return walletservice.V5PayProductTypesResponse{}, err } defer resp.Body.Close() rawResp, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) if err != nil { return walletservice.V5PayProductTypesResponse{}, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return walletservice.V5PayProductTypesResponse{}, xerr.New(xerr.Unavailable, "v5pay product types request failed") } var payload struct { Code string `json:"code"` Message string `json:"message"` MerchantNo string `json:"merchantNo"` AppKey string `json:"appKey"` Nonce any `json:"nonce"` Sign string `json:"sign"` PayinList []struct { ProductType string `json:"productType"` ProductName string `json:"productName"` Currency string `json:"currency"` SupportCashierMode int `json:"supportCashierMode"` } `json:"payinList"` } if err := json.Unmarshal(rawResp, &payload); err != nil { return walletservice.V5PayProductTypesResponse{}, err } if payload.Code != "" && payload.Code != "1000" { return walletservice.V5PayProductTypesResponse{}, v5payGatewayRejectionError{ cause: xerr.New(xerr.Unavailable, "v5pay product types rejected: "+payload.Code+": "+payload.Message), rawJSON: string(rawResp), } } signFields := map[string]string{ "code": payload.Code, "message": payload.Message, "merchantNo": payload.MerchantNo, "appKey": payload.AppKey, "nonce": valueToString(payload.Nonce), "sign": payload.Sign, } if !verifyFields(signFields, c.cfg.SecretKey) { return walletservice.V5PayProductTypesResponse{}, xerr.New(xerr.PermissionDenied, "v5pay signature is invalid") } products := make([]walletservice.V5PayProductType, 0, len(payload.PayinList)) for _, product := range payload.PayinList { products = append(products, walletservice.V5PayProductType{ ProductType: product.ProductType, ProductName: product.ProductName, Currency: product.Currency, SupportCashierMode: product.SupportCashierMode, }) } return walletservice.V5PayProductTypesResponse{PayinList: products, RawJSON: string(rawResp)}, nil } // ParseNotification 验签 V5Pay 回调并返回内层业务字段;验签失败不允许业务层入账。 func (c *Client) ParseNotification(notification ledger.V5PayNotification) (walletservice.V5PayNotifyData, error) { if c == nil || !c.ready() { return walletservice.V5PayNotifyData{}, xerr.New(xerr.Unavailable, "v5pay client is not configured") } fields := make(map[string]string, len(notification.Fields)) for key, value := range notification.Fields { fields[key] = value } if !verifyFields(fields, c.cfg.SecretKey) { return walletservice.V5PayNotifyData{}, xerr.New(xerr.PermissionDenied, "v5pay signature is invalid") } if strings.TrimSpace(fields["orderNo"]) == "" { return walletservice.V5PayNotifyData{}, xerr.New(xerr.InvalidArgument, "v5pay callback order_no is required") } return walletservice.V5PayNotifyData{ OrderID: fields["orderNo"], ProviderOrderID: fields["transactionId"], OrderStatus: fields["status"], Amount: fields["amount"], Currency: fields["currency"], ProductType: fields["productType"], RawJSON: firstNonEmpty(notification.RawJSON, mustJSON(fields)), }, nil } func (c *Client) ready() bool { return strings.TrimSpace(c.cfg.MerchantNo) != "" && strings.TrimSpace(c.cfg.AppKey) != "" && strings.TrimSpace(c.cfg.SecretKey) != "" && strings.TrimSpace(c.cfg.APIBaseURL) != "" } type v5payGatewayRejectionError struct { cause error rawJSON string } func (e v5payGatewayRejectionError) Error() string { return e.cause.Error() } func (e v5payGatewayRejectionError) Unwrap() error { return e.cause } func (e v5payGatewayRejectionError) ProviderPayloadJSON() string { return e.rawJSON } func signFields(fields map[string]any, secretKey string) string { normalized := make(map[string]string, len(fields)) for key, value := range fields { normalized[key] = valueToString(value) } return signStringFields(normalized, secretKey) } func verifyFields(fields map[string]string, secretKey string) bool { want := strings.ToLower(strings.TrimSpace(fields["sign"])) return want != "" && want == signStringFields(fields, secretKey) } func signStringFields(fields map[string]string, secretKey string) string { keys := make([]string, 0, len(fields)) for key, value := range fields { if key == "sign" || strings.TrimSpace(value) == "" { continue } keys = append(keys, key) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, key := range keys { parts = append(parts, key+"="+fields[key]) } sum := md5.Sum([]byte(strings.Join(parts, "&") + secretKey)) return hex.EncodeToString(sum[:]) } func decodeObject(raw []byte) (map[string]string, error) { decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() var payload map[string]any if err := decoder.Decode(&payload); err != nil { return nil, err } fields := make(map[string]string, len(payload)) for key, value := range payload { fields[key] = valueToString(value) } return fields, nil } func valueToString(value any) string { switch typed := value.(type) { case nil: return "" case string: return typed case json.Number: return typed.String() case float64: return strconv.FormatFloat(typed, 'f', -1, 64) case bool: if typed { return "true" } return "false" default: return fmt.Sprint(typed) } } func formatV5PayAmount(minor int64) string { return fmt.Sprintf("%d.%02d", minor/100, minor%100) } func productInfo(req walletservice.V5PayCreateOrderRequest) string { name := strings.TrimSpace(req.ProductName) if name == "" { name = "coin package" } return fmt.Sprintf("%s %d coins", name, req.CoinAmount) } func payerEmail(req walletservice.V5PayCreateOrderRequest) string { if strings.TrimSpace(req.PayerEmail) != "" { return strings.TrimSpace(req.PayerEmail) } return fmt.Sprintf("v5pay-payer-%d@haiyihy.com", req.TargetUserID) } func normalizeV5PayLanguage(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "zh", "zh-cn": return "zh-CN" case "zh-tw", "zh-hk", "zh-tc": return "zh-TC" case "es", "es-es": return "es-ES" case "pt", "pt-pt": return "pt-PT" case "ru", "ru-ru": return "ru-RU" case "ja", "jp", "ja-jp", "jp-jp": return "jp-JP" default: return "en-US" } } func mustJSON(value any) string { raw, err := json.Marshal(value) if err != nil { return "" } return string(raw) } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" }