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")
|
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))
|
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 = ""
|
||||||
|
|||||||
@@ -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) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|||||||
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().
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'
|
const resumeToken = typeof route.query.resume_token === 'string'
|
||||||
? route.query.resume_token
|
? 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 || '')
|
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 () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!order.value && !orderId && resumeToken) {
|
if (!order.value && resumeToken && orderId) {
|
||||||
try {
|
try {
|
||||||
const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken)
|
order.value = await paymentStore.pollOrderStatus(orderId)
|
||||||
order.value = result.data
|
|
||||||
orderId = result.data.id
|
|
||||||
} catch (_err: unknown) {
|
} 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 {
|
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 () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 () => {
|
|||||||
} 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>
|
||||||
|
|||||||
@@ -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', () => {
|
|||||||
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',
|
||||||
|
|||||||
Reference in New Issue
Block a user