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
16be82b9
"git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "c048ca80a458772a2ffca28313b8a9e01ca7e019"
Commit
16be82b9
authored
Apr 21, 2026
by
IanShaw027
Browse files
fix payment visible methods and resume recovery
parent
5d58c7c6
Changes
6
Hide whitespace changes
Inline
Side-by-side
backend/internal/payment/provider/wxpay.go
View file @
16be82b9
...
@@ -310,12 +310,14 @@ func buildWxpayResultURL(returnURL string, req payment.CreatePaymentRequest) (st
...
@@ -310,12 +310,14 @@ func buildWxpayResultURL(returnURL string, req payment.CreatePaymentRequest) (st
return
""
,
fmt
.
Errorf
(
"return URL must be an absolute http(s) URL"
)
return
""
,
fmt
.
Errorf
(
"return URL must be an absolute http(s) URL"
)
}
}
values
:=
u
rl
.
Values
{}
values
:=
u
.
Query
()
values
.
Set
(
"out_trade_no"
,
strings
.
TrimSpace
(
req
.
OrderID
))
values
.
Set
(
"out_trade_no"
,
strings
.
TrimSpace
(
req
.
OrderID
))
if
paymentType
:=
strings
.
TrimSpace
(
req
.
PaymentType
);
paymentType
!=
""
{
if
paymentType
:=
strings
.
TrimSpace
(
req
.
PaymentType
);
paymentType
!=
""
{
values
.
Set
(
"payment_type"
,
paymentType
)
values
.
Set
(
"payment_type"
,
paymentType
)
}
}
u
.
Path
=
wxpayResultPath
if
strings
.
TrimSpace
(
u
.
Path
)
==
""
{
u
.
Path
=
wxpayResultPath
}
u
.
RawPath
=
""
u
.
RawPath
=
""
u
.
RawQuery
=
values
.
Encode
()
u
.
RawQuery
=
values
.
Encode
()
u
.
Fragment
=
""
u
.
Fragment
=
""
...
...
backend/internal/payment/provider/wxpay_test.go
View file @
16be82b9
...
@@ -4,6 +4,7 @@ package provider
...
@@ -4,6 +4,7 @@ package provider
import
(
import
(
"context"
"context"
"net/url"
"strings"
"strings"
"testing"
"testing"
...
@@ -263,6 +264,36 @@ func TestNewWxpay(t *testing.T) {
...
@@ -263,6 +264,36 @@ func TestNewWxpay(t *testing.T) {
}
}
}
}
func
TestBuildWxpayResultURLPreservesResumeToken
(
t
*
testing
.
T
)
{
t
.
Parallel
()
resultURL
,
err
:=
buildWxpayResultURL
(
"https://app.example.com/payment/result?order_id=42&resume_token=resume-42&status=success"
,
payment
.
CreatePaymentRequest
{
OrderID
:
"sub2_42"
,
PaymentType
:
payment
.
TypeWxpay
,
})
if
err
!=
nil
{
t
.
Fatalf
(
"buildWxpayResultURL returned error: %v"
,
err
)
}
parsed
,
err
:=
url
.
Parse
(
resultURL
)
if
err
!=
nil
{
t
.
Fatalf
(
"url.Parse returned error: %v"
,
err
)
}
query
:=
parsed
.
Query
()
if
parsed
.
Path
!=
wxpayResultPath
{
t
.
Fatalf
(
"path = %q, want %q"
,
parsed
.
Path
,
wxpayResultPath
)
}
if
query
.
Get
(
"resume_token"
)
!=
"resume-42"
{
t
.
Fatalf
(
"resume_token = %q, want %q"
,
query
.
Get
(
"resume_token"
),
"resume-42"
)
}
if
query
.
Get
(
"order_id"
)
!=
"42"
{
t
.
Fatalf
(
"order_id = %q, want %q"
,
query
.
Get
(
"order_id"
),
"42"
)
}
if
query
.
Get
(
"out_trade_no"
)
!=
"sub2_42"
{
t
.
Fatalf
(
"out_trade_no = %q, want %q"
,
query
.
Get
(
"out_trade_no"
),
"sub2_42"
)
}
}
func
TestResolveWxpayJSAPIAppID
(
t
*
testing
.
T
)
{
func
TestResolveWxpayJSAPIAppID
(
t
*
testing
.
T
)
{
t
.
Parallel
()
t
.
Parallel
()
...
...
backend/internal/service/payment_config_limits.go
View file @
16be82b9
...
@@ -20,6 +20,18 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M
...
@@ -20,6 +20,18 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M
return
nil
,
fmt
.
Errorf
(
"query provider instances: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"query provider instances: %w"
,
err
)
}
}
typeInstances
:=
pcGroupByPaymentType
(
instances
)
typeInstances
:=
pcGroupByPaymentType
(
instances
)
if
s
.
settingRepo
!=
nil
{
vals
,
err
:=
s
.
settingRepo
.
GetMultiple
(
ctx
,
[]
string
{
SettingPaymentVisibleMethodAlipayEnabled
,
SettingPaymentVisibleMethodAlipaySource
,
SettingPaymentVisibleMethodWxpayEnabled
,
SettingPaymentVisibleMethodWxpaySource
,
})
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"query visible method settings: %w"
,
err
)
}
typeInstances
=
pcApplyVisibleMethodRouting
(
typeInstances
,
vals
,
buildVisibleMethodSourceAvailability
(
instances
))
}
resp
:=
&
MethodLimitsResponse
{
resp
:=
&
MethodLimitsResponse
{
Methods
:
make
(
map
[
string
]
MethodLimits
,
len
(
typeInstances
)),
Methods
:
make
(
map
[
string
]
MethodLimits
,
len
(
typeInstances
)),
}
}
...
@@ -31,6 +43,40 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M
...
@@ -31,6 +43,40 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M
return
resp
,
nil
return
resp
,
nil
}
}
func
pcApplyVisibleMethodRouting
(
typeInstances
map
[
string
][]
*
dbent
.
PaymentProviderInstance
,
vals
map
[
string
]
string
,
available
map
[
string
]
bool
)
map
[
string
][]
*
dbent
.
PaymentProviderInstance
{
if
len
(
typeInstances
)
==
0
{
return
typeInstances
}
filtered
:=
make
(
map
[
string
][]
*
dbent
.
PaymentProviderInstance
,
len
(
typeInstances
))
for
paymentType
,
instances
:=
range
typeInstances
{
visibleMethod
:=
NormalizeVisibleMethod
(
paymentType
)
switch
visibleMethod
{
case
payment
.
TypeAlipay
,
payment
.
TypeWxpay
:
if
!
visibleMethodShouldBeExposed
(
visibleMethod
,
vals
,
available
)
{
continue
}
targetProviderKey
,
ok
:=
VisibleMethodProviderKeyForSource
(
visibleMethod
,
vals
[
visibleMethodSourceSettingKey
(
visibleMethod
)])
if
!
ok
{
continue
}
matching
:=
make
([]
*
dbent
.
PaymentProviderInstance
,
0
,
len
(
instances
))
for
_
,
inst
:=
range
instances
{
if
inst
.
ProviderKey
==
targetProviderKey
{
matching
=
append
(
matching
,
inst
)
}
}
if
len
(
matching
)
==
0
{
continue
}
filtered
[
paymentType
]
=
matching
default
:
filtered
[
paymentType
]
=
instances
}
}
return
filtered
}
// GetMethodLimits returns per-payment-type limits from enabled provider instances.
// GetMethodLimits returns per-payment-type limits from enabled provider instances.
func
(
s
*
PaymentConfigService
)
GetMethodLimits
(
ctx
context
.
Context
,
types
[]
string
)
([]
MethodLimits
,
error
)
{
func
(
s
*
PaymentConfigService
)
GetMethodLimits
(
ctx
context
.
Context
,
types
[]
string
)
([]
MethodLimits
,
error
)
{
instances
,
err
:=
s
.
entClient
.
PaymentProviderInstance
.
Query
()
.
instances
,
err
:=
s
.
entClient
.
PaymentProviderInstance
.
Query
()
.
...
...
backend/internal/service/payment_config_limits_test.go
View file @
16be82b9
package
service
package
service
import
(
import
(
"context"
"testing"
"testing"
dbent
"github.com/Wei-Shaw/sub2api/ent"
dbent
"github.com/Wei-Shaw/sub2api/ent"
...
@@ -299,3 +300,73 @@ func TestPcInstanceTypeLimits(t *testing.T) {
...
@@ -299,3 +300,73 @@ func TestPcInstanceTypeLimits(t *testing.T) {
}
}
})
})
}
}
func
TestGetAvailableMethodLimitsRespectsVisibleMethodRouting
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
client
:=
newPaymentConfigServiceTestClient
(
t
)
_
,
err
:=
client
.
PaymentProviderInstance
.
Create
()
.
SetProviderKey
(
payment
.
TypeAlipay
)
.
SetName
(
"Official Alipay"
)
.
SetConfig
(
"{}"
)
.
SetSupportedTypes
(
"alipay"
)
.
SetLimits
(
`{"alipay":{"singleMin":10,"singleMax":100}}`
)
.
SetEnabled
(
true
)
.
Save
(
ctx
)
if
err
!=
nil
{
t
.
Fatalf
(
"create official alipay instance: %v"
,
err
)
}
_
,
err
=
client
.
PaymentProviderInstance
.
Create
()
.
SetProviderKey
(
payment
.
TypeEasyPay
)
.
SetName
(
"EasyPay Alipay"
)
.
SetConfig
(
"{}"
)
.
SetSupportedTypes
(
"alipay"
)
.
SetLimits
(
`{"alipay":{"singleMin":20,"singleMax":200}}`
)
.
SetEnabled
(
true
)
.
Save
(
ctx
)
if
err
!=
nil
{
t
.
Fatalf
(
"create easypay alipay instance: %v"
,
err
)
}
_
,
err
=
client
.
PaymentProviderInstance
.
Create
()
.
SetProviderKey
(
payment
.
TypeWxpay
)
.
SetName
(
"Official WeChat"
)
.
SetConfig
(
"{}"
)
.
SetSupportedTypes
(
"wxpay"
)
.
SetLimits
(
`{"wxpay":{"singleMin":30,"singleMax":300}}`
)
.
SetEnabled
(
true
)
.
Save
(
ctx
)
if
err
!=
nil
{
t
.
Fatalf
(
"create official wxpay instance: %v"
,
err
)
}
svc
:=
&
PaymentConfigService
{
entClient
:
client
,
settingRepo
:
&
paymentConfigSettingRepoStub
{
values
:
map
[
string
]
string
{
SettingPaymentVisibleMethodAlipayEnabled
:
"true"
,
SettingPaymentVisibleMethodAlipaySource
:
VisibleMethodSourceEasyPayAlipay
,
SettingPaymentVisibleMethodWxpayEnabled
:
"false"
,
SettingPaymentVisibleMethodWxpaySource
:
VisibleMethodSourceOfficialWechat
,
},
},
}
resp
,
err
:=
svc
.
GetAvailableMethodLimits
(
ctx
)
if
err
!=
nil
{
t
.
Fatalf
(
"GetAvailableMethodLimits returned error: %v"
,
err
)
}
alipayLimits
,
ok
:=
resp
.
Methods
[
payment
.
TypeAlipay
]
if
!
ok
{
t
.
Fatalf
(
"expected visible alipay limits, got %v"
,
resp
.
Methods
)
}
if
alipayLimits
.
SingleMin
!=
20
||
alipayLimits
.
SingleMax
!=
200
{
t
.
Fatalf
(
"alipay limits = %+v, want easypay-only min=20 max=200"
,
alipayLimits
)
}
if
_
,
ok
:=
resp
.
Methods
[
payment
.
TypeWxpay
];
ok
{
t
.
Fatalf
(
"wxpay should be hidden when visible method is disabled, got %v"
,
resp
.
Methods
[
payment
.
TypeWxpay
])
}
if
resp
.
GlobalMin
!=
20
||
resp
.
GlobalMax
!=
200
{
t
.
Fatalf
(
"global range = (%v, %v), want (20, 200)"
,
resp
.
GlobalMin
,
resp
.
GlobalMax
)
}
}
frontend/src/views/user/PaymentResultView.vue
View file @
16be82b9
...
@@ -142,10 +142,11 @@ onMounted(async () => {
...
@@ -142,10 +142,11 @@ onMounted(async () => {
const
resumeToken
=
typeof
route
.
query
.
resume_token
===
'
string
'
const
resumeToken
=
typeof
route
.
query
.
resume_token
===
'
string
'
?
route
.
query
.
resume_token
?
route
.
query
.
resume_token
:
''
:
''
let
o
rderId
=
Number
(
route
.
query
.
order_id
)
||
0
const
routeO
rderId
=
Number
(
route
.
query
.
order_id
)
||
0
const
outTradeNo
=
String
(
route
.
query
.
out_trade_no
||
''
)
const
outTradeNo
=
String
(
route
.
query
.
out_trade_no
||
''
)
let
orderId
=
0
if
(
!
orderId
&&
resumeToken
&&
typeof
window
!==
'
undefined
'
)
{
if
(
resumeToken
&&
typeof
window
!==
'
undefined
'
)
{
const
restored
=
readPaymentRecoverySnapshot
(
const
restored
=
readPaymentRecoverySnapshot
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
),
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
),
{
resumeToken
},
{
resumeToken
},
...
@@ -155,17 +156,31 @@ onMounted(async () => {
...
@@ -155,17 +156,31 @@ onMounted(async () => {
}
}
}
}
if
(
!
order
.
value
&&
!
orderId
&&
resumeToken
)
{
if
(
!
order
.
value
&&
resumeToken
&&
orderId
)
{
try
{
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
// Fall through to signed resume-token recovery below.
}
}
if
(
!
order
.
value
&&
resumeToken
)
{
try
{
try
{
const
result
=
await
paymentAPI
.
resolveOrderPublicByResumeToken
(
resumeToken
)
const
result
=
await
paymentAPI
.
resolveOrderPublicByResumeToken
(
resumeToken
)
order
.
value
=
result
.
data
order
.
value
=
result
.
data
orderId
=
result
.
data
.
id
if
(
!
orderId
)
{
orderId
=
result
.
data
.
id
}
}
catch
(
_err
:
unknown
)
{
}
catch
(
_err
:
unknown
)
{
// Resume token recovery failed
, continue to legacy
fallback
paths
.
// Resume token recovery failed
; do not trust legacy public out_trade_no
fallback.
}
}
}
}
if
(
!
order
.
value
&&
orderId
)
{
if
(
!
resumeToken
)
{
orderId
=
routeOrderId
}
if
(
!
order
.
value
&&
!
resumeToken
&&
orderId
)
{
try
{
try
{
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
}
catch
(
_err
:
unknown
)
{
...
@@ -173,7 +188,8 @@ onMounted(async () => {
...
@@ -173,7 +188,8 @@ onMounted(async () => {
}
}
}
}
if
(
!
order
.
value
&&
outTradeNo
)
{
const
hasLegacyFallbackContext
=
Boolean
(
route
.
query
.
trade_status
||
route
.
query
.
money
||
route
.
query
.
type
)
if
(
!
order
.
value
&&
!
resumeToken
&&
!
orderId
&&
outTradeNo
&&
hasLegacyFallbackContext
)
{
returnInfo
.
value
=
{
returnInfo
.
value
=
{
outTradeNo
,
outTradeNo
,
money
:
String
(
route
.
query
.
money
||
''
),
money
:
String
(
route
.
query
.
money
||
''
),
...
@@ -191,14 +207,6 @@ onMounted(async () => {
...
@@ -191,14 +207,6 @@ onMounted(async () => {
}
catch
(
_e
:
unknown
)
{
/* fall through */
}
}
catch
(
_e
:
unknown
)
{
/* fall through */
}
}
}
}
}
if
(
!
order
.
value
&&
orderId
)
{
try
{
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
// Order lookup failed, will show returnInfo fallback.
}
}
loading
.
value
=
false
loading
.
value
=
false
})
})
</
script
>
</
script
>
frontend/src/views/user/__tests__/PaymentResultView.spec.ts
View file @
16be82b9
...
@@ -76,6 +76,7 @@ describe('PaymentResultView', () => {
...
@@ -76,6 +76,7 @@ describe('PaymentResultView', () => {
it
(
'
restores order id from a matching resume token and does not trust query success flags
'
,
async
()
=>
{
it
(
'
restores order id from a matching resume token and does not trust query success flags
'
,
async
()
=>
{
routeState
.
query
=
{
routeState
.
query
=
{
resume_token
:
'
resume-42
'
,
resume_token
:
'
resume-42
'
,
order_id
:
'
999
'
,
status
:
'
success
'
,
status
:
'
success
'
,
}
}
window
.
localStorage
.
setItem
(
PAYMENT_RECOVERY_STORAGE_KEY
,
JSON
.
stringify
({
window
.
localStorage
.
setItem
(
PAYMENT_RECOVERY_STORAGE_KEY
,
JSON
.
stringify
({
...
@@ -110,6 +111,29 @@ describe('PaymentResultView', () => {
...
@@ -110,6 +111,29 @@ describe('PaymentResultView', () => {
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.success
'
)
})
})
it
(
'
does not fall back to public out_trade_no verification when resume_token recovery fails
'
,
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
,
{
global
:
{
stubs
:
{
OrderStatusBadge
:
true
,
},
},
})
await
flushPromises
()
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-fail
'
)
expect
(
verifyOrderPublic
).
not
.
toHaveBeenCalled
()
expect
(
verifyOrder
).
not
.
toHaveBeenCalled
()
})
it
(
'
keeps legacy out_trade_no verification as a fallback when no order context is available
'
,
async
()
=>
{
it
(
'
keeps legacy out_trade_no verification as a fallback when no order context is available
'
,
async
()
=>
{
routeState
.
query
=
{
routeState
.
query
=
{
out_trade_no
:
'
legacy-123
'
,
out_trade_no
:
'
legacy-123
'
,
...
...
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