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
Show 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,13 +226,21 @@ 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
,
OpenID
:
req
.
OpenID
,
ClientIP
:
c
.
ClientIP
(),
IsMobile
:
isMobile
(
c
),
IsMobile
:
mobile
,
IsWeChatBrowser
:
isWeChatBrowser
(
c
),
SrcHost
:
c
.
Request
.
Host
,
SrcURL
:
c
.
Request
.
Referer
(),
ReturnURL
:
req
.
ReturnURL
,
PaymentSource
:
req
.
PaymentSource
,
OrderType
:
req
.
OrderType
,
...
...
@@ -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"
...
...
@@ -28,6 +29,14 @@ import (
const
(
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
))},
}
if
clientIP
:=
strings
.
TrimSpace
(
req
.
ClientIP
);
clientIP
!=
""
{
prepayReq
.
SceneInfo
=
&
jsapi
.
SceneInfo
{
PayerClientIp
:
core
.
String
(
clientIP
)}
}
return
w
.
prepayNative
(
ctx
,
c
,
req
,
notifyURL
,
totalFen
)
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
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
,
providerReq
:=
buildProviderCreatePaymentRequest
(
CreateOrderRequest
{
PaymentType
:
req
.
PaymentType
,
Subject
:
subject
,
ReturnURL
:
providerReturnURL
,
OpenID
:
req
.
OpenID
,
ClientIP
:
req
.
ClientIP
,
IsMobile
:
req
.
IsMobile
,
InstanceSubMethods
:
sel
.
SupportedTypes
,
})
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
,
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
,
PayURL
:
pr
.
PayURL
,
QRCode
:
pr
.
QRCode
,
ClientSecret
:
pr
.
ClientSecret
,
ExpiresAt
:
order
.
ExpiresAt
,
PaymentMode
:
sel
.
PaymentMode
,
ResumeToken
:
resumeToken
,
},
nil
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
...
...
@@ -67,8 +67,10 @@ type CreateOrderRequest struct {
UserID
int64
Amount
float64
PaymentType
string
OpenID
string
ClientIP
string
IsMobile
bool
IsWeChatBrowser
bool
SrcHost
string
SrcURL
string
ReturnURL
string
...
...
@@ -83,10 +85,15 @@ type CreateOrderResponse struct {
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"`
...
...
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