428 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 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 ""
}