269 lines
8.1 KiB
Go
269 lines
8.1 KiB
Go
package mifapay
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"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.MifaPayConfig
|
|
httpClient *http.Client
|
|
privateKey *rsa.PrivateKey
|
|
publicKey *rsa.PublicKey
|
|
}
|
|
|
|
type envelope struct {
|
|
MerAccount string `json:"merAccount"`
|
|
Data string `json:"data"`
|
|
Sign string `json:"sign"`
|
|
}
|
|
|
|
func New(cfg config.MifaPayConfig) (*Client, error) {
|
|
privateKey, err := parsePrivateKey(cfg.PrivateKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
publicKey, err := parsePublicKey(cfg.PlatformPublicKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.HTTPTimeout <= 0 {
|
|
cfg.HTTPTimeout = 10 * time.Second
|
|
}
|
|
return &Client{
|
|
cfg: cfg,
|
|
httpClient: &http.Client{Timeout: cfg.HTTPTimeout},
|
|
privateKey: privateKey,
|
|
publicKey: publicKey,
|
|
}, nil
|
|
}
|
|
|
|
// CreateOrder 按 MiFaPay 统一 envelope 协议下单;业务层只消费 payUrl 和 mbOrderId。
|
|
func (c *Client) CreateOrder(ctx context.Context, req walletservice.MifaPayCreateOrderRequest) (walletservice.MifaPayCreateOrderResponse, error) {
|
|
if c == nil || c.privateKey == nil {
|
|
return walletservice.MifaPayCreateOrderResponse{}, xerr.New(xerr.Unavailable, "mifapay client is not configured")
|
|
}
|
|
data := map[string]any{
|
|
"amount": strconv.FormatInt(req.ProviderAmountMinor, 10),
|
|
"orderId": req.OrderID,
|
|
"payWay": req.PayWay,
|
|
"language": firstNonEmpty(req.Language, "en"),
|
|
"terminalType": "WEB",
|
|
"productInfo": productInfo(req),
|
|
"payType": req.PayType,
|
|
"merNo": c.cfg.MerNo,
|
|
"countryCode": req.CountryCode,
|
|
"notifyUrl": req.NotifyURL,
|
|
"currency": req.CurrencyCode,
|
|
"time": time.Now().Unix(),
|
|
"returnUrl": req.ReturnURL,
|
|
"memberId": strconv.FormatInt(req.TargetUserID, 10),
|
|
"userIp": req.ClientIP,
|
|
}
|
|
body, err := c.signedEnvelope(data)
|
|
if err != nil {
|
|
return walletservice.MifaPayCreateOrderResponse{}, err
|
|
}
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.APIBaseURL+"/paygateway/mbpay/order/en_v2", bytes.NewReader(body))
|
|
if err != nil {
|
|
return walletservice.MifaPayCreateOrderResponse{}, err
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json; charset=UTF-8")
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return walletservice.MifaPayCreateOrderResponse{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
|
if err != nil {
|
|
return walletservice.MifaPayCreateOrderResponse{}, err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return walletservice.MifaPayCreateOrderResponse{}, xerr.New(xerr.Unavailable, "mifapay order request failed")
|
|
}
|
|
var outer envelope
|
|
var withCode struct {
|
|
envelope
|
|
Code string `json:"code"`
|
|
Msg string `json:"msg"`
|
|
}
|
|
if err := json.Unmarshal(raw, &withCode); err != nil {
|
|
return walletservice.MifaPayCreateOrderResponse{}, err
|
|
}
|
|
outer = withCode.envelope
|
|
if withCode.Code != "" && withCode.Code != "000000" {
|
|
return walletservice.MifaPayCreateOrderResponse{}, xerr.New(xerr.Conflict, "mifapay order rejected: "+withCode.Msg)
|
|
}
|
|
if err := c.verify(outer.Data, outer.Sign); err != nil {
|
|
return walletservice.MifaPayCreateOrderResponse{}, err
|
|
}
|
|
var inner struct {
|
|
OrderID string `json:"orderId"`
|
|
PayURL string `json:"payUrl"`
|
|
MBOrderID string `json:"mbOrderId"`
|
|
}
|
|
if err := json.Unmarshal([]byte(outer.Data), &inner); err != nil {
|
|
return walletservice.MifaPayCreateOrderResponse{}, err
|
|
}
|
|
if strings.TrimSpace(inner.PayURL) == "" || strings.TrimSpace(inner.MBOrderID) == "" {
|
|
return walletservice.MifaPayCreateOrderResponse{}, xerr.New(xerr.Unavailable, "mifapay order response is incomplete")
|
|
}
|
|
return walletservice.MifaPayCreateOrderResponse{
|
|
OrderID: inner.OrderID,
|
|
ProviderOrderID: inner.MBOrderID,
|
|
PayURL: inner.PayURL,
|
|
RawJSON: string(raw),
|
|
}, nil
|
|
}
|
|
|
|
// ParseNotification 验签 MiFaPay 回调并返回内层业务字段;验签失败不允许业务层入账。
|
|
func (c *Client) ParseNotification(notification ledger.MifaPayNotification) (walletservice.MifaPayNotifyData, error) {
|
|
if c == nil || c.publicKey == nil {
|
|
return walletservice.MifaPayNotifyData{}, xerr.New(xerr.Unavailable, "mifapay client is not configured")
|
|
}
|
|
if strings.TrimSpace(notification.MerAccount) != c.cfg.MerAccount {
|
|
return walletservice.MifaPayNotifyData{}, xerr.New(xerr.PermissionDenied, "mifapay mer_account is invalid")
|
|
}
|
|
if err := c.verify(notification.Data, notification.Sign); err != nil {
|
|
return walletservice.MifaPayNotifyData{}, err
|
|
}
|
|
var inner struct {
|
|
Amount string `json:"amount"`
|
|
PayType string `json:"payType"`
|
|
OrderID string `json:"orderId"`
|
|
BankOrderNo string `json:"bankOrderNo"`
|
|
OrderStatus string `json:"orderStatus"`
|
|
Currency string `json:"currency"`
|
|
MBOrderID string `json:"mbOrderId"`
|
|
MemberID string `json:"memberId"`
|
|
}
|
|
if err := json.Unmarshal([]byte(notification.Data), &inner); err != nil {
|
|
return walletservice.MifaPayNotifyData{}, err
|
|
}
|
|
if strings.TrimSpace(inner.OrderID) == "" {
|
|
return walletservice.MifaPayNotifyData{}, xerr.New(xerr.InvalidArgument, "mifapay callback order_id is required")
|
|
}
|
|
return walletservice.MifaPayNotifyData{
|
|
OrderID: inner.OrderID,
|
|
ProviderOrderID: inner.MBOrderID,
|
|
OrderStatus: inner.OrderStatus,
|
|
Amount: inner.Amount,
|
|
Currency: inner.Currency,
|
|
PayType: inner.PayType,
|
|
RawJSON: notification.Data,
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) signedEnvelope(data any) ([]byte, error) {
|
|
dataJSON, err := json.Marshal(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sign, err := c.sign(string(dataJSON))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(envelope{MerAccount: c.cfg.MerAccount, Data: string(dataJSON), Sign: sign})
|
|
}
|
|
|
|
func (c *Client) sign(data string) (string, error) {
|
|
hash := sha256.Sum256([]byte(data))
|
|
signature, err := rsa.SignPKCS1v15(rand.Reader, c.privateKey, crypto.SHA256, hash[:])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString(signature), nil
|
|
}
|
|
|
|
func (c *Client) verify(data string, sign string) error {
|
|
if c.publicKey == nil {
|
|
return nil
|
|
}
|
|
signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sign))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hash := sha256.Sum256([]byte(data))
|
|
if err := rsa.VerifyPKCS1v15(c.publicKey, crypto.SHA256, hash[:], signature); err != nil {
|
|
return xerr.New(xerr.PermissionDenied, "mifapay signature is invalid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parsePrivateKey(value string) (*rsa.PrivateKey, error) {
|
|
block := pemBlock(value, "PRIVATE KEY")
|
|
key, err := x509.ParsePKCS8PrivateKey(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
privateKey, ok := key.(*rsa.PrivateKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("mifapay private key is not rsa")
|
|
}
|
|
return privateKey, nil
|
|
}
|
|
|
|
func parsePublicKey(value string) (*rsa.PublicKey, error) {
|
|
block := pemBlock(value, "PUBLIC KEY")
|
|
key, err := x509.ParsePKIXPublicKey(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
publicKey, ok := key.(*rsa.PublicKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("mifapay public key is not rsa")
|
|
}
|
|
return publicKey, nil
|
|
}
|
|
|
|
func pemBlock(value string, blockType string) []byte {
|
|
value = strings.TrimSpace(value)
|
|
if decoded, _ := pem.Decode([]byte(value)); decoded != nil {
|
|
return decoded.Bytes
|
|
}
|
|
clean := strings.NewReplacer("\n", "", "\r", "", " ", "").Replace(value)
|
|
body, err := base64.StdEncoding.DecodeString(clean)
|
|
if err == nil {
|
|
return body
|
|
}
|
|
return []byte(value)
|
|
}
|
|
|
|
func productInfo(req walletservice.MifaPayCreateOrderRequest) string {
|
|
body, _ := json.Marshal([]map[string]string{{
|
|
"quantity": "1",
|
|
"price": strconv.FormatInt(req.AmountMinor, 10),
|
|
"sku": req.OrderID,
|
|
"productName": req.ProductName,
|
|
}})
|
|
return string(body)
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if value = strings.TrimSpace(value); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|