fix: restore wechat payment oauth and jsapi flow
This commit is contained in:
@@ -9,12 +9,14 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
@@ -35,6 +37,13 @@ const (
|
||||
wechatOAuthDefaultFrontendCB = "/auth/wechat/callback"
|
||||
wechatOAuthProviderKey = "wechat-main"
|
||||
wechatOAuthLegacyProviderKey = "wechat"
|
||||
wechatPaymentOAuthCookiePath = "/api/v1/auth/oauth/wechat/payment"
|
||||
wechatPaymentOAuthStateName = "wechat_payment_oauth_state"
|
||||
wechatPaymentOAuthRedirect = "wechat_payment_oauth_redirect"
|
||||
wechatPaymentOAuthContextName = "wechat_payment_oauth_context"
|
||||
wechatPaymentOAuthScope = "wechat_payment_oauth_scope"
|
||||
wechatPaymentOAuthDefaultTo = "/purchase"
|
||||
wechatPaymentOAuthFrontendCB = "/auth/wechat/payment/callback"
|
||||
|
||||
wechatOAuthIntentLogin = "login"
|
||||
wechatOAuthIntentBind = "bind_current_user"
|
||||
@@ -76,6 +85,13 @@ type wechatOAuthUserInfoResponse struct {
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
type wechatPaymentOAuthContext struct {
|
||||
PaymentType string `json:"payment_type"`
|
||||
Amount string `json:"amount,omitempty"`
|
||||
OrderType string `json:"order_type,omitempty"`
|
||||
PlanID int64 `json:"plan_id,omitempty"`
|
||||
}
|
||||
|
||||
// WeChatOAuthStart starts the WeChat OAuth login flow and stores the short-lived
|
||||
// browser cookies required by the rebuild pending-auth bridge.
|
||||
func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
|
||||
@@ -294,6 +310,149 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
|
||||
redirectToFrontendCallback(c, frontendCallback)
|
||||
}
|
||||
|
||||
// WeChatPaymentOAuthStart starts the WeChat payment OAuth flow.
|
||||
// GET /api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay&redirect=/purchase
|
||||
func (h *AuthHandler) WeChatPaymentOAuthStart(c *gin.Context) {
|
||||
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), "mp", c)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
paymentType := normalizeWeChatPaymentType(c.Query("payment_type"))
|
||||
if paymentType == "" {
|
||||
response.BadRequest(c, "Invalid payment type")
|
||||
return
|
||||
}
|
||||
|
||||
state, err := oauth.GenerateState()
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
|
||||
return
|
||||
}
|
||||
|
||||
redirectTo := normalizeWeChatPaymentRedirectPath(sanitizeFrontendRedirectPath(c.Query("redirect")))
|
||||
if redirectTo == "" {
|
||||
redirectTo = wechatPaymentOAuthDefaultTo
|
||||
}
|
||||
rawContext, err := encodeWeChatPaymentOAuthContext(wechatPaymentOAuthContext{
|
||||
PaymentType: paymentType,
|
||||
Amount: strings.TrimSpace(c.Query("amount")),
|
||||
OrderType: strings.TrimSpace(c.Query("order_type")),
|
||||
PlanID: parseWeChatPaymentPlanID(c.Query("plan_id")),
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONTEXT_ENCODE_FAILED", "failed to encode oauth context").WithCause(err))
|
||||
return
|
||||
}
|
||||
|
||||
scope := normalizeWeChatPaymentScope(c.Query("scope"))
|
||||
secureCookie := isRequestHTTPS(c)
|
||||
wechatPaymentSetCookie(c, wechatPaymentOAuthStateName, encodeCookieValue(state), wechatOAuthCookieMaxAgeSec, secureCookie)
|
||||
wechatPaymentSetCookie(c, wechatPaymentOAuthRedirect, encodeCookieValue(redirectTo), wechatOAuthCookieMaxAgeSec, secureCookie)
|
||||
wechatPaymentSetCookie(c, wechatPaymentOAuthContextName, encodeCookieValue(rawContext), wechatOAuthCookieMaxAgeSec, secureCookie)
|
||||
wechatPaymentSetCookie(c, wechatPaymentOAuthScope, encodeCookieValue(scope), wechatOAuthCookieMaxAgeSec, secureCookie)
|
||||
|
||||
cfg.redirectURI = h.resolveWeChatPaymentOAuthCallbackURL(c.Request.Context(), c)
|
||||
cfg.scope = scope
|
||||
authURL, err := buildWeChatAuthorizeURL(cfg, state)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
// WeChatPaymentOAuthCallback exchanges a payment OAuth code for an OpenID and
|
||||
// forwards the browser back to the frontend callback route.
|
||||
func (h *AuthHandler) WeChatPaymentOAuthCallback(c *gin.Context) {
|
||||
frontendCallback := wechatPaymentOAuthFrontendCB
|
||||
|
||||
if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
|
||||
redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
|
||||
return
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(c.Query("code"))
|
||||
state := strings.TrimSpace(c.Query("state"))
|
||||
if code == "" || state == "" {
|
||||
redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
|
||||
return
|
||||
}
|
||||
|
||||
secureCookie := isRequestHTTPS(c)
|
||||
defer func() {
|
||||
wechatPaymentClearCookie(c, wechatPaymentOAuthStateName, secureCookie)
|
||||
wechatPaymentClearCookie(c, wechatPaymentOAuthRedirect, secureCookie)
|
||||
wechatPaymentClearCookie(c, wechatPaymentOAuthContextName, secureCookie)
|
||||
wechatPaymentClearCookie(c, wechatPaymentOAuthScope, secureCookie)
|
||||
}()
|
||||
|
||||
expectedState, err := readCookieDecoded(c, wechatPaymentOAuthStateName)
|
||||
if err != nil || expectedState == "" || state != expectedState {
|
||||
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
|
||||
return
|
||||
}
|
||||
|
||||
redirectTo, _ := readCookieDecoded(c, wechatPaymentOAuthRedirect)
|
||||
redirectTo = normalizeWeChatPaymentRedirectPath(sanitizeFrontendRedirectPath(redirectTo))
|
||||
if redirectTo == "" {
|
||||
redirectTo = wechatPaymentOAuthDefaultTo
|
||||
}
|
||||
|
||||
rawContext, _ := readCookieDecoded(c, wechatPaymentOAuthContextName)
|
||||
paymentContext, err := decodeWeChatPaymentOAuthContext(rawContext)
|
||||
if err != nil {
|
||||
redirectOAuthError(c, frontendCallback, "invalid_context", "invalid oauth context", "")
|
||||
return
|
||||
}
|
||||
if paymentContext.PaymentType == "" {
|
||||
paymentContext.PaymentType = payment.TypeWxpay
|
||||
}
|
||||
|
||||
scope, _ := readCookieDecoded(c, wechatPaymentOAuthScope)
|
||||
scope = normalizeWeChatPaymentScope(scope)
|
||||
|
||||
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), "mp", c)
|
||||
if err != nil {
|
||||
redirectOAuthError(c, frontendCallback, "provider_error", infraerrors.Reason(err), infraerrors.Message(err))
|
||||
return
|
||||
}
|
||||
cfg.redirectURI = h.resolveWeChatPaymentOAuthCallbackURL(c.Request.Context(), c)
|
||||
tokenResp, err := exchangeWeChatOAuthCode(c.Request.Context(), cfg, code)
|
||||
if err != nil {
|
||||
redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
openid := strings.TrimSpace(tokenResp.OpenID)
|
||||
if openid == "" {
|
||||
redirectOAuthError(c, frontendCallback, "missing_openid", "missing openid", "")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(tokenResp.Scope) != "" {
|
||||
scope = strings.TrimSpace(tokenResp.Scope)
|
||||
}
|
||||
|
||||
fragment := url.Values{}
|
||||
fragment.Set("openid", openid)
|
||||
fragment.Set("state", state)
|
||||
fragment.Set("scope", scope)
|
||||
fragment.Set("payment_type", paymentContext.PaymentType)
|
||||
if paymentContext.Amount != "" {
|
||||
fragment.Set("amount", paymentContext.Amount)
|
||||
}
|
||||
if paymentContext.OrderType != "" {
|
||||
fragment.Set("order_type", paymentContext.OrderType)
|
||||
}
|
||||
if paymentContext.PlanID > 0 {
|
||||
fragment.Set("plan_id", strconv.FormatInt(paymentContext.PlanID, 10))
|
||||
}
|
||||
fragment.Set("redirect", redirectTo)
|
||||
redirectWithFragment(c, frontendCallback, fragment)
|
||||
}
|
||||
|
||||
type completeWeChatOAuthRequest struct {
|
||||
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||
@@ -950,3 +1109,99 @@ func wechatClearCookie(c *gin.Context, name string, secure bool) {
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeWeChatPaymentType(raw string) string {
|
||||
switch strings.TrimSpace(raw) {
|
||||
case payment.TypeWxpay, payment.TypeWxpayDirect:
|
||||
return strings.TrimSpace(raw)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeWeChatPaymentScope(raw string) string {
|
||||
for _, part := range strings.FieldsFunc(strings.TrimSpace(raw), func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
}) {
|
||||
switch strings.TrimSpace(part) {
|
||||
case "snsapi_userinfo":
|
||||
return "snsapi_userinfo"
|
||||
case "snsapi_base":
|
||||
return "snsapi_base"
|
||||
}
|
||||
}
|
||||
return "snsapi_base"
|
||||
}
|
||||
|
||||
func normalizeWeChatPaymentRedirectPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return wechatPaymentOAuthDefaultTo
|
||||
}
|
||||
if path == "/payment" {
|
||||
return "/purchase"
|
||||
}
|
||||
if strings.HasPrefix(path, "/payment?") {
|
||||
return "/purchase" + strings.TrimPrefix(path, "/payment")
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (h *AuthHandler) resolveWeChatPaymentOAuthCallbackURL(ctx context.Context, c *gin.Context) string {
|
||||
apiBaseURL := ""
|
||||
if h != nil && h.settingSvc != nil {
|
||||
if settings, err := h.settingSvc.GetAllSettings(ctx); err == nil && settings != nil {
|
||||
apiBaseURL = strings.TrimSpace(settings.APIBaseURL)
|
||||
}
|
||||
}
|
||||
return resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/payment/callback")
|
||||
}
|
||||
|
||||
func encodeWeChatPaymentOAuthContext(ctx wechatPaymentOAuthContext) (string, error) {
|
||||
data, err := json.Marshal(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func decodeWeChatPaymentOAuthContext(raw string) (wechatPaymentOAuthContext, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return wechatPaymentOAuthContext{}, nil
|
||||
}
|
||||
var ctx wechatPaymentOAuthContext
|
||||
if err := json.Unmarshal([]byte(raw), &ctx); err != nil {
|
||||
return wechatPaymentOAuthContext{}, err
|
||||
}
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func parseWeChatPaymentPlanID(raw string) int64 {
|
||||
id, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
return id
|
||||
}
|
||||
|
||||
func wechatPaymentSetCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: wechatPaymentOAuthCookiePath,
|
||||
MaxAge: maxAgeSec,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func wechatPaymentClearCookie(c *gin.Context, name string, secure bool) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: wechatPaymentOAuthCookiePath,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,10 +204,12 @@ func (h *PaymentHandler) GetLimits(c *gin.Context) {
|
||||
type CreateOrderRequest struct {
|
||||
Amount float64 `json:"amount"`
|
||||
PaymentType string `json:"payment_type" binding:"required"`
|
||||
OpenID string `json:"openid"`
|
||||
ReturnURL string `json:"return_url"`
|
||||
PaymentSource string `json:"payment_source"`
|
||||
OrderType string `json:"order_type"`
|
||||
PlanID int64 `json:"plan_id"`
|
||||
IsMobile *bool `json:"is_mobile,omitempty"`
|
||||
}
|
||||
|
||||
// CreateOrder creates a new payment order.
|
||||
@@ -224,17 +226,25 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
mobile := isMobile(c)
|
||||
if req.IsMobile != nil {
|
||||
mobile = *req.IsMobile
|
||||
}
|
||||
|
||||
result, err := h.paymentService.CreateOrder(c.Request.Context(), service.CreateOrderRequest{
|
||||
UserID: subject.UserID,
|
||||
Amount: req.Amount,
|
||||
PaymentType: req.PaymentType,
|
||||
ClientIP: c.ClientIP(),
|
||||
IsMobile: isMobile(c),
|
||||
SrcHost: c.Request.Host,
|
||||
ReturnURL: req.ReturnURL,
|
||||
PaymentSource: req.PaymentSource,
|
||||
OrderType: req.OrderType,
|
||||
PlanID: req.PlanID,
|
||||
UserID: subject.UserID,
|
||||
Amount: req.Amount,
|
||||
PaymentType: req.PaymentType,
|
||||
OpenID: req.OpenID,
|
||||
ClientIP: c.ClientIP(),
|
||||
IsMobile: mobile,
|
||||
IsWeChatBrowser: isWeChatBrowser(c),
|
||||
SrcHost: c.Request.Host,
|
||||
SrcURL: c.Request.Referer(),
|
||||
ReturnURL: req.ReturnURL,
|
||||
PaymentSource: req.PaymentSource,
|
||||
OrderType: req.OrderType,
|
||||
PlanID: req.PlanID,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -467,3 +477,7 @@ func isMobile(c *gin.Context) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isWeChatBrowser(c *gin.Context) bool {
|
||||
return strings.Contains(strings.ToLower(c.GetHeader("User-Agent")), "micromessenger")
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/h5"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/utils"
|
||||
@@ -26,8 +27,16 @@ import (
|
||||
|
||||
// WeChat Pay constants.
|
||||
const (
|
||||
wxpayCurrency = "CNY"
|
||||
wxpayH5Type = "Wap"
|
||||
wxpayCurrency = "CNY"
|
||||
wxpayH5Type = "Wap"
|
||||
wxpayResultPath = "/payment/result"
|
||||
)
|
||||
|
||||
// WeChat Pay create-payment modes.
|
||||
const (
|
||||
wxpayModeNative = "native"
|
||||
wxpayModeH5 = "h5"
|
||||
wxpayModeJSAPI = "jsapi"
|
||||
)
|
||||
|
||||
// WeChat Pay trade states.
|
||||
@@ -48,6 +57,18 @@ const (
|
||||
wxpayErrNoAuth = "NO_AUTH"
|
||||
)
|
||||
|
||||
var (
|
||||
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
|
||||
return svc.Prepay(ctx, req)
|
||||
}
|
||||
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
|
||||
return svc.Prepay(ctx, req)
|
||||
}
|
||||
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
|
||||
return svc.PrepayWithRequestPayment(ctx, req)
|
||||
}
|
||||
)
|
||||
|
||||
type Wxpay struct {
|
||||
instanceID string
|
||||
config map[string]string
|
||||
@@ -75,6 +96,16 @@ func (w *Wxpay) SupportedTypes() []payment.PaymentType {
|
||||
return []payment.PaymentType{payment.TypeWxpay}
|
||||
}
|
||||
|
||||
// ResolveWxpayJSAPIAppID returns the AppID that JSAPI prepay will use for a
|
||||
// given provider config. A dedicated MP AppID takes precedence over the base
|
||||
// merchant AppID.
|
||||
func ResolveWxpayJSAPIAppID(config map[string]string) string {
|
||||
if appID := strings.TrimSpace(config["mpAppId"]); appID != "" {
|
||||
return appID
|
||||
}
|
||||
return strings.TrimSpace(config["appId"])
|
||||
}
|
||||
|
||||
func formatPEM(key, keyType string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if strings.HasPrefix(key, "-----BEGIN") {
|
||||
@@ -139,30 +170,68 @@ func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequ
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay create payment: %w", err)
|
||||
}
|
||||
if req.IsMobile && req.ClientIP != "" {
|
||||
resp, err := w.createOrder(ctx, client, req, notifyURL, totalFen, true)
|
||||
|
||||
mode, err := resolveWxpayCreateMode(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch mode {
|
||||
case wxpayModeJSAPI:
|
||||
return w.prepayJSAPI(ctx, client, req, notifyURL, totalFen)
|
||||
case wxpayModeH5:
|
||||
resp, err := w.prepayH5(ctx, client, req, notifyURL, totalFen)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
if !strings.Contains(err.Error(), wxpayErrNoAuth) {
|
||||
return nil, err
|
||||
if strings.Contains(err.Error(), wxpayErrNoAuth) {
|
||||
return nil, fmt.Errorf("wxpay h5 payments are not authorized for this merchant: %w", err)
|
||||
}
|
||||
slog.Warn("wxpay H5 payment not authorized, falling back to native", "order", req.OrderID)
|
||||
return nil, err
|
||||
case wxpayModeNative:
|
||||
return w.prepayNative(ctx, client, req, notifyURL, totalFen)
|
||||
default:
|
||||
return nil, fmt.Errorf("wxpay create payment: unsupported mode %q", mode)
|
||||
}
|
||||
return w.createOrder(ctx, client, req, notifyURL, totalFen, false)
|
||||
}
|
||||
|
||||
func (w *Wxpay) createOrder(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64, useH5 bool) (*payment.CreatePaymentResponse, error) {
|
||||
if useH5 {
|
||||
return w.prepayH5(ctx, c, req, notifyURL, totalFen)
|
||||
func (w *Wxpay) prepayJSAPI(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
|
||||
svc := jsapi.JsapiApiService{Client: c}
|
||||
cur := wxpayCurrency
|
||||
appID := ResolveWxpayJSAPIAppID(w.config)
|
||||
prepayReq := jsapi.PrepayRequest{
|
||||
Appid: core.String(appID),
|
||||
Mchid: core.String(w.config["mchId"]),
|
||||
Description: core.String(req.Subject),
|
||||
OutTradeNo: core.String(req.OrderID),
|
||||
NotifyUrl: core.String(notifyURL),
|
||||
Amount: &jsapi.Amount{Total: core.Int64(totalFen), Currency: &cur},
|
||||
Payer: &jsapi.Payer{Openid: core.String(strings.TrimSpace(req.OpenID))},
|
||||
}
|
||||
return w.prepayNative(ctx, c, req, notifyURL, totalFen)
|
||||
if clientIP := strings.TrimSpace(req.ClientIP); clientIP != "" {
|
||||
prepayReq.SceneInfo = &jsapi.SceneInfo{PayerClientIp: core.String(clientIP)}
|
||||
}
|
||||
resp, _, err := wxpayJSAPIPrepayWithRequestPayment(ctx, svc, prepayReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay jsapi prepay: %w", err)
|
||||
}
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: req.OrderID,
|
||||
ResultType: payment.CreatePaymentResultJSAPIReady,
|
||||
JSAPI: &payment.WechatJSAPIPayload{
|
||||
AppID: wxSV(resp.Appid),
|
||||
TimeStamp: wxSV(resp.TimeStamp),
|
||||
NonceStr: wxSV(resp.NonceStr),
|
||||
Package: wxSV(resp.Package),
|
||||
SignType: wxSV(resp.SignType),
|
||||
PaySign: wxSV(resp.PaySign),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) prepayNative(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
|
||||
svc := native.NativeApiService{Client: c}
|
||||
cur := wxpayCurrency
|
||||
resp, _, err := svc.Prepay(ctx, native.PrepayRequest{
|
||||
resp, _, err := wxpayNativePrepay(ctx, svc, native.PrepayRequest{
|
||||
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
|
||||
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
|
||||
NotifyUrl: core.String(notifyURL),
|
||||
@@ -182,7 +251,7 @@ func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.Create
|
||||
svc := h5.H5ApiService{Client: c}
|
||||
cur := wxpayCurrency
|
||||
tp := wxpayH5Type
|
||||
resp, _, err := svc.Prepay(ctx, h5.PrepayRequest{
|
||||
resp, _, err := wxpayH5Prepay(ctx, svc, h5.PrepayRequest{
|
||||
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
|
||||
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
|
||||
NotifyUrl: core.String(notifyURL),
|
||||
@@ -196,9 +265,63 @@ func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.Create
|
||||
if resp.H5Url != nil {
|
||||
h5URL = *resp.H5Url
|
||||
}
|
||||
h5URL, err = appendWxpayRedirectURL(h5URL, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, PayURL: h5URL}, nil
|
||||
}
|
||||
|
||||
func resolveWxpayCreateMode(req payment.CreatePaymentRequest) (string, error) {
|
||||
if strings.TrimSpace(req.OpenID) != "" {
|
||||
return wxpayModeJSAPI, nil
|
||||
}
|
||||
if req.IsMobile {
|
||||
if strings.TrimSpace(req.ClientIP) == "" {
|
||||
return "", fmt.Errorf("wxpay H5 payment requires client IP")
|
||||
}
|
||||
return wxpayModeH5, nil
|
||||
}
|
||||
return wxpayModeNative, nil
|
||||
}
|
||||
|
||||
func appendWxpayRedirectURL(h5URL string, req payment.CreatePaymentRequest) (string, error) {
|
||||
h5URL = strings.TrimSpace(h5URL)
|
||||
returnURL := strings.TrimSpace(req.ReturnURL)
|
||||
if h5URL == "" || returnURL == "" {
|
||||
return h5URL, nil
|
||||
}
|
||||
|
||||
redirectURL, err := buildWxpayResultURL(returnURL, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
sep := "&"
|
||||
if !strings.Contains(h5URL, "?") {
|
||||
sep = "?"
|
||||
}
|
||||
return h5URL + sep + "redirect_url=" + url.QueryEscape(redirectURL), nil
|
||||
}
|
||||
|
||||
func buildWxpayResultURL(returnURL string, req payment.CreatePaymentRequest) (string, error) {
|
||||
u, err := url.Parse(returnURL)
|
||||
if err != nil || !u.IsAbs() || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return "", fmt.Errorf("return URL must be an absolute http(s) URL")
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
values.Set("out_trade_no", strings.TrimSpace(req.OrderID))
|
||||
if paymentType := strings.TrimSpace(req.PaymentType); paymentType != "" {
|
||||
values.Set("payment_type", paymentType)
|
||||
}
|
||||
u.Path = wxpayResultPath
|
||||
u.RawPath = ""
|
||||
u.RawQuery = values.Encode()
|
||||
u.Fragment = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func wxSV(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/h5"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
|
||||
)
|
||||
|
||||
func TestMapWxState(t *testing.T) {
|
||||
@@ -257,3 +262,197 @@ func TestNewWxpay(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWxpayJSAPIAppID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prefers dedicated mp app id",
|
||||
config: map[string]string{
|
||||
"mpAppId": "wx-mp-app",
|
||||
"appId": "wx-merchant-app",
|
||||
},
|
||||
want: "wx-mp-app",
|
||||
},
|
||||
{
|
||||
name: "falls back to merchant app id",
|
||||
config: map[string]string{
|
||||
"appId": "wx-merchant-app",
|
||||
},
|
||||
want: "wx-merchant-app",
|
||||
},
|
||||
{
|
||||
name: "missing app ids returns empty",
|
||||
config: map[string]string{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := ResolveWxpayJSAPIAppID(tt.config); got != tt.want {
|
||||
t.Fatalf("ResolveWxpayJSAPIAppID() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWxpayCreateMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req payment.CreatePaymentRequest
|
||||
wantMode string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "desktop uses native",
|
||||
req: payment.CreatePaymentRequest{},
|
||||
wantMode: wxpayModeNative,
|
||||
},
|
||||
{
|
||||
name: "mobile uses h5 when client ip is present",
|
||||
req: payment.CreatePaymentRequest{
|
||||
IsMobile: true,
|
||||
ClientIP: "203.0.113.10",
|
||||
},
|
||||
wantMode: wxpayModeH5,
|
||||
},
|
||||
{
|
||||
name: "mobile without client ip returns clear error",
|
||||
req: payment.CreatePaymentRequest{
|
||||
IsMobile: true,
|
||||
},
|
||||
wantErr: "requires client IP",
|
||||
},
|
||||
{
|
||||
name: "openid uses jsapi mode",
|
||||
req: payment.CreatePaymentRequest{
|
||||
OpenID: "openid-123",
|
||||
},
|
||||
wantMode: wxpayModeJSAPI,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := resolveWxpayCreateMode(tt.req)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error %q should contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.wantMode {
|
||||
t.Fatalf("resolveWxpayCreateMode() = %q, want %q", got, tt.wantMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentWithOpenIDReturnsJSAPIResult(t *testing.T) {
|
||||
origJSAPIPrepay := wxpayJSAPIPrepayWithRequestPayment
|
||||
origNativePrepay := wxpayNativePrepay
|
||||
origH5Prepay := wxpayH5Prepay
|
||||
t.Cleanup(func() {
|
||||
wxpayJSAPIPrepayWithRequestPayment = origJSAPIPrepay
|
||||
wxpayNativePrepay = origNativePrepay
|
||||
wxpayH5Prepay = origH5Prepay
|
||||
})
|
||||
|
||||
jsapiCalls := 0
|
||||
nativeCalls := 0
|
||||
h5Calls := 0
|
||||
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
|
||||
jsapiCalls++
|
||||
if got := wxSV(req.Payer.Openid); got != "openid-123" {
|
||||
t.Fatalf("openid = %q, want %q", got, "openid-123")
|
||||
}
|
||||
if req.SceneInfo == nil || wxSV(req.SceneInfo.PayerClientIp) != "203.0.113.10" {
|
||||
t.Fatalf("scene_info payer_client_ip = %q, want %q", wxSV(req.SceneInfo.PayerClientIp), "203.0.113.10")
|
||||
}
|
||||
return &jsapi.PrepayWithRequestPaymentResponse{
|
||||
Appid: core.String("wx123"),
|
||||
TimeStamp: core.String("1712345678"),
|
||||
NonceStr: core.String("nonce-123"),
|
||||
Package: core.String("prepay_id=wx_prepay_123"),
|
||||
SignType: core.String("RSA"),
|
||||
PaySign: core.String("signed-payload"),
|
||||
}, nil, nil
|
||||
}
|
||||
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
|
||||
nativeCalls++
|
||||
return &native.PrepayResponse{}, nil, nil
|
||||
}
|
||||
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
|
||||
h5Calls++
|
||||
return &h5.PrepayResponse{}, nil, nil
|
||||
}
|
||||
|
||||
provider := &Wxpay{
|
||||
config: map[string]string{
|
||||
"appId": "wx123",
|
||||
"mchId": "mch123",
|
||||
},
|
||||
coreClient: &core.Client{},
|
||||
}
|
||||
|
||||
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||
OrderID: "sub2_88",
|
||||
Amount: "66.88",
|
||||
PaymentType: payment.TypeWxpay,
|
||||
NotifyURL: "https://merchant.example/payment/notify",
|
||||
OpenID: "openid-123",
|
||||
ClientIP: "203.0.113.10",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if jsapiCalls != 1 {
|
||||
t.Fatalf("jsapi prepay calls = %d, want 1", jsapiCalls)
|
||||
}
|
||||
if nativeCalls != 0 {
|
||||
t.Fatalf("native prepay calls = %d, want 0", nativeCalls)
|
||||
}
|
||||
if h5Calls != 0 {
|
||||
t.Fatalf("h5 prepay calls = %d, want 0", h5Calls)
|
||||
}
|
||||
if resp.ResultType != payment.CreatePaymentResultJSAPIReady {
|
||||
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultJSAPIReady)
|
||||
}
|
||||
if resp.JSAPI == nil {
|
||||
t.Fatal("expected jsapi payload, got nil")
|
||||
}
|
||||
if resp.JSAPI.AppID != "wx123" {
|
||||
t.Fatalf("jsapi appId = %q, want %q", resp.JSAPI.AppID, "wx123")
|
||||
}
|
||||
if resp.JSAPI.TimeStamp != "1712345678" {
|
||||
t.Fatalf("jsapi timeStamp = %q, want %q", resp.JSAPI.TimeStamp, "1712345678")
|
||||
}
|
||||
if resp.JSAPI.NonceStr != "nonce-123" {
|
||||
t.Fatalf("jsapi nonceStr = %q, want %q", resp.JSAPI.NonceStr, "nonce-123")
|
||||
}
|
||||
if resp.JSAPI.Package != "prepay_id=wx_prepay_123" {
|
||||
t.Fatalf("jsapi package = %q, want %q", resp.JSAPI.Package, "prepay_id=wx_prepay_123")
|
||||
}
|
||||
if resp.JSAPI.SignType != "RSA" {
|
||||
t.Fatalf("jsapi signType = %q, want %q", resp.JSAPI.SignType, "RSA")
|
||||
}
|
||||
if resp.JSAPI.PaySign != "signed-payload" {
|
||||
t.Fatalf("jsapi paySign = %q, want %q", resp.JSAPI.PaySign, "signed-payload")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,17 +101,50 @@ type CreatePaymentRequest struct {
|
||||
Subject string // Product description
|
||||
NotifyURL string // Webhook callback URL
|
||||
ReturnURL string // Browser redirect URL after payment
|
||||
OpenID string // WeChat JSAPI payer OpenID when available
|
||||
ClientIP string // Payer's IP address
|
||||
IsMobile bool // Whether the request comes from a mobile device
|
||||
InstanceSubMethods string // Comma-separated sub-methods from instance supported_types (for Stripe)
|
||||
}
|
||||
|
||||
// CreatePaymentResultType describes the shape of the create-payment result.
|
||||
type CreatePaymentResultType = string
|
||||
|
||||
const (
|
||||
CreatePaymentResultOrderCreated CreatePaymentResultType = "order_created"
|
||||
CreatePaymentResultOAuthRequired CreatePaymentResultType = "oauth_required"
|
||||
CreatePaymentResultJSAPIReady CreatePaymentResultType = "jsapi_ready"
|
||||
)
|
||||
|
||||
// WechatOAuthInfo describes the next step when WeChat OAuth is required before payment.
|
||||
type WechatOAuthInfo struct {
|
||||
AuthorizeURL string `json:"authorize_url,omitempty"`
|
||||
AppID string `json:"appid,omitempty"`
|
||||
OpenID string `json:"openid,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
}
|
||||
|
||||
// WechatJSAPIPayload contains the fields the frontend needs to invoke WeChat JSAPI payment.
|
||||
type WechatJSAPIPayload struct {
|
||||
AppID string `json:"appId,omitempty"`
|
||||
TimeStamp string `json:"timeStamp,omitempty"`
|
||||
NonceStr string `json:"nonceStr,omitempty"`
|
||||
Package string `json:"package,omitempty"`
|
||||
SignType string `json:"signType,omitempty"`
|
||||
PaySign string `json:"paySign,omitempty"`
|
||||
}
|
||||
|
||||
// CreatePaymentResponse is returned after successfully initiating a payment.
|
||||
type CreatePaymentResponse struct {
|
||||
TradeNo string // Third-party transaction ID
|
||||
PayURL string // H5 payment URL (alipay/wxpay)
|
||||
QRCode string // QR code content for scanning
|
||||
ClientSecret string // Stripe PaymentIntent client secret
|
||||
TradeNo string // Third-party transaction ID
|
||||
PayURL string // H5 payment URL (alipay/wxpay)
|
||||
QRCode string // QR code content for scanning
|
||||
ClientSecret string // Stripe PaymentIntent client secret
|
||||
ResultType CreatePaymentResultType // Typed result contract for frontend flows
|
||||
OAuth *WechatOAuthInfo // WeChat OAuth bootstrap payload when required
|
||||
JSAPI *WechatJSAPIPayload // WeChat JSAPI invocation payload when ready
|
||||
}
|
||||
|
||||
// QueryOrderResponse describes the payment status from the upstream provider.
|
||||
|
||||
@@ -66,6 +66,8 @@ func RegisterAuthRoutes(
|
||||
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
|
||||
auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart)
|
||||
auth.GET("/oauth/wechat/callback", h.Auth.WeChatOAuthCallback)
|
||||
auth.GET("/oauth/wechat/payment/start", h.Auth.WeChatPaymentOAuthStart)
|
||||
auth.GET("/oauth/wechat/payment/callback", h.Auth.WeChatPaymentOAuthCallback)
|
||||
auth.POST("/oauth/pending/exchange",
|
||||
rateLimiter.LimitWithOptions("oauth-pending-exchange", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -57,11 +59,25 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
|
||||
feeRate := cfg.RechargeFeeRate
|
||||
payAmountStr := payment.CalculatePayAmount(limitAmount, feeRate)
|
||||
payAmount, _ := strconv.ParseFloat(payAmountStr, 64)
|
||||
sel, err := s.selectCreateOrderInstance(ctx, req, cfg, payAmount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateSelectedCreateOrderInstance(ctx, req, sel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oauthResp, err := s.maybeBuildWeChatOAuthRequiredResponseForSelection(ctx, req, limitAmount, payAmount, feeRate, sel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if oauthResp != nil {
|
||||
return oauthResp, nil
|
||||
}
|
||||
order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.invokeProvider(ctx, order, req, cfg, limitAmount, payAmountStr, payAmount, plan)
|
||||
resp, err := s.invokeProvider(ctx, order, req, cfg, limitAmount, payAmountStr, payAmount, plan, sel)
|
||||
if err != nil {
|
||||
_, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID).
|
||||
SetStatus(OrderStatusFailed).
|
||||
@@ -199,9 +215,7 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, limitAmount float64, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan) (*CreateOrderResponse, error) {
|
||||
// Select an instance across all providers that support the requested payment type.
|
||||
// This enables cross-provider load balancing (e.g. EasyPay + Alipay direct for "alipay").
|
||||
func (s *PaymentService) selectCreateOrderInstance(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig, payAmount float64) (*payment.InstanceSelection, error) {
|
||||
sel, err := s.loadBalancer.SelectInstance(ctx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount)
|
||||
if err != nil {
|
||||
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment method (%s) is not configured", req.PaymentType))
|
||||
@@ -209,6 +223,10 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
|
||||
if sel == nil {
|
||||
return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no available payment instance")
|
||||
}
|
||||
return sel, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, limitAmount float64, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
|
||||
prov, err := provider.CreateProvider(sel.ProviderKey, sel.InstanceID, sel.Config)
|
||||
if err != nil {
|
||||
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable")
|
||||
@@ -237,19 +255,17 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pr, err := prov.CreatePayment(ctx, payment.CreatePaymentRequest{
|
||||
OrderID: outTradeNo,
|
||||
Amount: payAmountStr,
|
||||
PaymentType: req.PaymentType,
|
||||
Subject: subject,
|
||||
ReturnURL: providerReturnURL,
|
||||
ClientIP: req.ClientIP,
|
||||
IsMobile: req.IsMobile,
|
||||
InstanceSubMethods: sel.SupportedTypes,
|
||||
})
|
||||
providerReq := buildProviderCreatePaymentRequest(CreateOrderRequest{
|
||||
PaymentType: req.PaymentType,
|
||||
OpenID: req.OpenID,
|
||||
ClientIP: req.ClientIP,
|
||||
IsMobile: req.IsMobile,
|
||||
ReturnURL: providerReturnURL,
|
||||
}, sel, outTradeNo, payAmountStr, subject)
|
||||
pr, err := prov.CreatePayment(ctx, providerReq)
|
||||
if err != nil {
|
||||
slog.Error("[PaymentService] CreatePayment failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
|
||||
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error()))
|
||||
return nil, classifyCreatePaymentError(req, sel.ProviderKey, err)
|
||||
}
|
||||
_, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).
|
||||
SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).
|
||||
@@ -269,20 +285,34 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
|
||||
"orderType": req.OrderType,
|
||||
"paymentSource": NormalizePaymentSource(req.PaymentSource),
|
||||
})
|
||||
return &CreateOrderResponse{
|
||||
OrderID: order.ID,
|
||||
Amount: order.Amount,
|
||||
PayAmount: payAmount,
|
||||
FeeRate: order.FeeRate,
|
||||
Status: OrderStatusPending,
|
||||
PaymentType: req.PaymentType,
|
||||
PayURL: pr.PayURL,
|
||||
QRCode: pr.QRCode,
|
||||
ClientSecret: pr.ClientSecret,
|
||||
ExpiresAt: order.ExpiresAt,
|
||||
PaymentMode: sel.PaymentMode,
|
||||
ResumeToken: resumeToken,
|
||||
}, nil
|
||||
resultType := pr.ResultType
|
||||
if resultType == "" {
|
||||
resultType = payment.CreatePaymentResultOrderCreated
|
||||
}
|
||||
resp := buildCreateOrderResponse(order, req, payAmount, sel, pr, resultType)
|
||||
resp.ResumeToken = resumeToken
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func buildProviderCreatePaymentRequest(req CreateOrderRequest, sel *payment.InstanceSelection, orderID, amount, subject string) payment.CreatePaymentRequest {
|
||||
return payment.CreatePaymentRequest{
|
||||
OrderID: orderID,
|
||||
Amount: amount,
|
||||
PaymentType: req.PaymentType,
|
||||
Subject: subject,
|
||||
ReturnURL: req.ReturnURL,
|
||||
OpenID: strings.TrimSpace(req.OpenID),
|
||||
ClientIP: req.ClientIP,
|
||||
IsMobile: req.IsMobile,
|
||||
InstanceSubMethods: selectedInstanceSupportedTypes(sel),
|
||||
}
|
||||
}
|
||||
|
||||
func selectedInstanceSupportedTypes(sel *payment.InstanceSelection) string {
|
||||
if sel == nil {
|
||||
return ""
|
||||
}
|
||||
return sel.SupportedTypes
|
||||
}
|
||||
|
||||
func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limitAmount float64, cfg *PaymentConfig) string {
|
||||
@@ -301,6 +331,183 @@ func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limit
|
||||
return "Sub2API " + amountStr + " CNY"
|
||||
}
|
||||
|
||||
func (s *PaymentService) maybeBuildWeChatOAuthRequiredResponse(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64) (*CreateOrderResponse, error) {
|
||||
return s.maybeBuildWeChatOAuthRequiredResponseForSelection(ctx, req, amount, payAmount, feeRate, nil)
|
||||
}
|
||||
|
||||
func (s *PaymentService) maybeBuildWeChatOAuthRequiredResponseForSelection(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
|
||||
if sel != nil && sel.ProviderKey != "" && sel.ProviderKey != payment.TypeWxpay {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.TrimSpace(req.OpenID) != "" || !req.IsWeChatBrowser || payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
|
||||
return nil, nil
|
||||
}
|
||||
return s.buildWeChatOAuthRequiredResponse(ctx, req, amount, payAmount, feeRate)
|
||||
}
|
||||
|
||||
func (s *PaymentService) buildWeChatOAuthRequiredResponse(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64) (*CreateOrderResponse, error) {
|
||||
appID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authorizeURL, err := buildWeChatPaymentOAuthStartURL(req, "snsapi_base")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &CreateOrderResponse{
|
||||
Amount: amount,
|
||||
PayAmount: payAmount,
|
||||
FeeRate: feeRate,
|
||||
ResultType: payment.CreatePaymentResultOAuthRequired,
|
||||
PaymentType: req.PaymentType,
|
||||
OAuth: &payment.WechatOAuthInfo{
|
||||
AuthorizeURL: authorizeURL,
|
||||
AppID: appID,
|
||||
Scope: "snsapi_base",
|
||||
RedirectURL: "/auth/wechat/payment/callback",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) validateSelectedCreateOrderInstance(ctx context.Context, req CreateOrderRequest, sel *payment.InstanceSelection) error {
|
||||
if !requiresWeChatJSAPICompatibleSelection(req, sel) {
|
||||
return nil
|
||||
}
|
||||
expectedAppID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
selectedAppID := provider.ResolveWxpayJSAPIAppID(sel.Config)
|
||||
if selectedAppID == "" || selectedAppID != expectedAppID {
|
||||
return infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "selected payment instance is not compatible with the current WeChat OAuth app")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiresWeChatJSAPICompatibleSelection(req CreateOrderRequest, sel *payment.InstanceSelection) bool {
|
||||
if sel == nil || sel.ProviderKey != payment.TypeWxpay || payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
|
||||
return false
|
||||
}
|
||||
return req.IsWeChatBrowser || strings.TrimSpace(req.OpenID) != ""
|
||||
}
|
||||
|
||||
func (s *PaymentService) getWeChatPaymentOAuthCredential(context.Context) (string, string, error) {
|
||||
appID := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID"))
|
||||
appSecret := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET"))
|
||||
if appID == "" || appSecret == "" {
|
||||
return "", "", infraerrors.ServiceUnavailable(
|
||||
"WECHAT_PAYMENT_MP_NOT_CONFIGURED",
|
||||
"wechat in-app payment requires a complete WeChat MP OAuth credential",
|
||||
)
|
||||
}
|
||||
return appID, appSecret, nil
|
||||
}
|
||||
|
||||
func classifyCreatePaymentError(req CreateOrderRequest, providerKey string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if providerKey == payment.TypeWxpay &&
|
||||
payment.GetBasePaymentType(req.PaymentType) == payment.TypeWxpay &&
|
||||
strings.Contains(err.Error(), "wxpay h5 payments are not authorized for this merchant") {
|
||||
return infraerrors.ServiceUnavailable(
|
||||
"WECHAT_H5_NOT_AUTHORIZED",
|
||||
"wechat h5 payment is not available for this merchant",
|
||||
).WithMetadata(map[string]string{
|
||||
"action": "open_in_wechat_or_scan_qr",
|
||||
})
|
||||
}
|
||||
return infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error()))
|
||||
}
|
||||
|
||||
func buildCreateOrderResponse(order *dbent.PaymentOrder, req CreateOrderRequest, payAmount float64, sel *payment.InstanceSelection, pr *payment.CreatePaymentResponse, resultType payment.CreatePaymentResultType) *CreateOrderResponse {
|
||||
return &CreateOrderResponse{
|
||||
OrderID: order.ID,
|
||||
Amount: order.Amount,
|
||||
PayAmount: payAmount,
|
||||
FeeRate: order.FeeRate,
|
||||
Status: OrderStatusPending,
|
||||
ResultType: resultType,
|
||||
PaymentType: req.PaymentType,
|
||||
OutTradeNo: order.OutTradeNo,
|
||||
PayURL: pr.PayURL,
|
||||
QRCode: pr.QRCode,
|
||||
ClientSecret: pr.ClientSecret,
|
||||
OAuth: pr.OAuth,
|
||||
JSAPI: pr.JSAPI,
|
||||
JSAPIPayload: pr.JSAPI,
|
||||
ExpiresAt: order.ExpiresAt,
|
||||
PaymentMode: sel.PaymentMode,
|
||||
}
|
||||
}
|
||||
|
||||
func buildWeChatPaymentOAuthStartURL(req CreateOrderRequest, scope string) (string, error) {
|
||||
u, err := url.Parse("/api/v1/auth/oauth/wechat/payment/start")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build wechat payment oauth start url: %w", err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("payment_type", strings.TrimSpace(req.PaymentType))
|
||||
if req.Amount > 0 {
|
||||
q.Set("amount", strconv.FormatFloat(req.Amount, 'f', -1, 64))
|
||||
}
|
||||
if orderType := strings.TrimSpace(req.OrderType); orderType != "" {
|
||||
q.Set("order_type", orderType)
|
||||
}
|
||||
if req.PlanID > 0 {
|
||||
q.Set("plan_id", strconv.FormatInt(req.PlanID, 10))
|
||||
}
|
||||
if scope = strings.TrimSpace(scope); scope != "" {
|
||||
q.Set("scope", scope)
|
||||
}
|
||||
if redirectTo := paymentRedirectPathFromURL(req.SrcURL); redirectTo != "" {
|
||||
q.Set("redirect", redirectTo)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func paymentRedirectPathFromURL(rawURL string) string {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return "/purchase"
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "/") && !strings.HasPrefix(rawURL, "//") {
|
||||
return normalizePaymentRedirectPath(rawURL)
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "/purchase"
|
||||
}
|
||||
path := strings.TrimSpace(u.EscapedPath())
|
||||
if path == "" {
|
||||
path = strings.TrimSpace(u.Path)
|
||||
}
|
||||
if path == "" || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
|
||||
return "/purchase"
|
||||
}
|
||||
if strings.TrimSpace(u.RawQuery) != "" {
|
||||
path += "?" + u.RawQuery
|
||||
}
|
||||
return normalizePaymentRedirectPath(path)
|
||||
}
|
||||
|
||||
func normalizePaymentRedirectPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "/purchase"
|
||||
}
|
||||
if path == "/payment" {
|
||||
return "/purchase"
|
||||
}
|
||||
if strings.HasPrefix(path, "/payment?") {
|
||||
return "/purchase" + strings.TrimPrefix(path, "/payment")
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// --- Order Queries ---
|
||||
|
||||
func (s *PaymentService) GetOrder(ctx context.Context, orderID, userID int64) (*dbent.PaymentOrder, error) {
|
||||
|
||||
177
backend/internal/service/payment_order_result_test.go
Normal file
177
backend/internal/service/payment_order_result_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
func TestBuildCreateOrderResponseDefaultsToOrderCreated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expiresAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
|
||||
resp := buildCreateOrderResponse(
|
||||
&dbent.PaymentOrder{
|
||||
ID: 42,
|
||||
Amount: 12.34,
|
||||
FeeRate: 0.03,
|
||||
ExpiresAt: expiresAt,
|
||||
OutTradeNo: "sub2_42",
|
||||
},
|
||||
CreateOrderRequest{PaymentType: payment.TypeWxpay},
|
||||
12.71,
|
||||
&payment.InstanceSelection{PaymentMode: "qrcode"},
|
||||
&payment.CreatePaymentResponse{
|
||||
TradeNo: "sub2_42",
|
||||
QRCode: "weixin://wxpay/bizpayurl?pr=test",
|
||||
},
|
||||
payment.CreatePaymentResultOrderCreated,
|
||||
)
|
||||
|
||||
if resp.ResultType != payment.CreatePaymentResultOrderCreated {
|
||||
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultOrderCreated)
|
||||
}
|
||||
if resp.OutTradeNo != "sub2_42" {
|
||||
t.Fatalf("out_trade_no = %q, want %q", resp.OutTradeNo, "sub2_42")
|
||||
}
|
||||
if resp.QRCode != "weixin://wxpay/bizpayurl?pr=test" {
|
||||
t.Fatalf("qr_code = %q, want %q", resp.QRCode, "weixin://wxpay/bizpayurl?pr=test")
|
||||
}
|
||||
if resp.JSAPI != nil || resp.JSAPIPayload != nil {
|
||||
t.Fatal("order_created response should not include jsapi payload")
|
||||
}
|
||||
if !resp.ExpiresAt.Equal(expiresAt) {
|
||||
t.Fatalf("expires_at = %v, want %v", resp.ExpiresAt, expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateOrderResponseCopiesJSAPIPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
jsapiPayload := &payment.WechatJSAPIPayload{
|
||||
AppID: "wx123",
|
||||
TimeStamp: "1712345678",
|
||||
NonceStr: "nonce-123",
|
||||
Package: "prepay_id=wx123",
|
||||
SignType: "RSA",
|
||||
PaySign: "signed-payload",
|
||||
}
|
||||
resp := buildCreateOrderResponse(
|
||||
&dbent.PaymentOrder{
|
||||
ID: 88,
|
||||
Amount: 66.88,
|
||||
FeeRate: 0.01,
|
||||
ExpiresAt: time.Date(2026, 4, 16, 13, 0, 0, 0, time.UTC),
|
||||
OutTradeNo: "sub2_88",
|
||||
},
|
||||
CreateOrderRequest{PaymentType: payment.TypeWxpay},
|
||||
67.55,
|
||||
&payment.InstanceSelection{PaymentMode: "popup"},
|
||||
&payment.CreatePaymentResponse{
|
||||
TradeNo: "sub2_88",
|
||||
ResultType: payment.CreatePaymentResultJSAPIReady,
|
||||
JSAPI: jsapiPayload,
|
||||
},
|
||||
payment.CreatePaymentResultJSAPIReady,
|
||||
)
|
||||
|
||||
if resp.ResultType != payment.CreatePaymentResultJSAPIReady {
|
||||
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultJSAPIReady)
|
||||
}
|
||||
if resp.JSAPI == nil || resp.JSAPIPayload == nil {
|
||||
t.Fatal("expected jsapi payload aliases to be populated")
|
||||
}
|
||||
if resp.JSAPI != jsapiPayload || resp.JSAPIPayload != jsapiPayload {
|
||||
t.Fatal("expected jsapi aliases to preserve the original pointer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeBuildWeChatOAuthRequiredResponse(t *testing.T) {
|
||||
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx123456")
|
||||
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wechat-secret")
|
||||
|
||||
svc := &PaymentService{}
|
||||
|
||||
resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{
|
||||
Amount: 12.5,
|
||||
PaymentType: payment.TypeWxpay,
|
||||
IsWeChatBrowser: true,
|
||||
SrcURL: "https://merchant.example/payment?from=wechat",
|
||||
OrderType: payment.OrderTypeBalance,
|
||||
}, 12.5, 12.88, 0.03)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected oauth_required response, got nil")
|
||||
}
|
||||
if resp.ResultType != payment.CreatePaymentResultOAuthRequired {
|
||||
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultOAuthRequired)
|
||||
}
|
||||
if resp.OAuth == nil {
|
||||
t.Fatal("expected oauth payload, got nil")
|
||||
}
|
||||
if resp.OAuth.AppID != "wx123456" {
|
||||
t.Fatalf("appid = %q, want %q", resp.OAuth.AppID, "wx123456")
|
||||
}
|
||||
if resp.OAuth.Scope != "snsapi_base" {
|
||||
t.Fatalf("scope = %q, want %q", resp.OAuth.Scope, "snsapi_base")
|
||||
}
|
||||
if resp.OAuth.RedirectURL != "/auth/wechat/payment/callback" {
|
||||
t.Fatalf("redirect_url = %q, want %q", resp.OAuth.RedirectURL, "/auth/wechat/payment/callback")
|
||||
}
|
||||
if resp.OAuth.AuthorizeURL != "/api/v1/auth/oauth/wechat/payment/start?amount=12.5&order_type=balance&payment_type=wxpay&redirect=%2Fpurchase%3Ffrom%3Dwechat&scope=snsapi_base" {
|
||||
t.Fatalf("authorize_url = %q", resp.OAuth.AuthorizeURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeBuildWeChatOAuthRequiredResponseRequiresMPConfigInWeChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &PaymentService{}
|
||||
|
||||
resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{
|
||||
Amount: 12.5,
|
||||
PaymentType: payment.TypeWxpay,
|
||||
IsWeChatBrowser: true,
|
||||
SrcURL: "https://merchant.example/payment?from=wechat",
|
||||
OrderType: payment.OrderTypeBalance,
|
||||
}, 12.5, 12.88, 0.03)
|
||||
if resp != nil {
|
||||
t.Fatalf("expected nil response, got %+v", resp)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
appErr := infraerrors.FromError(err)
|
||||
if appErr.Reason != "WECHAT_PAYMENT_MP_NOT_CONFIGURED" {
|
||||
t.Fatalf("reason = %q, want %q", appErr.Reason, "WECHAT_PAYMENT_MP_NOT_CONFIGURED")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider(t *testing.T) {
|
||||
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx123456")
|
||||
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wechat-secret")
|
||||
|
||||
svc := &PaymentService{}
|
||||
|
||||
resp, err := svc.maybeBuildWeChatOAuthRequiredResponseForSelection(context.Background(), CreateOrderRequest{
|
||||
Amount: 12.5,
|
||||
PaymentType: payment.TypeWxpay,
|
||||
IsWeChatBrowser: true,
|
||||
OrderType: payment.OrderTypeBalance,
|
||||
}, 12.5, 12.88, 0.03, &payment.InstanceSelection{
|
||||
ProviderKey: payment.TypeEasyPay,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp != nil {
|
||||
t.Fatalf("expected nil response, got %+v", resp)
|
||||
}
|
||||
}
|
||||
@@ -64,32 +64,39 @@ func generateRandomString(n int) string {
|
||||
}
|
||||
|
||||
type CreateOrderRequest struct {
|
||||
UserID int64
|
||||
Amount float64
|
||||
PaymentType string
|
||||
ClientIP string
|
||||
IsMobile bool
|
||||
SrcHost string
|
||||
SrcURL string
|
||||
ReturnURL string
|
||||
PaymentSource string
|
||||
OrderType string
|
||||
PlanID int64
|
||||
UserID int64
|
||||
Amount float64
|
||||
PaymentType string
|
||||
OpenID string
|
||||
ClientIP string
|
||||
IsMobile bool
|
||||
IsWeChatBrowser bool
|
||||
SrcHost string
|
||||
SrcURL string
|
||||
ReturnURL string
|
||||
PaymentSource string
|
||||
OrderType string
|
||||
PlanID int64
|
||||
}
|
||||
|
||||
type CreateOrderResponse struct {
|
||||
OrderID int64 `json:"order_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
PayAmount float64 `json:"pay_amount"`
|
||||
FeeRate float64 `json:"fee_rate"`
|
||||
Status string `json:"status"`
|
||||
PaymentType string `json:"payment_type"`
|
||||
PayURL string `json:"pay_url,omitempty"`
|
||||
QRCode string `json:"qr_code,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
PaymentMode string `json:"payment_mode,omitempty"`
|
||||
ResumeToken string `json:"resume_token,omitempty"`
|
||||
OrderID int64 `json:"order_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
PayAmount float64 `json:"pay_amount"`
|
||||
FeeRate float64 `json:"fee_rate"`
|
||||
Status string `json:"status"`
|
||||
ResultType payment.CreatePaymentResultType `json:"result_type,omitempty"`
|
||||
PaymentType string `json:"payment_type"`
|
||||
OutTradeNo string `json:"out_trade_no,omitempty"`
|
||||
PayURL string `json:"pay_url,omitempty"`
|
||||
QRCode string `json:"qr_code,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
OAuth *payment.WechatOAuthInfo `json:"oauth,omitempty"`
|
||||
JSAPI *payment.WechatJSAPIPayload `json:"jsapi,omitempty"`
|
||||
JSAPIPayload *payment.WechatJSAPIPayload `json:"jsapi_payload,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
PaymentMode string `json:"payment_mode,omitempty"`
|
||||
ResumeToken string `json:"resume_token,omitempty"`
|
||||
}
|
||||
|
||||
type OrderListParams struct {
|
||||
|
||||
Reference in New Issue
Block a user