fix(退款): 修复创建退款申请插入审批尝试记录失败
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m23s

创建路径把审批尝试记录在事务外预构造后原样插入,缺失只能在事务内生成的
attempt_no 与 package_usage_snapshot,触发 NOT NULL 与 CHECK 约束,
接口统一返回 2002 数据库错误(5xx 脱敏掩盖了具体消息)。

- 尝试记录收敛为事务内唯一构造点 buildAttempt,CreateCommand 与
  ResubmitCommand 只传按冻结商户派生的渠道退款请求号
- TriggerHistorical 补上缺失的尝试记录插入,此前 attempt.ID 恒为 0,
  必然以「关联已变化」冲突收场
- 按「首次接入企业微信审批的实例」语义回写
  tb_refund_request.approval_instance_id,恢复本地人工终审的 IS NULL
  防重保护与退款导出投影
This commit is contained in:
2026-09-17 15:49:04 +08:00
parent aab56a6998
commit 70e6b186df
3 changed files with 66 additions and 40 deletions

View File

@@ -20,12 +20,15 @@ import (
)
// CreateCommand 描述已通过订单与金额校验的退款审批申请。
//
// 本次提交的审批尝试记录由本用例在退款申请落库后于同一事务内构造,调用方只提供
// 需要按冻结商户派生、应用层无法自行生成的渠道退款请求号。
type CreateCommand struct {
Refund *model.RefundRequest
Order *model.Order
SubmitterAccountID uint
// Attempt 是本次提交或重提新增的不可变审批尝试记录,其主键同时作为通用审批业务标识
Attempt *model.RefundRequestAttempt
// ChannelRefundRequestNo 是原路退款本次尝试冻结的渠道退款请求号;非原路方式为空
ChannelRefundRequestNo string
}
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
@@ -126,11 +129,14 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
}
attempt, err := buildAttempt(ctx, tx, &current, &currentOrder)
attempt, err := buildAttempt(ctx, tx, &current, &currentOrder, "")
if err != nil {
return err
}
attempt.SubmittedByAccountID = current.Creator
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
}
submitterSnapshot, requestSnapshot, err := refundSnapshots(&current, account)
if err != nil {
@@ -148,11 +154,13 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if err := attachRefundFirstInstance(ctx, tx, &current, reference.InstanceID); err != nil {
return err
}
if err := updateRefundLatest(ctx, tx, &current, attempt, reference.InstanceID); err != nil {
return err
}
current.ApprovalInstanceID = &reference.InstanceID
refund = current
order = currentOrder
var instance model.ApprovalInstance
@@ -178,7 +186,7 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if command.Refund == nil || command.Order == nil || command.Attempt == nil ||
if command.Refund == nil || command.Order == nil ||
command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" {
return nil, errors.New(errors.CodeInvalidParam)
@@ -199,6 +207,7 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
return nil, err
}
var approvalStatus int
var attempt *model.RefundRequestAttempt
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(command.Refund.OrderID)).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款订单申请边界失败")
@@ -215,47 +224,53 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败")
}
command.Attempt.RefundID = command.Refund.ID
if err := tx.WithContext(ctx).Create(command.Attempt).Error; err != nil {
attempt, err = buildAttempt(ctx, tx, command.Refund, command.Order, command.ChannelRefundRequestNo)
if err != nil {
return err
}
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: command.Attempt.ID, SubmitterAccountID: command.SubmitterAccountID,
BusinessID: attempt.ID, SubmitterAccountID: command.SubmitterAccountID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: command.Refund.RefundNo,
})
if err != nil {
return err
}
if err := attachAttemptInstance(ctx, tx, command.Attempt, reference.InstanceID); err != nil {
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if err := updateRefundLatest(ctx, tx, command.Refund, command.Attempt, reference.InstanceID); err != nil {
if err := attachRefundFirstInstance(ctx, tx, command.Refund, reference.InstanceID); err != nil {
return err
}
if err := updateRefundLatest(ctx, tx, command.Refund, attempt, reference.InstanceID); err != nil {
return err
}
command.Refund.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
var approval model.ApprovalInstance
if err := tx.WithContext(ctx).First(&approval, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account, Attempt: command.Attempt,
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account, Attempt: attempt,
})
})
if err != nil {
return nil, err
}
return &CreateResult{Refund: command.Refund, Attempt: command.Attempt, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
return &CreateResult{Refund: command.Refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
}
// ResubmitCommand 描述重提时的材料变更。
// Refund 携带本次重提后的新值(方式、金额、原因、客户收款信息、凭证与冻结实收)
// Attempt 是本次新增的不可变审批尝试记录。
// Refund 携带本次重提后的新值(方式、金额、原因、客户收款信息、凭证与冻结实收)
// 本次新增的不可变审批尝试记录由本用例在同一事务内构造
type ResubmitCommand struct {
Refund *model.RefundRequest
Attempt *model.RefundRequestAttempt
Refund *model.RefundRequest
// ChannelRefundRequestNo 是原路退款本次重提冻结的渠道退款请求号;非原路方式为空。
ChannelRefundRequestNo string
}
// Resubmit 修改并重提未成功退款申请,新增审批尝试记录与新的企业微信审批实例。
@@ -267,7 +282,7 @@ func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command R
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if refundID == 0 || command.Refund == nil || command.Attempt == nil || command.Refund.Creator == 0 {
if refundID == 0 || command.Refund == nil || command.Refund.Creator == 0 {
return nil, errors.New(errors.CodeInvalidParam, "重提退款申请参数不完整")
}
account, err := s.loadSubmitter(ctx, command.Refund.Creator)
@@ -303,7 +318,7 @@ func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command R
current.RefundVoucherKey = command.Refund.RefundVoucherKey
current.CustomerAccountInfo = command.Refund.CustomerAccountInfo
attempt, err := buildAttempt(ctx, tx, &current, &order)
attempt, err := buildAttempt(ctx, tx, &current, &order, command.ChannelRefundRequestNo)
if err != nil {
return err
}
@@ -415,7 +430,8 @@ func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*m
// buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、客户收款信息与套餐使用快照。
// attempt_no 在退款申请行已加锁的前提下于同一事务内递增,因此申请内唯一。
func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) (*model.RefundRequestAttempt, error) {
// package_usage_snapshot 必须是非空 JSON 对象,因此快照只能在这里按订单事实生成,不能由调用方预置。
func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, channelRefundRequestNo string) (*model.RefundRequestAttempt, error) {
attemptNo, err := nextAttemptNo(ctx, tx, refund.ID)
if err != nil {
return nil, err
@@ -434,6 +450,7 @@ func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest,
CustomerAccountInfo: refund.CustomerAccountInfo,
CustomerVoucherKeys: refund.RefundVoucherKey,
PackageUsageSnapshot: snapshot,
ChannelRefundRequestNo: strings.TrimSpace(channelRefundRequestNo),
SubmittedByAccountID: refund.Creator,
}, nil
}
@@ -495,8 +512,25 @@ func attachAttemptInstance(ctx context.Context, tx *gorm.DB, attempt *model.Refu
return nil
}
// attachRefundFirstInstance 把审批实例回写到退款申请的首次接入引用。
// 既有 approval_instance_id 保持「首次接入企业微信审批的实例」语义:条件更新在引用为空时才写入,
// 因此重提只新增尝试引用,不会改写首次接入事实,也不会破坏其部分唯一索引。
func attachRefundFirstInstance(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, instanceID uint) error {
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND approval_instance_id IS NULL", refund.ID).
Update("approval_instance_id", instanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "回写退款申请首次审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款申请首次审批实例已变化")
}
refund.ApprovalInstanceID = &instanceID
return nil
}
// updateRefundLatest 更新退款申请的最新审批尝试与最新审批实例引用,仅用于展示。
// 既有 approval_instance_id 在该函数外单独回写,保持「首次接入企业微信审批的实例」语义不变。
// 既有 approval_instance_id 由 attachRefundFirstInstance 单独回写,保持「首次接入企业微信审批的实例」语义不变。
func updateRefundLatest(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, instanceID uint) error {
updates := map[string]any{
"latest_attempt_id": attempt.ID,

View File

@@ -254,20 +254,12 @@ func validateFrozenRefundAmount(amount, frozenActualReceivedAmount int64) error
return nil
}
// buildAttemptFromDecision 依据方式判定结果构造一条不可变审批尝试记录
func buildAttemptFromDecision(decision *refundMethodDecision, refund *model.RefundRequest, config *model.WechatConfig, now time.Time) *model.RefundRequestAttempt {
attempt := &model.RefundRequestAttempt{
RefundID: refund.ID,
Method: refund.Method,
RefundAmount: refund.RequestedRefundAmount,
FrozenActualReceivedAmount: decision.FrozenActualReceivedAmount,
RefundReason: refund.RefundReason,
CustomerAccountInfo: refund.CustomerAccountInfo,
CustomerVoucherKeys: refund.RefundVoucherKey,
SubmittedByAccountID: refund.Creator,
// buildChannelRefundRequestNo 仅在原路退款方式下按冻结商户派生本次尝试的渠道退款请求号
// 请求号由收口审批申请用例冻结到不可变尝试记录上,同一尝试的渠道重试复用它作为幂等标识,
// 重提生成新值;非原路方式返回空串。
func buildChannelRefundRequestNo(method string, config *model.WechatConfig) string {
if method != constants.RefundMethodOriginalRoute {
return ""
}
if refund.Method == constants.RefundMethodOriginalRoute {
attempt.ChannelRefundRequestNo = requestChannelRefundNo(config, now)
}
return attempt
return requestChannelRefundNo(config, time.Now())
}

View File

@@ -200,9 +200,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
if s.refundApprovalCreation == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
attempt := buildAttemptFromDecision(decision, refund, decision.ChannelConfig, time.Now())
result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{
Refund: refund, Order: order, SubmitterAccountID: userID, Attempt: attempt,
Refund: refund, Order: order, SubmitterAccountID: userID,
ChannelRefundRequestNo: buildChannelRefundRequestNo(refund.Method, decision.ChannelConfig),
})
if err != nil {
failedRefund := *refund
@@ -852,12 +852,12 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
updated.CustomerAccountInfo = customerAccountInfo
updated.Creator = userID
attempt := buildAttemptFromDecision(decision, &updated, decision.ChannelConfig, time.Now())
attemptChannelRefundRequestNo := buildChannelRefundRequestNo(method, decision.ChannelConfig)
if s.refundApprovalCreation == nil {
return errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if _, err := s.refundApprovalCreation.Resubmit(ctx, id, refundapprovalapp.ResubmitCommand{
Refund: &updated, Attempt: attempt,
Refund: &updated, ChannelRefundRequestNo: attemptChannelRefundRequestNo,
}); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
return err