feat: H5 沉默期延期微信支付改为微信内直接拉起(JSAPI)
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

- H5 下单带 app_type 且选微信、商户为微信直连时,复用套餐购买的 JSAPI 预下单,返回 pay_config
- 调起参数保存在支付单,延期订单详情可继续支付;查单、回调、超时关单、作废沿用原流程
- 后台生成二维码、支付宝、富友商户保持原方式
This commit is contained in:
邹进
2026-11-07 21:57:22 +09:00
parent 8b0a01753d
commit b2fc74c021
9 changed files with 217 additions and 48 deletions

View File

@@ -45,6 +45,7 @@ type ExtensionService struct {
runtime *merchantpayment.RuntimeLoader
payments PaymentPorts
refunder refundchannel.Refunder
jsapi JSAPIPayer
logger *zap.Logger
now func() time.Time
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 特殊延期的一条明细。

View File

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

View File

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