428 lines
14 KiB
Go
428 lines
14 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")
|
||
}
|
||
payer := rechargePayer(req)
|
||
data := map[string]any{
|
||
"amount": mifaPayProviderAmountValue(req.ProviderAmountMinor, req.CurrencyCode),
|
||
"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,
|
||
"email": payer.email,
|
||
"firstName": payer.firstName,
|
||
"lastName": payer.lastName,
|
||
"phone": payer.phone,
|
||
}
|
||
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" {
|
||
// MiFaPay 已接收请求但按网关规则拒单时,本服务还没有进入任何账务状态流转;这类失败是三方依赖语义,
|
||
// 不能映射成业务 CONFLICT,否则 H5 只能看到“conflict”,排障也会误判成幂等或商品状态冲突。
|
||
return walletservice.MifaPayCreateOrderResponse{}, mifapayGatewayRejectionError{
|
||
cause: xerr.New(xerr.Unavailable, "mifapay order rejected: "+withCode.Code+": "+withCode.Msg),
|
||
rawJSON: string(raw),
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
// QueryOrder 主动查询 MiFaPay 订单状态;H5 返回页依赖它补偿异步回调延迟或回调丢失的窗口期。
|
||
func (c *Client) QueryOrder(ctx context.Context, req walletservice.MifaPayQueryOrderRequest) (walletservice.MifaPayQueryOrderResponse, error) {
|
||
if c == nil || c.privateKey == nil {
|
||
return walletservice.MifaPayQueryOrderResponse{}, xerr.New(xerr.Unavailable, "mifapay client is not configured")
|
||
}
|
||
data := map[string]any{"time": time.Now().Unix()}
|
||
if providerOrderID := strings.TrimSpace(req.ProviderOrderID); providerOrderID != "" {
|
||
data["mbOrderId"] = providerOrderID
|
||
} else if orderID := strings.TrimSpace(req.OrderID); orderID != "" {
|
||
data["orderId"] = orderID
|
||
data["orderDate"] = mifaPayOrderDate(req.OrderCreatedAtMS)
|
||
} else {
|
||
return walletservice.MifaPayQueryOrderResponse{}, xerr.New(xerr.InvalidArgument, "mifapay query order id is required")
|
||
}
|
||
body, err := c.signedEnvelope(data)
|
||
if err != nil {
|
||
return walletservice.MifaPayQueryOrderResponse{}, err
|
||
}
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.APIBaseURL+"/paygateway/mbpay/order/query/en_v2", bytes.NewReader(body))
|
||
if err != nil {
|
||
return walletservice.MifaPayQueryOrderResponse{}, err
|
||
}
|
||
httpReq.Header.Set("Content-Type", "application/json; charset=UTF-8")
|
||
resp, err := c.httpClient.Do(httpReq)
|
||
if err != nil {
|
||
return walletservice.MifaPayQueryOrderResponse{}, err
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||
if err != nil {
|
||
return walletservice.MifaPayQueryOrderResponse{}, err
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return walletservice.MifaPayQueryOrderResponse{}, xerr.New(xerr.Unavailable, "mifapay order query 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.MifaPayQueryOrderResponse{}, err
|
||
}
|
||
outer = withCode.envelope
|
||
if withCode.Code != "" && withCode.Code != "000000" {
|
||
return walletservice.MifaPayQueryOrderResponse{}, mifapayGatewayRejectionError{
|
||
cause: xerr.New(xerr.Unavailable, "mifapay order query rejected: "+withCode.Code+": "+withCode.Msg),
|
||
rawJSON: string(raw),
|
||
}
|
||
}
|
||
if err := c.verify(outer.Data, outer.Sign); err != nil {
|
||
return walletservice.MifaPayQueryOrderResponse{}, err
|
||
}
|
||
var inner struct {
|
||
Amount string `json:"amount"`
|
||
OrderID string `json:"orderId"`
|
||
PayType string `json:"payType"`
|
||
Status string `json:"status"`
|
||
Currency string `json:"currency"`
|
||
MBOrderID string `json:"mbOrderId"`
|
||
}
|
||
if err := json.Unmarshal([]byte(outer.Data), &inner); err != nil {
|
||
return walletservice.MifaPayQueryOrderResponse{}, err
|
||
}
|
||
if strings.TrimSpace(inner.OrderID) == "" {
|
||
return walletservice.MifaPayQueryOrderResponse{}, xerr.New(xerr.Unavailable, "mifapay order query response is incomplete")
|
||
}
|
||
return walletservice.MifaPayQueryOrderResponse{
|
||
OrderID: inner.OrderID,
|
||
ProviderOrderID: inner.MBOrderID,
|
||
Status: inner.Status,
|
||
Amount: inner.Amount,
|
||
Currency: inner.Currency,
|
||
PayType: inner.PayType,
|
||
RawJSON: outer.Data,
|
||
}, 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
|
||
}
|
||
|
||
type mifapayGatewayRejectionError struct {
|
||
cause error
|
||
rawJSON string
|
||
}
|
||
|
||
func (e mifapayGatewayRejectionError) Error() string {
|
||
return e.cause.Error()
|
||
}
|
||
|
||
func (e mifapayGatewayRejectionError) Unwrap() error {
|
||
return e.cause
|
||
}
|
||
|
||
func (e mifapayGatewayRejectionError) ProviderPayloadJSON() string {
|
||
return e.rawJSON
|
||
}
|
||
|
||
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 mifaPayProviderAmountValue(providerAmountMinor int64, currencyCode string) string {
|
||
if strings.EqualFold(strings.TrimSpace(currencyCode), "INR") {
|
||
// MiFaPay India treats amount as whole INR units while wallet snapshots keep local minor units;
|
||
// converting at the client boundary keeps V5Pay and non-INR MiFaPay contracts unchanged.
|
||
return strconv.FormatInt(providerAmountMinor/100, 10)
|
||
}
|
||
return strconv.FormatInt(providerAmountMinor, 10)
|
||
}
|
||
|
||
type payerIdentity struct {
|
||
email string
|
||
firstName string
|
||
lastName string
|
||
phone string
|
||
}
|
||
|
||
func rechargePayer(req walletservice.MifaPayCreateOrderRequest) payerIdentity {
|
||
account := strings.TrimSpace(req.PayerAccount)
|
||
if account == "" {
|
||
account = strconv.FormatInt(normalizedPayerUserID(req.TargetUserID), 10)
|
||
}
|
||
name := strings.TrimSpace(req.PayerName)
|
||
if name == "" {
|
||
name = account
|
||
}
|
||
email := strings.TrimSpace(req.PayerEmail)
|
||
if email == "" {
|
||
email = fmt.Sprintf("mifapay-payer-%d@haiyihy.com", normalizedPayerUserID(req.TargetUserID))
|
||
}
|
||
// H5 付款人参数必须来自 gateway 已解析出的目标用户资料:昵称同时写 firstName/lastName,账号写 phone,
|
||
// 邮箱按“账号@app_code.com”生成。兜底只服务旧内部调用方,防止 MiFaPay 因空字段拒单,不代表用户资料事实。
|
||
return payerIdentity{
|
||
email: email,
|
||
firstName: name,
|
||
lastName: name,
|
||
phone: account,
|
||
}
|
||
}
|
||
|
||
func normalizedPayerUserID(targetUserID int64) int64 {
|
||
if targetUserID <= 0 {
|
||
return 1
|
||
}
|
||
return targetUserID
|
||
}
|
||
|
||
func mifaPayOrderDate(createdAtMS int64) string {
|
||
if createdAtMS <= 0 {
|
||
return time.Now().UTC().Format("20060102")
|
||
}
|
||
return time.UnixMilli(createdAtMS).UTC().Format("20060102")
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
if value = strings.TrimSpace(value); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|