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
1aab084e
Commit
1aab084e
authored
Apr 22, 2026
by
IanShaw027
Browse files
fix(payment): restore upgrade-safe payment flows
parent
36aed359
Changes
14
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/auth_wechat_oauth.go
View file @
1aab084e
...
...
@@ -471,11 +471,12 @@ func (h *AuthHandler) WeChatPaymentOAuthCallback(c *gin.Context) {
}
func
(
h
*
AuthHandler
)
wechatPaymentResumeService
()
*
service
.
PaymentResumeService
{
var
legacyKey
[]
byte
key
,
err
:=
payment
.
ProvideEncryptionKey
(
h
.
cfg
)
if
err
!
=
nil
{
return
service
.
NewPaymentResumeService
(
nil
)
if
err
=
=
nil
{
legacyKey
=
[]
byte
(
key
)
}
return
service
.
NewPaymentResumeService
(
[]
byte
(
k
ey
)
)
return
service
.
New
LegacyAware
PaymentResumeService
(
legacyK
ey
)
}
type
completeWeChatOAuthRequest
struct
{
...
...
backend/internal/handler/auth_wechat_oauth_test.go
View file @
1aab084e
...
...
@@ -378,6 +378,7 @@ func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T)
handler
,
client
:=
newWeChatOAuthTestHandlerWithSettings
(
t
,
false
,
wechatOAuthTestSettings
(
"mp"
,
"wx-mp-app"
,
"wx-mp-secret"
,
"/auth/wechat/callback"
))
defer
client
.
Close
()
handler
.
cfg
.
Totp
.
EncryptionKey
=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
handler
.
cfg
.
Totp
.
EncryptionKeyConfigured
=
true
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
...
...
@@ -415,6 +416,67 @@ func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T)
require
.
Equal
(
t
,
"/purchase?from=wechat"
,
claims
.
RedirectTo
)
}
func
TestWeChatPaymentOAuthCallbackUsesExplicitPaymentResumeSigningKeyWhenMixedKeysConfigured
(
t
*
testing
.
T
)
{
originalAccessTokenURL
:=
wechatOAuthAccessTokenURL
t
.
Cleanup
(
func
()
{
wechatOAuthAccessTokenURL
=
originalAccessTokenURL
})
upstream
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
if
strings
.
Contains
(
r
.
URL
.
Path
,
"/sns/oauth2/access_token"
)
{
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
`{"access_token":"wechat-access","openid":"openid-mixed-key","scope":"snsapi_base"}`
))
return
}
http
.
NotFound
(
w
,
r
)
}))
defer
upstream
.
Close
()
wechatOAuthAccessTokenURL
=
upstream
.
URL
+
"/sns/oauth2/access_token"
handler
,
client
:=
newWeChatOAuthTestHandlerWithSettings
(
t
,
false
,
wechatOAuthTestSettings
(
"mp"
,
"wx-mp-app"
,
"wx-mp-secret"
,
"/auth/wechat/callback"
))
defer
client
.
Close
()
legacyKeyHex
:=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
explicitSigningKey
:=
"explicit-payment-resume-signing-key"
t
.
Setenv
(
"PAYMENT_RESUME_SIGNING_KEY"
,
explicitSigningKey
)
handler
.
cfg
.
Totp
.
EncryptionKey
=
legacyKeyHex
handler
.
cfg
.
Totp
.
EncryptionKeyConfigured
=
true
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/auth/oauth/wechat/payment/callback?code=wechat-code&state=state-mixed"
,
nil
)
req
.
Host
=
"api.example.com"
req
.
AddCookie
(
encodedCookie
(
wechatPaymentOAuthStateName
,
"state-mixed"
))
req
.
AddCookie
(
encodedCookie
(
wechatPaymentOAuthRedirect
,
"/purchase?from=wechat"
))
req
.
AddCookie
(
encodedCookie
(
wechatPaymentOAuthContextName
,
`{"payment_type":"wxpay","amount":"18.8","order_type":"subscription","plan_id":9}`
))
req
.
AddCookie
(
encodedCookie
(
wechatPaymentOAuthScope
,
"snsapi_base"
))
c
.
Request
=
req
handler
.
WeChatPaymentOAuthCallback
(
c
)
require
.
Equal
(
t
,
http
.
StatusFound
,
recorder
.
Code
)
location
:=
recorder
.
Header
()
.
Get
(
"Location"
)
parsed
,
err
:=
url
.
Parse
(
location
)
require
.
NoError
(
t
,
err
)
fragment
,
err
:=
url
.
ParseQuery
(
parsed
.
Fragment
)
require
.
NoError
(
t
,
err
)
token
:=
fragment
.
Get
(
"wechat_resume_token"
)
require
.
NotEmpty
(
t
,
token
)
claims
,
err
:=
service
.
NewPaymentResumeService
([]
byte
(
explicitSigningKey
))
.
ParseWeChatPaymentResumeToken
(
token
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
"openid-mixed-key"
,
claims
.
OpenID
)
require
.
Equal
(
t
,
payment
.
TypeWxpay
,
claims
.
PaymentType
)
require
.
Equal
(
t
,
"18.8"
,
claims
.
Amount
)
require
.
Equal
(
t
,
payment
.
OrderTypeSubscription
,
claims
.
OrderType
)
require
.
EqualValues
(
t
,
9
,
claims
.
PlanID
)
require
.
Equal
(
t
,
"/purchase?from=wechat"
,
claims
.
RedirectTo
)
_
,
err
=
service
.
NewPaymentResumeService
([]
byte
(
"0123456789abcdef0123456789abcdef"
))
.
ParseWeChatPaymentResumeToken
(
token
)
require
.
Error
(
t
,
err
)
}
func
TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels
(
t
*
testing
.
T
)
{
testCases
:=
[]
struct
{
name
string
...
...
backend/internal/payment/provider/wxpay.go
View file @
1aab084e
...
...
@@ -204,8 +204,8 @@ func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequ
if
err
==
nil
{
return
resp
,
nil
}
if
strings
.
Contains
(
err
.
Error
(),
wxpayErrNoAuth
)
{
return
nil
,
fmt
.
Errorf
(
"wxpay h5 payments are not authorized for this merchant: %w"
,
err
)
if
wxpayShouldFallbackToNative
(
err
)
{
return
w
.
prepayNativeFallback
(
ctx
,
client
,
req
,
notifyURL
,
totalFen
)
}
return
nil
,
err
case
wxpayModeNative
:
...
...
@@ -292,6 +292,23 @@ func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.Create
return
&
payment
.
CreatePaymentResponse
{
TradeNo
:
req
.
OrderID
,
PayURL
:
h5URL
},
nil
}
func
(
w
*
Wxpay
)
prepayNativeFallback
(
ctx
context
.
Context
,
c
*
core
.
Client
,
req
payment
.
CreatePaymentRequest
,
notifyURL
string
,
totalFen
int64
)
(
*
payment
.
CreatePaymentResponse
,
error
)
{
resp
,
err
:=
w
.
prepayNative
(
ctx
,
c
,
req
,
notifyURL
,
totalFen
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"wxpay native fallback after NO_AUTH: %w"
,
err
)
}
nativeURL
:=
strings
.
TrimSpace
(
resp
.
PayURL
)
if
nativeURL
==
""
{
nativeURL
=
strings
.
TrimSpace
(
resp
.
QRCode
)
}
if
nativeURL
==
""
{
return
resp
,
nil
}
resp
.
PayURL
=
nativeURL
resp
.
QRCode
=
nativeURL
return
resp
,
nil
}
func
buildWxpayH5Info
(
config
map
[
string
]
string
)
*
h5
.
H5Info
{
tp
:=
wxpayH5Type
info
:=
&
h5
.
H5Info
{
Type
:
&
tp
}
...
...
@@ -304,6 +321,10 @@ func buildWxpayH5Info(config map[string]string) *h5.H5Info {
return
info
}
func
wxpayShouldFallbackToNative
(
err
error
)
bool
{
return
err
!=
nil
&&
strings
.
Contains
(
err
.
Error
(),
wxpayErrNoAuth
)
}
func
resolveWxpayCreateMode
(
req
payment
.
CreatePaymentRequest
)
(
string
,
error
)
{
if
strings
.
TrimSpace
(
req
.
OpenID
)
!=
""
{
return
wxpayModeJSAPI
,
nil
...
...
backend/internal/payment/provider/wxpay_test.go
View file @
1aab084e
...
...
@@ -8,6 +8,7 @@ import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"net/url"
"strings"
"testing"
...
...
@@ -641,3 +642,68 @@ func TestCreatePaymentMobileH5IncludesConfiguredSceneInfo(t *testing.T) {
t
.
Fatalf
(
"pay_url = %q, want redirect_url query appended"
,
resp
.
PayURL
)
}
}
func
TestCreatePaymentMobileH5FallsBackToNativeOnNoAuth
(
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
++
return
&
jsapi
.
PrepayWithRequestPaymentResponse
{},
nil
,
nil
}
wxpayH5Prepay
=
func
(
ctx
context
.
Context
,
svc
h5
.
H5ApiService
,
req
h5
.
PrepayRequest
)
(
*
h5
.
PrepayResponse
,
*
core
.
APIResult
,
error
)
{
h5Calls
++
return
nil
,
nil
,
errors
.
New
(
"NO_AUTH"
)
}
wxpayNativePrepay
=
func
(
ctx
context
.
Context
,
svc
native
.
NativeApiService
,
req
native
.
PrepayRequest
)
(
*
native
.
PrepayResponse
,
*
core
.
APIResult
,
error
)
{
nativeCalls
++
return
&
native
.
PrepayResponse
{
CodeUrl
:
core
.
String
(
"weixin://wxpay/bizpayurl?pr=fallback-native"
),
},
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_100"
,
Amount
:
"66.88"
,
PaymentType
:
payment
.
TypeWxpay
,
Subject
:
"Balance Recharge"
,
NotifyURL
:
"https://merchant.example/payment/notify"
,
ClientIP
:
"203.0.113.10"
,
IsMobile
:
true
,
})
if
err
!=
nil
{
t
.
Fatalf
(
"unexpected error: %v"
,
err
)
}
if
jsapiCalls
!=
0
{
t
.
Fatalf
(
"jsapi prepay calls = %d, want 0"
,
jsapiCalls
)
}
if
h5Calls
!=
1
{
t
.
Fatalf
(
"h5 prepay calls = %d, want 1"
,
h5Calls
)
}
if
nativeCalls
!=
1
{
t
.
Fatalf
(
"native prepay calls = %d, want 1"
,
nativeCalls
)
}
if
resp
.
PayURL
!=
"weixin://wxpay/bizpayurl?pr=fallback-native"
{
t
.
Fatalf
(
"pay_url = %q, want native fallback url"
,
resp
.
PayURL
)
}
if
resp
.
QRCode
!=
"weixin://wxpay/bizpayurl?pr=fallback-native"
{
t
.
Fatalf
(
"qr_code = %q, want native fallback url"
,
resp
.
QRCode
)
}
}
backend/internal/service/payment_config_providers.go
View file @
1aab084e
...
...
@@ -116,6 +116,17 @@ var providerSensitiveConfigFields = map[string]map[string]struct{}{
payment
.
TypeStripe
:
{
"secretkey"
:
{},
"webhooksecret"
:
{}},
}
// providerPendingOrderProtectedConfigFields lists config keys that cannot be
// changed while the instance has in-progress orders. This includes secrets plus
// all provider identity fields that are snapshotted into orders or used by
// webhook/refund verification.
var
providerPendingOrderProtectedConfigFields
=
map
[
string
]
map
[
string
]
struct
{}{
payment
.
TypeEasyPay
:
{
"pkey"
:
{},
"pid"
:
{}},
payment
.
TypeAlipay
:
{
"privatekey"
:
{},
"publickey"
:
{},
"alipaypublickey"
:
{},
"appid"
:
{}},
payment
.
TypeWxpay
:
{
"privatekey"
:
{},
"apiv3key"
:
{},
"publickey"
:
{},
"appid"
:
{},
"mpappid"
:
{},
"mchid"
:
{},
"publickeyid"
:
{},
"certserial"
:
{}},
payment
.
TypeStripe
:
{
"secretkey"
:
{},
"webhooksecret"
:
{}},
}
func
isSensitiveProviderConfigField
(
providerKey
,
fieldName
string
)
bool
{
fields
,
ok
:=
providerSensitiveConfigFields
[
providerKey
]
if
!
ok
{
...
...
@@ -125,6 +136,28 @@ func isSensitiveProviderConfigField(providerKey, fieldName string) bool {
return
found
}
func
hasPendingOrderProtectedConfigChange
(
providerKey
string
,
currentConfig
,
nextConfig
map
[
string
]
string
)
bool
{
fields
,
ok
:=
providerPendingOrderProtectedConfigFields
[
providerKey
]
if
!
ok
{
return
false
}
for
fieldName
:=
range
fields
{
if
providerConfigFieldValue
(
currentConfig
,
fieldName
)
!=
providerConfigFieldValue
(
nextConfig
,
fieldName
)
{
return
true
}
}
return
false
}
func
providerConfigFieldValue
(
config
map
[
string
]
string
,
fieldName
string
)
string
{
for
key
,
value
:=
range
config
{
if
strings
.
EqualFold
(
key
,
fieldName
)
{
return
value
}
}
return
""
}
func
(
s
*
PaymentConfigService
)
countPendingOrders
(
ctx
context
.
Context
,
providerInstanceID
int64
)
(
int
,
error
)
{
return
s
.
entClient
.
PaymentOrder
.
Query
()
.
Where
(
...
...
@@ -190,6 +223,18 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"load provider instance: %w"
,
err
)
}
var
pendingOrderCount
*
int
getPendingOrderCount
:=
func
()
(
int
,
error
)
{
if
pendingOrderCount
!=
nil
{
return
*
pendingOrderCount
,
nil
}
count
,
err
:=
s
.
countPendingOrders
(
ctx
,
id
)
if
err
!=
nil
{
return
0
,
fmt
.
Errorf
(
"check pending orders: %w"
,
err
)
}
pendingOrderCount
=
&
count
return
count
,
nil
}
nextEnabled
:=
current
.
Enabled
if
req
.
Enabled
!=
nil
{
nextEnabled
=
*
req
.
Enabled
...
...
@@ -201,18 +246,20 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
if
err
:=
s
.
validateVisibleMethodEnablementConflicts
(
ctx
,
id
,
current
.
ProviderKey
,
nextSupportedTypes
,
nextEnabled
);
err
!=
nil
{
return
nil
,
err
}
var
mergedConfig
map
[
string
]
string
if
req
.
Config
!=
nil
{
hasSensitive
:=
false
for
k
,
v
:=
range
req
.
Config
{
if
v
!=
""
&&
isSensitiveProviderConfigField
(
current
.
ProviderKey
,
k
)
{
hasSensitive
=
true
break
}
currentConfig
,
err
:=
s
.
decryptConfig
(
current
.
Config
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"decrypt existing config: %w"
,
err
)
}
if
hasSensitive
{
count
,
err
:=
s
.
countPendingOrders
(
ctx
,
id
)
mergedConfig
,
err
=
s
.
mergeConfig
(
ctx
,
id
,
req
.
Config
)
if
err
!=
nil
{
return
nil
,
err
}
if
hasPendingOrderProtectedConfigChange
(
current
.
ProviderKey
,
currentConfig
,
mergedConfig
)
{
count
,
err
:=
getPendingOrderCount
()
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"check pending orders: %w"
,
err
)
return
nil
,
err
}
if
count
>
0
{
return
nil
,
infraerrors
.
Conflict
(
"PENDING_ORDERS"
,
"instance has pending orders"
)
.
...
...
@@ -221,9 +268,9 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
}
}
if
req
.
Enabled
!=
nil
&&
!*
req
.
Enabled
{
count
,
err
:=
s
.
coun
tPendingOrder
s
(
ctx
,
id
)
count
,
err
:=
ge
tPendingOrder
Count
(
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"check pending orders: %w"
,
err
)
return
nil
,
err
}
if
count
>
0
{
return
nil
,
infraerrors
.
Conflict
(
"PENDING_ORDERS"
,
"instance has pending orders"
)
.
...
...
@@ -237,13 +284,6 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
if
req
.
Enabled
!=
nil
{
finalEnabled
=
*
req
.
Enabled
}
var
mergedConfig
map
[
string
]
string
if
req
.
Config
!=
nil
{
mergedConfig
,
err
=
s
.
mergeConfig
(
ctx
,
id
,
req
.
Config
)
if
err
!=
nil
{
return
nil
,
err
}
}
if
finalEnabled
{
configToValidate
:=
mergedConfig
if
configToValidate
==
nil
{
...
...
@@ -269,9 +309,9 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
}
if
req
.
SupportedTypes
!=
nil
{
// Check pending orders before removing payment types
count
,
err
:=
s
.
coun
tPendingOrder
s
(
ctx
,
id
)
count
,
err
:=
ge
tPendingOrder
Count
(
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"check pending orders: %w"
,
err
)
return
nil
,
err
}
if
count
>
0
{
// Load current instance to compare types
...
...
backend/internal/service/payment_config_providers_test.go
View file @
1aab084e
...
...
@@ -8,8 +8,13 @@ import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"strconv"
"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"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
...
...
@@ -315,10 +320,263 @@ func TestUpdateProviderInstancePersistsEnabledAndSupportedTypes(t *testing.T) {
require
.
Equal
(
t
,
"alipay,wxpay"
,
saved
.
SupportedTypes
)
}
func
TestUpdateProviderInstanceRejectsProtectedConfigChangesWhilePendingOrders
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
name
string
providerKey
string
createConfig
func
(
*
testing
.
T
)
map
[
string
]
string
supportedType
[]
string
updateConfig
map
[
string
]
string
fieldName
string
wantValue
string
}{
{
name
:
"wxpay appId"
,
providerKey
:
payment
.
TypeWxpay
,
createConfig
:
validWxpayProviderConfig
,
supportedType
:
[]
string
{
payment
.
TypeWxpay
},
updateConfig
:
map
[
string
]
string
{
"appId"
:
"wx-app-updated"
},
fieldName
:
"appId"
,
wantValue
:
"wx-app-test"
,
},
{
name
:
"wxpay mpAppId"
,
providerKey
:
payment
.
TypeWxpay
,
createConfig
:
validWxpayProviderConfigWithJSAPIAppID
,
supportedType
:
[]
string
{
payment
.
TypeWxpay
},
updateConfig
:
map
[
string
]
string
{
"mpAppId"
:
"wx-mp-app-updated"
},
fieldName
:
"mpAppId"
,
wantValue
:
"wx-mp-app-test"
,
},
{
name
:
"wxpay mchId"
,
providerKey
:
payment
.
TypeWxpay
,
createConfig
:
validWxpayProviderConfig
,
supportedType
:
[]
string
{
payment
.
TypeWxpay
},
updateConfig
:
map
[
string
]
string
{
"mchId"
:
"mch-updated"
},
fieldName
:
"mchId"
,
wantValue
:
"mch-test"
,
},
{
name
:
"wxpay publicKeyId"
,
providerKey
:
payment
.
TypeWxpay
,
createConfig
:
validWxpayProviderConfig
,
supportedType
:
[]
string
{
payment
.
TypeWxpay
},
updateConfig
:
map
[
string
]
string
{
"publicKeyId"
:
"public-key-id-updated"
},
fieldName
:
"publicKeyId"
,
wantValue
:
"public-key-id-test"
,
},
{
name
:
"wxpay certSerial"
,
providerKey
:
payment
.
TypeWxpay
,
createConfig
:
validWxpayProviderConfig
,
supportedType
:
[]
string
{
payment
.
TypeWxpay
},
updateConfig
:
map
[
string
]
string
{
"certSerial"
:
"cert-serial-updated"
},
fieldName
:
"certSerial"
,
wantValue
:
"cert-serial-test"
,
},
{
name
:
"alipay appId"
,
providerKey
:
payment
.
TypeAlipay
,
createConfig
:
validAlipayProviderConfig
,
supportedType
:
[]
string
{
payment
.
TypeAlipay
},
updateConfig
:
map
[
string
]
string
{
"appId"
:
"alipay-app-updated"
},
fieldName
:
"appId"
,
wantValue
:
"alipay-app-test"
,
},
{
name
:
"easypay pid"
,
providerKey
:
payment
.
TypeEasyPay
,
createConfig
:
validEasyPayProviderConfig
,
supportedType
:
[]
string
{
payment
.
TypeAlipay
},
updateConfig
:
map
[
string
]
string
{
"pid"
:
"pid-updated"
},
fieldName
:
"pid"
,
wantValue
:
"pid-test"
,
},
}
for
_
,
tc
:=
range
tests
{
tc
:=
tc
t
.
Run
(
tc
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
ctx
:=
context
.
Background
()
client
:=
newPaymentConfigServiceTestClient
(
t
)
svc
:=
&
PaymentConfigService
{
entClient
:
client
,
encryptionKey
:
[]
byte
(
"0123456789abcdef0123456789abcdef"
),
}
instance
,
err
:=
svc
.
CreateProviderInstance
(
ctx
,
CreateProviderInstanceRequest
{
ProviderKey
:
tc
.
providerKey
,
Name
:
"protected-config-instance"
,
Config
:
tc
.
createConfig
(
t
),
SupportedTypes
:
tc
.
supportedType
,
Enabled
:
true
,
})
require
.
NoError
(
t
,
err
)
createPendingProviderConfigOrder
(
t
,
ctx
,
client
,
instance
)
updated
,
err
:=
svc
.
UpdateProviderInstance
(
ctx
,
instance
.
ID
,
UpdateProviderInstanceRequest
{
Config
:
tc
.
updateConfig
,
})
require
.
Nil
(
t
,
updated
)
require
.
Error
(
t
,
err
)
require
.
Equal
(
t
,
"PENDING_ORDERS"
,
infraerrors
.
Reason
(
err
))
saved
,
err
:=
client
.
PaymentProviderInstance
.
Get
(
ctx
,
instance
.
ID
)
require
.
NoError
(
t
,
err
)
cfg
,
err
:=
svc
.
decryptConfig
(
saved
.
Config
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
tc
.
wantValue
,
cfg
[
tc
.
fieldName
])
})
}
}
func
TestUpdateProviderInstanceAllowsSafeConfigChangesWhilePendingOrders
(
t
*
testing
.
T
)
{
t
.
Parallel
()
tests
:=
[]
struct
{
name
string
providerKey
string
createConfig
func
(
*
testing
.
T
)
map
[
string
]
string
supportedType
[]
string
updateConfig
map
[
string
]
string
fieldName
string
wantValue
string
}{
{
name
:
"wxpay notifyUrl"
,
providerKey
:
payment
.
TypeWxpay
,
createConfig
:
validWxpayProviderConfig
,
supportedType
:
[]
string
{
payment
.
TypeWxpay
},
updateConfig
:
map
[
string
]
string
{
"notifyUrl"
:
"https://merchant.example.com/wxpay/notify-v2"
},
fieldName
:
"notifyUrl"
,
wantValue
:
"https://merchant.example.com/wxpay/notify-v2"
,
},
{
name
:
"alipay same appId"
,
providerKey
:
payment
.
TypeAlipay
,
createConfig
:
validAlipayProviderConfig
,
supportedType
:
[]
string
{
payment
.
TypeAlipay
},
updateConfig
:
map
[
string
]
string
{
"appId"
:
"alipay-app-test"
},
fieldName
:
"appId"
,
wantValue
:
"alipay-app-test"
,
},
}
for
_
,
tc
:=
range
tests
{
tc
:=
tc
t
.
Run
(
tc
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Parallel
()
ctx
:=
context
.
Background
()
client
:=
newPaymentConfigServiceTestClient
(
t
)
svc
:=
&
PaymentConfigService
{
entClient
:
client
,
encryptionKey
:
[]
byte
(
"0123456789abcdef0123456789abcdef"
),
}
instance
,
err
:=
svc
.
CreateProviderInstance
(
ctx
,
CreateProviderInstanceRequest
{
ProviderKey
:
tc
.
providerKey
,
Name
:
"safe-config-instance"
,
Config
:
tc
.
createConfig
(
t
),
SupportedTypes
:
tc
.
supportedType
,
Enabled
:
true
,
})
require
.
NoError
(
t
,
err
)
createPendingProviderConfigOrder
(
t
,
ctx
,
client
,
instance
)
updated
,
err
:=
svc
.
UpdateProviderInstance
(
ctx
,
instance
.
ID
,
UpdateProviderInstanceRequest
{
Config
:
tc
.
updateConfig
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
updated
)
saved
,
err
:=
client
.
PaymentProviderInstance
.
Get
(
ctx
,
instance
.
ID
)
require
.
NoError
(
t
,
err
)
cfg
,
err
:=
svc
.
decryptConfig
(
saved
.
Config
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
tc
.
wantValue
,
cfg
[
tc
.
fieldName
])
})
}
}
func
createPendingProviderConfigOrder
(
t
*
testing
.
T
,
ctx
context
.
Context
,
client
*
dbent
.
Client
,
instance
*
dbent
.
PaymentProviderInstance
)
{
t
.
Helper
()
user
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"provider-config-pending@example.com"
)
.
SetPasswordHash
(
"hash"
)
.
SetUsername
(
"provider-config-pending-user"
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
instanceID
:=
strconv
.
FormatInt
(
instance
.
ID
,
10
)
_
,
err
=
client
.
PaymentOrder
.
Create
()
.
SetUserID
(
user
.
ID
)
.
SetUserEmail
(
user
.
Email
)
.
SetUserName
(
user
.
Username
)
.
SetAmount
(
88
)
.
SetPayAmount
(
88
)
.
SetFeeRate
(
0
)
.
SetRechargeCode
(
"PENDING-PROVIDER-CONFIG-"
+
instanceID
)
.
SetOutTradeNo
(
"sub2_pending_provider_config_"
+
instanceID
)
.
SetPaymentType
(
providerPendingOrderPaymentType
(
instance
.
ProviderKey
))
.
SetPaymentTradeNo
(
""
)
.
SetOrderType
(
payment
.
OrderTypeBalance
)
.
SetStatus
(
OrderStatusPending
)
.
SetExpiresAt
(
time
.
Now
()
.
Add
(
time
.
Hour
))
.
SetClientIP
(
"127.0.0.1"
)
.
SetSrcHost
(
"api.example.com"
)
.
SetProviderInstanceID
(
instanceID
)
.
SetProviderKey
(
instance
.
ProviderKey
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
}
func
providerPendingOrderPaymentType
(
providerKey
string
)
string
{
switch
providerKey
{
case
payment
.
TypeWxpay
:
return
payment
.
TypeWxpay
case
payment
.
TypeAlipay
:
return
payment
.
TypeAlipay
default
:
return
payment
.
TypeAlipay
}
}
func
boolPtrValue
(
v
bool
)
*
bool
{
return
&
v
}
func
validAlipayProviderConfig
(
t
*
testing
.
T
)
map
[
string
]
string
{
t
.
Helper
()
return
map
[
string
]
string
{
"appId"
:
"alipay-app-test"
,
"privateKey"
:
"alipay-private-key-test"
,
"notifyUrl"
:
"https://merchant.example.com/alipay/notify"
,
"returnUrl"
:
"https://merchant.example.com/alipay/return"
,
}
}
func
validEasyPayProviderConfig
(
t
*
testing
.
T
)
map
[
string
]
string
{
t
.
Helper
()
return
map
[
string
]
string
{
"pid"
:
"pid-test"
,
"pkey"
:
"pkey-test"
,
"apiBase"
:
"https://pay.example.com"
,
"notifyUrl"
:
"https://merchant.example.com/easypay/notify"
,
"returnUrl"
:
"https://merchant.example.com/easypay/return"
,
}
}
func
validWxpayProviderConfig
(
t
*
testing
.
T
)
map
[
string
]
string
{
t
.
Helper
()
...
...
@@ -340,3 +598,11 @@ func validWxpayProviderConfig(t *testing.T) map[string]string {
"certSerial"
:
"cert-serial-test"
,
}
}
func
validWxpayProviderConfigWithJSAPIAppID
(
t
*
testing
.
T
)
map
[
string
]
string
{
t
.
Helper
()
cfg
:=
validWxpayProviderConfig
(
t
)
cfg
[
"mpAppId"
]
=
"wx-mp-app-test"
return
cfg
}
backend/internal/service/payment_resume_service_test.go
View file @
1aab084e
...
...
@@ -387,6 +387,45 @@ func TestPaymentServiceParseWeChatPaymentResumeTokenAcceptsLegacyEncryptionKeyDu
}
}
func
TestNewConfiguredPaymentResumeServicePrefersExplicitSigningKeyAndKeepsLegacyVerificationFallback
(
t
*
testing
.
T
)
{
t
.
Setenv
(
"PAYMENT_RESUME_SIGNING_KEY"
,
"explicit-payment-resume-signing-key"
)
legacyKey
:=
[]
byte
(
"0123456789abcdef0123456789abcdef"
)
svc
:=
newLegacyAwarePaymentResumeService
(
legacyKey
)
explicitToken
,
err
:=
svc
.
CreateWeChatPaymentResumeToken
(
WeChatPaymentResumeClaims
{
OpenID
:
"openid-explicit-key"
,
PaymentType
:
payment
.
TypeWxpay
,
})
if
err
!=
nil
{
t
.
Fatalf
(
"CreateWeChatPaymentResumeToken returned error: %v"
,
err
)
}
explicitClaims
,
err
:=
NewPaymentResumeService
([]
byte
(
"explicit-payment-resume-signing-key"
))
.
ParseWeChatPaymentResumeToken
(
explicitToken
)
if
err
!=
nil
{
t
.
Fatalf
(
"ParseWeChatPaymentResumeToken returned error: %v"
,
err
)
}
if
explicitClaims
.
OpenID
!=
"openid-explicit-key"
{
t
.
Fatalf
(
"openid = %q, want %q"
,
explicitClaims
.
OpenID
,
"openid-explicit-key"
)
}
legacyToken
,
err
:=
NewPaymentResumeService
(
legacyKey
)
.
CreateWeChatPaymentResumeToken
(
WeChatPaymentResumeClaims
{
OpenID
:
"openid-legacy-key"
,
PaymentType
:
payment
.
TypeWxpay
,
})
if
err
!=
nil
{
t
.
Fatalf
(
"CreateWeChatPaymentResumeToken returned error: %v"
,
err
)
}
legacyClaims
,
err
:=
svc
.
ParseWeChatPaymentResumeToken
(
legacyToken
)
if
err
!=
nil
{
t
.
Fatalf
(
"ParseWeChatPaymentResumeToken returned error: %v"
,
err
)
}
if
legacyClaims
.
OpenID
!=
"openid-legacy-key"
{
t
.
Fatalf
(
"openid = %q, want %q"
,
legacyClaims
.
OpenID
,
"openid-legacy-key"
)
}
}
func
TestNormalizeVisibleMethodSource
(
t
*
testing
.
T
)
{
t
.
Parallel
()
...
...
backend/internal/service/payment_service.go
View file @
1aab084e
...
...
@@ -268,8 +268,16 @@ func (s *PaymentService) paymentResume() *PaymentResumeService {
return
psNewPaymentResumeService
(
s
.
configService
)
}
func
NewLegacyAwarePaymentResumeService
(
legacyKey
[]
byte
)
*
PaymentResumeService
{
return
newLegacyAwarePaymentResumeService
(
legacyKey
)
}
func
psNewPaymentResumeService
(
configService
*
PaymentConfigService
)
*
PaymentResumeService
{
signingKey
,
verifyFallbacks
:=
psResumeSigningKeys
(
configService
)
return
newLegacyAwarePaymentResumeService
(
psResumeLegacyVerificationKey
(
configService
))
}
func
newLegacyAwarePaymentResumeService
(
legacyKey
[]
byte
)
*
PaymentResumeService
{
signingKey
,
verifyFallbacks
:=
resolvePaymentResumeSigningKeys
(
legacyKey
)
return
NewPaymentResumeService
(
signingKey
,
verifyFallbacks
...
)
}
...
...
@@ -279,8 +287,18 @@ func psResumeSigningKey(configService *PaymentConfigService) []byte {
}
func
psResumeSigningKeys
(
configService
*
PaymentConfigService
)
([]
byte
,
[][]
byte
)
{
return
resolvePaymentResumeSigningKeys
(
psResumeLegacyVerificationKey
(
configService
))
}
func
psResumeLegacyVerificationKey
(
configService
*
PaymentConfigService
)
[]
byte
{
if
configService
==
nil
{
return
nil
}
return
configService
.
encryptionKey
}
func
resolvePaymentResumeSigningKeys
(
legacyKey
[]
byte
)
([]
byte
,
[][]
byte
)
{
signingKey
:=
parsePaymentResumeSigningKey
(
os
.
Getenv
(
paymentResumeSigningKeyEnv
))
legacyKey
:=
psResumeLegacyVerificationKey
(
configService
)
if
len
(
signingKey
)
==
0
{
if
len
(
legacyKey
)
==
0
{
return
nil
,
nil
...
...
@@ -293,13 +311,6 @@ func psResumeSigningKeys(configService *PaymentConfigService) ([]byte, [][]byte)
return
signingKey
,
[][]
byte
{
legacyKey
}
}
func
psResumeLegacyVerificationKey
(
configService
*
PaymentConfigService
)
[]
byte
{
if
configService
==
nil
{
return
nil
}
return
configService
.
encryptionKey
}
func
parsePaymentResumeSigningKey
(
raw
string
)
[]
byte
{
raw
=
strings
.
TrimSpace
(
raw
)
if
raw
==
""
{
...
...
backend/migrations/120_enforce_payment_orders_out_trade_no_unique_notx.sql
View file @
1aab084e
-- Build the payment order uniqueness guarantee online.
-- The migration runner performs an explicit duplicate out_trade_no precheck and
-- drops any stale invalid paymentorder_out_trade_no_unique index before retrying.
-- Create the new partial unique index concurrently first so writes keep flowing,
-- then remove the legacy index name once the replacement is ready.
CREATE
UNIQUE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
paymentorder_out_trade_no_unique
...
...
backend/migrations/auth_identity_payment_migrations_regression_test.go
View file @
1aab084e
...
...
@@ -63,6 +63,8 @@ func TestMigration119DefersPaymentIndexRolloutToOnlineFollowup(t *testing.T) {
require
.
NoError
(
t
,
err
)
followupSQL
:=
string
(
followupContent
)
require
.
Contains
(
t
,
followupSQL
,
"explicit duplicate out_trade_no precheck"
)
require
.
Contains
(
t
,
followupSQL
,
"stale invalid paymentorder_out_trade_no_unique index"
)
require
.
Contains
(
t
,
followupSQL
,
"CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique"
)
require
.
NotContains
(
t
,
followupSQL
,
"DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no_unique"
)
require
.
Contains
(
t
,
followupSQL
,
"DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no"
)
...
...
@@ -76,6 +78,18 @@ func TestMigration119DefersPaymentIndexRolloutToOnlineFollowup(t *testing.T) {
require
.
Contains
(
t
,
alignmentSQL
,
"RENAME TO paymentorder_out_trade_no"
)
}
func
TestMigration110SeedsAuthSourceSignupGrantsDisabledByDefault
(
t
*
testing
.
T
)
{
content
,
err
:=
FS
.
ReadFile
(
"110_pending_auth_and_provider_default_grants.sql"
)
require
.
NoError
(
t
,
err
)
sql
:=
string
(
content
)
require
.
Contains
(
t
,
sql
,
"('auth_source_default_email_grant_on_signup', 'false')"
)
require
.
Contains
(
t
,
sql
,
"('auth_source_default_linuxdo_grant_on_signup', 'false')"
)
require
.
Contains
(
t
,
sql
,
"('auth_source_default_oidc_grant_on_signup', 'false')"
)
require
.
Contains
(
t
,
sql
,
"('auth_source_default_wechat_grant_on_signup', 'false')"
)
require
.
NotContains
(
t
,
sql
,
"('auth_source_default_email_grant_on_signup', 'true')"
)
}
func
TestMigration122ScrubsPendingOAuthCompletionTokensAtRest
(
t
*
testing
.
T
)
{
content
,
err
:=
FS
.
ReadFile
(
"122_pending_auth_completion_token_cleanup.sql"
)
require
.
NoError
(
t
,
err
)
...
...
@@ -94,7 +108,10 @@ func TestMigration123BackfillsLegacyAuthSourceGrantDefaultsSafely(t *testing.T)
require
.
NoError
(
t
,
err
)
sql
:=
string
(
content
)
require
.
Contains
(
t
,
sql
,
"Intentionally left as a no-op"
)
require
.
NotContains
(
t
,
sql
,
"UPDATE settings"
)
require
.
NotContains
(
t
,
sql
,
"value = 'false'"
)
require
.
Contains
(
t
,
sql
,
"110_pending_auth_and_provider_default_grants.sql"
)
require
.
Contains
(
t
,
sql
,
"schema_migrations"
)
require
.
Contains
(
t
,
sql
,
"updated_at"
)
require
.
Contains
(
t
,
sql
,
"'_grant_on_signup'"
)
require
.
Contains
(
t
,
sql
,
"value = 'false'"
)
require
.
Contains
(
t
,
sql
,
"auth_identity_migration_reports"
)
}
frontend/src/views/user/PaymentResultView.vue
View file @
1aab084e
...
...
@@ -291,6 +291,7 @@ onMounted(async () => {
const
routeOrderId
=
Number
(
readRouteQueryString
(
'
order_id
'
))
||
0
let
outTradeNo
=
readRouteQueryString
(
'
out_trade_no
'
)
let
orderId
=
0
let
resumeTokenLookupFailed
=
false
const
restored
=
restoreRecoverySnapshot
({
resumeToken
,
...
...
@@ -312,16 +313,27 @@ onMounted(async () => {
orderId
=
resolvedOrder
.
id
}
}
else
if
(
routeOrderId
>
0
)
{
resumeTokenLookupFailed
=
true
orderId
=
routeOrderId
}
else
{
resumeTokenLookupFailed
=
true
}
}
else
if
(
routeOrderId
>
0
)
{
orderId
=
routeOrderId
}
const
hasLegacyFallbackContext
=
readRouteQueryString
(
'
trade_status
'
).
trim
()
!==
''
const
shouldUsePublicOutTradeNo
=
!
resumeToken
&&
outTradeNo
!==
''
&&
(
hasLegacyFallbackContext
||
routeOrderId
>
0
||
orderId
>
0
)
const
shouldUsePublicOutTradeNo
=
outTradeNo
!==
''
&&
(
hasLegacyFallbackContext
||
routeOrderId
>
0
||
orderId
>
0
)
if
(
!
order
.
value
&&
shouldUsePublicOutTradeNo
)
{
if
(
!
order
.
value
&&
orderId
&&
(
!
resumeToken
||
routeOrderId
>
0
))
{
try
{
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
// Order lookup failed, will try legacy fallback below when possible.
}
}
if
(
!
order
.
value
&&
shouldUsePublicOutTradeNo
&&
(
!
resumeToken
||
resumeTokenLookupFailed
))
{
const
legacyOrder
=
await
resolveOrderFromOutTradeNo
(
outTradeNo
)
if
(
legacyOrder
)
{
order
.
value
=
legacyOrder
...
...
@@ -331,15 +343,7 @@ onMounted(async () => {
}
}
if
(
!
order
.
value
&&
orderId
&&
(
!
resumeToken
||
routeOrderId
>
0
))
{
try
{
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
// Order lookup failed, will try legacy fallback below when possible.
}
}
if
(
!
order
.
value
&&
!
resumeToken
&&
!
orderId
&&
outTradeNo
&&
hasLegacyFallbackContext
)
{
if
(
!
order
.
value
&&
!
orderId
&&
outTradeNo
&&
hasLegacyFallbackContext
)
{
returnInfo
.
value
=
{
outTradeNo
,
money
:
String
(
route
.
query
.
money
||
''
),
...
...
@@ -350,15 +354,22 @@ onMounted(async () => {
const
refreshOrder
=
async
():
Promise
<
PaymentOrder
|
null
>
=>
{
if
(
resumeToken
)
{
return
await
resolveOrderFromResumeToken
(
resumeToken
)
const
resolvedOrder
=
await
resolveOrderFromResumeToken
(
resumeToken
)
if
(
resolvedOrder
)
{
return
resolvedOrder
}
}
if
(
shouldUsePublicOutTradeNo
)
{
return
await
resolveOrderFromOutTradeNo
(
outTradeNo
)
if
(
orderId
)
{
try
{
return
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
// Fall through to legacy public verification when order polling is unavailable.
}
}
if
(
orderId
)
{
return
await
paymentStore
.
pollOrderStatus
(
orderId
)
if
(
shouldUsePublicOutTradeNo
)
{
return
await
resolveOrderFromOutTradeNo
(
outTradeNo
)
}
return
null
...
...
frontend/src/views/user/PaymentView.vue
View file @
1aab084e
...
...
@@ -740,18 +740,23 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
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
'
))
resetPayment
()
}
else
if
(
errMsg
&&
!
errMsg
.
includes
(
'
ok
'
))
{
applyScenarioError
({
reason
:
'
WECHAT_JSAPI_FAILED
'
,
message
:
errMsg
}
,
visibleMethod
)
resetPayment
()
}
else
{
const
resultState
=
{
...
decision
.
paymentState
}
try
{
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
'
))
resetPayment
()
}
else
if
(
errMsg
&&
!
errMsg
.
includes
(
'
ok
'
))
{
applyScenarioError
({
reason
:
'
WECHAT_JSAPI_FAILED
'
,
message
:
errMsg
}
,
visibleMethod
)
resetPayment
()
}
else
{
const
resultState
=
{
...
decision
.
paymentState
}
resetPayment
()
await
redirectToPaymentResult
(
resultState
)
}
}
catch
(
err
:
unknown
)
{
resetPayment
()
await
redirectToPaymentResult
(
resultState
)
throw
err
}
return
}
...
...
frontend/src/views/user/__tests__/PaymentResultView.spec.ts
View file @
1aab084e
...
...
@@ -255,14 +255,21 @@ describe('PaymentResultView', () => {
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
})
it
(
'
does not
fall back to public out_trade_no verification when resume_token recovery fails
'
,
async
()
=>
{
it
(
'
fall
s
back to public out_trade_no verification when resume_token recovery fails
in legacy return flows
'
,
async
()
=>
{
routeState
.
query
=
{
resume_token
:
'
resume-fail
'
,
out_trade_no
:
'
legacy-should-not-run
'
,
trade_status
:
'
TRADE_SUCCESS
'
,
}
resolveOrderPublicByResumeToken
.
mockRejectedValueOnce
(
new
Error
(
'
resume failed
'
))
mount
(
PaymentResultView
,
{
verifyOrderPublic
.
mockResolvedValueOnce
({
data
:
{
...
orderFactory
(
'
PAID
'
),
out_trade_no
:
'
legacy-should-not-run
'
,
},
})
const
wrapper
=
mount
(
PaymentResultView
,
{
global
:
{
stubs
:
{
OrderStatusBadge
:
true
,
...
...
@@ -273,7 +280,9 @@ describe('PaymentResultView', () => {
await
flushPromises
()
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-fail
'
)
expect
(
verifyOrderPublic
).
not
.
toHaveBeenCalled
()
expect
(
verifyOrderPublic
).
toHaveBeenCalledWith
(
'
legacy-should-not-run
'
)
expect
(
pollOrderStatus
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
})
it
(
'
ignores a stale global recovery snapshot when legacy return markers do not identify the order
'
,
async
()
=>
{
...
...
frontend/src/views/user/__tests__/PaymentView.spec.ts
View file @
1aab084e
...
...
@@ -252,6 +252,33 @@ describe('PaymentView WeChat JSAPI flow', () => {
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
})
it
(
'
clears stale recovery state when JSAPI never becomes available
'
,
async
()
=>
{
vi
.
useFakeTimers
()
createOrder
.
mockResolvedValue
(
jsapiOrderFixture
(
'
resume-token-missing-bridge
'
))
;(
window
as
Window
&
{
WeixinJSBridge
?:
{
invoke
:
typeof
bridgeInvoke
}
}).
WeixinJSBridge
=
undefined
const
wrapper
=
shallowMount
(
PaymentView
,
{
global
:
{
stubs
:
{
Teleport
:
true
,
Transition
:
false
,
},
},
})
await
flushPromises
()
await
vi
.
advanceTimersByTimeAsync
(
4000
)
await
flushPromises
()
await
flushPromises
()
expect
(
showError
).
toHaveBeenCalledWith
(
'
payment.errors.wechatJsapiUnavailable payment.errors.wechatOpenInWeChatHint
'
,
)
expect
(
routerPush
).
not
.
toHaveBeenCalled
()
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
expect
(
wrapper
.
html
()).
not
.
toContain
(
'
payment-status-panel-stub
'
)
})
it
(
'
clears a stale recovery snapshot before handling wechat resume callback params
'
,
async
()
=>
{
createOrder
.
mockRejectedValueOnce
(
new
Error
(
'
resume failed
'
))
window
.
localStorage
.
setItem
(
PAYMENT_RECOVERY_STORAGE_KEY
,
JSON
.
stringify
({
...
...
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