2026-06-22 16:55:22 +08:00

411 lines
14 KiB
Go
Raw 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.

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 收银台接口并返回 checkoutUrlproductType 由后台支付方式配置决定。
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 ""
}