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
f83fd59d
Commit
f83fd59d
authored
Apr 21, 2026
by
IanShaw027
Browse files
Refine payment UX for wallet flows
parent
4ebdfcd1
Changes
9
Hide whitespace changes
Inline
Side-by-side
backend/internal/payment/provider/alipay.go
View file @
f83fd59d
...
...
@@ -26,6 +26,18 @@ const (
alipayRefundSuffix
=
"-refund"
)
var
(
alipayTradeWapPay
=
func
(
client
*
alipay
.
Client
,
param
alipay
.
TradeWapPay
)
(
*
url
.
URL
,
error
)
{
return
client
.
TradeWapPay
(
param
)
}
alipayTradePagePay
=
func
(
client
*
alipay
.
Client
,
param
alipay
.
TradePagePay
)
(
*
url
.
URL
,
error
)
{
return
client
.
TradePagePay
(
param
)
}
alipayTradePreCreate
=
func
(
ctx
context
.
Context
,
client
*
alipay
.
Client
,
param
alipay
.
TradePreCreate
)
(
*
alipay
.
TradePreCreateRsp
,
error
)
{
return
client
.
TradePreCreate
(
ctx
,
param
)
}
)
// Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK.
type
Alipay
struct
{
instanceID
string
...
...
@@ -80,7 +92,7 @@ func (a *Alipay) SupportedTypes() []payment.PaymentType {
}
// CreatePayment creates an Alipay payment page URL.
func
(
a
*
Alipay
)
CreatePayment
(
_
context
.
Context
,
req
payment
.
CreatePaymentRequest
)
(
*
payment
.
CreatePaymentResponse
,
error
)
{
func
(
a
*
Alipay
)
CreatePayment
(
ctx
context
.
Context
,
req
payment
.
CreatePaymentRequest
)
(
*
payment
.
CreatePaymentResponse
,
error
)
{
client
,
err
:=
a
.
getClient
()
if
err
!=
nil
{
return
nil
,
err
...
...
@@ -96,12 +108,12 @@ func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentReque
}
if
req
.
IsMobile
{
return
a
.
createTrade
(
client
,
req
,
notifyURL
,
returnURL
,
true
)
return
a
.
createTrade
(
ctx
,
client
,
req
,
notifyURL
,
returnURL
,
true
)
}
return
a
.
createTrade
(
client
,
req
,
notifyURL
,
returnURL
,
false
)
return
a
.
createTrade
(
ctx
,
client
,
req
,
notifyURL
,
returnURL
,
false
)
}
func
(
a
*
Alipay
)
createTrade
(
client
*
alipay
.
Client
,
req
payment
.
CreatePaymentRequest
,
notifyURL
,
returnURL
string
,
isMobile
bool
)
(
*
payment
.
CreatePaymentResponse
,
error
)
{
func
(
a
*
Alipay
)
createTrade
(
ctx
context
.
Context
,
client
*
alipay
.
Client
,
req
payment
.
CreatePaymentRequest
,
notifyURL
,
returnURL
string
,
isMobile
bool
)
(
*
payment
.
CreatePaymentResponse
,
error
)
{
if
isMobile
{
param
:=
alipay
.
TradeWapPay
{}
param
.
OutTradeNo
=
req
.
OrderID
...
...
@@ -111,7 +123,7 @@ func (a *Alipay) createTrade(client *alipay.Client, req payment.CreatePaymentReq
param
.
NotifyURL
=
notifyURL
param
.
ReturnURL
=
returnURL
payURL
,
err
:=
c
li
ent
.
TradeWapPay
(
param
)
payURL
,
err
:=
a
li
pay
TradeWapPay
(
client
,
param
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"alipay TradeWapPay: %w"
,
err
)
}
...
...
@@ -121,22 +133,19 @@ func (a *Alipay) createTrade(client *alipay.Client, req payment.CreatePaymentReq
},
nil
}
param
:=
alipay
.
TradeP
agePay
{}
param
:=
alipay
.
TradeP
reCreate
{}
param
.
OutTradeNo
=
req
.
OrderID
param
.
TotalAmount
=
req
.
Amount
param
.
Subject
=
req
.
Subject
param
.
ProductCode
=
alipayProductCodePagePay
param
.
NotifyURL
=
notifyURL
param
.
ReturnURL
=
returnURL
payURL
,
err
:=
c
li
ent
.
TradeP
agePay
(
param
)
resp
,
err
:=
a
li
pay
TradeP
reCreate
(
ctx
,
client
,
param
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"alipay TradeP
agePay
: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"alipay TradeP
reCreate
: %w"
,
err
)
}
return
&
payment
.
CreatePaymentResponse
{
TradeNo
:
req
.
OrderID
,
PayURL
:
payURL
.
String
(),
QRCode
:
payURL
.
String
(),
QRCode
:
strings
.
TrimSpace
(
resp
.
QRCode
),
},
nil
}
...
...
backend/internal/payment/provider/alipay_test.go
View file @
f83fd59d
...
...
@@ -3,9 +3,14 @@
package
provider
import
(
"context"
"errors"
"net/url"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/smartwalle/alipay/v3"
)
func
TestIsTradeNotExist
(
t
*
testing
.
T
)
{
...
...
@@ -130,3 +135,111 @@ func TestNewAlipay(t *testing.T) {
})
}
}
func
TestCreateTradeUsesPreCreateForDesktop
(
t
*
testing
.
T
)
{
origPreCreate
:=
alipayTradePreCreate
origPagePay
:=
alipayTradePagePay
origWapPay
:=
alipayTradeWapPay
t
.
Cleanup
(
func
()
{
alipayTradePreCreate
=
origPreCreate
alipayTradePagePay
=
origPagePay
alipayTradeWapPay
=
origWapPay
})
preCreateCalls
:=
0
pagePayCalls
:=
0
wapPayCalls
:=
0
alipayTradePreCreate
=
func
(
ctx
context
.
Context
,
client
*
alipay
.
Client
,
param
alipay
.
TradePreCreate
)
(
*
alipay
.
TradePreCreateRsp
,
error
)
{
preCreateCalls
++
if
param
.
OutTradeNo
!=
"sub2_100"
{
t
.
Fatalf
(
"out_trade_no = %q, want %q"
,
param
.
OutTradeNo
,
"sub2_100"
)
}
if
param
.
NotifyURL
!=
"https://merchant.example.com/api/v1/payment/webhook/alipay"
{
t
.
Fatalf
(
"notify_url = %q"
,
param
.
NotifyURL
)
}
return
&
alipay
.
TradePreCreateRsp
{
OutTradeNo
:
"sub2_100"
,
QRCode
:
"https://qr.alipay.example.com/precreate-token"
,
},
nil
}
alipayTradePagePay
=
func
(
client
*
alipay
.
Client
,
param
alipay
.
TradePagePay
)
(
*
url
.
URL
,
error
)
{
pagePayCalls
++
return
url
.
Parse
(
"https://openapi.alipay.com/gateway.do?page-pay"
)
}
alipayTradeWapPay
=
func
(
client
*
alipay
.
Client
,
param
alipay
.
TradeWapPay
)
(
*
url
.
URL
,
error
)
{
wapPayCalls
++
return
url
.
Parse
(
"https://openapi.alipay.com/gateway.do?wap-pay"
)
}
provider
:=
&
Alipay
{}
resp
,
err
:=
provider
.
createTrade
(
context
.
Background
(),
&
alipay
.
Client
{},
payment
.
CreatePaymentRequest
{
OrderID
:
"sub2_100"
,
Amount
:
"88.00"
,
Subject
:
"Balance recharge"
,
},
"https://merchant.example.com/api/v1/payment/webhook/alipay"
,
"https://merchant.example.com/payment/result"
,
false
)
if
err
!=
nil
{
t
.
Fatalf
(
"unexpected error: %v"
,
err
)
}
if
preCreateCalls
!=
1
{
t
.
Fatalf
(
"precreate calls = %d, want 1"
,
preCreateCalls
)
}
if
pagePayCalls
!=
0
{
t
.
Fatalf
(
"page pay calls = %d, want 0"
,
pagePayCalls
)
}
if
wapPayCalls
!=
0
{
t
.
Fatalf
(
"wap pay calls = %d, want 0"
,
wapPayCalls
)
}
if
resp
.
QRCode
!=
"https://qr.alipay.example.com/precreate-token"
{
t
.
Fatalf
(
"qr_code = %q"
,
resp
.
QRCode
)
}
if
resp
.
PayURL
!=
""
{
t
.
Fatalf
(
"pay_url = %q, want empty"
,
resp
.
PayURL
)
}
}
func
TestCreateTradeUsesWapPayForMobile
(
t
*
testing
.
T
)
{
origPreCreate
:=
alipayTradePreCreate
origWapPay
:=
alipayTradeWapPay
t
.
Cleanup
(
func
()
{
alipayTradePreCreate
=
origPreCreate
alipayTradeWapPay
=
origWapPay
})
preCreateCalls
:=
0
alipayTradePreCreate
=
func
(
ctx
context
.
Context
,
client
*
alipay
.
Client
,
param
alipay
.
TradePreCreate
)
(
*
alipay
.
TradePreCreateRsp
,
error
)
{
preCreateCalls
++
return
&
alipay
.
TradePreCreateRsp
{},
nil
}
wapPayCalls
:=
0
alipayTradeWapPay
=
func
(
client
*
alipay
.
Client
,
param
alipay
.
TradeWapPay
)
(
*
url
.
URL
,
error
)
{
wapPayCalls
++
if
param
.
ReturnURL
!=
"https://merchant.example.com/payment/result"
{
t
.
Fatalf
(
"return_url = %q"
,
param
.
ReturnURL
)
}
return
url
.
Parse
(
"https://openapi.alipay.com/gateway.do?wap-pay"
)
}
provider
:=
&
Alipay
{}
resp
,
err
:=
provider
.
createTrade
(
context
.
Background
(),
&
alipay
.
Client
{},
payment
.
CreatePaymentRequest
{
OrderID
:
"sub2_101"
,
Amount
:
"18.00"
,
Subject
:
"Balance recharge"
,
IsMobile
:
true
,
},
"https://merchant.example.com/api/v1/payment/webhook/alipay"
,
"https://merchant.example.com/payment/result"
,
true
)
if
err
!=
nil
{
t
.
Fatalf
(
"unexpected error: %v"
,
err
)
}
if
preCreateCalls
!=
0
{
t
.
Fatalf
(
"precreate calls = %d, want 0"
,
preCreateCalls
)
}
if
wapPayCalls
!=
1
{
t
.
Fatalf
(
"wap pay calls = %d, want 1"
,
wapPayCalls
)
}
if
resp
.
PayURL
==
""
{
t
.
Fatal
(
"expected pay_url for mobile wap pay"
)
}
if
resp
.
QRCode
!=
""
{
t
.
Fatalf
(
"qr_code = %q, want empty"
,
resp
.
QRCode
)
}
}
frontend/src/i18n/locales/en.ts
View file @
f83fd59d
...
...
@@ -5453,6 +5453,18 @@ export default {
errors
:
{
tooManyPending
:
'
Too many pending orders (max {max}). Please complete or cancel existing orders first.
'
,
cancelRateLimited
:
'
Too many cancellations. Please try again later.
'
,
wechatH5NotAuthorized
:
'
This merchant has not enabled WeChat H5 payment. Open this page in WeChat to continue.
'
,
wechatPaymentMpNotConfigured
:
'
This site has not completed WeChat MP/JSAPI payment setup, so in-app WeChat payment is unavailable right now.
'
,
wechatJsapiUnavailable
:
'
WeChat payment could not be invoked in the current environment. Reopen this page inside WeChat and try again.
'
,
wechatJsapiFailed
:
'
WeChat payment did not complete. Try invoking it again or switch to QR payment.
'
,
wechatUnavailable
:
'
WeChat payment is temporarily unavailable. Please try again later.
'
,
wechatOpenInWeChatHint
:
'
Open the current page inside WeChat, or switch to desktop WeChat QR payment.
'
,
wechatScanOnDesktopHint
:
'
On desktop, use WeChat Scan to pay; on mobile, reopen the current page inside WeChat.
'
,
wechatSwitchBrowserHint
:
'
Switch to desktop WeChat QR payment, or reopen this page in an external browser and retry.
'
,
alipayDesktopUnavailable
:
'
The desktop Alipay flow could not generate a QR code.
'
,
alipayDesktopQrHint
:
'
Desktop Alipay should render a QR code. Refresh and retry, or make sure the payment page was not blocked.
'
,
alipayMobileUnavailable
:
'
This page could not hand off to Alipay.
'
,
alipayMobileOpenHint
:
'
Allow the current page to open the Alipay app, or retry from the system browser.
'
,
PENDING_ORDERS
:
'
This provider has pending orders. Please wait for them to complete before making changes.
'
,
},
stripePay
:
'
Pay Now
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
f83fd59d
...
...
@@ -5641,6 +5641,18 @@ export default {
errors
:
{
tooManyPending
:
'
待支付订单过多(最多 {max} 个),请先完成或取消现有订单
'
,
cancelRateLimited
:
'
取消订单过于频繁,请稍后再试
'
,
wechatH5NotAuthorized
:
'
当前商户未开通微信 H5 支付,请在微信中打开当前页面继续支付。
'
,
wechatPaymentMpNotConfigured
:
'
当前站点未完成公众号/JSAPI 支付配置,暂时无法在微信内直接拉起支付。
'
,
wechatJsapiUnavailable
:
'
当前环境未能拉起微信支付,请确认正在微信内打开本页后重试。
'
,
wechatJsapiFailed
:
'
微信支付未完成,请重新拉起支付或改用扫码支付。
'
,
wechatUnavailable
:
'
当前微信支付暂不可用,请稍后重试。
'
,
wechatOpenInWeChatHint
:
'
请复制当前页面链接到微信内打开,或直接改用电脑端微信扫码支付。
'
,
wechatScanOnDesktopHint
:
'
电脑端请直接使用微信扫一扫完成支付;移动端请在微信内打开当前页面。
'
,
wechatSwitchBrowserHint
:
'
请改用电脑端微信扫码,或在外部浏览器重新打开本页后再试。
'
,
alipayDesktopUnavailable
:
'
当前支付宝桌面支付未成功生成二维码。
'
,
alipayDesktopQrHint
:
'
电脑端支付宝应展示扫码单,请刷新后重试,或确认浏览器未拦截当前支付页。
'
,
alipayMobileUnavailable
:
'
当前页面未成功跳转到支付宝。
'
,
alipayMobileOpenHint
:
'
请允许当前页面打开支付宝 App,或改用系统浏览器重新发起支付。
'
,
PENDING_ORDERS
:
'
该服务商有未完成的订单,请等待订单完成后再操作
'
,
},
stripePay
:
'
立即支付
'
,
...
...
frontend/src/views/user/PaymentResultView.vue
View file @
f83fd59d
...
...
@@ -54,7 +54,7 @@
</div>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.paymentMethod
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
payment
.m
ethod
s.
'
+
order
.
payment_type
,
order
.
payment_type
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
t
(
payment
M
ethod
I18nKey
(
order
.
payment_type
)
,
normalizedOrderPaymentType
(
order
.
payment_type
)
)
}}
</span>
</div>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.status
'
)
}}
</span>
...
...
@@ -75,7 +75,7 @@
</div>
<div
v-if=
"returnInfo.type"
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.paymentMethod
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
payment
.m
ethod
s.
'
+
returnInfo
.
type
,
returnInfo
.
type
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
t
(
payment
M
ethod
I18nKey
(
returnInfo
.
type
)
,
normalizedOrderPaymentType
(
returnInfo
.
type
)
)
}}
</span>
</div>
</div>
</div>
...
...
@@ -98,6 +98,7 @@ import { PAYMENT_RECOVERY_STORAGE_KEY, readPaymentRecoverySnapshot } from '@/com
import
{
usePaymentStore
}
from
'
@/stores/payment
'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
type
{
PaymentOrder
}
from
'
@/types/payment
'
import
{
normalizePaymentMethodForDisplay
,
paymentMethodI18nKey
}
from
'
./paymentUx
'
const
{
t
}
=
useI18n
()
const
route
=
useRoute
()
...
...
@@ -133,6 +134,10 @@ const isSuccess = computed(() => {
return
!!
order
.
value
&&
SUCCESS_STATUSES
.
has
(
order
.
value
.
status
)
})
function
normalizedOrderPaymentType
(
paymentType
:
string
):
string
{
return
normalizePaymentMethodForDisplay
(
paymentType
)
||
paymentType
}
onMounted
(
async
()
=>
{
const
resumeToken
=
typeof
route
.
query
.
resume_token
===
'
string
'
?
route
.
query
.
resume_token
...
...
frontend/src/views/user/PaymentView.vue
View file @
f83fd59d
...
...
@@ -88,6 +88,7 @@
<
/button
>
<
div
v
-
if
=
"
errorMessage
"
class
=
"
rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20
"
>
<
p
class
=
"
text-sm text-red-700 dark:text-red-400
"
>
{{
errorMessage
}}
<
/p
>
<
p
v
-
if
=
"
errorHintMessage
"
class
=
"
mt-2 text-xs text-red-600 dark:text-red-300
"
>
{{
errorHintMessage
}}
<
/p
>
<
/div
>
<
/template
>
<
/template
>
...
...
@@ -174,6 +175,7 @@
<
button
class
=
"
btn btn-secondary w-full
"
@
click
=
"
selectedPlan = null
"
>
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
div
v
-
if
=
"
errorMessage
"
class
=
"
rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20
"
>
<
p
class
=
"
text-sm text-red-700 dark:text-red-400
"
>
{{
errorMessage
}}
<
/p
>
<
p
v
-
if
=
"
errorHintMessage
"
class
=
"
mt-2 text-xs text-red-600 dark:text-red-300
"
>
{{
errorHintMessage
}}
<
/p
>
<
/div
>
<
/template
>
<!--
Plan
list
-->
...
...
@@ -281,6 +283,7 @@ import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import
PaymentStatusPanel
from
'
@/components/payment/PaymentStatusPanel.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
PaymentMethodOption
}
from
'
@/components/payment/PaymentMethodSelector.vue
'
import
{
describePaymentScenarioError
}
from
'
./paymentUx
'
const
{
t
}
=
useI18n
()
const
route
=
useRoute
()
...
...
@@ -301,6 +304,7 @@ function getDaysRemaining(expiresAt: string): number {
const
loading
=
ref
(
true
)
const
submitting
=
ref
(
false
)
const
errorMessage
=
ref
(
''
)
const
errorHintMessage
=
ref
(
''
)
const
activeTab
=
ref
<
'
recharge
'
|
'
subscription
'
>
(
'
recharge
'
)
const
amount
=
ref
<
number
|
null
>
(
null
)
const
selectedMethod
=
ref
(
''
)
...
...
@@ -619,6 +623,7 @@ async function confirmSubscribe() {
async
function
createOrder
(
orderAmount
:
number
,
orderType
:
OrderType
,
planId
?:
number
,
options
:
CreateOrderOptions
=
{
}
)
{
submitting
.
value
=
true
errorMessage
.
value
=
''
errorHintMessage
.
value
=
''
try
{
const
requestType
=
normalizeVisibleMethod
(
options
.
paymentType
||
selectedMethod
.
value
)
||
options
.
paymentType
||
selectedMethod
.
value
const
payload
=
buildCreateOrderPayload
({
...
...
@@ -668,8 +673,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
if
(
decision
.
kind
===
'
unhandled
'
)
{
errorMessage
.
value
=
t
(
'
payment.result.failed
'
)
appStore
.
showError
(
errorMessage
.
value
)
applyScenarioError
({
reason
:
'
UNHANDLED_PAYMENT_SCENARIO
'
}
,
visibleMethod
)
return
}
...
...
@@ -691,7 +695,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
if
(
errMsg
.
includes
(
'
cancel
'
))
{
appStore
.
showInfo
(
t
(
'
payment.qr.cancelled
'
))
}
else
if
(
errMsg
&&
!
errMsg
.
includes
(
'
ok
'
))
{
app
Store
.
showError
(
t
(
'
payment.result.failed
'
)
)
app
lyScenarioError
({
reason
:
'
WECHAT_JSAPI_FAILED
'
,
message
:
errMsg
}
,
visibleMethod
)
}
return
}
...
...
@@ -707,10 +711,16 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
if
(
apiErr
.
reason
===
'
TOO_MANY_PENDING
'
)
{
const
metadata
=
apiErr
.
metadata
as
Record
<
string
,
unknown
>
|
undefined
errorMessage
.
value
=
t
(
'
payment.errors.tooManyPending
'
,
{
max
:
metadata
?.
max
||
''
}
)
errorHintMessage
.
value
=
''
}
else
if
(
apiErr
.
reason
===
'
CANCEL_RATE_LIMITED
'
)
{
errorMessage
.
value
=
t
(
'
payment.errors.cancelRateLimited
'
)
errorHintMessage
.
value
=
''
}
else
{
errorMessage
.
value
=
extractApiErrorMessage
(
err
,
t
(
'
payment.result.failed
'
))
applyScenarioError
(
err
,
normalizeVisibleMethod
(
options
.
paymentType
||
selectedMethod
.
value
)
||
selectedMethod
.
value
)
if
(
!
errorMessage
.
value
)
{
errorMessage
.
value
=
extractApiErrorMessage
(
err
,
t
(
'
payment.result.failed
'
))
errorHintMessage
.
value
=
''
}
}
appStore
.
showError
(
errorMessage
.
value
)
}
finally
{
...
...
@@ -718,6 +728,21 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
}
function
applyScenarioError
(
err
:
unknown
,
paymentMethod
:
string
)
{
const
descriptor
=
describePaymentScenarioError
(
err
,
{
paymentMethod
,
isMobile
:
isMobileDevice
(),
isWechatBrowser
:
typeof
window
!==
'
undefined
'
&&
/MicroMessenger/i
.
test
(
window
.
navigator
.
userAgent
),
}
)
if
(
!
descriptor
)
{
errorMessage
.
value
=
''
errorHintMessage
.
value
=
''
return
}
errorMessage
.
value
=
t
(
descriptor
.
messageKey
)
errorHintMessage
.
value
=
descriptor
.
hintKey
?
t
(
descriptor
.
hintKey
)
:
''
}
async
function
resumeWechatPaymentFromQuery
()
{
const
openid
=
readRouteQueryValue
(
route
.
query
.
openid
)
if
(
readRouteQueryValue
(
route
.
query
.
wechat_resume
)
!==
'
1
'
||
!
openid
)
{
...
...
frontend/src/views/user/__tests__/PaymentResultView.spec.ts
View file @
f83fd59d
...
...
@@ -155,4 +155,29 @@ describe('PaymentResultView', () => {
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
verifyOrderPublic
).
not
.
toHaveBeenCalled
()
})
it
(
'
normalizes aliased payment methods before rendering the label
'
,
async
()
=>
{
routeState
.
query
=
{
resume_token
:
'
resume-88
'
,
}
resolveOrderPublicByResumeToken
.
mockResolvedValueOnce
({
data
:
{
...
orderFactory
(
'
PAID
'
),
payment_type
:
'
alipay_direct
'
,
},
})
const
wrapper
=
mount
(
PaymentResultView
,
{
global
:
{
stubs
:
{
OrderStatusBadge
:
true
,
},
},
})
await
flushPromises
()
expect
(
wrapper
.
text
()).
toContain
(
'
payment.methods.alipay
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.methods.alipay_direct
'
)
})
})
frontend/src/views/user/__tests__/paymentUx.spec.ts
0 → 100644
View file @
f83fd59d
import
{
describe
,
expect
,
it
}
from
'
vitest
'
import
{
describePaymentScenarioError
,
normalizePaymentMethodForDisplay
,
}
from
'
../paymentUx
'
describe
(
'
normalizePaymentMethodForDisplay
'
,
()
=>
{
it
(
'
collapses visible payment aliases to canonical method ids
'
,
()
=>
{
expect
(
normalizePaymentMethodForDisplay
(
'
alipay_direct
'
)).
toBe
(
'
alipay
'
)
expect
(
normalizePaymentMethodForDisplay
(
'
wxpay_direct
'
)).
toBe
(
'
wxpay
'
)
expect
(
normalizePaymentMethodForDisplay
(
'
wechat_pay
'
)).
toBe
(
'
wxpay
'
)
})
it
(
'
leaves non-aliased methods untouched
'
,
()
=>
{
expect
(
normalizePaymentMethodForDisplay
(
'
stripe
'
)).
toBe
(
'
stripe
'
)
})
})
describe
(
'
describePaymentScenarioError
'
,
()
=>
{
it
(
'
maps WeChat H5 authorization errors to explicit in-app guidance
'
,
()
=>
{
expect
(
describePaymentScenarioError
(
{
reason
:
'
WECHAT_H5_NOT_AUTHORIZED
'
},
{
paymentMethod
:
'
wxpay
'
,
isMobile
:
true
,
isWechatBrowser
:
false
},
)).
toEqual
({
messageKey
:
'
payment.errors.wechatH5NotAuthorized
'
,
hintKey
:
'
payment.errors.wechatOpenInWeChatHint
'
,
})
})
it
(
'
maps missing WeixinJSBridge to a JSAPI-specific prompt
'
,
()
=>
{
expect
(
describePaymentScenarioError
(
new
Error
(
'
WeixinJSBridge is unavailable
'
),
{
paymentMethod
:
'
wxpay
'
,
isMobile
:
true
,
isWechatBrowser
:
true
},
)).
toEqual
({
messageKey
:
'
payment.errors.wechatJsapiUnavailable
'
,
hintKey
:
'
payment.errors.wechatOpenInWeChatHint
'
,
})
})
it
(
'
maps generic desktop Alipay failures to QR guidance
'
,
()
=>
{
expect
(
describePaymentScenarioError
(
{
reason
:
'
PAYMENT_GATEWAY_ERROR
'
},
{
paymentMethod
:
'
alipay
'
,
isMobile
:
false
,
isWechatBrowser
:
false
},
)).
toEqual
({
messageKey
:
'
payment.errors.alipayDesktopUnavailable
'
,
hintKey
:
'
payment.errors.alipayDesktopQrHint
'
,
})
})
})
frontend/src/views/user/paymentUx.ts
0 → 100644
View file @
f83fd59d
import
{
normalizeVisibleMethod
}
from
'
@/components/payment/paymentFlow
'
import
{
extractApiErrorCode
}
from
'
@/utils/apiError
'
const
DISPLAY_METHOD_ALIASES
:
Record
<
string
,
string
>
=
{
wechat
:
'
wxpay
'
,
wechat_pay
:
'
wxpay
'
,
}
export
interface
PaymentScenarioContext
{
paymentMethod
:
string
isMobile
:
boolean
isWechatBrowser
:
boolean
}
export
interface
PaymentScenarioErrorDescriptor
{
messageKey
:
string
hintKey
?:
string
}
export
function
normalizePaymentMethodForDisplay
(
paymentType
:
string
):
string
{
const
trimmed
=
paymentType
.
trim
().
toLowerCase
()
const
visibleMethod
=
normalizeVisibleMethod
(
trimmed
)
if
(
visibleMethod
)
return
visibleMethod
return
DISPLAY_METHOD_ALIASES
[
trimmed
]
??
trimmed
}
export
function
paymentMethodI18nKey
(
paymentType
:
string
):
string
{
return
`payment.methods.
${
normalizePaymentMethodForDisplay
(
paymentType
)}
`
}
function
defaultWechatHint
(
context
:
PaymentScenarioContext
):
string
{
if
(
!
context
.
isMobile
)
return
'
payment.errors.wechatScanOnDesktopHint
'
return
'
payment.errors.wechatOpenInWeChatHint
'
}
function
defaultAlipayHint
(
context
:
PaymentScenarioContext
):
string
{
if
(
context
.
isMobile
)
return
'
payment.errors.alipayMobileOpenHint
'
return
'
payment.errors.alipayDesktopQrHint
'
}
export
function
describePaymentScenarioError
(
error
:
unknown
,
context
:
PaymentScenarioContext
,
):
PaymentScenarioErrorDescriptor
|
null
{
const
method
=
normalizePaymentMethodForDisplay
(
context
.
paymentMethod
)
const
code
=
extractApiErrorCode
(
error
)
const
message
=
error
instanceof
Error
?
error
.
message
:
(
typeof
error
===
'
object
'
&&
error
&&
'
message
'
in
error
&&
typeof
error
.
message
===
'
string
'
?
error
.
message
:
String
(
error
||
''
))
const
normalizedMessage
=
message
.
toLowerCase
()
if
(
method
===
'
wxpay
'
)
{
if
(
code
===
'
WECHAT_H5_NOT_AUTHORIZED
'
)
{
return
{
messageKey
:
'
payment.errors.wechatH5NotAuthorized
'
,
hintKey
:
defaultWechatHint
(
context
),
}
}
if
(
code
===
'
WECHAT_PAYMENT_MP_NOT_CONFIGURED
'
)
{
return
{
messageKey
:
'
payment.errors.wechatPaymentMpNotConfigured
'
,
hintKey
:
context
.
isWechatBrowser
?
'
payment.errors.wechatSwitchBrowserHint
'
:
defaultWechatHint
(
context
),
}
}
if
(
code
===
'
NO_AVAILABLE_INSTANCE
'
)
{
return
{
messageKey
:
'
payment.errors.wechatUnavailable
'
,
hintKey
:
defaultWechatHint
(
context
),
}
}
if
(
code
===
'
WECHAT_JSAPI_FAILED
'
||
normalizedMessage
.
includes
(
'
get_brand_wcpay_request:fail
'
))
{
return
{
messageKey
:
'
payment.errors.wechatJsapiFailed
'
,
hintKey
:
defaultWechatHint
(
context
),
}
}
if
(
normalizedMessage
.
includes
(
'
weixinjsbridge is unavailable
'
))
{
return
{
messageKey
:
'
payment.errors.wechatJsapiUnavailable
'
,
hintKey
:
'
payment.errors.wechatOpenInWeChatHint
'
,
}
}
if
(
code
===
'
PAYMENT_GATEWAY_ERROR
'
||
code
===
'
UNHANDLED_PAYMENT_SCENARIO
'
)
{
return
{
messageKey
:
'
payment.errors.wechatUnavailable
'
,
hintKey
:
defaultWechatHint
(
context
),
}
}
}
if
(
method
===
'
alipay
'
&&
(
code
===
'
PAYMENT_GATEWAY_ERROR
'
||
code
===
'
UNHANDLED_PAYMENT_SCENARIO
'
))
{
return
{
messageKey
:
context
.
isMobile
?
'
payment.errors.alipayMobileUnavailable
'
:
'
payment.errors.alipayDesktopUnavailable
'
,
hintKey
:
defaultAlipayHint
(
context
),
}
}
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