diff --git a/internal/application/refundapproval/creation.go b/internal/application/refundapproval/creation.go index 11da24d..4375a6f 100644 --- a/internal/application/refundapproval/creation.go +++ b/internal/application/refundapproval/creation.go @@ -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, ¤t, ¤tOrder) + attempt, err := buildAttempt(ctx, tx, ¤t, ¤tOrder, "") 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(¤t, 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, ¤t, reference.InstanceID); err != nil { + return err + } if err := updateRefundLatest(ctx, tx, ¤t, 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, ¤t, &order) + attempt, err := buildAttempt(ctx, tx, ¤t, &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, diff --git a/internal/service/refund/method.go b/internal/service/refund/method.go index b9b548f..39c9bf1 100644 --- a/internal/service/refund/method.go +++ b/internal/service/refund/method.go @@ -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()) } diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index ee9eeb3..afc25dc 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -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