417 lines
14 KiB
Go
417 lines
14 KiB
Go
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"`
|
||
Description string `json:"description"`
|
||
Logo string `json:"logo"`
|
||
Currency string `json:"currency"`
|
||
ProductMode any `json:"productMode"`
|
||
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,
|
||
Description: product.Description,
|
||
Logo: product.Logo,
|
||
Currency: product.Currency,
|
||
ProductMode: valueToString(product.ProductMode),
|
||
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 ""
|
||
}
|