Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
7ef7fd19
Commit
7ef7fd19
authored
Apr 20, 2026
by
IanShaw027
Browse files
fix: restore wechat payment oauth and jsapi flow
parent
6f00efa3
Changes
16
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/auth_wechat_oauth.go
View file @
7ef7fd19
...
...
@@ -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
,
})
}
backend/internal/handler/payment_handler.go
View file @
7ef7fd19
...
...
@@ -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"
)
}
backend/internal/payment/provider/wxpay.go
View file @
7ef7fd19
...
...
@@ -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
:=
wxpayNative
Prepay
(
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
:=
wxpayH5
Prepay
(
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
""
...
...
backend/internal/payment/provider/wxpay_test.go
View file @
7ef7fd19
...
...
@@ -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"
)
}
}
backend/internal/payment/types.go
View file @
7ef7fd19
...
...
@@ -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.
...
...
backend/internal/server/routes/auth.go
View file @
7ef7fd19
...
...
@@ -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
,
...
...
backend/internal/service/payment_order.go
View file @
7ef7fd19
...
...
@@ -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
)
{
...
...
backend/internal/service/payment_order_result_test.go
0 → 100644
View file @
7ef7fd19
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
)
}
}
backend/internal/service/payment_service.go
View file @
7ef7fd19
...
...
@@ -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
{
...
...
frontend/src/components/payment/__tests__/paymentFlow.spec.ts
View file @
7ef7fd19
...
...
@@ -105,6 +105,50 @@ describe('decidePaymentLaunch', () => {
expect
(
decision
.
recovery
.
paymentMode
).
toBe
(
'
popup
'
)
expect
(
decision
.
recovery
.
resumeToken
).
toBe
(
'
resume-2
'
)
})
it
(
'
returns wechat oauth launch when backend requires in-app authorization
'
,
()
=>
{
const
decision
=
decidePaymentLaunch
(
createOrderResult
({
result_type
:
'
oauth_required
'
,
payment_type
:
'
wxpay
'
,
oauth
:
{
authorize_url
:
'
/api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay
'
,
appid
:
'
wx123
'
,
scope
:
'
snsapi_base
'
,
redirect_url
:
'
/auth/wechat/payment/callback
'
,
},
}),
{
visibleMethod
:
'
wxpay
'
,
orderType
:
'
balance
'
,
isMobile
:
true
,
})
expect
(
decision
.
kind
).
toBe
(
'
wechat_oauth
'
)
expect
(
decision
.
oauth
?.
authorize_url
).
toContain
(
'
/api/v1/auth/oauth/wechat/payment/start
'
)
expect
(
decision
.
paymentState
.
paymentType
).
toBe
(
'
wxpay
'
)
})
it
(
'
returns wechat jsapi launch when backend has a jsapi payload ready
'
,
()
=>
{
const
decision
=
decidePaymentLaunch
(
createOrderResult
({
result_type
:
'
jsapi_ready
'
,
payment_type
:
'
wxpay
'
,
jsapi
:
{
appId
:
'
wx123
'
,
timeStamp
:
'
1712345678
'
,
nonceStr
:
'
nonce-123
'
,
package
:
'
prepay_id=wx123
'
,
signType
:
'
RSA
'
,
paySign
:
'
signed-payload
'
,
},
}),
{
visibleMethod
:
'
wxpay
'
,
orderType
:
'
subscription
'
,
isMobile
:
true
,
})
expect
(
decision
.
kind
).
toBe
(
'
wechat_jsapi
'
)
expect
(
decision
.
jsapi
?.
appId
).
toBe
(
'
wx123
'
)
expect
(
decision
.
paymentState
.
orderType
).
toBe
(
'
subscription
'
)
})
})
describe
(
'
buildCreateOrderPayload
'
,
()
=>
{
...
...
frontend/src/components/payment/paymentFlow.ts
View file @
7ef7fd19
import
type
{
CreateOrderRequest
,
CreateOrderResult
,
MethodLimit
,
OrderType
}
from
'
@/types/payment
'
import
type
{
CreateOrderRequest
,
CreateOrderResult
,
MethodLimit
,
OrderType
,
WechatJSAPIPayload
,
WechatOAuthInfo
,
}
from
'
@/types/payment
'
export
const
PAYMENT_RECOVERY_STORAGE_KEY
=
'
payment.recovery.current
'
...
...
@@ -16,6 +23,8 @@ export type PaymentLaunchKind =
|
'
redirect_waiting
'
|
'
stripe_popup
'
|
'
stripe_route
'
|
'
wechat_oauth
'
|
'
wechat_jsapi
'
|
'
unhandled
'
export
interface
PaymentRecoverySnapshot
{
...
...
@@ -47,6 +56,8 @@ export interface PaymentLaunchDecision {
paymentState
:
PaymentRecoverySnapshot
recovery
:
PaymentRecoverySnapshot
stripeMethod
?:
StripeVisibleMethod
oauth
?:
WechatOAuthInfo
jsapi
?:
WechatJSAPIPayload
}
export
interface
BuildCreateOrderPayloadInput
{
...
...
@@ -139,6 +150,15 @@ export function decidePaymentLaunch(
return
{
kind
,
paymentState
,
recovery
:
paymentState
,
stripeMethod
}
}
if
(
result
.
result_type
===
'
oauth_required
'
&&
result
.
oauth
?.
authorize_url
)
{
return
{
kind
:
'
wechat_oauth
'
,
paymentState
:
baseState
,
recovery
:
baseState
,
oauth
:
result
.
oauth
}
}
const
jsapiPayload
=
result
.
jsapi
??
result
.
jsapi_payload
if
(
result
.
result_type
===
'
jsapi_ready
'
&&
jsapiPayload
)
{
return
{
kind
:
'
wechat_jsapi
'
,
paymentState
:
baseState
,
recovery
:
baseState
,
jsapi
:
jsapiPayload
}
}
if
(
baseState
.
qrCode
)
{
return
{
kind
:
'
qr_waiting
'
,
paymentState
:
baseState
,
recovery
:
baseState
}
}
...
...
frontend/src/router/index.ts
View file @
7ef7fd19
...
...
@@ -92,6 +92,15 @@ const routes: RouteRecordRaw[] = [
title
:
'
WeChat OAuth Callback
'
}
},
{
path
:
'
/auth/wechat/payment/callback
'
,
name
:
'
WeChatPaymentOAuthCallback
'
,
component
:
()
=>
import
(
'
@/views/auth/WechatPaymentCallbackView.vue
'
),
meta
:
{
requiresAuth
:
false
,
title
:
'
WeChat Payment Callback
'
}
},
{
path
:
'
/auth/oidc/callback
'
,
name
:
'
OIDCOAuthCallback
'
,
...
...
frontend/src/types/payment.ts
View file @
7ef7fd19
...
...
@@ -156,6 +156,28 @@ export interface CreateOrderRequest {
plan_id
?:
number
return_url
?:
string
payment_source
?:
string
openid
?:
string
is_mobile
?:
boolean
}
export
type
CreateOrderResultType
=
'
order_created
'
|
'
oauth_required
'
|
'
jsapi_ready
'
export
interface
WechatOAuthInfo
{
authorize_url
?:
string
appid
?:
string
openid
?:
string
scope
?:
string
state
?:
string
redirect_url
?:
string
}
export
interface
WechatJSAPIPayload
{
appId
?:
string
timeStamp
?:
string
nonceStr
?:
string
package
?:
string
signType
?:
string
paySign
?:
string
}
export
interface
CreateOrderResult
{
...
...
@@ -167,8 +189,14 @@ export interface CreateOrderResult {
pay_amount
:
number
fee_rate
:
number
expires_at
:
string
result_type
?:
CreateOrderResultType
payment_type
?:
string
out_trade_no
?:
string
payment_mode
?:
string
resume_token
?:
string
oauth
?:
WechatOAuthInfo
jsapi
?:
WechatJSAPIPayload
jsapi_payload
?:
WechatJSAPIPayload
}
export
interface
DashboardStats
{
...
...
frontend/src/views/auth/WechatPaymentCallbackView.vue
0 → 100644
View file @
7ef7fd19
<
template
>
<div
class=
"min-h-screen bg-gray-50 px-4 py-10 dark:bg-dark-900"
>
<div
class=
"mx-auto max-w-2xl"
>
<div
class=
"card p-6"
>
<h1
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
callbackTitleText
}}
</h1>
<p
class=
"mt-2 text-sm text-gray-600 dark:text-gray-400"
>
{{
errorMessage
||
callbackProcessingText
}}
</p>
<div
v-if=
"!errorMessage"
class=
"mt-6 flex items-center justify-center py-10"
>
<div
class=
"h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"
></div>
</div>
<div
v-else
class=
"mt-6 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-700/50 dark:bg-red-900/20"
>
<p
class=
"text-sm text-red-700 dark:text-red-400"
>
{{
errorMessage
}}
</p>
<button
class=
"btn btn-primary mt-4"
type=
"button"
@
click=
"goBackToPayment"
>
{{
backToPaymentText
}}
</button>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
onMounted
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
const
{
t
,
locale
}
=
useI18n
()
const
route
=
useRoute
()
const
router
=
useRouter
()
const
errorMessage
=
ref
(
''
)
function
textWithFallback
(
key
:
string
,
zh
:
string
,
en
:
string
):
string
{
const
translated
=
t
(
key
)
if
(
translated
!==
key
)
return
translated
return
String
(
locale
.
value
).
toLowerCase
().
startsWith
(
'
zh
'
)
?
zh
:
en
}
const
callbackProcessingText
=
computed
(()
=>
textWithFallback
(
'
auth.wechatPayment.callbackProcessing
'
,
'
正在恢复微信支付...
'
,
'
Resuming WeChat payment...
'
,
))
const
callbackTitleText
=
computed
(()
=>
textWithFallback
(
'
auth.wechatPayment.callbackTitle
'
,
'
正在恢复微信支付
'
,
'
Resuming WeChat payment
'
,
))
const
backToPaymentText
=
computed
(()
=>
textWithFallback
(
'
auth.wechatPayment.backToPayment
'
,
'
返回支付页
'
,
'
Back to payment
'
,
))
function
readQueryString
(
key
:
string
):
string
{
const
value
=
route
.
query
[
key
]
if
(
Array
.
isArray
(
value
))
{
return
typeof
value
[
0
]
===
'
string
'
?
value
[
0
]
:
''
}
return
typeof
value
===
'
string
'
?
value
:
''
}
function
parseFragmentParams
():
URLSearchParams
{
const
raw
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
hash
:
''
const
hash
=
raw
.
startsWith
(
'
#
'
)
?
raw
.
slice
(
1
)
:
raw
return
new
URLSearchParams
(
hash
)
}
function
normalizeRedirectPath
(
path
:
string
|
null
|
undefined
):
string
{
const
value
=
(
path
||
''
).
trim
()
if
(
!
value
)
return
'
/purchase
'
if
(
!
value
.
startsWith
(
'
/
'
))
return
'
/purchase
'
if
(
value
.
startsWith
(
'
//
'
)
||
value
.
includes
(
'
://
'
))
return
'
/purchase
'
if
(
value
===
'
/payment
'
)
return
'
/purchase
'
if
(
value
.
startsWith
(
'
/payment?
'
))
return
'
/purchase
'
+
value
.
slice
(
'
/payment
'
.
length
)
return
value
}
function
goBackToPayment
()
{
void
router
.
replace
(
'
/purchase
'
)
}
onMounted
(
async
()
=>
{
const
fragment
=
parseFragmentParams
()
const
readParam
=
(
key
:
string
)
=>
fragment
.
get
(
key
)
||
readQueryString
(
key
)
const
error
=
readParam
(
'
error
'
)
||
readParam
(
'
err_msg
'
)
||
readParam
(
'
errmsg
'
)
const
errorDescription
=
readParam
(
'
error_description
'
)
||
readParam
(
'
message
'
)
if
(
error
)
{
errorMessage
.
value
=
errorDescription
||
error
return
}
const
openid
=
readParam
(
'
openid
'
)
const
state
=
readParam
(
'
state
'
)
const
scope
=
readParam
(
'
scope
'
)
const
paymentType
=
readParam
(
'
payment_type
'
)
const
amount
=
readParam
(
'
amount
'
)
const
orderType
=
readParam
(
'
order_type
'
)
const
planId
=
readParam
(
'
plan_id
'
)
const
redirectURL
=
new
URL
(
normalizeRedirectPath
(
readParam
(
'
redirect
'
)),
window
.
location
.
origin
,
)
if
(
!
openid
)
{
errorMessage
.
value
=
textWithFallback
(
'
auth.wechatPayment.callbackMissingOpenId
'
,
'
微信支付回调缺少 openid。
'
,
'
The WeChat payment callback is missing the openid.
'
,
)
return
}
const
query
:
Record
<
string
,
string
>
=
{
...
Object
.
fromEntries
(
redirectURL
.
searchParams
.
entries
()),
wechat_resume
:
'
1
'
,
openid
,
}
if
(
state
)
query
.
state
=
state
if
(
scope
)
query
.
scope
=
scope
if
(
paymentType
)
query
.
payment_type
=
paymentType
if
(
amount
)
query
.
amount
=
amount
if
(
orderType
)
query
.
order_type
=
orderType
if
(
planId
)
query
.
plan_id
=
planId
await
router
.
replace
({
path
:
redirectURL
.
pathname
,
query
,
})
})
</
script
>
frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts
0 → 100644
View file @
7ef7fd19
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
WechatPaymentCallbackView
from
'
@/views/auth/WechatPaymentCallbackView.vue
'
const
{
replaceMock
,
routeState
,
locationState
}
=
vi
.
hoisted
(()
=>
({
replaceMock
:
vi
.
fn
(),
routeState
:
{
query
:
{}
as
Record
<
string
,
unknown
>
,
},
locationState
:
{
current
:
{
href
:
'
http://localhost/auth/wechat/payment/callback
'
,
hash
:
''
,
search
:
''
,
pathname
:
'
/auth/wechat/payment/callback
'
,
origin
:
'
http://localhost
'
,
}
as
Location
&
{
origin
:
string
},
},
}))
vi
.
mock
(
'
vue-router
'
,
()
=>
({
useRoute
:
()
=>
routeState
,
useRouter
:
()
=>
({
replace
:
replaceMock
,
}),
}))
vi
.
mock
(
'
vue-i18n
'
,
()
=>
({
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
key
,
locale
:
{
value
:
'
zh-CN
'
},
}),
}))
describe
(
'
WechatPaymentCallbackView
'
,
()
=>
{
beforeEach
(()
=>
{
replaceMock
.
mockReset
()
routeState
.
query
=
{}
locationState
.
current
=
{
href
:
'
http://localhost/auth/wechat/payment/callback
'
,
hash
:
''
,
search
:
''
,
pathname
:
'
/auth/wechat/payment/callback
'
,
origin
:
'
http://localhost
'
,
}
as
Location
&
{
origin
:
string
}
Object
.
defineProperty
(
window
,
'
location
'
,
{
configurable
:
true
,
value
:
locationState
.
current
,
})
})
it
(
'
redirects back to purchase with openid and payment context from hash fragment
'
,
async
()
=>
{
locationState
.
current
.
hash
=
'
#openid=openid-123&payment_type=wxpay&amount=12.5&order_type=balance&redirect=%2Fpurchase%3Ffrom%3Dwechat
'
mount
(
WechatPaymentCallbackView
)
await
flushPromises
()
expect
(
replaceMock
).
toHaveBeenCalledWith
({
path
:
'
/purchase
'
,
query
:
{
from
:
'
wechat
'
,
wechat_resume
:
'
1
'
,
openid
:
'
openid-123
'
,
payment_type
:
'
wxpay
'
,
amount
:
'
12.5
'
,
order_type
:
'
balance
'
,
},
})
})
it
(
'
shows an error when the callback payload is missing openid
'
,
async
()
=>
{
locationState
.
current
.
hash
=
'
#payment_type=wxpay
'
const
wrapper
=
mount
(
WechatPaymentCallbackView
)
await
flushPromises
()
expect
(
replaceMock
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
text
()).
toContain
(
'
微信支付回调缺少 openid。
'
)
})
})
frontend/src/views/user/PaymentView.vue
View file @
7ef7fd19
...
...
@@ -309,6 +309,20 @@ const previewImage = ref('')
const
paymentPhase
=
ref
<
'
select
'
|
'
paying
'
>
(
'
select
'
)
interface
CreateOrderOptions
{
openid
?:
string
paymentType
?:
string
isResume
?:
boolean
}
interface
WeixinJSBridgeLike
{
invoke
(
action
:
string
,
payload
:
Record
<
string
,
unknown
>
,
callback
:
(
result
:
Record
<
string
,
unknown
>
)
=>
void
,
):
void
}
function
emptyPaymentState
():
PaymentRecoverySnapshot
{
return
{
orderId
:
0
,
...
...
@@ -326,6 +340,48 @@ function emptyPaymentState(): PaymentRecoverySnapshot {
}
}
function
readRouteQueryValue
(
value
:
unknown
):
string
{
if
(
Array
.
isArray
(
value
))
{
return
typeof
value
[
0
]
===
'
string
'
?
value
[
0
]
:
''
}
return
typeof
value
===
'
string
'
?
value
:
''
}
function
getWeixinJSBridge
():
WeixinJSBridgeLike
|
undefined
{
return
(
window
as
Window
&
{
WeixinJSBridge
?:
WeixinJSBridgeLike
}
).
WeixinJSBridge
}
function
waitForWeixinJSBridge
(
timeoutMs
=
4000
):
Promise
<
WeixinJSBridgeLike
|
null
>
{
const
existing
=
getWeixinJSBridge
()
if
(
existing
)
return
Promise
.
resolve
(
existing
)
return
new
Promise
((
resolve
)
=>
{
let
settled
=
false
const
finish
=
(
bridge
:
WeixinJSBridgeLike
|
null
)
=>
{
if
(
settled
)
return
settled
=
true
document
.
removeEventListener
(
'
WeixinJSBridgeReady
'
,
handleReady
)
document
.
removeEventListener
(
'
onWeixinJSBridgeReady
'
,
handleReady
)
window
.
clearTimeout
(
timer
)
resolve
(
bridge
)
}
const
handleReady
=
()
=>
finish
(
getWeixinJSBridge
()
??
null
)
const
timer
=
window
.
setTimeout
(()
=>
finish
(
getWeixinJSBridge
()
??
null
),
timeoutMs
)
document
.
addEventListener
(
'
WeixinJSBridgeReady
'
,
handleReady
,
false
)
document
.
addEventListener
(
'
onWeixinJSBridgeReady
'
,
handleReady
,
false
)
}
)
}
async
function
invokeWechatJsapiPayment
(
payload
:
Record
<
string
,
unknown
>
):
Promise
<
Record
<
string
,
unknown
>>
{
const
bridge
=
await
waitForWeixinJSBridge
()
if
(
!
bridge
)
{
throw
new
Error
(
'
WeixinJSBridge is unavailable
'
)
}
return
new
Promise
((
resolve
)
=>
{
bridge
.
invoke
(
'
getBrandWCPayRequest
'
,
payload
,
(
result
)
=>
resolve
(
result
||
{
}
))
}
)
}
const
paymentState
=
ref
<
PaymentRecoverySnapshot
>
(
emptyPaymentState
())
function
persistRecoverySnapshot
(
snapshot
:
PaymentRecoverySnapshot
)
{
...
...
@@ -560,25 +616,32 @@ async function confirmSubscribe() {
await
createOrder
(
selectedPlan
.
value
.
price
,
'
subscription
'
,
selectedPlan
.
value
.
id
)
}
async
function
createOrder
(
orderAmount
:
number
,
orderType
:
OrderType
,
planId
?:
number
)
{
async
function
createOrder
(
orderAmount
:
number
,
orderType
:
OrderType
,
planId
?:
number
,
options
:
CreateOrderOptions
=
{
}
)
{
submitting
.
value
=
true
errorMessage
.
value
=
''
try
{
const
result
=
await
paymentStore
.
createOrder
(
buildCreateOrderPayload
({
const
requestType
=
normalizeVisibleMethod
(
options
.
paymentType
||
selectedMethod
.
value
)
||
options
.
paymentType
||
selectedMethod
.
value
const
payload
=
buildCreateOrderPayload
({
amount
:
orderAmount
,
paymentType
:
selectedMethod
.
valu
e
,
paymentType
:
requestTyp
e
,
orderType
,
planId
,
origin
:
typeof
window
!==
'
undefined
'
?
window
.
location
.
origin
:
''
,
isWechatBrowser
:
typeof
window
!==
'
undefined
'
&&
/MicroMessenger/i
.
test
(
window
.
navigator
.
userAgent
),
}
))
as
CreateOrderResult
&
{
resume_token
?:
string
}
}
)
if
(
options
.
openid
)
{
payload
.
openid
=
options
.
openid
}
payload
.
is_mobile
=
isMobileDevice
()
const
result
=
await
paymentStore
.
createOrder
(
payload
)
as
CreateOrderResult
&
{
resume_token
?:
string
}
const
openWindow
=
(
url
:
string
,
features
=
POPUP_WINDOW_FEATURES
)
=>
{
const
win
=
window
.
open
(
url
,
'
paymentPopup
'
,
features
)
if
(
!
win
||
win
.
closed
)
{
window
.
location
.
href
=
url
}
}
const
visibleMethod
=
normalizeVisibleMethod
(
selectedMethod
.
value
)
||
selectedMethod
.
valu
e
const
visibleMethod
=
normalizeVisibleMethod
(
requestType
)
||
requestTyp
e
const
stripeMethod
=
visibleMethod
===
'
wxpay
'
?
'
wechat_pay
'
:
'
alipay
'
const
stripeRouteUrl
=
result
.
client_secret
?
router
.
resolve
({
...
...
@@ -599,6 +662,11 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
stripeRouteUrl
,
}
)
if
(
decision
.
kind
===
'
wechat_oauth
'
&&
decision
.
oauth
?.
authorize_url
)
{
window
.
location
.
href
=
decision
.
oauth
.
authorize_url
return
}
if
(
decision
.
kind
===
'
unhandled
'
)
{
errorMessage
.
value
=
t
(
'
payment.result.failed
'
)
appStore
.
showError
(
errorMessage
.
value
)
...
...
@@ -617,6 +685,16 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
window
.
location
.
href
=
decision
.
paymentState
.
payUrl
return
}
if
(
decision
.
kind
===
'
wechat_jsapi
'
&&
decision
.
jsapi
)
{
const
jsapiResult
=
await
invokeWechatJsapiPayment
(
decision
.
jsapi
as
Record
<
string
,
unknown
>
)
const
errMsg
=
String
(
jsapiResult
.
err_msg
||
''
).
toLowerCase
()
if
(
errMsg
.
includes
(
'
cancel
'
))
{
appStore
.
showInfo
(
t
(
'
payment.qr.cancelled
'
))
}
else
if
(
errMsg
&&
!
errMsg
.
includes
(
'
ok
'
))
{
appStore
.
showError
(
t
(
'
payment.result.failed
'
))
}
return
}
if
(
decision
.
kind
===
'
redirect_waiting
'
&&
decision
.
paymentState
.
payUrl
)
{
if
(
isMobileDevice
())
{
window
.
location
.
href
=
decision
.
paymentState
.
payUrl
...
...
@@ -640,6 +718,50 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
}
async
function
resumeWechatPaymentFromQuery
()
{
const
openid
=
readRouteQueryValue
(
route
.
query
.
openid
)
if
(
readRouteQueryValue
(
route
.
query
.
wechat_resume
)
!==
'
1
'
||
!
openid
)
{
return
}
const
paymentType
=
normalizeVisibleMethod
(
readRouteQueryValue
(
route
.
query
.
payment_type
))
||
'
wxpay
'
const
orderType
=
readRouteQueryValue
(
route
.
query
.
order_type
)
===
'
subscription
'
?
'
subscription
'
:
'
balance
'
const
planId
=
Number
.
parseInt
(
readRouteQueryValue
(
route
.
query
.
plan_id
),
10
)
const
rawAmount
=
Number
.
parseFloat
(
readRouteQueryValue
(
route
.
query
.
amount
))
const
orderAmount
=
Number
.
isFinite
(
rawAmount
)
&&
rawAmount
>
0
?
rawAmount
:
(
orderType
===
'
subscription
'
?
(
checkout
.
value
.
plans
.
find
(
plan
=>
plan
.
id
===
planId
)?.
price
??
0
)
:
validAmount
.
value
)
selectedMethod
.
value
=
paymentType
if
(
orderType
===
'
balance
'
&&
orderAmount
>
0
)
{
amount
.
value
=
orderAmount
}
if
(
orderType
===
'
subscription
'
&&
Number
.
isFinite
(
planId
)
&&
planId
>
0
)
{
selectedPlan
.
value
=
checkout
.
value
.
plans
.
find
(
plan
=>
plan
.
id
===
planId
)
??
null
}
const
nextQuery
=
{
...
route
.
query
}
delete
nextQuery
.
wechat_resume
delete
nextQuery
.
openid
delete
nextQuery
.
state
delete
nextQuery
.
scope
delete
nextQuery
.
payment_type
delete
nextQuery
.
amount
delete
nextQuery
.
order_type
delete
nextQuery
.
plan_id
await
router
.
replace
({
path
:
route
.
path
,
query
:
nextQuery
}
)
if
(
orderAmount
>
0
)
{
await
createOrder
(
orderAmount
,
orderType
,
Number
.
isFinite
(
planId
)
&&
planId
>
0
?
planId
:
undefined
,
{
openid
,
paymentType
,
isResume
:
true
,
}
)
}
}
onMounted
(
async
()
=>
{
try
{
const
res
=
await
paymentAPI
.
getCheckoutInfo
()
...
...
@@ -672,6 +794,7 @@ onMounted(async () => {
removeRecoverySnapshot
()
}
}
await
resumeWechatPaymentFromQuery
()
if
(
checkout
.
value
.
balance_disabled
)
{
activeTab
.
value
=
'
subscription
'
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment