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