Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 38s
- 字段 outbound_at → first_purchase_at,记录表 tb_asset_outbound_record → tb_asset_first_purchase_record,接口 /outbound-records → /first-purchase-records,筛选参数与导出列同步更名 - 换货后的新资产不因换货写入,在当前世代真正支付套餐订单时才记录;记录来源只保留 order / backfill,去掉换货单字段 - 迁移 000266 重命名列、表、索引与约束;同步 Spec、设计、任务与前端接口变更说明 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
680 lines
30 KiB
Go
680 lines
30 KiB
Go
package packagesilence
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
stderrors "errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
|
||
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||
)
|
||
|
||
// OnlineOrderCommand 是在线付费延期下单命令(H5 客户自助或运营后台生成二维码)。
|
||
type OnlineOrderCommand struct {
|
||
Request dto.SilenceOnlineOrderRequest
|
||
Scope Scope
|
||
PayerClientIP string
|
||
}
|
||
|
||
// CreateOnlineOrder 创建在线付费延期订单与支付单(技术方案 §8.4):
|
||
// 冻结月数、单价、金额与政策快照;商户按支付方式经商户池路由并冻结;事务外调用渠道生成支付内容。
|
||
// 同一套餐已有待支付订单时先查单:未支付则关闭旧单再新建,已支付则按已支付处理并返回旧单。
|
||
func (s *ExtensionService) CreateOnlineOrder(ctx context.Context, command OnlineOrderCommand) (*dto.SilenceExtensionOrderResponse, error) {
|
||
if err := s.requireEnabled(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
if s.runtime == nil || s.payments.Wechat == nil || s.payments.Alipay == nil || s.payments.Fuiou == nil {
|
||
return nil, errors.New(errors.CodeServiceUnavailable, "沉默期在线支付能力未配置")
|
||
}
|
||
req := command.Request
|
||
source := constants.SilenceOrderSourceClient
|
||
var createdBy *uint
|
||
if command.Scope.CustomerID == 0 {
|
||
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceExtend); err != nil {
|
||
return nil, err
|
||
}
|
||
source = constants.SilenceOrderSourceAdmin
|
||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||
createdBy = &operatorID
|
||
}
|
||
requestKey := scopedRequestKey(source, command.Scope.CustomerID, createdBy, req.RequestID)
|
||
if existing, err := postgres.NewPackageSilenceOrderStore(s.db).FindByRequestID(ctx, requestKey); err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单幂等记录失败")
|
||
} else if existing != nil {
|
||
if existing.PackageUsageID != req.PackageUsageID || existing.InputDays != req.Days || existing.PaymentMethod != req.PaymentMethod {
|
||
return nil, errors.New(errors.CodeConflict, "同一请求标识对应的延期内容不一致")
|
||
}
|
||
return s.orderResponse(ctx, existing, true)
|
||
}
|
||
usage, err := s.loadUsage(ctx, s.db, req.PackageUsageID, command.Scope)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := s.resolver.Resolve(ctx, usage.ID, ResolveOptions{Trigger: constants.SilenceTriggerEligibilityRead, AllowExpire: true}); err != nil {
|
||
return nil, err
|
||
}
|
||
if replaced, err := s.settleOpenOrder(ctx, usage.ID); err != nil {
|
||
return nil, err
|
||
} else if replaced != nil {
|
||
return s.orderResponse(ctx, replaced, false)
|
||
}
|
||
usage, err = s.loadUsage(ctx, s.db, req.PackageUsageID, command.Scope)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := s.now()
|
||
facts, err := s.computeEligibility(ctx, s.db, usage, now)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !facts.canPay() {
|
||
return nil, facts.paidError()
|
||
}
|
||
quote := packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
|
||
DeadlineAt: *usage.SilenceDeadlineAt, Now: now, InputDays: req.Days, Policy: facts.match.Snapshot,
|
||
PaidExtendedDays: usage.SilencePaidExtendedDays, Kind: packagedomain.SilenceQuotePaid,
|
||
})
|
||
if !quote.InputValid {
|
||
return nil, errors.NewWithData(errors.CodeSilenceExtensionMonthsInvalid, map[string]any{
|
||
"min_days": quote.MinDays, "max_days": quote.MaxDays, "months": quote.Months,
|
||
})
|
||
}
|
||
order, payment, config, adapter, err := s.createOnlineFacts(ctx, usage, facts, quote, req, source, command.Scope.CustomerID, createdBy, requestKey, now)
|
||
if err != nil {
|
||
if existing, findErr := postgres.NewPackageSilenceOrderStore(s.db).FindByRequestID(ctx, requestKey); findErr == nil && existing != nil {
|
||
return s.orderResponse(ctx, existing, true)
|
||
}
|
||
return nil, err
|
||
}
|
||
paymentResult, err := adapter.CreatePaymentURL(ctx, agentrechargeApp.OnlinePaymentRequest{
|
||
PaymentID: payment.ID, PaymentNo: payment.PaymentNo, CorrelationID: payment.PaymentNo,
|
||
Description: "沉默期延期", Amount: payment.Amount, ExpireAt: *payment.ExpireAt,
|
||
PayerClientIP: command.PayerClientIP, Config: config,
|
||
})
|
||
if err != nil {
|
||
if merchantpayment.ShouldDisableMerchant(err) {
|
||
if disableErr := merchantpayment.DisableMerchantAfterExplicitFailure(ctx, s.db, payment, "payment_precreate_failed", time.Now()); disableErr != nil {
|
||
return nil, disableErr
|
||
}
|
||
}
|
||
if !isUnknownPayment(err) {
|
||
_ = s.closeOrder(ctx, order.ID, "支付内容生成失败")
|
||
}
|
||
return nil, err
|
||
}
|
||
if strings.TrimSpace(paymentResult.QRContent) == "" {
|
||
_ = s.closeOrder(ctx, order.ID, "支付渠道未返回支付内容")
|
||
return nil, errors.New(errors.CodeServiceUnavailable, "支付渠道未返回支付内容")
|
||
}
|
||
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", paymentResult.QRContent).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存支付内容失败")
|
||
}
|
||
return s.orderResponse(ctx, order, true)
|
||
}
|
||
|
||
func (s *ExtensionService) createOnlineFacts(ctx context.Context, usage *model.PackageUsage, facts *eligibility, quote packagedomain.SilenceQuote,
|
||
req dto.SilenceOnlineOrderRequest, source string, customerID uint, createdBy *uint, requestKey string, now time.Time,
|
||
) (*model.PackageSilenceExtensionOrder, *model.Payment, *model.WechatConfig, agentrechargeApp.OnlinePaymentPort, error) {
|
||
identifier, err := assetIdentifier(ctx, s.db, usage)
|
||
if err != nil {
|
||
return nil, nil, nil, nil, err
|
||
}
|
||
shopID := assetShopID(ctx, s.db, usage)
|
||
var order *model.PackageSilenceExtensionOrder
|
||
var payment *model.Payment
|
||
var config *model.WechatConfig
|
||
var adapter agentrechargeApp.OnlinePaymentPort
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
if err := lockCarrier(ctx, tx, usage); err != nil {
|
||
return err
|
||
}
|
||
route, err := s.runtime.SelectForNewPaymentWithTx(ctx, tx, req.PaymentMethod, now)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
config, err = s.runtime.MerchantConfigWithAuthorization(ctx, route.Merchant)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
adapter = s.adapter(req.PaymentMethod, config)
|
||
if adapter == nil || !adapter.Available(config) {
|
||
return errors.New(errors.CodeNoPaymentConfig)
|
||
}
|
||
expireAt := onlineExpireAt(config, now)
|
||
snapshot := facts.match.Snapshot
|
||
batch := &model.PackageSilenceExtensionBatch{
|
||
BatchNo: generateNo(constants.SilenceExtensionBatchNoPrefix, now), Path: constants.SilencePathOnline,
|
||
ExtensionType: constants.SilenceExtensionTypeStandardPaid, EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)),
|
||
PolicyID: &snapshot.PolicyID, PolicyNameSnapshot: snapshot.Name, UnitPriceSnapshot: snapshot.UnitPrice,
|
||
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(snapshot.ApprovalExemptTypes)),
|
||
FreeExtensionLimitSnapshot: snapshot.FreeExtensionLimit, PaidMaxMonthsSnapshot: snapshot.PaidMaxMonths,
|
||
SingleMaxMonthsSnapshot: snapshot.SingleMaxMonths, Amount: quote.Amount,
|
||
RequestedBy: requesterID(customerID, createdBy), RequestedByType: requesterType(customerID),
|
||
RequestedAt: now, Status: constants.SilenceBatchStatusPendingPayment, RequestedCount: 1,
|
||
PaymentVoucherKeys: model.StringJSONBArray{}, ShopIDTag: shopID,
|
||
}
|
||
if err := postgres.NewPackageSilenceBatchStore(tx).Create(ctx, batch); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期批次失败")
|
||
}
|
||
order = &model.PackageSilenceExtensionOrder{
|
||
OrderNo: generateNo(constants.SilenceExtensionOrderNoPrefix, now), BatchID: batch.ID,
|
||
PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageNameSnapshot: usage.PackageName,
|
||
AssetIdentifierSnapshot: identifier, ShopID: shopID, Source: source, CreatedBy: createdBy,
|
||
EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)), InputDays: req.Days,
|
||
Months: quote.Months, ExtensionDays: quote.BilledDays, PolicyID: &snapshot.PolicyID,
|
||
PolicyNameSnapshot: snapshot.Name, UnitPrice: snapshot.UnitPrice,
|
||
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(snapshot.ApprovalExemptTypes)),
|
||
FreeExtensionLimitSnapshot: snapshot.FreeExtensionLimit, PaidMaxMonthsSnapshot: snapshot.PaidMaxMonths,
|
||
SingleMaxMonthsSnapshot: snapshot.SingleMaxMonths, Amount: quote.Amount,
|
||
BeforeDeadlineAt: *usage.SilenceDeadlineAt, ExpectedDeadlineAt: quote.NewDeadline,
|
||
Status: constants.SilenceOrderStatusPendingPayment, PaymentMethod: req.PaymentMethod,
|
||
PaymentVoucherKeys: model.StringJSONBArray{}, ExpireAt: &expireAt, RequestID: &requestKey, ShopIDTag: shopID,
|
||
}
|
||
order.AssetType, order.AssetID = usageAsset(usage)
|
||
if customerID > 0 {
|
||
order.CustomerID = &customerID
|
||
}
|
||
if err := postgres.NewPackageSilenceOrderStore(tx).Create(ctx, order); err != nil {
|
||
if isUniqueViolation(err) {
|
||
return errors.New(errors.CodeSilenceExtensionOrderPending)
|
||
}
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期订单失败")
|
||
}
|
||
extension := &model.PackageSilenceExtension{
|
||
BatchID: batch.ID, PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageNameSnapshot: usage.PackageName,
|
||
AssetType: order.AssetType, AssetID: order.AssetID, AssetIdentifierSnapshot: identifier,
|
||
ExtensionType: constants.SilenceExtensionTypeStandardPaid, InputDays: req.Days, Months: quote.Months,
|
||
ExtensionDays: quote.BilledDays, UnitPriceSnapshot: snapshot.UnitPrice, Amount: quote.Amount,
|
||
ExtensionOrderID: &order.ID, BeforeDeadlineAt: usage.SilenceDeadlineAt,
|
||
Status: constants.SilenceExtensionStatusPendingPayment,
|
||
}
|
||
if err := postgres.NewPackageSilenceExtensionStore(tx).Create(ctx, extension); err != nil {
|
||
if isUniqueViolation(err) {
|
||
return errors.New(errors.CodeSilenceExtensionOrderPending)
|
||
}
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期明细失败")
|
||
}
|
||
paymentNo := generateNo("PAY", now)
|
||
payment = &model.Payment{
|
||
PaymentNo: paymentNo, OrderID: order.ID, OrderType: constants.PaymentOrderTypeSilenceExtension,
|
||
PaymentMethod: req.PaymentMethod, Amount: quote.Amount, Status: model.PaymentRecordStatusPending, ExpireAt: &expireAt,
|
||
}
|
||
merchantpayment.FreezeRoute(payment, route)
|
||
if err := tx.Create(payment).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期支付单失败")
|
||
}
|
||
if err := merchantpayment.RecordRoutingAttempt(ctx, tx, payment); err != nil {
|
||
return err
|
||
}
|
||
return s.writeOrderAudit(ctx, tx, order, "", "创建在线付费延期订单")
|
||
})
|
||
if err != nil {
|
||
return nil, nil, nil, nil, err
|
||
}
|
||
return order, payment, config, adapter, nil
|
||
}
|
||
|
||
// settleOpenOrder 处理同一套餐的未结束订单:待支付订单先查单,未支付关闭、已支付按已支付处理并返回该订单。
|
||
func (s *ExtensionService) settleOpenOrder(ctx context.Context, usageID uint) (*model.PackageSilenceExtensionOrder, error) {
|
||
open, err := postgres.NewPackageSilenceOrderStore(s.db).FindOpenByUsage(ctx, usageID)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询未结束延期订单失败")
|
||
}
|
||
if open == nil {
|
||
return nil, nil
|
||
}
|
||
if open.Status == constants.SilenceOrderStatusPendingApproval {
|
||
return nil, errors.New(errors.CodeSilenceExtensionOrderPending, "该套餐有待审批的线下延期订单")
|
||
}
|
||
paid, err := s.queryAndConfirm(ctx, open)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if paid {
|
||
current, getErr := postgres.NewPackageSilenceOrderStore(s.db).GetByID(ctx, open.ID)
|
||
if getErr != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, getErr, "查询延期订单失败")
|
||
}
|
||
return current, nil
|
||
}
|
||
if err := s.closeOrder(ctx, open.ID, constants.SilenceExtensionOrderCloseReasonSuperseded); err != nil {
|
||
return nil, err
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
// queryAndConfirm 对订单的待支付支付单查单;已支付时走支付确认,返回是否已支付。
|
||
// 渠道无法确认时保守返回错误,避免旧单已付又新建。
|
||
func (s *ExtensionService) queryAndConfirm(ctx context.Context, order *model.PackageSilenceExtensionOrder) (bool, error) {
|
||
var payments []model.Payment
|
||
if err := s.db.WithContext(ctx).Where("order_type = ? AND order_id = ?", constants.PaymentOrderTypeSilenceExtension, order.ID).
|
||
Order("id DESC").Find(&payments).Error; err != nil {
|
||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
|
||
}
|
||
for index := range payments {
|
||
payment := &payments[index]
|
||
if payment.Status == model.PaymentRecordStatusPaid {
|
||
return true, nil
|
||
}
|
||
if payment.Status != model.PaymentRecordStatusPending {
|
||
continue
|
||
}
|
||
state, result, err := s.queryPayment(ctx, payment)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
switch state {
|
||
case agentrechargeApp.OnlinePaymentStatePaid:
|
||
paidAt := s.now()
|
||
if result.PaidAt != nil {
|
||
paidAt = *result.PaidAt
|
||
}
|
||
if err := s.ConfirmPayment(ctx, PaymentConfirmCommand{
|
||
PaymentNo: payment.PaymentNo, ThirdPartyTradeNo: result.ThirdPartyTradeNo, Amount: result.Amount, PaidAt: paidAt,
|
||
}); err != nil {
|
||
return false, err
|
||
}
|
||
return true, nil
|
||
case agentrechargeApp.OnlinePaymentStatePending, agentrechargeApp.OnlinePaymentStateUnknown:
|
||
if payment.QRContent == "" && state == agentrechargeApp.OnlinePaymentStateUnknown {
|
||
// 支付内容从未生成成功:客户无法付款,可以安全关闭。
|
||
continue
|
||
}
|
||
if state == agentrechargeApp.OnlinePaymentStateUnknown {
|
||
return false, errors.New(errors.CodeServiceUnavailable, "原待支付订单状态暂时无法确认,请稍后重试")
|
||
}
|
||
}
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
func (s *ExtensionService) queryPayment(ctx context.Context, payment *model.Payment) (string, agentrechargeApp.OnlinePaymentQueryResult, error) {
|
||
config, err := s.paymentConfig(ctx, payment)
|
||
if err != nil {
|
||
return "", agentrechargeApp.OnlinePaymentQueryResult{}, err
|
||
}
|
||
adapter := s.adapter(payment.PaymentMethod, config)
|
||
if adapter == nil {
|
||
return "", agentrechargeApp.OnlinePaymentQueryResult{}, errors.New(errors.CodeNoPaymentConfig)
|
||
}
|
||
result, err := adapter.Query(ctx, agentrechargeApp.OnlinePaymentRequest{
|
||
PaymentID: payment.ID, PaymentNo: payment.PaymentNo, CorrelationID: payment.PaymentNo, Config: config,
|
||
})
|
||
if err != nil {
|
||
return agentrechargeApp.OnlinePaymentStateUnknown, result, nil
|
||
}
|
||
return result.State, result, nil
|
||
}
|
||
|
||
// paymentConfig 按支付单冻结的商户加载凭证,不按当前商户池推断。
|
||
func (s *ExtensionService) paymentConfig(ctx context.Context, payment *model.Payment) (*model.WechatConfig, error) {
|
||
if s.runtime == nil {
|
||
return nil, errors.New(errors.CodeServiceUnavailable, "商户池运行时未配置")
|
||
}
|
||
if payment.MerchantID == nil {
|
||
return nil, errors.New(errors.CodeNoPaymentConfig, "延期支付单缺少冻结商户")
|
||
}
|
||
merchant, err := s.runtime.LoadMerchant(ctx, *payment.MerchantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.runtime.MerchantConfigWithAuthorization(ctx, merchant)
|
||
}
|
||
|
||
func (s *ExtensionService) adapter(paymentMethod string, config *model.WechatConfig) agentrechargeApp.OnlinePaymentPort {
|
||
switch paymentMethod {
|
||
case model.PaymentByWechat:
|
||
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
|
||
return s.payments.Fuiou
|
||
}
|
||
return s.payments.Wechat
|
||
case model.PaymentByAlipay:
|
||
return s.payments.Alipay
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// PaymentConfirmCommand 是支付回调或查单确认的支付事实(已验签、已按冻结商户核对)。
|
||
type PaymentConfirmCommand struct {
|
||
PaymentNo string
|
||
ThirdPartyTradeNo string
|
||
Amount int64
|
||
PaidAt time.Time
|
||
}
|
||
|
||
// ConfirmPayment 处理延期支付成功(回调或查单):pending_payment → paid → completed,重复回调幂等。
|
||
// 金额不一致或执行校验不通过时进入 exception,并在提交后按冻结商户原路退款。
|
||
// 总开关关闭时照常处理(避免收钱不延期)。
|
||
func (s *ExtensionService) ConfirmPayment(ctx context.Context, command PaymentConfirmCommand) error {
|
||
var orderID uint
|
||
var usageID uint
|
||
completed, needRefund := false, false
|
||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var payment model.Payment
|
||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Where("payment_no = ? AND order_type = ?", command.PaymentNo, constants.PaymentOrderTypeSilenceExtension).
|
||
First(&payment).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return errors.New(errors.CodeNotFound, "延期支付单不存在")
|
||
}
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期支付单失败")
|
||
}
|
||
if payment.Status == model.PaymentRecordStatusPaid || payment.Status == model.PaymentRecordStatusRefunded {
|
||
return nil
|
||
}
|
||
paidAt := command.PaidAt
|
||
if paidAt.IsZero() {
|
||
paidAt = s.now()
|
||
}
|
||
if err := tx.Model(&model.Payment{}).Where("id = ? AND status IN ?", payment.ID, []int{model.PaymentRecordStatusPending, model.PaymentRecordStatusFailed}).
|
||
Updates(map[string]any{"status": model.PaymentRecordStatusPaid, "third_party_trade_no": command.ThirdPartyTradeNo, "paid_at": paidAt, "updated_at": s.now()}).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期支付单失败")
|
||
}
|
||
order, err := postgres.NewPackageSilenceOrderStore(tx).LockByID(ctx, payment.OrderID)
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期订单失败")
|
||
}
|
||
orderID, usageID = order.ID, order.PackageUsageID
|
||
paidValues := map[string]any{"paid_payment_id": payment.ID, "paid_amount": command.Amount, "paid_at": paidAt}
|
||
if order.Status != constants.SilenceOrderStatusPendingPayment {
|
||
// 订单已关闭(超时或被新订单替换)后才到达的成功支付:收到钱但不能延期,进入异常并原路退款。
|
||
needRefund = true
|
||
return s.orderToException(ctx, tx, order, []string{order.Status}, paidValues, constants.SilenceFailureUsageInvalid, "订单已关闭后收到支付")
|
||
}
|
||
if command.Amount != order.Amount {
|
||
needRefund = true
|
||
return s.orderToException(ctx, tx, order, []string{constants.SilenceOrderStatusPendingPayment}, paidValues, constants.SilenceFailureAmountMismatch, "回调金额与订单金额不一致")
|
||
}
|
||
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID, []string{constants.SilenceOrderStatusPendingPayment}, constants.SilenceOrderStatusPaid, paidValues); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期订单为已支付失败")
|
||
}
|
||
before := order.Status
|
||
order.Status = constants.SilenceOrderStatusPaid
|
||
if err := s.writeOrderAudit(ctx, tx, order, before, "延期订单支付成功"); err != nil {
|
||
return err
|
||
}
|
||
ok, err := s.executeOrderInTx(ctx, tx, order)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
completed, needRefund = ok, !ok
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if completed {
|
||
s.afterExecuted(ctx, []uint{usageID})
|
||
}
|
||
if needRefund && orderID > 0 {
|
||
if refundErr := s.refundOrder(ctx, orderID, "延期执行异常原路退款"); refundErr != nil {
|
||
s.logger.Warn("延期异常订单原路退款失败,等待恢复任务重试", zap.Uint("order_id", orderID), zap.Error(refundErr))
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// executeOrderInTx 执行已支付订单对应的明细;成功订单 completed,失败订单与明细进入 exception。
|
||
func (s *ExtensionService) executeOrderInTx(ctx context.Context, tx *gorm.DB, order *model.PackageSilenceExtensionOrder) (bool, error) {
|
||
var extension model.PackageSilenceExtension
|
||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("extension_order_id = ?", order.ID).First(&extension).Error; err != nil {
|
||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询延期明细失败")
|
||
}
|
||
if extension.Status == constants.SilenceExtensionStatusCompleted {
|
||
return true, nil
|
||
}
|
||
from := []string{constants.SilenceExtensionStatusPendingPayment, constants.SilenceExtensionStatusPendingApproval,
|
||
constants.SilenceExtensionStatusApproved, constants.SilenceExtensionStatusExecuting, constants.SilenceExtensionStatusException}
|
||
outcome, err := s.executeExtensionInTx(ctx, tx, &extension, executeKind{paid: true}, paidSnapshot{
|
||
paidMaxMonths: order.PaidMaxMonthsSnapshot, singleMaxMonths: order.SingleMaxMonthsSnapshot, freeLimit: order.FreeExtensionLimitSnapshot,
|
||
}, from)
|
||
if err != nil {
|
||
if isAlreadyExecuted(err) {
|
||
return true, nil
|
||
}
|
||
return false, err
|
||
}
|
||
now := s.now()
|
||
if !outcome.executed {
|
||
if err := s.markExtensionFailedInTx(ctx, tx, &extension, constants.SilenceExtensionStatusException, from, outcome.failureCode, outcome.failureText); err != nil {
|
||
return false, err
|
||
}
|
||
if err := s.orderToException(ctx, tx, order, []string{constants.SilenceOrderStatusPaid, constants.SilenceOrderStatusPendingApproval}, nil, outcome.failureCode, outcome.failureText); err != nil {
|
||
return false, err
|
||
}
|
||
return false, refreshBatchCountsInTx(ctx, tx, order.BatchID, now)
|
||
}
|
||
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID,
|
||
[]string{constants.SilenceOrderStatusPaid, constants.SilenceOrderStatusPendingApproval, constants.SilenceOrderStatusException},
|
||
constants.SilenceOrderStatusCompleted, map[string]any{"extension_id": extension.ID, "completed_at": now, "exception_code": "", "exception_summary": ""}); err != nil {
|
||
return false, errors.Wrap(errors.CodeDatabaseError, err, "更新延期订单为已完成失败")
|
||
}
|
||
before := order.Status
|
||
order.Status = constants.SilenceOrderStatusCompleted
|
||
if err := s.writeOrderAudit(ctx, tx, order, before, "延期订单执行完成"); err != nil {
|
||
return false, err
|
||
}
|
||
return true, refreshBatchCountsInTx(ctx, tx, order.BatchID, now)
|
||
}
|
||
|
||
func (s *ExtensionService) orderToException(ctx context.Context, tx *gorm.DB, order *model.PackageSilenceExtensionOrder, from []string, values map[string]any, code, text string) error {
|
||
updates := map[string]any{"exception_code": code, "exception_summary": truncate(text, 500)}
|
||
for key, value := range values {
|
||
updates[key] = value
|
||
}
|
||
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID, from, constants.SilenceOrderStatusException, updates); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期订单为异常失败")
|
||
}
|
||
before := order.Status
|
||
order.Status = constants.SilenceOrderStatusException
|
||
order.ExceptionCode, order.ExceptionSummary = code, text
|
||
return s.writeOrderAudit(ctx, tx, order, before, "延期订单执行异常:"+text)
|
||
}
|
||
|
||
// closeOrder 关闭待支付订单(超时、被替换或支付内容生成失败):订单 closed、支付单失败、明细与批次关闭。
|
||
func (s *ExtensionService) closeOrder(ctx context.Context, orderID uint, reason string) error {
|
||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
order, err := postgres.NewPackageSilenceOrderStore(tx).LockByID(ctx, orderID)
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期订单失败")
|
||
}
|
||
if order.Status != constants.SilenceOrderStatusPendingPayment {
|
||
return nil
|
||
}
|
||
now := s.now()
|
||
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID, []string{constants.SilenceOrderStatusPendingPayment},
|
||
constants.SilenceOrderStatusClosed, map[string]any{"closed_at": now, "close_reason": truncate(reason, 200)}); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "关闭延期订单失败")
|
||
}
|
||
if err := tx.Model(&model.Payment{}).Where("order_type = ? AND order_id = ? AND status = ?", constants.PaymentOrderTypeSilenceExtension, order.ID, model.PaymentRecordStatusPending).
|
||
Update("status", model.PaymentRecordStatusFailed).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "关闭延期支付单失败")
|
||
}
|
||
if err := tx.Model(&model.PackageSilenceExtension{}).Where("extension_order_id = ? AND status = ?", order.ID, constants.SilenceExtensionStatusPendingPayment).
|
||
Updates(map[string]any{"status": constants.SilenceExtensionStatusClosed, "failure_summary": truncate(reason, 500), "updated_at": now}).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "关闭延期明细失败")
|
||
}
|
||
if _, err := postgres.NewPackageSilenceBatchStore(tx).TransitionStatus(ctx, order.BatchID, []string{constants.SilenceBatchStatusPendingPayment},
|
||
constants.SilenceBatchStatusClosed, map[string]any{"completed_at": now}); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "关闭延期批次失败")
|
||
}
|
||
before := order.Status
|
||
order.Status = constants.SilenceOrderStatusClosed
|
||
return s.writeOrderAudit(ctx, tx, order, before, "关闭延期订单:"+reason)
|
||
})
|
||
}
|
||
|
||
// refundOrder 对异常订单按支付单冻结商户原路退款(延期费正常不退,只在执行异常时退)。
|
||
func (s *ExtensionService) refundOrder(ctx context.Context, orderID uint, reason string) error {
|
||
if s.refunder == nil || s.runtime == nil {
|
||
return errors.New(errors.CodeServiceUnavailable, "原路退款能力未配置")
|
||
}
|
||
order, err := postgres.NewPackageSilenceOrderStore(s.db).GetByID(ctx, orderID)
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
|
||
}
|
||
if order.Status != constants.SilenceOrderStatusException || order.PaidPaymentID == nil || order.PaymentMethod == model.PaymentByOffline {
|
||
return nil
|
||
}
|
||
refundNo := ""
|
||
if order.RefundNo != nil {
|
||
refundNo = *order.RefundNo
|
||
} else {
|
||
refundNo = generateNo("SR", s.now())
|
||
result := s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionOrder{}).Where("id = ? AND refund_no IS NULL", order.ID).
|
||
Updates(map[string]any{"refund_no": refundNo, "refund_reason": truncate(reason, 500), "updated_at": s.now()})
|
||
if result.Error != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "登记延期订单退款单号失败")
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return nil
|
||
}
|
||
}
|
||
var payment model.Payment
|
||
if err := s.db.WithContext(ctx).Where("id = ?", *order.PaidPaymentID).First(&payment).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
|
||
}
|
||
config, err := s.paymentConfig(ctx, &payment)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
merchant, err := s.runtime.LoadMerchant(ctx, *payment.MerchantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
amount := payment.Amount
|
||
if order.PaidAmount != nil && *order.PaidAmount > 0 {
|
||
amount = *order.PaidAmount
|
||
}
|
||
target := refundTarget(order, &payment, config, merchant.ProviderType, refundNo, amount)
|
||
result, err := s.refunder.Refund(ctx, target)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if result.State != "success" {
|
||
return errors.New(errors.CodeServiceUnavailable, "原路退款未确认成功:"+result.FailureMessage)
|
||
}
|
||
return s.markRefunded(ctx, order.ID, payment.ID, amount)
|
||
}
|
||
|
||
func (s *ExtensionService) markRefunded(ctx context.Context, orderID, paymentID uint, amount int64) error {
|
||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
order, err := postgres.NewPackageSilenceOrderStore(tx).LockByID(ctx, orderID)
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期订单失败")
|
||
}
|
||
now := s.now()
|
||
affected, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, orderID, []string{constants.SilenceOrderStatusException},
|
||
constants.SilenceOrderStatusRefunded, map[string]any{"refund_amount": amount, "refunded_at": now})
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期订单为已退款失败")
|
||
}
|
||
if affected == 0 {
|
||
return nil
|
||
}
|
||
if err := tx.Model(&model.Payment{}).Where("id = ? AND status = ?", paymentID, model.PaymentRecordStatusPaid).
|
||
Update("status", model.PaymentRecordStatusRefunded).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期支付单为已退款失败")
|
||
}
|
||
before := order.Status
|
||
order.Status = constants.SilenceOrderStatusRefunded
|
||
return s.writeOrderAudit(ctx, tx, order, before, "延期异常订单已原路退款")
|
||
})
|
||
}
|
||
|
||
// writeOrderAudit 写延期订单状态变化审计(主资源为套餐记录)。
|
||
func (s *ExtensionService) writeOrderAudit(ctx context.Context, tx *gorm.DB, order *model.PackageSilenceExtensionOrder, before, summary string) error {
|
||
if s.audit == nil {
|
||
return nil
|
||
}
|
||
var usage model.PackageUsage
|
||
if err := tx.WithContext(ctx).Where("id = ?", order.PackageUsageID).First(&usage).Error; err != nil {
|
||
return nil
|
||
}
|
||
return s.audit.AppendSilenceUsage(ctx, tx, orderAuditInput(&usage, order, before, summary))
|
||
}
|
||
|
||
func onlineExpireAt(config *model.WechatConfig, now time.Time) time.Time {
|
||
minutes := model.DefaultAliPayExpireMinutes
|
||
if config != nil && config.AliPayExpireMinutes > 0 {
|
||
minutes = config.AliPayExpireMinutes
|
||
}
|
||
expireAt := now.Add(time.Duration(minutes) * time.Minute)
|
||
// 支付截止不跨天:降低跨天支付导致新截止不再晚于当天的概率(技术方案 Risks)。
|
||
if endOfDay := packagedomain.SilenceDeadline(now, 0); expireAt.After(endOfDay) {
|
||
expireAt = endOfDay
|
||
}
|
||
return expireAt
|
||
}
|
||
|
||
func scopedRequestKey(source string, customerID uint, createdBy *uint, requestID string) string {
|
||
actor := uint(0)
|
||
if createdBy != nil {
|
||
actor = *createdBy
|
||
}
|
||
digest := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d:%s", source, customerID, actor, strings.TrimSpace(requestID))))
|
||
return hex.EncodeToString(digest[:])
|
||
}
|
||
|
||
func requesterID(customerID uint, createdBy *uint) uint {
|
||
if customerID > 0 {
|
||
return customerID
|
||
}
|
||
if createdBy != nil {
|
||
return *createdBy
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func requesterType(customerID uint) string {
|
||
if customerID > 0 {
|
||
return constants.SilenceOperatorCustomer
|
||
}
|
||
return constants.SilenceOperatorAccount
|
||
}
|
||
|
||
func nonNil(values []string) []string {
|
||
if values == nil {
|
||
return []string{}
|
||
}
|
||
return values
|
||
}
|
||
|
||
func isUnknownPayment(err error) bool {
|
||
var appErr *errors.AppError
|
||
return stderrors.As(err, &appErr) && appErr.Code == errors.CodeTimeout
|
||
}
|
||
|
||
func isUniqueViolation(err error) bool {
|
||
return err != nil && (strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "duplicate key"))
|
||
}
|
||
|
||
// assetShopID 返回套餐载体当前所属店铺(数据范围与对账用)。
|
||
func assetShopID(ctx context.Context, db *gorm.DB, usage *model.PackageUsage) uint {
|
||
assetType, assetID := usageAsset(usage)
|
||
var shopID *uint
|
||
_ = db.WithContext(ctx).Table(firstPurchaseTable(assetType)).Select("shop_id").Where("id = ?", assetID).Scan(&shopID).Error
|
||
if shopID == nil {
|
||
return 0
|
||
}
|
||
return *shopID
|
||
}
|