fix payment visible methods and resume recovery
This commit is contained in:
@@ -310,12 +310,14 @@ func buildWxpayResultURL(returnURL string, req payment.CreatePaymentRequest) (st
|
||||
return "", fmt.Errorf("return URL must be an absolute http(s) URL")
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
values := u.Query()
|
||||
values.Set("out_trade_no", strings.TrimSpace(req.OrderID))
|
||||
if paymentType := strings.TrimSpace(req.PaymentType); paymentType != "" {
|
||||
values.Set("payment_type", paymentType)
|
||||
}
|
||||
u.Path = wxpayResultPath
|
||||
if strings.TrimSpace(u.Path) == "" {
|
||||
u.Path = wxpayResultPath
|
||||
}
|
||||
u.RawPath = ""
|
||||
u.RawQuery = values.Encode()
|
||||
u.Fragment = ""
|
||||
|
||||
@@ -4,6 +4,7 @@ package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -20,6 +20,18 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M
|
||||
return nil, fmt.Errorf("query provider instances: %w", err)
|
||||
}
|
||||
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{
|
||||
Methods: make(map[string]MethodLimits, len(typeInstances)),
|
||||
}
|
||||
@@ -31,6 +43,40 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M
|
||||
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.
|
||||
func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []string) ([]MethodLimits, error) {
|
||||
instances, err := s.entClient.PaymentProviderInstance.Query().
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,10 +142,11 @@ onMounted(async () => {
|
||||
const resumeToken = typeof route.query.resume_token === 'string'
|
||||
? route.query.resume_token
|
||||
: ''
|
||||
let orderId = Number(route.query.order_id) || 0
|
||||
const routeOrderId = Number(route.query.order_id) || 0
|
||||
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(
|
||||
window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY),
|
||||
{ resumeToken },
|
||||
@@ -155,17 +156,31 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!order.value && !orderId && resumeToken) {
|
||||
if (!order.value && resumeToken && orderId) {
|
||||
try {
|
||||
const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken)
|
||||
order.value = result.data
|
||||
orderId = result.data.id
|
||||
order.value = await paymentStore.pollOrderStatus(orderId)
|
||||
} catch (_err: unknown) {
|
||||
// Resume token recovery failed, continue to legacy fallback paths.
|
||||
// Fall through to signed resume-token recovery below.
|
||||
}
|
||||
}
|
||||
|
||||
if (!order.value && orderId) {
|
||||
if (!order.value && resumeToken) {
|
||||
try {
|
||||
const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken)
|
||||
order.value = result.data
|
||||
if (!orderId) {
|
||||
orderId = result.data.id
|
||||
}
|
||||
} catch (_err: unknown) {
|
||||
// Resume token recovery failed; do not trust legacy public out_trade_no fallback.
|
||||
}
|
||||
}
|
||||
|
||||
if (!resumeToken) {
|
||||
orderId = routeOrderId
|
||||
}
|
||||
|
||||
if (!order.value && !resumeToken && orderId) {
|
||||
try {
|
||||
order.value = await paymentStore.pollOrderStatus(orderId)
|
||||
} catch (_err: unknown) {
|
||||
@@ -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 = {
|
||||
outTradeNo,
|
||||
money: String(route.query.money || ''),
|
||||
@@ -191,14 +207,6 @@ onMounted(async () => {
|
||||
} 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
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -76,6 +76,7 @@ describe('PaymentResultView', () => {
|
||||
it('restores order id from a matching resume token and does not trust query success flags', async () => {
|
||||
routeState.query = {
|
||||
resume_token: 'resume-42',
|
||||
order_id: '999',
|
||||
status: 'success',
|
||||
}
|
||||
window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({
|
||||
@@ -110,6 +111,29 @@ describe('PaymentResultView', () => {
|
||||
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 () => {
|
||||
routeState.query = {
|
||||
out_trade_no: 'legacy-123',
|
||||
|
||||
Reference in New Issue
Block a user