fix payment visible methods and resume recovery

This commit is contained in:
IanShaw027
2026-04-21 00:14:05 +08:00
parent 5d58c7c6fb
commit 16be82b959
6 changed files with 201 additions and 19 deletions

View File

@@ -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 = ""

View File

@@ -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()

View File

@@ -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().

View File

@@ -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)
}
}

View File

@@ -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>

View File

@@ -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',