Files
junhong_cmp_fiber/internal/application/packagesilence/online_order.go
邹进 62ec94adec
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 38s
refactor: 出库时间更名为首次购买套餐时间,换货新资产不再按换货完成时间记录
- 字段 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>
2026-10-27 16:47:02 +09:00

680 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}