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
9bebf1c1
Commit
9bebf1c1
authored
Apr 20, 2026
by
IanShaw027
Browse files
feat: resolve payment results by resume token
parent
c0b24aef
Changes
7
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/payment_handler.go
View file @
9bebf1c1
...
...
@@ -357,6 +357,10 @@ type VerifyOrderRequest struct {
OutTradeNo
string
`json:"out_trade_no" binding:"required"`
}
type
ResolveOrderByResumeTokenRequest
struct
{
ResumeToken
string
`json:"resume_token" binding:"required"`
}
// VerifyOrder actively queries the upstream payment provider to check
// if payment was made, and processes it if so.
// POST /api/v1/payment/orders/verify
...
...
@@ -417,6 +421,31 @@ func (h *PaymentHandler) VerifyOrderPublic(c *gin.Context) {
})
}
// ResolveOrderPublicByResumeToken resolves a payment order from a signed resume token.
// POST /api/v1/payment/public/orders/resolve
func
(
h
*
PaymentHandler
)
ResolveOrderPublicByResumeToken
(
c
*
gin
.
Context
)
{
var
req
ResolveOrderByResumeTokenRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
order
,
err
:=
h
.
paymentService
.
GetPublicOrderByResumeToken
(
c
.
Request
.
Context
(),
req
.
ResumeToken
)
if
err
!=
nil
{
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
,
})
}
// requireAuth extracts the authenticated subject from the context.
// Returns the subject and true on success; on failure it writes an Unauthorized response and returns false.
func
requireAuth
(
c
*
gin
.
Context
)
(
middleware2
.
AuthSubject
,
bool
)
{
...
...
backend/internal/server/routes/payment.go
View file @
9bebf1c1
...
...
@@ -49,6 +49,7 @@ func RegisterPaymentRoutes(
public
:=
v1
.
Group
(
"/payment/public"
)
{
public
.
POST
(
"/orders/verify"
,
paymentHandler
.
VerifyOrderPublic
)
public
.
POST
(
"/orders/resolve"
,
paymentHandler
.
ResolveOrderPublicByResumeToken
)
}
// --- Webhook endpoints (no auth) ---
...
...
backend/internal/service/payment_resume_lookup.go
0 → 100644
View file @
9bebf1c1
package
service
import
(
"context"
"fmt"
"strings"
dbent
"github.com/Wei-Shaw/sub2api/ent"
)
func
(
s
*
PaymentService
)
GetPublicOrderByResumeToken
(
ctx
context
.
Context
,
token
string
)
(
*
dbent
.
PaymentOrder
,
error
)
{
claims
,
err
:=
s
.
paymentResume
()
.
ParseToken
(
strings
.
TrimSpace
(
token
))
if
err
!=
nil
{
return
nil
,
err
}
order
,
err
:=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
claims
.
OrderID
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"get order by resume token: %w"
,
err
)
}
if
claims
.
UserID
>
0
&&
order
.
UserID
!=
claims
.
UserID
{
return
nil
,
fmt
.
Errorf
(
"resume token user mismatch"
)
}
if
claims
.
ProviderInstanceID
!=
""
&&
strings
.
TrimSpace
(
psStringValue
(
order
.
ProviderInstanceID
))
!=
claims
.
ProviderInstanceID
{
return
nil
,
fmt
.
Errorf
(
"resume token provider instance mismatch"
)
}
if
claims
.
ProviderKey
!=
""
&&
strings
.
TrimSpace
(
psStringValue
(
order
.
ProviderKey
))
!=
claims
.
ProviderKey
{
return
nil
,
fmt
.
Errorf
(
"resume token provider key mismatch"
)
}
if
claims
.
PaymentType
!=
""
&&
strings
.
TrimSpace
(
order
.
PaymentType
)
!=
claims
.
PaymentType
{
return
nil
,
fmt
.
Errorf
(
"resume token payment type mismatch"
)
}
return
order
,
nil
}
backend/internal/service/payment_resume_lookup_test.go
0 → 100644
View file @
9bebf1c1
//go:build unit
package
service
import
(
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/stretchr/testify/require"
)
func
TestGetPublicOrderByResumeTokenReturnsMatchingOrder
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
client
:=
newPaymentConfigServiceTestClient
(
t
)
user
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"resume@example.com"
)
.
SetPasswordHash
(
"hash"
)
.
SetUsername
(
"resume-user"
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
instanceID
:=
"12"
providerKey
:=
payment
.
TypeEasyPay
order
,
err
:=
client
.
PaymentOrder
.
Create
()
.
SetUserID
(
user
.
ID
)
.
SetUserEmail
(
user
.
Email
)
.
SetUserName
(
user
.
Username
)
.
SetAmount
(
88
)
.
SetPayAmount
(
88
)
.
SetFeeRate
(
0
)
.
SetRechargeCode
(
"RESUME-ORDER"
)
.
SetOutTradeNo
(
"sub2_resume_lookup"
)
.
SetPaymentType
(
payment
.
TypeAlipay
)
.
SetPaymentTradeNo
(
"trade-1"
)
.
SetOrderType
(
payment
.
OrderTypeBalance
)
.
SetStatus
(
OrderStatusPending
)
.
SetExpiresAt
(
time
.
Now
()
.
Add
(
time
.
Hour
))
.
SetClientIP
(
"127.0.0.1"
)
.
SetSrcHost
(
"api.example.com"
)
.
SetProviderInstanceID
(
instanceID
)
.
SetProviderKey
(
providerKey
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
resumeSvc
:=
NewPaymentResumeService
([]
byte
(
"0123456789abcdef0123456789abcdef"
))
token
,
err
:=
resumeSvc
.
CreateToken
(
ResumeTokenClaims
{
OrderID
:
order
.
ID
,
UserID
:
user
.
ID
,
ProviderInstanceID
:
instanceID
,
ProviderKey
:
providerKey
,
PaymentType
:
payment
.
TypeAlipay
,
CanonicalReturnURL
:
"https://app.example.com/payment/result"
,
})
require
.
NoError
(
t
,
err
)
svc
:=
&
PaymentService
{
entClient
:
client
,
resumeService
:
resumeSvc
,
}
got
,
err
:=
svc
.
GetPublicOrderByResumeToken
(
ctx
,
token
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
order
.
ID
,
got
.
ID
)
}
func
TestGetPublicOrderByResumeTokenRejectsSnapshotMismatch
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
client
:=
newPaymentConfigServiceTestClient
(
t
)
user
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"resume-mismatch@example.com"
)
.
SetPasswordHash
(
"hash"
)
.
SetUsername
(
"resume-mismatch-user"
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
order
,
err
:=
client
.
PaymentOrder
.
Create
()
.
SetUserID
(
user
.
ID
)
.
SetUserEmail
(
user
.
Email
)
.
SetUserName
(
user
.
Username
)
.
SetAmount
(
88
)
.
SetPayAmount
(
88
)
.
SetFeeRate
(
0
)
.
SetRechargeCode
(
"RESUME-MISMATCH"
)
.
SetOutTradeNo
(
"sub2_resume_lookup_mismatch"
)
.
SetPaymentType
(
payment
.
TypeAlipay
)
.
SetPaymentTradeNo
(
"trade-2"
)
.
SetOrderType
(
payment
.
OrderTypeBalance
)
.
SetStatus
(
OrderStatusPending
)
.
SetExpiresAt
(
time
.
Now
()
.
Add
(
time
.
Hour
))
.
SetClientIP
(
"127.0.0.1"
)
.
SetSrcHost
(
"api.example.com"
)
.
SetProviderInstanceID
(
"12"
)
.
SetProviderKey
(
payment
.
TypeEasyPay
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
resumeSvc
:=
NewPaymentResumeService
([]
byte
(
"0123456789abcdef0123456789abcdef"
))
token
,
err
:=
resumeSvc
.
CreateToken
(
ResumeTokenClaims
{
OrderID
:
order
.
ID
,
UserID
:
user
.
ID
,
ProviderInstanceID
:
"99"
,
ProviderKey
:
payment
.
TypeEasyPay
,
PaymentType
:
payment
.
TypeAlipay
,
CanonicalReturnURL
:
"https://app.example.com/payment/result"
,
})
require
.
NoError
(
t
,
err
)
svc
:=
&
PaymentService
{
entClient
:
client
,
resumeService
:
resumeSvc
,
}
_
,
err
=
svc
.
GetPublicOrderByResumeToken
(
ctx
,
token
)
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"resume token"
)
}
frontend/src/api/payment.ts
View file @
9bebf1c1
...
...
@@ -72,6 +72,11 @@ export const paymentAPI = {
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
})
},
/** Request a refund for a completed order */
requestRefund
(
id
:
number
,
data
:
{
reason
:
string
})
{
return
apiClient
.
post
(
`/payment/orders/
${
id
}
/refund-request`
,
data
)
...
...
frontend/src/views/user/PaymentResultView.vue
View file @
9bebf1c1
...
...
@@ -150,7 +150,17 @@ onMounted(async () => {
}
}
if
(
orderId
)
{
if
(
!
order
.
value
&&
!
orderId
&&
resumeToken
)
{
try
{
const
result
=
await
paymentAPI
.
resolveOrderPublicByResumeToken
(
resumeToken
)
order
.
value
=
result
.
data
orderId
=
result
.
data
.
id
}
catch
(
_err
:
unknown
)
{
// Resume token recovery failed, continue to legacy fallback paths.
}
}
if
(
!
order
.
value
&&
orderId
)
{
try
{
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
...
...
frontend/src/views/user/__tests__/PaymentResultView.spec.ts
View file @
9bebf1c1
...
...
@@ -9,6 +9,7 @@ const routerPush = vi.hoisted(() => vi.fn())
const
pollOrderStatus
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
verifyOrderPublic
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
verifyOrder
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
resolveOrderPublicByResumeToken
=
vi
.
hoisted
(()
=>
vi
.
fn
())
vi
.
mock
(
'
vue-router
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-router
'
)
>
(
'
vue-router
'
)
...
...
@@ -39,6 +40,7 @@ vi.mock('@/api/payment', () => ({
paymentAPI
:
{
verifyOrderPublic
,
verifyOrder
,
resolveOrderPublicByResumeToken
,
},
}))
...
...
@@ -67,6 +69,7 @@ describe('PaymentResultView', () => {
pollOrderStatus
.
mockReset
()
verifyOrderPublic
.
mockReset
()
verifyOrder
.
mockReset
()
resolveOrderPublicByResumeToken
.
mockReset
()
window
.
localStorage
.
clear
()
})
...
...
@@ -129,4 +132,27 @@ describe('PaymentResultView', () => {
expect
(
verifyOrderPublic
).
toHaveBeenCalledWith
(
'
legacy-123
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
})
it
(
'
resolves order by resume token when local recovery snapshot is missing
'
,
async
()
=>
{
routeState
.
query
=
{
resume_token
:
'
resume-77
'
,
}
resolveOrderPublicByResumeToken
.
mockResolvedValue
({
data
:
orderFactory
(
'
PAID
'
),
})
const
wrapper
=
mount
(
PaymentResultView
,
{
global
:
{
stubs
:
{
OrderStatusBadge
:
true
,
},
},
})
await
flushPromises
()
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-77
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
verifyOrderPublic
).
not
.
toHaveBeenCalled
()
})
})
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