diff --git a/backend/internal/payment/provider/wxpay.go b/backend/internal/payment/provider/wxpay.go index 47569ee3..7d51dff0 100644 --- a/backend/internal/payment/provider/wxpay.go +++ b/backend/internal/payment/provider/wxpay.go @@ -250,13 +250,12 @@ func (w *Wxpay) prepayNative(ctx context.Context, c *core.Client, req payment.Cr func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) { svc := h5.H5ApiService{Client: c} cur := wxpayCurrency - tp := wxpayH5Type resp, _, err := wxpayH5Prepay(ctx, svc, h5.PrepayRequest{ Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]), Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID), NotifyUrl: core.String(notifyURL), Amount: &h5.Amount{Total: core.Int64(totalFen), Currency: &cur}, - SceneInfo: &h5.SceneInfo{PayerClientIp: core.String(req.ClientIP), H5Info: &h5.H5Info{Type: &tp}}, + SceneInfo: &h5.SceneInfo{PayerClientIp: core.String(req.ClientIP), H5Info: buildWxpayH5Info(w.config)}, }) if err != nil { return nil, fmt.Errorf("wxpay h5 prepay: %w", err) @@ -272,6 +271,18 @@ func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.Create return &payment.CreatePaymentResponse{TradeNo: req.OrderID, PayURL: h5URL}, nil } +func buildWxpayH5Info(config map[string]string) *h5.H5Info { + tp := wxpayH5Type + info := &h5.H5Info{Type: &tp} + if appName := strings.TrimSpace(config["h5AppName"]); appName != "" { + info.AppName = core.String(appName) + } + if appURL := strings.TrimSpace(config["h5AppUrl"]); appURL != "" { + info.AppUrl = core.String(appURL) + } + return info +} + func resolveWxpayCreateMode(req payment.CreatePaymentRequest) (string, error) { if strings.TrimSpace(req.OpenID) != "" { return wxpayModeJSAPI, nil diff --git a/backend/internal/payment/provider/wxpay_test.go b/backend/internal/payment/provider/wxpay_test.go index 8489e261..6d0006be 100644 --- a/backend/internal/payment/provider/wxpay_test.go +++ b/backend/internal/payment/provider/wxpay_test.go @@ -487,3 +487,86 @@ func TestCreatePaymentWithOpenIDReturnsJSAPIResult(t *testing.T) { t.Fatalf("jsapi paySign = %q, want %q", resp.JSAPI.PaySign, "signed-payload") } } + +func TestCreatePaymentMobileH5IncludesConfiguredSceneInfo(t *testing.T) { + origJSAPIPrepay := wxpayJSAPIPrepayWithRequestPayment + origNativePrepay := wxpayNativePrepay + origH5Prepay := wxpayH5Prepay + t.Cleanup(func() { + wxpayJSAPIPrepayWithRequestPayment = origJSAPIPrepay + wxpayNativePrepay = origNativePrepay + wxpayH5Prepay = origH5Prepay + }) + + jsapiCalls := 0 + nativeCalls := 0 + h5Calls := 0 + wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) { + jsapiCalls++ + return &jsapi.PrepayWithRequestPaymentResponse{}, nil, nil + } + wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) { + nativeCalls++ + return &native.PrepayResponse{}, nil, nil + } + wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) { + h5Calls++ + if req.SceneInfo == nil { + t.Fatal("expected scene_info, got nil") + } + if got := wxSV(req.SceneInfo.PayerClientIp); got != "203.0.113.10" { + t.Fatalf("scene_info payer_client_ip = %q, want %q", got, "203.0.113.10") + } + if req.SceneInfo.H5Info == nil { + t.Fatal("expected scene_info.h5_info, got nil") + } + if got := wxSV(req.SceneInfo.H5Info.Type); got != wxpayH5Type { + t.Fatalf("scene_info.h5_info.type = %q, want %q", got, wxpayH5Type) + } + if got := wxSV(req.SceneInfo.H5Info.AppName); got != "Sub2API" { + t.Fatalf("scene_info.h5_info.app_name = %q, want %q", got, "Sub2API") + } + if got := wxSV(req.SceneInfo.H5Info.AppUrl); got != "https://app.example.com" { + t.Fatalf("scene_info.h5_info.app_url = %q, want %q", got, "https://app.example.com") + } + return &h5.PrepayResponse{ + H5Url: core.String("https://wx.tenpay.example/h5pay?prepay_id=1"), + }, nil, nil + } + + provider := &Wxpay{ + config: map[string]string{ + "appId": "wx123", + "mchId": "mch123", + "h5AppName": "Sub2API", + "h5AppUrl": "https://app.example.com", + }, + coreClient: &core.Client{}, + } + + resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{ + OrderID: "sub2_99", + Amount: "66.88", + PaymentType: payment.TypeWxpay, + Subject: "Balance Recharge", + NotifyURL: "https://merchant.example/payment/notify", + ReturnURL: "https://merchant.example/payment/result?resume_token=resume-99", + ClientIP: "203.0.113.10", + IsMobile: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if jsapiCalls != 0 { + t.Fatalf("jsapi prepay calls = %d, want 0", jsapiCalls) + } + if nativeCalls != 0 { + t.Fatalf("native prepay calls = %d, want 0", nativeCalls) + } + if h5Calls != 1 { + t.Fatalf("h5 prepay calls = %d, want 1", h5Calls) + } + if !strings.Contains(resp.PayURL, "redirect_url=") { + t.Fatalf("pay_url = %q, want redirect_url query appended", resp.PayURL) + } +} diff --git a/frontend/src/components/payment/__tests__/providerConfig.spec.ts b/frontend/src/components/payment/__tests__/providerConfig.spec.ts new file mode 100644 index 00000000..6a4c9c26 --- /dev/null +++ b/frontend/src/components/payment/__tests__/providerConfig.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { PROVIDER_CONFIG_FIELDS } from '@/components/payment/providerConfig' + +function findField(key: string) { + const fields = PROVIDER_CONFIG_FIELDS.wxpay || [] + return fields.find(field => field.key === key) +} + +describe('PROVIDER_CONFIG_FIELDS.wxpay', () => { + it('keeps admin form validation aligned with backend-required credentials', () => { + expect(findField('publicKeyId')?.optional).toBeFalsy() + expect(findField('certSerial')?.optional).toBeFalsy() + }) + + it('exposes optional mp and H5 metadata fields for WeChat-specific flows', () => { + expect(findField('mpAppId')?.optional).toBe(true) + expect(findField('h5AppName')?.optional).toBe(true) + expect(findField('h5AppUrl')?.optional).toBe(true) + }) +}) diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts index a83787fd..5d52eddf 100644 --- a/frontend/src/components/payment/providerConfig.ts +++ b/frontend/src/components/payment/providerConfig.ts @@ -83,12 +83,15 @@ export const PROVIDER_CONFIG_FIELDS: Record = { ], wxpay: [ { key: 'appId', label: 'App ID', sensitive: false }, + { key: 'mpAppId', label: '', sensitive: false, optional: true }, { key: 'mchId', label: '', sensitive: false }, { key: 'privateKey', label: '', sensitive: true }, { key: 'apiV3Key', label: '', sensitive: true }, { key: 'publicKey', label: '', sensitive: true }, - { key: 'publicKeyId', label: '', sensitive: false, optional: true }, - { key: 'certSerial', label: '', sensitive: false, optional: true }, + { key: 'publicKeyId', label: '', sensitive: false }, + { key: 'certSerial', label: '', sensitive: false }, + { key: 'h5AppName', label: '', sensitive: false, optional: true }, + { key: 'h5AppUrl', label: '', sensitive: false, optional: true }, ], stripe: [ { key: 'secretKey', label: '', sensitive: true }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 10160d73..78db9e8a 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4655,10 +4655,13 @@ export default { callbackBaseUrl: 'Callback Base URL', field_privateKey: 'Private Key', field_publicKey: 'Public Key', + field_mpAppId: 'MP App ID', field_mchId: 'Merchant ID', field_apiV3Key: 'API v3 Key', field_publicKeyId: 'Public Key ID', field_certSerial: 'Certificate Serial', + field_h5AppName: 'H5 App Name', + field_h5AppUrl: 'H5 App URL', field_secretKey: 'Secret Key', field_publishableKey: 'Publishable Key', field_webhookSecret: 'Webhook Secret', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 8676df4b..9bb265e1 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4819,10 +4819,13 @@ export default { callbackBaseUrl: '回调基础地址', field_privateKey: '私钥', field_publicKey: '公钥', + field_mpAppId: '公众号 App ID', field_mchId: '商户号', field_apiV3Key: 'API v3 密钥', field_publicKeyId: '公钥 ID', field_certSerial: '证书序列号', + field_h5AppName: 'H5 应用名称', + field_h5AppUrl: 'H5 应用地址', field_secretKey: '密钥', field_publishableKey: '公开密钥', field_webhookSecret: 'Webhook 密钥',