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
dd314c41
Commit
dd314c41
authored
Apr 22, 2026
by
IanShaw027
Browse files
fix(payment): restore public resume and result flows
parent
c229f33e
Changes
15
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/payment_handler.go
View file @
dd314c41
...
...
@@ -2,9 +2,9 @@ package handler
import
(
"fmt"
"net/http"
"strconv"
"strings"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/payment"
...
...
@@ -458,25 +458,61 @@ type PublicOrderResult struct {
OutTradeNo
string
`json:"out_trade_no"`
Amount
float64
`json:"amount"`
PayAmount
float64
`json:"pay_amount"`
FeeRate
float64
`json:"fee_rate"`
PaymentType
string
`json:"payment_type"`
OrderType
string
`json:"order_type"`
Status
string
`json:"status"`
CreatedAt
time
.
Time
`json:"created_at"`
ExpiresAt
time
.
Time
`json:"expires_at"`
PaidAt
*
time
.
Time
`json:"paid_at,omitempty"`
CompletedAt
*
time
.
Time
`json:"completed_at,omitempty"`
RefundAmount
float64
`json:"refund_amount"`
RefundReason
*
string
`json:"refund_reason,omitempty"`
RefundRequestedAt
*
time
.
Time
`json:"refund_requested_at,omitempty"`
RefundRequestedBy
*
string
`json:"refund_requested_by,omitempty"`
RefundRequestReason
*
string
`json:"refund_request_reason,omitempty"`
PlanID
*
int64
`json:"plan_id,omitempty"`
}
var
errPaymentPublicOrderVerifyRemoved
=
infraerrors
.
New
(
http
.
StatusGone
,
"PAYMENT_PUBLIC_ORDER_VERIFY_REMOVED"
,
"public payment order verification by out_trade_no has been removed; use resume_token recovery instead"
,
)
.
WithMetadata
(
map
[
string
]
string
{
"replacement_endpoint"
:
"/api/v1/payment/public/orders/resolve"
,
"replacement_field"
:
"resume_token"
,
})
// VerifyOrderPublic is kept as a compatibility shim for the removed anonymous
// out_trade_no lookup endpoint and always returns HTTP 410 Gone.
func
buildPublicOrderResult
(
order
*
dbent
.
PaymentOrder
)
PublicOrderResult
{
return
PublicOrderResult
{
ID
:
order
.
ID
,
OutTradeNo
:
order
.
OutTradeNo
,
Amount
:
order
.
Amount
,
PayAmount
:
order
.
PayAmount
,
FeeRate
:
order
.
FeeRate
,
PaymentType
:
order
.
PaymentType
,
OrderType
:
order
.
OrderType
,
Status
:
order
.
Status
,
CreatedAt
:
order
.
CreatedAt
,
ExpiresAt
:
order
.
ExpiresAt
,
PaidAt
:
order
.
PaidAt
,
CompletedAt
:
order
.
CompletedAt
,
RefundAmount
:
order
.
RefundAmount
,
RefundReason
:
order
.
RefundReason
,
RefundRequestedAt
:
order
.
RefundRequestedAt
,
RefundRequestedBy
:
order
.
RefundRequestedBy
,
RefundRequestReason
:
order
.
RefundRequestReason
,
PlanID
:
order
.
PlanID
,
}
}
// VerifyOrderPublic keeps the legacy anonymous out_trade_no lookup available as
// a compatibility path for older result pages and staggered deploys.
// POST /api/v1/payment/public/orders/verify
func
(
h
*
PaymentHandler
)
VerifyOrderPublic
(
c
*
gin
.
Context
)
{
response
.
ErrorFrom
(
c
,
errPaymentPublicOrderVerifyRemoved
)
var
req
VerifyOrderRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
order
,
err
:=
h
.
paymentService
.
VerifyOrderPublic
(
c
.
Request
.
Context
(),
req
.
OutTradeNo
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
buildPublicOrderResult
(
order
))
}
// ResolveOrderPublicByResumeToken resolves a payment order from a signed resume token.
...
...
@@ -493,15 +529,7 @@ func (h *PaymentHandler) ResolveOrderPublicByResumeToken(c *gin.Context) {
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
PublicOrderResult
{
ID
:
order
.
ID
,
OutTradeNo
:
order
.
OutTradeNo
,
Amount
:
order
.
Amount
,
PayAmount
:
order
.
PayAmount
,
PaymentType
:
order
.
PaymentType
,
OrderType
:
order
.
OrderType
,
Status
:
order
.
Status
,
})
response
.
Success
(
c
,
buildPublicOrderResult
(
order
))
}
// requireAuth extracts the authenticated subject from the context.
...
...
backend/internal/handler/payment_handler_resume_test.go
View file @
dd314c41
...
...
@@ -4,16 +4,17 @@ package handler
import
(
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/enttest"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
...
...
@@ -74,7 +75,7 @@ func TestApplyWeChatPaymentResumeClaimsRejectsPaymentTypeMismatch(t *testing.T)
}
}
func
TestVerifyOrderPublicReturns
Gon
e
(
t
*
testing
.
T
)
{
func
TestVerifyOrderPublicReturns
LegacyOrderStat
e
(
t
*
testing
.
T
)
{
t
.
Parallel
()
gin
.
SetMode
(
gin
.
TestMode
)
...
...
@@ -90,6 +91,32 @@ func TestVerifyOrderPublicReturnsGone(t *testing.T) {
client
:=
enttest
.
NewClient
(
t
,
enttest
.
WithOptions
(
dbent
.
Driver
(
drv
)))
t
.
Cleanup
(
func
()
{
_
=
client
.
Close
()
})
user
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"public-verify@example.com"
)
.
SetPasswordHash
(
"hash"
)
.
SetUsername
(
"public-verify-user"
)
.
Save
(
context
.
Background
())
require
.
NoError
(
t
,
err
)
order
,
err
:=
client
.
PaymentOrder
.
Create
()
.
SetUserID
(
user
.
ID
)
.
SetUserEmail
(
user
.
Email
)
.
SetUserName
(
user
.
Username
)
.
SetAmount
(
88
)
.
SetPayAmount
(
90.64
)
.
SetFeeRate
(
0.03
)
.
SetRechargeCode
(
"PUBLIC-VERIFY"
)
.
SetOutTradeNo
(
"legacy-order-no"
)
.
SetPaymentType
(
payment
.
TypeAlipay
)
.
SetPaymentTradeNo
(
"trade-public-verify"
)
.
SetOrderType
(
payment
.
OrderTypeBalance
)
.
SetStatus
(
service
.
OrderStatusPending
)
.
SetExpiresAt
(
time
.
Now
()
.
Add
(
time
.
Hour
))
.
SetClientIP
(
"127.0.0.1"
)
.
SetSrcHost
(
"api.example.com"
)
.
Save
(
context
.
Background
())
require
.
NoError
(
t
,
err
)
paymentSvc
:=
service
.
NewPaymentService
(
client
,
payment
.
NewRegistry
(),
nil
,
nil
,
nil
,
nil
,
nil
,
nil
)
h
:=
NewPaymentHandler
(
paymentSvc
,
nil
,
nil
)
...
...
@@ -104,11 +131,122 @@ func TestVerifyOrderPublicReturnsGone(t *testing.T) {
h
.
VerifyOrderPublic
(
ctx
)
require
.
Equal
(
t
,
http
.
StatusGone
,
recorder
.
Code
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
var
resp
struct
{
Code
int
`json:"code"`
Data
struct
{
ID
int64
`json:"id"`
OutTradeNo
string
`json:"out_trade_no"`
Amount
float64
`json:"amount"`
PayAmount
float64
`json:"pay_amount"`
FeeRate
float64
`json:"fee_rate"`
PaymentType
string
`json:"payment_type"`
OrderType
string
`json:"order_type"`
Status
string
`json:"status"`
RefundAmount
float64
`json:"refund_amount"`
CreatedAt
string
`json:"created_at"`
ExpiresAt
string
`json:"expires_at"`
}
`json:"data"`
}
require
.
NoError
(
t
,
json
.
Unmarshal
(
recorder
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
Equal
(
t
,
order
.
ID
,
resp
.
Data
.
ID
)
require
.
Equal
(
t
,
"legacy-order-no"
,
resp
.
Data
.
OutTradeNo
)
require
.
Equal
(
t
,
90.64
,
resp
.
Data
.
PayAmount
)
require
.
Equal
(
t
,
0.03
,
resp
.
Data
.
FeeRate
)
require
.
Equal
(
t
,
payment
.
TypeAlipay
,
resp
.
Data
.
PaymentType
)
require
.
Equal
(
t
,
payment
.
OrderTypeBalance
,
resp
.
Data
.
OrderType
)
require
.
Equal
(
t
,
service
.
OrderStatusPending
,
resp
.
Data
.
Status
)
require
.
Equal
(
t
,
0.0
,
resp
.
Data
.
RefundAmount
)
require
.
NotEmpty
(
t
,
resp
.
Data
.
CreatedAt
)
require
.
NotEmpty
(
t
,
resp
.
Data
.
ExpiresAt
)
}
func
TestResolveOrderPublicByResumeTokenReturnsFrontendContractFields
(
t
*
testing
.
T
)
{
t
.
Parallel
()
gin
.
SetMode
(
gin
.
TestMode
)
db
,
err
:=
sql
.
Open
(
"sqlite"
,
"file:payment_handler_public_resolve?mode=memory&cache=shared"
)
require
.
NoError
(
t
,
err
)
t
.
Cleanup
(
func
()
{
_
=
db
.
Close
()
})
_
,
err
=
db
.
Exec
(
"PRAGMA foreign_keys = ON"
)
require
.
NoError
(
t
,
err
)
drv
:=
entsql
.
OpenDB
(
dialect
.
SQLite
,
db
)
client
:=
enttest
.
NewClient
(
t
,
enttest
.
WithOptions
(
dbent
.
Driver
(
drv
)))
t
.
Cleanup
(
func
()
{
_
=
client
.
Close
()
})
var
resp
response
.
Response
user
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"public-resolve@example.com"
)
.
SetPasswordHash
(
"hash"
)
.
SetUsername
(
"public-resolve-user"
)
.
Save
(
context
.
Background
())
require
.
NoError
(
t
,
err
)
order
,
err
:=
client
.
PaymentOrder
.
Create
()
.
SetUserID
(
user
.
ID
)
.
SetUserEmail
(
user
.
Email
)
.
SetUserName
(
user
.
Username
)
.
SetAmount
(
100
)
.
SetPayAmount
(
103
)
.
SetFeeRate
(
0.03
)
.
SetRechargeCode
(
"PUBLIC-RESOLVE"
)
.
SetOutTradeNo
(
"resolve-order-no"
)
.
SetPaymentType
(
payment
.
TypeAlipay
)
.
SetPaymentTradeNo
(
"trade-public-resolve"
)
.
SetOrderType
(
payment
.
OrderTypeBalance
)
.
SetStatus
(
service
.
OrderStatusPaid
)
.
SetExpiresAt
(
time
.
Now
()
.
Add
(
time
.
Hour
))
.
SetPaidAt
(
time
.
Now
())
.
SetClientIP
(
"127.0.0.1"
)
.
SetSrcHost
(
"api.example.com"
)
.
Save
(
context
.
Background
())
require
.
NoError
(
t
,
err
)
resumeSvc
:=
service
.
NewPaymentResumeService
([]
byte
(
"0123456789abcdef0123456789abcdef"
))
token
,
err
:=
resumeSvc
.
CreateToken
(
service
.
ResumeTokenClaims
{
OrderID
:
order
.
ID
,
UserID
:
user
.
ID
,
PaymentType
:
payment
.
TypeAlipay
,
CanonicalReturnURL
:
"https://app.example.com/payment/result"
,
})
require
.
NoError
(
t
,
err
)
configSvc
:=
service
.
NewPaymentConfigService
(
client
,
nil
,
[]
byte
(
"0123456789abcdef0123456789abcdef"
))
paymentSvc
:=
service
.
NewPaymentService
(
client
,
payment
.
NewRegistry
(),
nil
,
nil
,
nil
,
configSvc
,
nil
,
nil
)
h
:=
NewPaymentHandler
(
paymentSvc
,
nil
,
nil
)
recorder
:=
httptest
.
NewRecorder
()
ctx
,
_
:=
gin
.
CreateTestContext
(
recorder
)
ctx
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/payment/public/orders/resolve"
,
bytes
.
NewBufferString
(
`{"resume_token":"`
+
token
+
`"}`
),
)
ctx
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
h
.
ResolveOrderPublicByResumeToken
(
ctx
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
var
resp
struct
{
Code
int
`json:"code"`
Data
map
[
string
]
any
`json:"data"`
}
require
.
NoError
(
t
,
json
.
Unmarshal
(
recorder
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
http
.
StatusGone
,
resp
.
Code
)
require
.
Equal
(
t
,
"PAYMENT_PUBLIC_ORDER_VERIFY_REMOVED"
,
resp
.
Reason
)
require
.
Contains
(
t
,
resp
.
Message
,
"removed"
)
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
Equal
(
t
,
float64
(
order
.
ID
),
resp
.
Data
[
"id"
])
require
.
Equal
(
t
,
"resolve-order-no"
,
resp
.
Data
[
"out_trade_no"
])
require
.
Equal
(
t
,
100.0
,
resp
.
Data
[
"amount"
])
require
.
Equal
(
t
,
103.0
,
resp
.
Data
[
"pay_amount"
])
require
.
Equal
(
t
,
0.03
,
resp
.
Data
[
"fee_rate"
])
require
.
Equal
(
t
,
payment
.
TypeAlipay
,
resp
.
Data
[
"payment_type"
])
require
.
Equal
(
t
,
payment
.
OrderTypeBalance
,
resp
.
Data
[
"order_type"
])
require
.
Equal
(
t
,
service
.
OrderStatusPaid
,
resp
.
Data
[
"status"
])
require
.
Contains
(
t
,
resp
.
Data
,
"created_at"
)
require
.
Contains
(
t
,
resp
.
Data
,
"expires_at"
)
require
.
Contains
(
t
,
resp
.
Data
,
"refund_amount"
)
}
backend/internal/server/routes/payment.go
View file @
dd314c41
...
...
@@ -44,9 +44,9 @@ func RegisterPaymentRoutes(
}
// --- Public payment endpoints (no auth) ---
// Signed resume-token recovery is the
support
ed public lookup path.
// The legacy anonymous out_trade_no verify endpoint
is kept only
as a
//
compatibility shim that returns HTTP 410 Gone
.
// Signed resume-token recovery is the
preferr
ed public lookup path.
// The legacy anonymous out_trade_no verify endpoint
remains available
as a
//
persisted-state compatibility path for staggered upgrades
.
public
:=
v1
.
Group
(
"/payment/public"
)
{
public
.
POST
(
"/orders/verify"
,
paymentHandler
.
VerifyOrderPublic
)
...
...
backend/internal/service/payment_order.go
View file @
dd314c41
...
...
@@ -379,16 +379,13 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
}
subject
:=
s
.
buildPaymentSubject
(
plan
,
limitAmount
,
cfg
)
outTradeNo
:=
order
.
OutTradeNo
canonicalReturnURL
,
err
:=
CanonicalizeReturnURL
(
req
.
ReturnURL
,
req
.
SrcHost
)
canonicalReturnURL
,
err
:=
CanonicalizeReturnURL
(
req
.
ReturnURL
,
req
.
SrcHost
,
req
.
SrcURL
)
if
err
!=
nil
{
return
nil
,
err
}
resumeToken
:=
""
if
resume
:=
s
.
paymentResume
();
resume
!=
nil
{
if
canonicalReturnURL
!=
""
{
if
err
:=
resume
.
ensureSigningKey
();
err
!=
nil
{
return
nil
,
err
}
if
canonicalReturnURL
!=
""
&&
resume
.
isSigningConfigured
()
{
resumeToken
,
err
=
resume
.
CreateToken
(
ResumeTokenClaims
{
OrderID
:
order
.
ID
,
UserID
:
order
.
UserID
,
...
...
@@ -402,7 +399,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
}
}
}
providerReturnURL
,
err
:=
buildPaymentReturnURL
(
canonicalReturnURL
,
order
.
ID
,
resumeToken
)
providerReturnURL
,
err
:=
buildPaymentReturnURL
(
canonicalReturnURL
,
order
.
ID
,
outTradeNo
,
resumeToken
)
if
err
!=
nil
{
return
nil
,
err
}
...
...
backend/internal/service/payment_resume_service.go
View file @
dd314c41
...
...
@@ -209,7 +209,7 @@ func visibleMethodSourceSettingKey(method string) string {
}
}
func
CanonicalizeReturnURL
(
raw
string
,
srcHost
string
)
(
string
,
error
)
{
func
CanonicalizeReturnURL
(
raw
string
,
srcHost
string
,
srcURL
string
)
(
string
,
error
)
{
raw
=
strings
.
TrimSpace
(
raw
)
if
raw
==
""
{
return
""
,
nil
...
...
@@ -228,13 +228,29 @@ func CanonicalizeReturnURL(raw string, srcHost string) (string, error) {
if
parsed
.
Path
!=
paymentResultReturnPath
{
return
""
,
infraerrors
.
BadRequest
(
"INVALID_RETURN_URL"
,
"return_url must target the canonical internal payment result page"
)
}
if
!
sameOrigin
Host
(
parsed
.
Host
,
srcHost
)
{
return
""
,
infraerrors
.
BadRequest
(
"INVALID_RETURN_URL"
,
"return_url must use the same host as the current site"
)
if
!
allowedReturnURL
Host
(
parsed
.
Host
,
srcHost
,
srcURL
)
{
return
""
,
infraerrors
.
BadRequest
(
"INVALID_RETURN_URL"
,
"return_url must use the same host as the current site
or browser origin
"
)
}
return
parsed
.
String
(),
nil
}
func
buildPaymentReturnURL
(
base
string
,
orderID
int64
,
resumeToken
string
)
(
string
,
error
)
{
func
allowedReturnURLHost
(
returnURLHost
string
,
requestHost
string
,
refererURL
string
)
bool
{
if
sameOriginHost
(
returnURLHost
,
requestHost
)
{
return
true
}
refererURL
=
strings
.
TrimSpace
(
refererURL
)
if
refererURL
==
""
{
return
false
}
parsedReferer
,
err
:=
url
.
Parse
(
refererURL
)
if
err
!=
nil
||
parsedReferer
.
Host
==
""
{
return
false
}
return
sameOriginHost
(
returnURLHost
,
parsedReferer
.
Host
)
}
func
buildPaymentReturnURL
(
base
string
,
orderID
int64
,
outTradeNo
string
,
resumeToken
string
)
(
string
,
error
)
{
canonical
:=
strings
.
TrimSpace
(
base
)
if
canonical
==
""
{
return
""
,
nil
...
...
@@ -253,6 +269,9 @@ func buildPaymentReturnURL(base string, orderID int64, resumeToken string) (stri
if
orderID
>
0
{
query
.
Set
(
"order_id"
,
strconv
.
FormatInt
(
orderID
,
10
))
}
if
strings
.
TrimSpace
(
outTradeNo
)
!=
""
{
query
.
Set
(
"out_trade_no"
,
strings
.
TrimSpace
(
outTradeNo
))
}
if
strings
.
TrimSpace
(
resumeToken
)
!=
""
{
query
.
Set
(
"resume_token"
,
strings
.
TrimSpace
(
resumeToken
))
}
...
...
backend/internal/service/payment_resume_service_test.go
View file @
dd314c41
...
...
@@ -64,7 +64,7 @@ func TestNormalizePaymentSource(t *testing.T) {
func
TestCanonicalizeReturnURL
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
,
err
:=
CanonicalizeReturnURL
(
"https://example.com/payment/result?b=2#a"
,
"example.com"
)
got
,
err
:=
CanonicalizeReturnURL
(
"https://example.com/payment/result?b=2#a"
,
"example.com"
,
""
)
if
err
!=
nil
{
t
.
Fatalf
(
"CanonicalizeReturnURL returned error: %v"
,
err
)
}
...
...
@@ -76,7 +76,7 @@ func TestCanonicalizeReturnURL(t *testing.T) {
func
TestCanonicalizeReturnURLRejectsRelativeURL
(
t
*
testing
.
T
)
{
t
.
Parallel
()
if
_
,
err
:=
CanonicalizeReturnURL
(
"/payment/result"
,
"example.com"
);
err
==
nil
{
if
_
,
err
:=
CanonicalizeReturnURL
(
"/payment/result"
,
"example.com"
,
""
);
err
==
nil
{
t
.
Fatal
(
"CanonicalizeReturnURL should reject relative URLs"
)
}
}
...
...
@@ -84,15 +84,31 @@ func TestCanonicalizeReturnURLRejectsRelativeURL(t *testing.T) {
func
TestCanonicalizeReturnURLRejectsExternalHost
(
t
*
testing
.
T
)
{
t
.
Parallel
()
if
_
,
err
:=
CanonicalizeReturnURL
(
"https://evil.example/payment/result"
,
"app.example.com"
);
err
==
nil
{
if
_
,
err
:=
CanonicalizeReturnURL
(
"https://evil.example/payment/result"
,
"app.example.com"
,
""
);
err
==
nil
{
t
.
Fatal
(
"CanonicalizeReturnURL should reject external hosts"
)
}
}
func
TestCanonicalizeReturnURLAllowsConfiguredFrontendHost
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
,
err
:=
CanonicalizeReturnURL
(
"https://app.example.com/payment/result?from=checkout"
,
"api.example.com"
,
"https://app.example.com/purchase"
,
)
if
err
!=
nil
{
t
.
Fatalf
(
"CanonicalizeReturnURL returned error: %v"
,
err
)
}
if
got
!=
"https://app.example.com/payment/result?from=checkout"
{
t
.
Fatalf
(
"CanonicalizeReturnURL = %q, want %q"
,
got
,
"https://app.example.com/payment/result?from=checkout"
)
}
}
func
TestCanonicalizeReturnURLRejectsNonCanonicalPath
(
t
*
testing
.
T
)
{
t
.
Parallel
()
if
_
,
err
:=
CanonicalizeReturnURL
(
"https://app.example.com/orders/42"
,
"app.example.com"
);
err
==
nil
{
if
_
,
err
:=
CanonicalizeReturnURL
(
"https://app.example.com/orders/42"
,
"app.example.com"
,
""
);
err
==
nil
{
t
.
Fatal
(
"CanonicalizeReturnURL should reject non-canonical result paths"
)
}
}
...
...
@@ -100,7 +116,7 @@ func TestCanonicalizeReturnURLRejectsNonCanonicalPath(t *testing.T) {
func
TestBuildPaymentReturnURL
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
,
err
:=
buildPaymentReturnURL
(
"https://example.com/payment/result?from=checkout#fragment"
,
42
,
"resume-token"
)
got
,
err
:=
buildPaymentReturnURL
(
"https://example.com/payment/result?from=checkout#fragment"
,
42
,
"sub2_42"
,
"resume-token"
)
if
err
!=
nil
{
t
.
Fatalf
(
"buildPaymentReturnURL returned error: %v"
,
err
)
}
...
...
@@ -119,6 +135,9 @@ func TestBuildPaymentReturnURL(t *testing.T) {
if
query
.
Get
(
"order_id"
)
!=
strconv
.
FormatInt
(
42
,
10
)
{
t
.
Fatalf
(
"order_id = %q"
,
query
.
Get
(
"order_id"
))
}
if
query
.
Get
(
"out_trade_no"
)
!=
"sub2_42"
{
t
.
Fatalf
(
"out_trade_no = %q"
,
query
.
Get
(
"out_trade_no"
))
}
if
query
.
Get
(
"resume_token"
)
!=
"resume-token"
{
t
.
Fatalf
(
"resume_token = %q"
,
query
.
Get
(
"resume_token"
))
}
...
...
@@ -127,10 +146,34 @@ func TestBuildPaymentReturnURL(t *testing.T) {
}
}
func
TestBuildPaymentReturnURLWithoutResumeTokenStillIncludesOutTradeNo
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
,
err
:=
buildPaymentReturnURL
(
"https://example.com/payment/result"
,
42
,
"sub2_42"
,
""
)
if
err
!=
nil
{
t
.
Fatalf
(
"buildPaymentReturnURL returned error: %v"
,
err
)
}
parsed
,
err
:=
url
.
Parse
(
got
)
if
err
!=
nil
{
t
.
Fatalf
(
"url.Parse returned error: %v"
,
err
)
}
query
:=
parsed
.
Query
()
if
query
.
Get
(
"order_id"
)
!=
"42"
{
t
.
Fatalf
(
"order_id = %q"
,
query
.
Get
(
"order_id"
))
}
if
query
.
Get
(
"out_trade_no"
)
!=
"sub2_42"
{
t
.
Fatalf
(
"out_trade_no = %q"
,
query
.
Get
(
"out_trade_no"
))
}
if
query
.
Get
(
"resume_token"
)
!=
""
{
t
.
Fatalf
(
"resume_token = %q, want empty"
,
query
.
Get
(
"resume_token"
))
}
}
func
TestBuildPaymentReturnURLEmptyBase
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
,
err
:=
buildPaymentReturnURL
(
""
,
42
,
"resume-token"
)
got
,
err
:=
buildPaymentReturnURL
(
""
,
42
,
"sub2_42"
,
"resume-token"
)
if
err
!=
nil
{
t
.
Fatalf
(
"buildPaymentReturnURL returned error: %v"
,
err
)
}
...
...
frontend/src/api/__tests__/payment.spec.ts
View file @
dd314c41
...
...
@@ -22,8 +22,12 @@ describe('payment api', () => {
post
.
mockResolvedValue
({
data
:
{}
})
})
it
(
'
does not expose anonymous public out_trade_no verification
'
,
()
=>
{
expect
(
Object
.
prototype
.
hasOwnProperty
.
call
(
paymentAPI
,
'
verifyOrderPublic
'
)).
toBe
(
false
)
it
(
'
keeps legacy public out_trade_no verification for upgrade compatibility
'
,
async
()
=>
{
await
paymentAPI
.
verifyOrderPublic
(
'
legacy-order-no
'
)
expect
(
post
).
toHaveBeenCalledWith
(
'
/payment/public/orders/verify
'
,
{
out_trade_no
:
'
legacy-order-no
'
,
})
})
it
(
'
keeps signed public resume-token resolve endpoint
'
,
async
()
=>
{
...
...
frontend/src/api/payment.ts
View file @
dd314c41
...
...
@@ -67,6 +67,11 @@ export const paymentAPI = {
return
apiClient
.
post
<
PaymentOrder
>
(
'
/payment/orders/verify
'
,
{
out_trade_no
:
outTradeNo
})
},
/** Legacy-compatible public order lookup by out_trade_no */
verifyOrderPublic
(
outTradeNo
:
string
)
{
return
apiClient
.
post
<
PaymentOrder
>
(
'
/payment/public/orders/verify
'
,
{
out_trade_no
:
outTradeNo
})
},
/** Resolve an order from a signed resume token without auth */
resolveOrderPublicByResumeToken
(
resumeToken
:
string
)
{
return
apiClient
.
post
<
PaymentOrder
>
(
'
/payment/public/orders/resolve
'
,
{
resume_token
:
resumeToken
})
...
...
frontend/src/components/payment/__tests__/paymentFlow.spec.ts
View file @
dd314c41
...
...
@@ -73,6 +73,7 @@ describe('decidePaymentLaunch', () => {
expect
(
decision
.
paymentState
.
paymentType
).
toBe
(
'
alipay
'
)
expect
(
decision
.
stripeMethod
).
toBe
(
'
alipay
'
)
expect
(
decision
.
recovery
.
resumeToken
).
toBe
(
'
resume-1
'
)
expect
(
decision
.
recovery
.
outTradeNo
).
toBe
(
''
)
})
it
(
'
uses Stripe route flow for mobile WeChat client secret
'
,
()
=>
{
...
...
@@ -94,6 +95,7 @@ describe('decidePaymentLaunch', () => {
pay_url
:
'
https://pay.example.com/session/abc
'
,
payment_mode
:
'
popup
'
,
resume_token
:
'
resume-2
'
,
out_trade_no
:
'
sub2_abc
'
,
}),
{
visibleMethod
:
'
wxpay
'
,
orderType
:
'
balance
'
,
...
...
@@ -103,6 +105,7 @@ describe('decidePaymentLaunch', () => {
expect
(
decision
.
kind
).
toBe
(
'
redirect_waiting
'
)
expect
(
decision
.
paymentState
.
payUrl
).
toBe
(
'
https://pay.example.com/session/abc
'
)
expect
(
decision
.
recovery
.
paymentMode
).
toBe
(
'
popup
'
)
expect
(
decision
.
recovery
.
outTradeNo
).
toBe
(
'
sub2_abc
'
)
expect
(
decision
.
recovery
.
resumeToken
).
toBe
(
'
resume-2
'
)
})
...
...
@@ -225,6 +228,7 @@ describe('readPaymentRecoverySnapshot', () => {
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/33
'
,
outTradeNo
:
'
sub2_33
'
,
clientSecret
:
''
,
payAmount
:
18
,
orderType
:
'
balance
'
,
...
...
@@ -249,6 +253,7 @@ describe('readPaymentRecoverySnapshot', () => {
expiresAt
:
'
2024-01-01T00:10:00.000Z
'
,
paymentType
:
'
wxpay
'
,
payUrl
:
'
https://pay.example.com/session/55
'
,
outTradeNo
:
'
sub2_55
'
,
clientSecret
:
''
,
payAmount
:
18
,
orderType
:
'
balance
'
,
...
...
@@ -264,10 +269,34 @@ describe('readPaymentRecoverySnapshot', () => {
expect
(
readPaymentRecoverySnapshot
(
JSON
.
stringify
({
...
expiredSnapshot
,
outTradeNo
:
'
sub2_55
'
,
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
}),
{
now
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
1
,
0
),
resumeToken
:
'
other-token
'
,
})).
toBeNull
()
})
it
(
'
keeps backward compatibility with snapshots written before outTradeNo existed
'
,
()
=>
{
const
restored
=
readPaymentRecoverySnapshot
(
JSON
.
stringify
({
orderId
:
44
,
amount
:
18
,
qrCode
:
''
,
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/44
'
,
clientSecret
:
''
,
payAmount
:
18
,
orderType
:
'
balance
'
,
paymentMode
:
'
popup
'
,
resumeToken
:
'
resume-44
'
,
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
}),
{
now
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
1
,
0
),
resumeToken
:
'
resume-44
'
,
})
expect
(
restored
?.
orderId
).
toBe
(
44
)
expect
(
restored
?.
outTradeNo
).
toBe
(
''
)
})
})
frontend/src/components/payment/paymentFlow.ts
View file @
dd314c41
...
...
@@ -34,6 +34,7 @@ export interface PaymentRecoverySnapshot {
expiresAt
:
string
paymentType
:
string
payUrl
:
string
outTradeNo
:
string
clientSecret
:
string
payAmount
:
number
orderType
:
OrderType
|
''
...
...
@@ -132,6 +133,7 @@ export function decidePaymentLaunch(
expiresAt
:
result
.
expires_at
||
''
,
paymentType
:
visibleMethod
,
payUrl
:
result
.
pay_url
||
''
,
outTradeNo
:
result
.
out_trade_no
||
''
,
clientSecret
:
result
.
client_secret
||
''
,
payAmount
:
result
.
pay_amount
,
orderType
:
context
.
orderType
,
...
...
@@ -227,6 +229,7 @@ export function readPaymentRecoverySnapshot(
||
typeof
parsed
.
expiresAt
!==
'
string
'
||
typeof
parsed
.
paymentType
!==
'
string
'
||
typeof
parsed
.
payUrl
!==
'
string
'
||
(
parsed
.
outTradeNo
!=
null
&&
typeof
parsed
.
outTradeNo
!==
'
string
'
)
||
typeof
parsed
.
clientSecret
!==
'
string
'
||
typeof
parsed
.
payAmount
!==
'
number
'
||
typeof
parsed
.
paymentMode
!==
'
string
'
...
...
@@ -241,7 +244,7 @@ export function readPaymentRecoverySnapshot(
if
(
Number
.
isFinite
(
expiresAt
)
&&
expiresAt
<=
now
)
{
return
null
}
if
(
options
.
resumeToken
&&
parsed
.
resumeToken
&&
parsed
.
resumeToken
!==
options
.
resumeToken
)
{
if
(
options
.
resumeToken
&&
parsed
.
resumeToken
!==
options
.
resumeToken
)
{
return
null
}
...
...
@@ -252,6 +255,7 @@ export function readPaymentRecoverySnapshot(
expiresAt
:
parsed
.
expiresAt
,
paymentType
:
parsed
.
paymentType
,
payUrl
:
parsed
.
payUrl
,
outTradeNo
:
parsed
.
outTradeNo
||
''
,
clientSecret
:
parsed
.
clientSecret
,
payAmount
:
parsed
.
payAmount
,
orderType
:
parsed
.
orderType
===
'
subscription
'
?
'
subscription
'
:
'
balance
'
,
...
...
frontend/src/views/user/PaymentResultView.vue
View file @
dd314c41
...
...
@@ -190,6 +190,15 @@ async function resolveOrderFromResumeToken(resumeToken: string): Promise<Payment
}
}
async
function
resolveOrderFromOutTradeNo
(
outTradeNo
:
string
):
Promise
<
PaymentOrder
|
null
>
{
try
{
const
result
=
await
paymentAPI
.
verifyOrderPublic
(
outTradeNo
)
return
result
.
data
}
catch
(
_err
:
unknown
)
{
return
null
}
}
function
clearStatusRefreshTimer
():
void
{
if
(
statusRefreshTimer
!==
null
)
{
clearTimeout
(
statusRefreshTimer
)
...
...
@@ -234,24 +243,19 @@ onMounted(async () => {
?
route
.
query
.
resume_token
:
''
const
routeOrderId
=
Number
(
route
.
query
.
order_id
)
||
0
cons
t
outTradeNo
=
String
(
route
.
query
.
out_trade_no
||
''
)
le
t
outTradeNo
=
String
(
route
.
query
.
out_trade_no
||
''
)
let
orderId
=
0
if
(
resumeToken
&&
typeof
window
!==
'
undefined
'
)
{
if
(
typeof
window
!==
'
undefined
'
)
{
const
restored
=
readPaymentRecoverySnapshot
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
),
{
resumeToken
},
resumeToken
?
{
resumeToken
}
:
{}
,
)
if
(
restored
?.
orderId
)
{
orderId
=
restored
.
orderId
}
}
if
(
!
order
.
value
&&
resumeToken
&&
orderId
)
{
try
{
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
// Fall through to signed resume-token recovery below.
if
(
!
outTradeNo
&&
restored
?.
outTradeNo
)
{
outTradeNo
=
restored
.
outTradeNo
}
}
...
...
@@ -269,6 +273,20 @@ onMounted(async () => {
orderId
=
routeOrderId
}
const
hasLegacyFallbackContext
=
typeof
route
.
query
.
trade_status
===
'
string
'
&&
route
.
query
.
trade_status
.
trim
()
!==
''
const
shouldUsePublicOutTradeNo
=
!
resumeToken
&&
outTradeNo
!==
''
&&
(
hasLegacyFallbackContext
||
routeOrderId
>
0
||
orderId
>
0
)
if
(
!
order
.
value
&&
shouldUsePublicOutTradeNo
)
{
const
legacyOrder
=
await
resolveOrderFromOutTradeNo
(
outTradeNo
)
if
(
legacyOrder
)
{
order
.
value
=
legacyOrder
if
(
!
orderId
)
{
orderId
=
legacyOrder
.
id
}
}
}
if
(
!
order
.
value
&&
!
resumeToken
&&
orderId
)
{
try
{
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
...
...
@@ -277,8 +295,6 @@ onMounted(async () => {
}
}
const
hasLegacyFallbackContext
=
typeof
route
.
query
.
trade_status
===
'
string
'
&&
route
.
query
.
trade_status
.
trim
()
!==
''
if
(
!
order
.
value
&&
!
resumeToken
&&
!
orderId
&&
outTradeNo
&&
hasLegacyFallbackContext
)
{
returnInfo
.
value
=
{
outTradeNo
,
...
...
@@ -293,6 +309,10 @@ onMounted(async () => {
return
await
resolveOrderFromResumeToken
(
resumeToken
)
}
if
(
shouldUsePublicOutTradeNo
)
{
return
await
resolveOrderFromOutTradeNo
(
outTradeNo
)
}
if
(
orderId
)
{
return
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
...
...
frontend/src/views/user/PaymentView.vue
View file @
dd314c41
...
...
@@ -276,7 +276,7 @@ import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
PaymentMethodOption
}
from
'
@/components/payment/PaymentMethodSelector.vue
'
import
{
buildPaymentErrorToastMessage
,
describePaymentScenarioError
}
from
'
./paymentUx
'
import
{
parseWechatResumeRoute
,
stripWechatResumeQuery
}
from
'
./paymentWechatResume
'
import
{
hasWechatResumeQuery
,
parseWechatResumeRoute
,
stripWechatResumeQuery
}
from
'
./paymentWechatResume
'
const
{
t
}
=
useI18n
()
const
route
=
useRoute
()
...
...
@@ -329,6 +329,7 @@ function emptyPaymentState(): PaymentRecoverySnapshot {
expiresAt
:
''
,
paymentType
:
''
,
payUrl
:
''
,
outTradeNo
:
''
,
clientSecret
:
''
,
payAmount
:
0
,
orderType
:
''
,
...
...
@@ -396,6 +397,9 @@ async function redirectToPaymentResult(state: PaymentRecoverySnapshot): Promise<
if
(
state
.
orderId
>
0
)
{
query
.
order_id
=
String
(
state
.
orderId
)
}
if
(
state
.
outTradeNo
)
{
query
.
out_trade_no
=
state
.
outTradeNo
}
if
(
state
.
resumeToken
)
{
query
.
resume_token
=
state
.
resumeToken
}
...
...
@@ -809,8 +813,13 @@ onMounted(async () => {
selectedMethod
.
value
=
sorted
[
0
]
}
if
(
typeof
window
!==
'
undefined
'
)
{
if
(
hasWechatResumeQuery
(
route
.
query
))
{
removeRecoverySnapshot
()
}
const
routeResumeToken
=
typeof
route
.
query
.
resume_token
===
'
string
'
?
route
.
query
.
resume_token
:
typeof
route
.
query
.
wechat_resume_token
===
'
string
'
?
route
.
query
.
wechat_resume_token
:
undefined
const
restored
=
readPaymentRecoverySnapshot
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
),
...
...
frontend/src/views/user/__tests__/PaymentResultView.spec.ts
View file @
dd314c41
...
...
@@ -7,7 +7,7 @@ const routeState = vi.hoisted(() => ({
const
routerPush
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
pollOrderStatus
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
verifyOrder
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
verifyOrder
Public
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
resolveOrderPublicByResumeToken
=
vi
.
hoisted
(()
=>
vi
.
fn
())
vi
.
mock
(
'
vue-router
'
,
async
()
=>
{
...
...
@@ -37,7 +37,7 @@ vi.mock('@/stores/payment', () => ({
vi
.
mock
(
'
@/api/payment
'
,
()
=>
({
paymentAPI
:
{
verifyOrder
,
verifyOrder
Public
,
resolveOrderPublicByResumeToken
,
},
}))
...
...
@@ -67,6 +67,7 @@ const recoverySnapshotFactory = (resumeToken: string) => ({
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/42
'
,
outTradeNo
:
'
sub2_20260420abcd1234
'
,
clientSecret
:
''
,
payAmount
:
88
,
orderType
:
'
balance
'
,
...
...
@@ -80,7 +81,7 @@ describe('PaymentResultView', () => {
routeState
.
query
=
{}
routerPush
.
mockReset
()
pollOrderStatus
.
mockReset
()
verifyOrder
.
mockReset
()
verifyOrder
Public
.
mockReset
()
resolveOrderPublicByResumeToken
.
mockReset
()
window
.
localStorage
.
clear
()
})
...
...
@@ -102,6 +103,7 @@ describe('PaymentResultView', () => {
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/42
'
,
outTradeNo
:
'
sub2_20260420abcd1234
'
,
clientSecret
:
''
,
payAmount
:
88
,
orderType
:
'
balance
'
,
...
...
@@ -109,7 +111,9 @@ describe('PaymentResultView', () => {
resumeToken
:
'
resume-42
'
,
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
}))
pollOrderStatus
.
mockResolvedValue
(
orderFactory
(
'
PENDING
'
))
resolveOrderPublicByResumeToken
.
mockResolvedValue
({
data
:
orderFactory
(
'
PENDING
'
),
})
const
wrapper
=
mount
(
PaymentResultView
,
{
global
:
{
...
...
@@ -121,7 +125,8 @@ describe('PaymentResultView', () => {
await
flushPromises
()
expect
(
pollOrderStatus
).
toHaveBeenCalledWith
(
42
)
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-42
'
)
expect
(
pollOrderStatus
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.processing
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.failed
'
)
...
...
@@ -140,6 +145,7 @@ describe('PaymentResultView', () => {
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/42
'
,
outTradeNo
:
'
sub2_20260420abcd1234
'
,
clientSecret
:
''
,
payAmount
:
88
,
orderType
:
'
balance
'
,
...
...
@@ -147,12 +153,6 @@ describe('PaymentResultView', () => {
resumeToken
:
'
resume-authoritative
'
,
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
}))
pollOrderStatus
.
mockResolvedValue
({
...
orderFactory
(
'
PENDING
'
),
amount
:
88
,
pay_amount
:
88
,
fee_rate
:
0
,
})
resolveOrderPublicByResumeToken
.
mockResolvedValue
({
data
:
{
...
orderFactory
(
'
PAID
'
),
...
...
@@ -172,7 +172,7 @@ describe('PaymentResultView', () => {
await
flushPromises
()
expect
(
pollOrderStatus
).
toHaveBeenCalled
With
(
42
)
expect
(
pollOrderStatus
).
not
.
toHaveBeenCalled
(
)
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-authoritative
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
103.00
'
)
...
...
@@ -227,7 +227,6 @@ describe('PaymentResultView', () => {
trade_status
:
'
TRADE_SUCCESS
'
,
}
resolveOrderPublicByResumeToken
.
mockRejectedValueOnce
(
new
Error
(
'
resume failed
'
))
mount
(
PaymentResultView
,
{
global
:
{
stubs
:
{
...
...
@@ -239,16 +238,19 @@ describe('PaymentResultView', () => {
await
flushPromises
()
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-fail
'
)
expect
(
verifyOrder
).
not
.
toHaveBeenCalled
()
expect
(
verifyOrder
Public
).
not
.
toHaveBeenCalled
()
})
it
(
'
does not use anonymous
out_trade_no verification when no signed resume context is available
'
,
async
()
=>
{
it
(
'
uses public
out_trade_no verification when no signed resume context is available
'
,
async
()
=>
{
routeState
.
query
=
{
out_trade_no
:
'
legacy-123
'
,
trade_status
:
'
TRADE_SUCCESS
'
,
}
verifyOrderPublic
.
mockResolvedValue
({
data
:
orderFactory
(
'
PAID
'
),
})
mount
(
PaymentResultView
,
{
const
wrapper
=
mount
(
PaymentResultView
,
{
global
:
{
stubs
:
{
OrderStatusBadge
:
true
,
...
...
@@ -258,7 +260,9 @@ describe('PaymentResultView', () => {
await
flushPromises
()
expect
(
verifyOrder
).
not
.
toHaveBeenCalled
()
expect
(
verifyOrderPublic
).
toHaveBeenCalledWith
(
'
legacy-123
'
)
expect
(
pollOrderStatus
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
})
it
(
'
does not use public out_trade_no verification for bare order numbers without legacy return markers
'
,
async
()
=>
{
...
...
@@ -276,7 +280,7 @@ describe('PaymentResultView', () => {
await
flushPromises
()
expect
(
verifyOrder
).
not
.
toHaveBeenCalled
()
expect
(
verifyOrder
Public
).
not
.
toHaveBeenCalled
()
})
it
(
'
resolves order by resume token when local recovery snapshot is missing
'
,
async
()
=>
{
...
...
frontend/src/views/user/__tests__/PaymentView.spec.ts
View file @
dd314c41
...
...
@@ -117,6 +117,7 @@ function jsapiOrderFixture(resumeToken: string) {
fee_rate
:
0
,
expires_at
:
'
2099-01-01T00:10:00.000Z
'
,
payment_type
:
'
wxpay
'
,
out_trade_no
:
'
sub2_jsapi_123
'
,
result_type
:
'
jsapi_ready
'
as
const
,
resume_token
:
resumeToken
,
jsapi
:
{
...
...
@@ -175,6 +176,7 @@ describe('PaymentView WeChat JSAPI flow', () => {
path
:
'
/payment/result
'
,
query
:
{
order_id
:
'
123
'
,
out_trade_no
:
'
sub2_jsapi_123
'
,
resume_token
:
'
resume-token-123
'
,
},
})
...
...
@@ -202,4 +204,39 @@ describe('PaymentView WeChat JSAPI flow', () => {
expect
(
routerPush
).
not
.
toHaveBeenCalled
()
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
})
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
({
orderId
:
999
,
amount
:
66
,
qrCode
:
'
stale-qr
'
,
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/stale
'
,
outTradeNo
:
'
stale-out-trade-no
'
,
clientSecret
:
''
,
payAmount
:
66
,
orderType
:
'
balance
'
,
paymentMode
:
'
popup
'
,
resumeToken
:
''
,
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
}))
shallowMount
(
PaymentView
,
{
global
:
{
stubs
:
{
Teleport
:
true
,
Transition
:
false
,
},
},
})
await
flushPromises
()
await
flushPromises
()
expect
(
createOrder
).
toHaveBeenCalledWith
(
expect
.
objectContaining
({
wechat_resume_token
:
'
resume-token-123
'
,
}))
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
})
})
frontend/src/views/user/paymentWechatResume.ts
View file @
dd314c41
...
...
@@ -19,12 +19,20 @@ function readQueryString(query: LocationQuery, key: string): string {
return
typeof
value
===
'
string
'
?
value
:
''
}
export
function
hasWechatResumeQuery
(
query
:
LocationQuery
):
boolean
{
if
(
readQueryString
(
query
,
'
wechat_resume
'
)
===
'
1
'
)
{
return
true
}
return
readQueryString
(
query
,
'
wechat_resume_token
'
)
!==
''
||
readQueryString
(
query
,
'
openid
'
)
!==
''
}
export
function
parseWechatResumeRoute
(
query
:
LocationQuery
,
plans
:
SubscriptionPlan
[],
fallbackBalanceAmount
:
number
,
):
ParsedWechatResumeRoute
|
null
{
if
(
readQueryString
(
query
,
'
wechat_resume
'
)
!==
'
1
'
)
{
if
(
!
hasWechatResumeQuery
(
query
)
)
{
return
null
}
...
...
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