diff --git a/backend/internal/payment/provider/wxpay.go b/backend/internal/payment/provider/wxpay.go index 84b324a8..47569ee3 100644 --- a/backend/internal/payment/provider/wxpay.go +++ b/backend/internal/payment/provider/wxpay.go @@ -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 = "" diff --git a/backend/internal/payment/provider/wxpay_test.go b/backend/internal/payment/provider/wxpay_test.go index 5074c545..8489e261 100644 --- a/backend/internal/payment/provider/wxpay_test.go +++ b/backend/internal/payment/provider/wxpay_test.go @@ -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() diff --git a/backend/internal/service/payment_config_limits.go b/backend/internal/service/payment_config_limits.go index 56905278..f30b119a 100644 --- a/backend/internal/service/payment_config_limits.go +++ b/backend/internal/service/payment_config_limits.go @@ -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(). diff --git a/backend/internal/service/payment_config_limits_test.go b/backend/internal/service/payment_config_limits_test.go index 73ad66ef..4a9d663d 100644 --- a/backend/internal/service/payment_config_limits_test.go +++ b/backend/internal/service/payment_config_limits_test.go @@ -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) + } +} diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index 53bbb550..e1bcbbe5 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -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 }) diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts index b1caa526..bfc044a7 100644 --- a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts @@ -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',