From b2fc74c0218b24a4481fb9197ead184fc98ea8ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Sat, 7 Nov 2026 21:57:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20H5=20=E6=B2=89=E9=BB=98=E6=9C=9F?= =?UTF-8?q?=E5=BB=B6=E6=9C=9F=E5=BE=AE=E4=BF=A1=E6=94=AF=E4=BB=98=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=BE=AE=E4=BF=A1=E5=86=85=E7=9B=B4=E6=8E=A5=E6=8B=89?= =?UTF-8?q?=E8=B5=B7=EF=BC=88JSAPI=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H5 下单带 app_type 且选微信、商户为微信直连时,复用套餐购买的 JSAPI 预下单,返回 pay_config - 调起参数保存在支付单,延期订单详情可继续支付;查单、回调、超时关单、作废沿用原流程 - 后台生成二维码、支付宝、富友商户保持原方式 --- .../packagesilence/extension_service.go | 1 + .../packagesilence/online_jsapi.go | 64 +++++++++++++ .../packagesilence/online_jsapi_test.go | 57 ++++++++++++ .../packagesilence/online_order.go | 14 +++ .../packagesilence/order_helpers.go | 5 +- internal/bootstrap/handlers.go | 4 + internal/model/dto/package_silence_dto.go | 92 ++++++++++--------- internal/query/packagesilence/query.go | 2 +- .../service/client_order/payment_provider.go | 26 ++++++ 9 files changed, 217 insertions(+), 48 deletions(-) create mode 100644 internal/application/packagesilence/online_jsapi.go create mode 100644 internal/application/packagesilence/online_jsapi_test.go diff --git a/internal/application/packagesilence/extension_service.go b/internal/application/packagesilence/extension_service.go index f3fabb23..7b1e71b7 100644 --- a/internal/application/packagesilence/extension_service.go +++ b/internal/application/packagesilence/extension_service.go @@ -45,6 +45,7 @@ type ExtensionService struct { runtime *merchantpayment.RuntimeLoader payments PaymentPorts refunder refundchannel.Refunder + jsapi JSAPIPayer logger *zap.Logger now func() time.Time } diff --git a/internal/application/packagesilence/online_jsapi.go b/internal/application/packagesilence/online_jsapi.go new file mode 100644 index 00000000..eb6881c3 --- /dev/null +++ b/internal/application/packagesilence/online_jsapi.go @@ -0,0 +1,64 @@ +package packagesilence + +import ( + "context" + "encoding/json" + "strings" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// jsapiContentPrefix 标记支付单 qr_content 里保存的是微信 JSAPI 调起参数(JSON),而不是二维码 / 支付链接。 +const jsapiContentPrefix = "jsapi:" + +// JSAPIPayer 为 C 端客户按已冻结商户创建微信 JSAPI 预下单(复用套餐购买的公众号 / 小程序支付)。 +type JSAPIPayer interface { + CreateJSAPIPayConfig(ctx context.Context, customerID uint, appType string, config *model.WechatConfig, + paymentNo, description string, amount int64) (*dto.ClientPayConfig, error) +} + +// SetJSAPIPayer 注入微信 JSAPI 预下单能力;未注入时 H5 微信延期仍走 H5 支付链接。 +func (s *ExtensionService) SetJSAPIPayer(payer JSAPIPayer) { s.jsapi = payer } + +// useJSAPI 判断本次下单是否走微信内直接支付:H5 客户、微信支付、带应用类型、商户为微信直连(v3 / v2)。 +// 富友商户仍走扫码,避免查单与下单协议不一致。 +func (s *ExtensionService) useJSAPI(source string, req dto.SilenceOnlineOrderRequest, config *model.WechatConfig) bool { + return s.jsapi != nil && source == constants.SilenceOrderSourceClient && req.PaymentMethod == model.PaymentByWechat && + strings.TrimSpace(req.AppType) != "" && config != nil && + (config.ProviderType == model.ProviderTypeWechat || config.ProviderType == model.ProviderTypeWechatV2) +} + +// createJSAPIContent 预下单并把调起参数编码成支付单内容,便于"继续支付"复用(预支付有效期 2 小时,长于支付截止)。 +func (s *ExtensionService) createJSAPIContent(ctx context.Context, customerID uint, appType string, config *model.WechatConfig, payment *model.Payment) (string, error) { + payConfig, err := s.jsapi.CreateJSAPIPayConfig(ctx, customerID, appType, config, payment.PaymentNo, "沉默期延期", payment.Amount) + if err != nil { + return "", err + } + if payConfig == nil || strings.TrimSpace(payConfig.PackageVal) == "" { + return "", errors.New(errors.CodeWechatPayFailed, "微信支付未返回调起参数") + } + raw, err := json.Marshal(payConfig) + if err != nil { + return "", errors.Wrap(errors.CodeInternalError, err, "编码微信支付参数失败") + } + return jsapiContentPrefix + string(raw), nil +} + +// ApplyPaymentContent 把支付单内容写到响应:JSAPI 参数放 pay_config,其余(二维码 / 支付链接)放 qr_content。 +func ApplyPaymentContent(resp *dto.SilenceExtensionOrderResponse, content string) { + if resp == nil || content == "" { + return + } + if strings.HasPrefix(content, jsapiContentPrefix) { + var payConfig dto.ClientPayConfig + if err := json.Unmarshal([]byte(strings.TrimPrefix(content, jsapiContentPrefix)), &payConfig); err == nil { + resp.PayConfig = &payConfig + resp.QRContent = "" + } + return + } + resp.QRContent = content +} diff --git a/internal/application/packagesilence/online_jsapi_test.go b/internal/application/packagesilence/online_jsapi_test.go new file mode 100644 index 00000000..c200e11e --- /dev/null +++ b/internal/application/packagesilence/online_jsapi_test.go @@ -0,0 +1,57 @@ +package packagesilence + +import ( + "context" + "testing" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +type fakeJSAPIPayer struct{} + +func (fakeJSAPIPayer) CreateJSAPIPayConfig(context.Context, uint, string, *model.WechatConfig, string, string, int64) (*dto.ClientPayConfig, error) { + return &dto.ClientPayConfig{PackageVal: "prepay_id=wx1"}, nil +} + +func TestUseJSAPI(t *testing.T) { + s := &ExtensionService{jsapi: fakeJSAPIPayer{}} + wechatReq := dto.SilenceOnlineOrderRequest{PaymentMethod: model.PaymentByWechat, AppType: "official_account"} + tests := []struct { + name string + svc *ExtensionService + source string + req dto.SilenceOnlineOrderRequest + config *model.WechatConfig + want bool + }{ + {"H5 微信直连 v2", s, constants.SilenceOrderSourceClient, wechatReq, &model.WechatConfig{ProviderType: model.ProviderTypeWechatV2}, true}, + {"H5 微信直连 v3", s, constants.SilenceOrderSourceClient, wechatReq, &model.WechatConfig{ProviderType: model.ProviderTypeWechat}, true}, + {"富友仍走扫码", s, constants.SilenceOrderSourceClient, wechatReq, &model.WechatConfig{ProviderType: model.ProviderTypeFuiou}, false}, + {"后台二维码", s, constants.SilenceOrderSourceAdmin, wechatReq, &model.WechatConfig{ProviderType: model.ProviderTypeWechatV2}, false}, + {"未带应用类型", s, constants.SilenceOrderSourceClient, dto.SilenceOnlineOrderRequest{PaymentMethod: model.PaymentByWechat}, &model.WechatConfig{ProviderType: model.ProviderTypeWechatV2}, false}, + {"支付宝", s, constants.SilenceOrderSourceClient, dto.SilenceOnlineOrderRequest{PaymentMethod: model.PaymentByAlipay, AppType: "official_account"}, &model.WechatConfig{ProviderType: "alipay"}, false}, + {"未注入", &ExtensionService{}, constants.SilenceOrderSourceClient, wechatReq, &model.WechatConfig{ProviderType: model.ProviderTypeWechatV2}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.svc.useJSAPI(tt.source, tt.req, tt.config); got != tt.want { + t.Fatalf("useJSAPI() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestApplyPaymentContent(t *testing.T) { + resp := &dto.SilenceExtensionOrderResponse{} + ApplyPaymentContent(resp, `jsapi:{"app_id":"wxA","package":"prepay_id=wx1","pay_sign":"S"}`) + if resp.PayConfig == nil || resp.PayConfig.PackageVal != "prepay_id=wx1" || resp.PayConfig.AppID != "wxA" || resp.QRContent != "" { + t.Fatalf("jsapi content not mapped: %+v", resp) + } + link := &dto.SilenceExtensionOrderResponse{} + ApplyPaymentContent(link, "https://wx.tenpay.com/checkmweb?x=1") + if link.QRContent != "https://wx.tenpay.com/checkmweb?x=1" || link.PayConfig != nil { + t.Fatalf("link content not mapped: %+v", link) + } +} diff --git a/internal/application/packagesilence/online_order.go b/internal/application/packagesilence/online_order.go index 05d5e441..cc893dad 100644 --- a/internal/application/packagesilence/online_order.go +++ b/internal/application/packagesilence/online_order.go @@ -101,6 +101,20 @@ func (s *ExtensionService) CreateOnlineOrder(ctx context.Context, command Online } return nil, err } + if s.useJSAPI(source, req, config) { + // H5 客户在微信内用微信支付:走 JSAPI 直接拉起,不生成 H5 支付链接(H5 链接在微信内无法支付) + content, jsapiErr := s.createJSAPIContent(ctx, command.Scope.CustomerID, req.AppType, config, payment) + if jsapiErr != nil { + _ = s.closeOrder(ctx, order.ID, "微信支付参数生成失败") + return nil, jsapiErr + } + if err := s.db.WithContext(ctx).Model(&model.Payment{}). + Where("id = ? AND status = ? AND (qr_content IS NULL OR qr_content = '')", payment.ID, model.PaymentRecordStatusPending). + Update("qr_content", content).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存支付内容失败") + } + return s.orderResponse(ctx, order, true) + } paymentResult, err := adapter.CreatePaymentURL(ctx, agentrechargeApp.OnlinePaymentRequest{ PaymentID: payment.ID, PaymentNo: payment.PaymentNo, CorrelationID: payment.PaymentNo, Description: "沉默期延期", Amount: payment.Amount, ExpireAt: *payment.ExpireAt, diff --git a/internal/application/packagesilence/order_helpers.go b/internal/application/packagesilence/order_helpers.go index 0d9cbfdf..dfa96bf2 100644 --- a/internal/application/packagesilence/order_helpers.go +++ b/internal/application/packagesilence/order_helpers.go @@ -67,8 +67,9 @@ func (s *ExtensionService) orderResponse(ctx context.Context, order *model.Packa resp.ThirdPartyTradeNo = payment.ThirdPartyTradeNo resp.PaymentNo = payment.PaymentNo } - if includeQR && order.Status == constants.SilenceOrderStatusPendingPayment && payment.Status == model.PaymentRecordStatusPending && resp.QRContent == "" { - resp.QRContent = payment.QRContent + if includeQR && order.Status == constants.SilenceOrderStatusPendingPayment && payment.Status == model.PaymentRecordStatusPending && + resp.QRContent == "" && resp.PayConfig == nil { + ApplyPaymentContent(resp, payment.QRContent) resp.PaymentNo = payment.PaymentNo } } diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 18c7aacd..87e695f7 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -122,6 +122,10 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { deps.Redis, deps.Logger, ) + // 沉默期延期的 H5 微信支付复用套餐购买的 JSAPI 预下单(微信内直接拉起支付) + if svc.PackageSilence != nil && svc.PackageSilence.Extensions != nil { + svc.PackageSilence.Extensions.SetJSAPIPayer(clientOrderService) + } systemConfigRegistry := deps.SystemConfigRegistry if systemConfigRegistry == nil { systemConfigRegistry = systemConfigInfra.NewRegistry() diff --git a/internal/model/dto/package_silence_dto.go b/internal/model/dto/package_silence_dto.go index bd2a3ba0..f410d940 100644 --- a/internal/model/dto/package_silence_dto.go +++ b/internal/model/dto/package_silence_dto.go @@ -159,6 +159,7 @@ type SilenceOnlineOrderRequest struct { PackageUsageID uint `json:"package_usage_id" validate:"required,min=1" required:"true" description:"套餐记录 ID"` Days int `json:"days" validate:"required,min=1,max=3650" required:"true" description:"输入的延期天数"` PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" required:"true" description:"支付方式:wechat / alipay(经商户池路由)"` + AppType string `json:"app_type" validate:"omitempty,oneof=official_account miniapp" description:"H5 微信支付时的应用类型:official_account / miniapp;传入时在微信内直接拉起支付(JSAPI),返回 pay_config"` RequestID string `json:"request_id" validate:"required,min=1,max=64" required:"true" description:"下单幂等键"` EvidenceRefs []string `json:"evidence_refs" validate:"omitempty,max=10,dive,max=500" description:"客户申请记录或截图对象存储 Key(后台创建时填写)"` } @@ -178,51 +179,52 @@ type SilenceOfflineOrderRequest struct { // SilenceExtensionOrderResponse 延期订单。 type SilenceExtensionOrderResponse struct { - ID uint `json:"id" description:"订单 ID"` - OrderNo string `json:"order_no" description:"订单号"` - BatchNo string `json:"batch_no,omitempty" description:"批次号"` - PackageUsageID uint `json:"package_usage_id" description:"套餐记录 ID"` - PackageID uint `json:"package_id" description:"套餐 ID"` - PackageName string `json:"package_name" description:"套餐名称快照"` - AssetType string `json:"asset_type" description:"资产类型 iot_card / device"` - AssetID uint `json:"asset_id" description:"资产 ID"` - AssetIdentifier string `json:"asset_identifier" description:"资产标识快照"` - ShopID uint `json:"shop_id" description:"下单时资产所属店铺"` - CustomerID *uint `json:"customer_id,omitempty" description:"H5 下单的客户"` - Source string `json:"source" description:"来源:client / admin / offline"` - SourceName string `json:"source_name" description:"来源名称"` - InputDays int `json:"input_days" description:"输入天数"` - Months int `json:"months" description:"计费月数"` - ExtensionDays int `json:"extension_days" description:"计费 / 延长天数"` - UnitPrice int64 `json:"unit_price" description:"单价(分/月)"` - Amount int64 `json:"amount" description:"应付金额(分)"` - PolicyName string `json:"policy_name" description:"政策名称快照"` - BeforeDeadlineAt time.Time `json:"before_deadline_at" description:"下单时截止时间"` - ExpectedDeadlineAt time.Time `json:"expected_deadline_at" description:"预计新截止时间"` - Status string `json:"status" description:"状态:pending_payment/pending_approval/rejected/paid/completed/closed/exception/refunded"` - StatusName string `json:"status_name" description:"状态名称"` - PaymentMethod string `json:"payment_method" description:"支付方式:wechat/alipay/offline"` - PaymentMerchantName string `json:"payment_merchant_name,omitempty" description:"收款商户名称(取已支付或已退款支付单快照)"` - ThirdPartyTradeNo string `json:"third_party_trade_no,omitempty" description:"交易流水号"` - PaymentNo string `json:"payment_no,omitempty" description:"支付单号"` - QRContent string `json:"qr_content,omitempty" description:"支付内容(待支付时返回,前端渲染为二维码或跳转链接)"` - OfflinePaymentMethodName string `json:"offline_payment_method_name,omitempty" description:"线下收款方式"` - OfflineTradeNo string `json:"offline_trade_no,omitempty" description:"线下交易单号"` - PaymentVoucherKeys []string `json:"payment_voucher_keys,omitempty" description:"线下收款凭证"` - EvidenceRefs []string `json:"evidence_refs,omitempty" description:"申请依据"` - ApprovalRequired bool `json:"approval_required" description:"是否需要审批"` - ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"审批实例 ID"` - PaidAmount *int64 `json:"paid_amount,omitempty" description:"实付金额(分)"` - PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"` - ExpireAt *time.Time `json:"expire_at,omitempty" description:"支付截止时间"` - CompletedAt *time.Time `json:"completed_at,omitempty" description:"延期生效时间"` - ExceptionCode string `json:"exception_code,omitempty" description:"异常原因码"` - ExceptionSummary string `json:"exception_summary,omitempty" description:"异常说明"` - RefundNo string `json:"refund_no,omitempty" description:"原路退款单号(仅执行异常)"` - RefundAmount *int64 `json:"refund_amount,omitempty" description:"退款金额"` - RefundedAt *time.Time `json:"refunded_at,omitempty" description:"退款时间"` - CreatedBy *uint `json:"created_by,omitempty" description:"后台创建人"` - CreatedAt time.Time `json:"created_at" description:"创建时间"` + ID uint `json:"id" description:"订单 ID"` + OrderNo string `json:"order_no" description:"订单号"` + BatchNo string `json:"batch_no,omitempty" description:"批次号"` + PackageUsageID uint `json:"package_usage_id" description:"套餐记录 ID"` + PackageID uint `json:"package_id" description:"套餐 ID"` + PackageName string `json:"package_name" description:"套餐名称快照"` + AssetType string `json:"asset_type" description:"资产类型 iot_card / device"` + AssetID uint `json:"asset_id" description:"资产 ID"` + AssetIdentifier string `json:"asset_identifier" description:"资产标识快照"` + ShopID uint `json:"shop_id" description:"下单时资产所属店铺"` + CustomerID *uint `json:"customer_id,omitempty" description:"H5 下单的客户"` + Source string `json:"source" description:"来源:client / admin / offline"` + SourceName string `json:"source_name" description:"来源名称"` + InputDays int `json:"input_days" description:"输入天数"` + Months int `json:"months" description:"计费月数"` + ExtensionDays int `json:"extension_days" description:"计费 / 延长天数"` + UnitPrice int64 `json:"unit_price" description:"单价(分/月)"` + Amount int64 `json:"amount" description:"应付金额(分)"` + PolicyName string `json:"policy_name" description:"政策名称快照"` + BeforeDeadlineAt time.Time `json:"before_deadline_at" description:"下单时截止时间"` + ExpectedDeadlineAt time.Time `json:"expected_deadline_at" description:"预计新截止时间"` + Status string `json:"status" description:"状态:pending_payment/pending_approval/rejected/paid/completed/closed/exception/refunded"` + StatusName string `json:"status_name" description:"状态名称"` + PaymentMethod string `json:"payment_method" description:"支付方式:wechat/alipay/offline"` + PaymentMerchantName string `json:"payment_merchant_name,omitempty" description:"收款商户名称(取已支付或已退款支付单快照)"` + ThirdPartyTradeNo string `json:"third_party_trade_no,omitempty" description:"交易流水号"` + PaymentNo string `json:"payment_no,omitempty" description:"支付单号"` + QRContent string `json:"qr_content,omitempty" description:"支付内容(待支付时返回,前端渲染为二维码或跳转链接)"` + PayConfig *ClientPayConfig `json:"pay_config,omitempty" description:"微信内直接支付(JSAPI)参数,H5 带 app_type 下微信单时返回,前端用它拉起支付"` + OfflinePaymentMethodName string `json:"offline_payment_method_name,omitempty" description:"线下收款方式"` + OfflineTradeNo string `json:"offline_trade_no,omitempty" description:"线下交易单号"` + PaymentVoucherKeys []string `json:"payment_voucher_keys,omitempty" description:"线下收款凭证"` + EvidenceRefs []string `json:"evidence_refs,omitempty" description:"申请依据"` + ApprovalRequired bool `json:"approval_required" description:"是否需要审批"` + ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"审批实例 ID"` + PaidAmount *int64 `json:"paid_amount,omitempty" description:"实付金额(分)"` + PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"` + ExpireAt *time.Time `json:"expire_at,omitempty" description:"支付截止时间"` + CompletedAt *time.Time `json:"completed_at,omitempty" description:"延期生效时间"` + ExceptionCode string `json:"exception_code,omitempty" description:"异常原因码"` + ExceptionSummary string `json:"exception_summary,omitempty" description:"异常说明"` + RefundNo string `json:"refund_no,omitempty" description:"原路退款单号(仅执行异常)"` + RefundAmount *int64 `json:"refund_amount,omitempty" description:"退款金额"` + RefundedAt *time.Time `json:"refunded_at,omitempty" description:"退款时间"` + CreatedBy *uint `json:"created_by,omitempty" description:"后台创建人"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` } // SilenceSpecialItem 特殊延期的一条明细。 diff --git a/internal/query/packagesilence/query.go b/internal/query/packagesilence/query.go index 957f4ae5..eec6db87 100644 --- a/internal/query/packagesilence/query.go +++ b/internal/query/packagesilence/query.go @@ -136,7 +136,7 @@ func (q *Query) GetOrder(ctx context.Context, id uint, customerID uint) (*dto.Si return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败") } if err == nil { - item.QRContent = payment.QRContent + packagesilenceapp.ApplyPaymentContent(item, payment.QRContent) item.PaymentNo = payment.PaymentNo } } diff --git a/internal/service/client_order/payment_provider.go b/internal/service/client_order/payment_provider.go index 50a16b1e..1ab475e0 100644 --- a/internal/service/client_order/payment_provider.go +++ b/internal/service/client_order/payment_provider.go @@ -182,3 +182,29 @@ func payFirstNonEmpty(values ...string) string { } return "" } + +// CreateJSAPIPayConfig 为 C 端客户按已选定(已冻结)的商户配置创建微信 JSAPI 预下单,返回前端调起支付的参数。 +// 供套餐以外的 C 端在线支付复用(如沉默期延期):openid 取客户在当前应用(公众号 / 小程序)下的授权。 +func (s *Service) CreateJSAPIPayConfig(ctx context.Context, customerID uint, appType string, config *model.WechatConfig, + paymentNo, description string, amount int64) (*dto.ClientPayConfig, error) { + if config == nil { + return nil, errors.New(errors.CodeNoPaymentConfig) + } + _, appID, err := s.loadWechatAuthorization(ctx, appType) + if err != nil { + return nil, err + } + openID, err := s.resolveCustomerOpenID(ctx, customerID, appID) + if err != nil { + return nil, err + } + provider, err := s.newPaymentProvider(config, appID, appType) + if err != nil { + return nil, err + } + result, err := provider.CreateJSAPIPayment(ctx, paymentNo, description, openID, int(amount)) + if err != nil { + return nil, err + } + return buildClientPayConfigFromResult(result), nil +}