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
b51bc7ee
Commit
b51bc7ee
authored
Apr 20, 2026
by
IanShaw027
Browse files
feat: wire payment return url payloads
parent
7826e988
Changes
8
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/payment_handler.go
View file @
b51bc7ee
...
...
@@ -202,10 +202,12 @@ func (h *PaymentHandler) GetLimits(c *gin.Context) {
// CreateOrderRequest is the request body for creating a payment order.
type
CreateOrderRequest
struct
{
Amount
float64
`json:"amount"`
PaymentType
string
`json:"payment_type" binding:"required"`
OrderType
string
`json:"order_type"`
PlanID
int64
`json:"plan_id"`
Amount
float64
`json:"amount"`
PaymentType
string
`json:"payment_type" binding:"required"`
ReturnURL
string
`json:"return_url"`
PaymentSource
string
`json:"payment_source"`
OrderType
string
`json:"order_type"`
PlanID
int64
`json:"plan_id"`
}
// CreateOrder creates a new payment order.
...
...
@@ -223,15 +225,16 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
}
result
,
err
:=
h
.
paymentService
.
CreateOrder
(
c
.
Request
.
Context
(),
service
.
CreateOrderRequest
{
UserID
:
subject
.
UserID
,
Amount
:
req
.
Amount
,
PaymentType
:
req
.
PaymentType
,
ClientIP
:
c
.
ClientIP
(),
IsMobile
:
isMobile
(
c
),
SrcHost
:
c
.
Request
.
Host
,
SrcURL
:
c
.
Request
.
Referer
(),
OrderType
:
req
.
OrderType
,
PlanID
:
req
.
PlanID
,
UserID
:
subject
.
UserID
,
Amount
:
req
.
Amount
,
PaymentType
:
req
.
PaymentType
,
ClientIP
:
c
.
ClientIP
(),
IsMobile
:
isMobile
(
c
),
SrcHost
:
c
.
Request
.
Host
,
ReturnURL
:
req
.
ReturnURL
,
PaymentSource
:
req
.
PaymentSource
,
OrderType
:
req
.
OrderType
,
PlanID
:
req
.
PlanID
,
})
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
...
...
backend/internal/service/payment_order.go
View file @
b51bc7ee
...
...
@@ -22,6 +22,9 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
if
req
.
OrderType
==
""
{
req
.
OrderType
=
payment
.
OrderTypeBalance
}
if
normalized
:=
NormalizeVisibleMethod
(
req
.
PaymentType
);
normalized
!=
""
{
req
.
PaymentType
=
normalized
}
cfg
,
err
:=
s
.
configService
.
GetPaymentConfig
(
ctx
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"get payment config: %w"
,
err
)
...
...
@@ -212,7 +215,38 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
}
subject
:=
s
.
buildPaymentSubject
(
plan
,
limitAmount
,
cfg
)
outTradeNo
:=
order
.
OutTradeNo
pr
,
err
:=
prov
.
CreatePayment
(
ctx
,
payment
.
CreatePaymentRequest
{
OrderID
:
outTradeNo
,
Amount
:
payAmountStr
,
PaymentType
:
req
.
PaymentType
,
Subject
:
subject
,
ClientIP
:
req
.
ClientIP
,
IsMobile
:
req
.
IsMobile
,
InstanceSubMethods
:
sel
.
SupportedTypes
})
canonicalReturnURL
,
err
:=
CanonicalizeReturnURL
(
req
.
ReturnURL
)
if
err
!=
nil
{
return
nil
,
err
}
resumeToken
:=
""
if
resume
:=
s
.
paymentResume
();
resume
!=
nil
{
resumeToken
,
err
=
resume
.
CreateToken
(
ResumeTokenClaims
{
OrderID
:
order
.
ID
,
UserID
:
order
.
UserID
,
ProviderInstanceID
:
sel
.
InstanceID
,
ProviderKey
:
sel
.
ProviderKey
,
PaymentType
:
req
.
PaymentType
,
CanonicalReturnURL
:
canonicalReturnURL
,
})
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"create payment resume token: %w"
,
err
)
}
}
providerReturnURL
,
err
:=
buildPaymentReturnURL
(
canonicalReturnURL
,
order
.
ID
,
resumeToken
)
if
err
!=
nil
{
return
nil
,
err
}
pr
,
err
:=
prov
.
CreatePayment
(
ctx
,
payment
.
CreatePaymentRequest
{
OrderID
:
outTradeNo
,
Amount
:
payAmountStr
,
PaymentType
:
req
.
PaymentType
,
Subject
:
subject
,
ReturnURL
:
providerReturnURL
,
ClientIP
:
req
.
ClientIP
,
IsMobile
:
req
.
IsMobile
,
InstanceSubMethods
:
sel
.
SupportedTypes
,
})
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
()))
...
...
@@ -227,8 +261,22 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
"payAmount"
:
order
.
PayAmount
,
"paymentType"
:
req
.
PaymentType
,
"orderType"
:
req
.
OrderType
,
"paymentSource"
:
NormalizePaymentSource
(
req
.
PaymentSource
),
})
return
&
CreateOrderResponse
{
OrderID
:
order
.
ID
,
Amount
:
order
.
Amount
,
PayAmount
:
payAmount
,
FeeRate
:
order
.
FeeRate
,
Status
:
OrderStatusPending
,
PaymentType
:
req
.
PaymentType
,
PayURL
:
pr
.
PayURL
,
QRCode
:
pr
.
QRCode
,
ClientSecret
:
pr
.
ClientSecret
,
ExpiresAt
:
order
.
ExpiresAt
,
PaymentMode
:
sel
.
PaymentMode
},
nil
return
&
CreateOrderResponse
{
OrderID
:
order
.
ID
,
Amount
:
order
.
Amount
,
PayAmount
:
payAmount
,
FeeRate
:
order
.
FeeRate
,
Status
:
OrderStatusPending
,
PaymentType
:
req
.
PaymentType
,
PayURL
:
pr
.
PayURL
,
QRCode
:
pr
.
QRCode
,
ClientSecret
:
pr
.
ClientSecret
,
ExpiresAt
:
order
.
ExpiresAt
,
PaymentMode
:
sel
.
PaymentMode
,
ResumeToken
:
resumeToken
,
},
nil
}
func
(
s
*
PaymentService
)
buildPaymentSubject
(
plan
*
dbent
.
SubscriptionPlan
,
limitAmount
float64
,
cfg
*
PaymentConfig
)
string
{
...
...
backend/internal/service/payment_resume_service.go
View file @
b51bc7ee
...
...
@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"time"
...
...
@@ -200,6 +201,30 @@ func CanonicalizeReturnURL(raw string) (string, error) {
return
parsed
.
String
(),
nil
}
func
buildPaymentReturnURL
(
base
string
,
orderID
int64
,
resumeToken
string
)
(
string
,
error
)
{
canonical
,
err
:=
CanonicalizeReturnURL
(
base
)
if
err
!=
nil
||
canonical
==
""
{
return
canonical
,
err
}
parsed
,
err
:=
url
.
Parse
(
canonical
)
if
err
!=
nil
{
return
""
,
infraerrors
.
BadRequest
(
"INVALID_RETURN_URL"
,
"return_url must be a valid URL"
)
}
query
:=
parsed
.
Query
()
if
orderID
>
0
{
query
.
Set
(
"order_id"
,
strconv
.
FormatInt
(
orderID
,
10
))
}
if
strings
.
TrimSpace
(
resumeToken
)
!=
""
{
query
.
Set
(
"resume_token"
,
strings
.
TrimSpace
(
resumeToken
))
}
query
.
Set
(
"status"
,
"success"
)
parsed
.
RawQuery
=
query
.
Encode
()
return
parsed
.
String
(),
nil
}
func
(
s
*
PaymentResumeService
)
CreateToken
(
claims
ResumeTokenClaims
)
(
string
,
error
)
{
if
claims
.
OrderID
<=
0
{
return
""
,
fmt
.
Errorf
(
"resume token requires order id"
)
...
...
backend/internal/service/payment_resume_service_test.go
View file @
b51bc7ee
...
...
@@ -4,6 +4,8 @@ package service
import
(
"context"
"net/url"
"strconv"
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
...
...
@@ -74,6 +76,48 @@ func TestCanonicalizeReturnURLRejectsRelativeURL(t *testing.T) {
}
}
func
TestBuildPaymentReturnURL
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
,
err
:=
buildPaymentReturnURL
(
"https://example.com/payment/result?from=checkout#fragment"
,
42
,
"resume-token"
)
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
)
}
if
parsed
.
Fragment
!=
""
{
t
.
Fatalf
(
"buildPaymentReturnURL should strip fragments, got %q"
,
parsed
.
Fragment
)
}
query
:=
parsed
.
Query
()
if
query
.
Get
(
"from"
)
!=
"checkout"
{
t
.
Fatalf
(
"expected original query to be preserved, got %q"
,
query
.
Get
(
"from"
))
}
if
query
.
Get
(
"order_id"
)
!=
strconv
.
FormatInt
(
42
,
10
)
{
t
.
Fatalf
(
"order_id = %q"
,
query
.
Get
(
"order_id"
))
}
if
query
.
Get
(
"resume_token"
)
!=
"resume-token"
{
t
.
Fatalf
(
"resume_token = %q"
,
query
.
Get
(
"resume_token"
))
}
if
query
.
Get
(
"status"
)
!=
"success"
{
t
.
Fatalf
(
"status = %q"
,
query
.
Get
(
"status"
))
}
}
func
TestBuildPaymentReturnURLEmptyBase
(
t
*
testing
.
T
)
{
t
.
Parallel
()
got
,
err
:=
buildPaymentReturnURL
(
""
,
42
,
"resume-token"
)
if
err
!=
nil
{
t
.
Fatalf
(
"buildPaymentReturnURL returned error: %v"
,
err
)
}
if
got
!=
""
{
t
.
Fatalf
(
"buildPaymentReturnURL = %q, want empty string"
,
got
)
}
}
func
TestPaymentResumeTokenRoundTrip
(
t
*
testing
.
T
)
{
t
.
Parallel
()
...
...
frontend/src/components/payment/__tests__/paymentFlow.spec.ts
View file @
b51bc7ee
import
{
describe
,
expect
,
it
}
from
'
vitest
'
import
type
{
CreateOrderResult
,
MethodLimit
}
from
'
@/types/payment
'
import
{
buildCreateOrderPayload
,
decidePaymentLaunch
,
getVisibleMethods
,
readPaymentRecoverySnapshot
,
...
...
@@ -106,6 +107,42 @@ describe('decidePaymentLaunch', () => {
})
})
describe
(
'
buildCreateOrderPayload
'
,
()
=>
{
it
(
'
normalizes visible method aliases and attaches a canonical result URL
'
,
()
=>
{
expect
(
buildCreateOrderPayload
({
amount
:
88
,
paymentType
:
'
alipay_direct
'
,
orderType
:
'
balance
'
,
origin
:
'
https://app.example.com/
'
,
isWechatBrowser
:
false
,
})).
toEqual
({
amount
:
88
,
payment_type
:
'
alipay
'
,
order_type
:
'
balance
'
,
return_url
:
'
https://app.example.com/payment/result
'
,
payment_source
:
'
hosted_redirect
'
,
})
})
it
(
'
uses WeChat in-app resume source for visible WeChat payments in the WeChat browser
'
,
()
=>
{
expect
(
buildCreateOrderPayload
({
amount
:
128
,
paymentType
:
'
wxpay
'
,
orderType
:
'
subscription
'
,
planId
:
7
,
origin
:
'
https://app.example.com
'
,
isWechatBrowser
:
true
,
})).
toEqual
({
amount
:
128
,
payment_type
:
'
wxpay
'
,
order_type
:
'
subscription
'
,
plan_id
:
7
,
return_url
:
'
https://app.example.com/payment/result
'
,
payment_source
:
'
wechat_in_app_resume
'
,
})
})
})
describe
(
'
readPaymentRecoverySnapshot
'
,
()
=>
{
it
(
'
restores an unexpired snapshot when the resume token matches
'
,
()
=>
{
const
snapshot
:
PaymentRecoverySnapshot
=
{
...
...
frontend/src/components/payment/paymentFlow.ts
View file @
b51bc7ee
import
type
{
CreateOrderResult
,
MethodLimit
,
OrderType
}
from
'
@/types/payment
'
import
type
{
CreateOrderRequest
,
CreateOrderResult
,
MethodLimit
,
OrderType
}
from
'
@/types/payment
'
export
const
PAYMENT_RECOVERY_STORAGE_KEY
=
'
payment.recovery.current
'
...
...
@@ -49,6 +49,15 @@ export interface PaymentLaunchDecision {
stripeMethod
?:
StripeVisibleMethod
}
export
interface
BuildCreateOrderPayloadInput
{
amount
:
number
paymentType
:
string
orderType
:
OrderType
planId
?:
number
origin
?:
string
isWechatBrowser
:
boolean
}
type
CreateOrderFlowResult
=
CreateOrderResult
&
{
resume_token
?:
string
}
...
...
@@ -77,6 +86,28 @@ export function getVisibleMethods(methods: Record<string, MethodLimit>): Record<
return
visible
}
export
function
buildCreateOrderPayload
(
input
:
BuildCreateOrderPayloadInput
):
CreateOrderRequest
{
const
visibleMethod
=
normalizeVisibleMethod
(
input
.
paymentType
)
||
input
.
paymentType
.
trim
()
const
normalizedOrigin
=
(
input
.
origin
||
''
).
trim
().
replace
(
/
\/
+$/
,
''
)
const
payload
:
CreateOrderRequest
=
{
amount
:
input
.
amount
,
payment_type
:
visibleMethod
,
order_type
:
input
.
orderType
,
payment_source
:
visibleMethod
===
'
wxpay
'
&&
input
.
isWechatBrowser
?
'
wechat_in_app_resume
'
:
'
hosted_redirect
'
,
}
if
(
input
.
planId
)
{
payload
.
plan_id
=
input
.
planId
}
if
(
normalizedOrigin
)
{
payload
.
return_url
=
`
${
normalizedOrigin
}
/payment/result`
}
return
payload
}
export
function
decidePaymentLaunch
(
result
:
CreateOrderFlowResult
,
context
:
PaymentLaunchContext
,
...
...
frontend/src/types/payment.ts
View file @
b51bc7ee
...
...
@@ -154,6 +154,8 @@ export interface CreateOrderRequest {
payment_type
:
string
order_type
:
string
plan_id
?:
number
return_url
?:
string
payment_source
?:
string
}
export
interface
CreateOrderResult
{
...
...
@@ -166,6 +168,7 @@ export interface CreateOrderResult {
fee_rate
:
number
expires_at
:
string
payment_mode
?:
string
resume_token
?:
string
}
export
interface
DashboardStats
{
...
...
frontend/src/views/user/PaymentView.vue
View file @
b51bc7ee
...
...
@@ -267,6 +267,7 @@ import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vu
import
{
METHOD_ORDER
,
POPUP_WINDOW_FEATURES
,
STRIPE_POPUP_WINDOW_FEATURES
}
from
'
@/components/payment/providerConfig
'
import
{
PAYMENT_RECOVERY_STORAGE_KEY
,
buildCreateOrderPayload
,
clearPaymentRecoverySnapshot
,
decidePaymentLaunch
,
getVisibleMethods
,
...
...
@@ -563,12 +564,14 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
submitting
.
value
=
true
errorMessage
.
value
=
''
try
{
const
result
=
await
paymentStore
.
createOrder
({
const
result
=
await
paymentStore
.
createOrder
(
buildCreateOrderPayload
(
{
amount
:
orderAmount
,
payment_type
:
selectedMethod
.
value
,
order_type
:
orderType
,
plan_id
:
planId
,
}
)
as
CreateOrderResult
&
{
resume_token
?:
string
}
paymentType
:
selectedMethod
.
value
,
orderType
,
planId
,
origin
:
typeof
window
!==
'
undefined
'
?
window
.
location
.
origin
:
''
,
isWechatBrowser
:
typeof
window
!==
'
undefined
'
&&
/MicroMessenger/i
.
test
(
window
.
navigator
.
userAgent
),
}
))
as
CreateOrderResult
&
{
resume_token
?:
string
}
const
openWindow
=
(
url
:
string
,
features
=
POPUP_WINDOW_FEATURES
)
=>
{
const
win
=
window
.
open
(
url
,
'
paymentPopup
'
,
features
)
if
(
!
win
||
win
.
closed
)
{
...
...
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