// Package refundapproval 收口退款申请与渠道无关审批的事务边界。 package refundapproval import ( "context" "fmt" "strconv" "strings" "time" "github.com/bytedance/sonic" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // CreateCommand 描述已通过订单与金额校验的退款审批申请。 type CreateCommand struct { Refund *model.RefundRequest Order *model.Order SubmitterAccountID uint // Attempt 是本次提交或重提新增的不可变审批尝试记录,其主键同时作为通用审批业务标识。 Attempt *model.RefundRequestAttempt } // ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。 type ApplicationAudit struct { Refund *model.RefundRequest Order *model.Order Approval *model.ApprovalInstance Submitter *model.Account // Attempt 非空时表示本次写入新增了一条审批尝试记录。 Attempt *model.RefundRequestAttempt // Action 与 EventID 为空时按「首次提交」写入;重提时由调用方显式指定, // 使同一次重提的审计事件在该尝试上保持幂等。 Action string EventID string } // AuditWriter 接收退款申请事务内审计事实。 type AuditWriter interface { WriteRefundApplication(ctx context.Context, tx *gorm.DB, audit ApplicationAudit) error } // CreateResult 返回原子保存后的退款申请和初始审批状态。 type CreateResult struct { Refund *model.RefundRequest Attempt *model.RefundRequestAttempt SubmitterName string ApprovalStatus int } // CreationService 原子创建退款申请、审批尝试记录、通用审批实例和提交 Outbox。 // // 每次提交或重提新增一条不可变审批尝试记录,并以尝试记录主键作为通用审批业务标识, // 使同一退款单的每次提交各自持有独立审批实例;退款单只保存最新尝试与最新实例引用用于展示, // 其既有 approval_instance_id 语义与唯一约束保持不变。 type CreationService struct { db *gorm.DB approval approvalapp.Port audit AuditWriter } // NewCreationService 创建退款审批申请用例。 func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *CreationService { return &CreationService{db: db, approval: approval, audit: audit} } // TriggerHistorical 为历史待审批退款补发一次企业微信审批。 // 历史申请尚未接入尝试模式,因此本次补发同时建立首条尝试记录并把业务标识切换到该记录。 func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint) (*CreateResult, error) { if s == nil || s.db == nil || s.approval == nil || s.audit == nil || refundID == 0 { return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置") } var refund model.RefundRequest if err := s.db.WithContext(ctx).First(&refund, refundID).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeNotFound, "退款申请不存在") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史退款申请失败") } if refund.Status != model.RefundStatusPending || refund.ApprovalInstanceID != nil { return nil, errors.New(errors.CodeConflict, "退款申请状态不允许补发审批") } account, err := s.loadSubmitter(ctx, refund.Creator) if err != nil { return nil, err } var order model.Order if err := s.db.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeNotFound, "退款关联订单不存在") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败") } preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{ BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: refund.Creator, CorrelationID: refund.RefundNo, }) if err != nil { return nil, err } var result *CreateResult err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var current model.RefundRequest if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, refundID).Error; err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeNotFound, "退款申请不存在") } return errors.Wrap(errors.CodeDatabaseError, err, "锁定历史退款申请失败") } if current.Status != model.RefundStatusPending || current.ApprovalInstanceID != nil { return errors.New(errors.CodeConflict, "退款申请状态不允许补发审批") } var currentOrder model.Order if err := tx.WithContext(ctx).First(¤tOrder, current.OrderID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败") } attempt, err := buildAttempt(ctx, tx, ¤t, ¤tOrder) if err != nil { return err } attempt.SubmittedByAccountID = current.Creator submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account) if err != nil { return err } reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{ Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund, BusinessID: attempt.ID, SubmitterAccountID: current.Creator, SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot, CorrelationID: current.RefundNo, }) if err != nil { return err } if err := attachAttemptInstance(ctx, tx, attempt, 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 if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败") } if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{ Refund: ¤t, Order: ¤tOrder, Approval: &instance, Submitter: account, Attempt: attempt, }); err != nil { return err } result = &CreateResult{Refund: &refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status} return nil }) if err != nil { return nil, err } return result, nil } // Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实、审批尝试事实和审批事实。 func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) { 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 || 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) } account, err := s.loadSubmitter(ctx, command.SubmitterAccountID) if err != nil { return nil, err } preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{ BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: command.SubmitterAccountID, CorrelationID: command.Refund.RefundNo, }) if err != nil { return nil, err } submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account) if err != nil { return nil, err } var approvalStatus int 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, "锁定退款订单申请边界失败") } var activeCount int64 if err := tx.WithContext(ctx).Model(&model.RefundRequest{}). Where("order_id = ? AND status IN ?", command.Refund.OrderID, model.RefundActiveStatuses()). Count(&activeCount).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "复核订单活跃退款申请失败") } if activeCount > 0 { return errors.New(errors.CodeConflict, "该订单已存在活动退款申请") } 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 { 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, SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot, CorrelationID: command.Refund.RefundNo, }) if err != nil { return err } if err := attachAttemptInstance(ctx, tx, command.Attempt, reference.InstanceID); err != nil { return err } if err := updateRefundLatest(ctx, tx, command.Refund, command.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, }) }) if err != nil { return nil, err } return &CreateResult{Refund: command.Refund, Attempt: command.Attempt, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil } // ResubmitCommand 描述重提时的材料变更。 // Refund 携带本次重提后的新值(方式、金额、原因、客户收款信息、凭证与冻结实收), // Attempt 是本次新增的不可变审批尝试记录。 type ResubmitCommand struct { Refund *model.RefundRequest Attempt *model.RefundRequestAttempt } // Resubmit 修改并重提未成功退款申请,新增审批尝试记录与新的企业微信审批实例。 // // 仅已拒绝、已退回或原路退款失败且无审批异常的申请可重提;已成功、待审批、原路处理中或 // 存在审批异常的申请返回状态冲突。每次重提新增不可变尝试记录与独立审批实例, // 历史材料与审批结果不被覆盖,退款单只更新为最新尝试引用。 func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command ResubmitCommand) (*CreateResult, error) { 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 { return nil, errors.New(errors.CodeInvalidParam, "重提退款申请参数不完整") } account, err := s.loadSubmitter(ctx, command.Refund.Creator) if err != nil { return nil, err } var created *CreateResult err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(refundID)).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请重提边界失败") } var current model.RefundRequest if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, refundID).Error; err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeNotFound, "退款申请不存在") } return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败") } if !isResubmittable(¤t) { return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请") } var order model.Order if err := tx.WithContext(ctx).First(&order, current.OrderID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败") } // 材料已在调用方校验,这里把新值并入当前事实后冻结快照。 current.Method = command.Refund.Method current.RequestedRefundAmount = command.Refund.RequestedRefundAmount current.FrozenActualReceivedAmount = command.Refund.FrozenActualReceivedAmount current.RefundReason = command.Refund.RefundReason current.RefundVoucherKey = command.Refund.RefundVoucherKey current.CustomerAccountInfo = command.Refund.CustomerAccountInfo attempt, err := buildAttempt(ctx, tx, ¤t, &order) if err != nil { return err } attempt.SubmittedByAccountID = current.Creator preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{ BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: current.Creator, CorrelationID: current.RefundNo, }) if err != nil { return err } submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account) if err != nil { return err } // 同一事务内回写材料、回到待审批并创建新的审批实例。 updates := map[string]any{ "status": model.RefundStatusPending, "method": current.Method, "requested_refund_amount": current.RequestedRefundAmount, "frozen_actual_received_amount": current.FrozenActualReceivedAmount, "refund_reason": current.RefundReason, "refund_voucher_key": current.RefundVoucherKey, "customer_account_info": current.CustomerAccountInfo, "failure_reason": "", "failure_message": "", "channel_refund_status": constants.RefundChannelStatusNone, "reject_reason": "", "processor_id": nil, "processed_at": nil, "updater": current.Creator, "updated_at": time.Now().UTC(), } result := tx.WithContext(ctx).Model(&model.RefundRequest{}). Where("id = ? AND status IN ?", refundID, model.RefundResubmittableStatuses()). Updates(updates) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新退款申请重提材料失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "退款申请状态已变化") } current.Status = model.RefundStatusPending 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: attempt.ID, SubmitterAccountID: current.Creator, SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot, CorrelationID: current.RefundNo, }) if err != nil { return err } if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil { return err } if err := updateRefundLatest(ctx, tx, ¤t, attempt, reference.InstanceID); err != nil { return err } var instance model.ApprovalInstance if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败") } if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{ Refund: ¤t, Order: &order, Approval: &instance, Submitter: account, Attempt: attempt, Action: constants.AuditActionRefundResubmitted, EventID: "refund:" + strconv.FormatUint(uint64(refundID), 10) + ":attempt:" + strconv.FormatUint(uint64(attempt.ID), 10), }); err != nil { return err } created = &CreateResult{Refund: ¤t, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status} return nil }) if err != nil { return nil, err } return created, nil } // isResubmittable 判断退款申请是否处于可重提状态且不存在审批异常。 // 企业微信通过后撤销的申请标记异常并禁止自动重提,只能由人工线下处理。 func isResubmittable(refund *model.RefundRequest) bool { if refund == nil || refund.AnomalyFlag != 0 { return false } for _, status := range model.RefundResubmittableStatuses() { if refund.Status == status { return true } } return false } func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*model.Account, error) { var account model.Account if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", accountID, constants.StatusEnabled).First(&account).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeForbidden, "退款提交人账号不可用") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款提交人失败") } return &account, nil } // buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、客户收款信息与套餐使用快照。 // attempt_no 在退款申请行已加锁的前提下于同一事务内递增,因此申请内唯一。 func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) (*model.RefundRequestAttempt, error) { attemptNo, err := nextAttemptNo(ctx, tx, refund.ID) if err != nil { return nil, err } snapshot, err := packageUsageSnapshot(ctx, tx, refund, order) if err != nil { return nil, err } return &model.RefundRequestAttempt{ RefundID: refund.ID, AttemptNo: attemptNo, Method: refund.Method, RefundAmount: refund.RequestedRefundAmount, FrozenActualReceivedAmount: refund.FrozenActualReceivedAmount, RefundReason: refund.RefundReason, CustomerAccountInfo: refund.CustomerAccountInfo, CustomerVoucherKeys: refund.RefundVoucherKey, PackageUsageSnapshot: snapshot, SubmittedByAccountID: refund.Creator, }, nil } // nextAttemptNo 返回该退款申请的下一条审批尝试序号;退款申请行已加锁,序号在同一事务内唯一。 func nextAttemptNo(ctx context.Context, tx *gorm.DB, refundID uint) (int, error) { var row struct { MaxAttemptNo int } if err := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}). Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no"). Where("refund_id = ?", refundID).Scan(&row).Error; err != nil { return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试序号失败") } return row.MaxAttemptNo + 1, nil } // packageUsageSnapshot 冻结本次申请关联的套餐使用情况,作为企业微信审批判断材料。 // 本期退款不按套餐已用流量计算金额,因此该快照只作审批与追溯材料,不参与金额校验。 func packageUsageSnapshot(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) (datatypes.JSON, error) { snapshot := map[string]any{ "order_type": order.OrderType, "asset_identifier": order.AssetIdentifier, } if refund.PackageUsageID != nil && *refund.PackageUsageID > 0 { var usage model.PackageUsage if err := tx.WithContext(ctx).First(&usage, *refund.PackageUsageID).Error; err != nil { if err != gorm.ErrRecordNotFound { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐使用记录失败") } } else { snapshot["package_usage"] = map[string]any{ "id": usage.ID, "package_id": usage.PackageID, "package_name": usage.PackageName, "usage_type": usage.UsageType, "status": usage.Status, "data_limit_mb": usage.DataLimitMB, "data_usage_mb": usage.DataUsageMB, "activated_at": usage.ActivatedAt, "expires_at": usage.ExpiresAt, } } } encoded, err := sonic.Marshal(snapshot) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "编码退款套餐使用快照失败") } return datatypes.JSON(encoded), nil } // attachAttemptInstance 把审批实例 ID 回写到本次审批尝试记录,写入一次后不可修改。 func attachAttemptInstance(ctx context.Context, tx *gorm.DB, attempt *model.RefundRequestAttempt, instanceID uint) error { result := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}). Where("id = ? AND approval_instance_id IS NULL", attempt.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, "退款审批尝试实例关联已变化") } attempt.ApprovalInstanceID = &instanceID return nil } // updateRefundLatest 更新退款申请的最新审批尝试与最新审批实例引用,仅用于展示。 // 既有 approval_instance_id 在该函数外单独回写,保持「首次接入企业微信审批的实例」语义不变。 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, "latest_approval_instance_id": instanceID, "updated_at": time.Now().UTC(), } if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).Where("id = ?", refund.ID).Updates(updates).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新退款申请最新审批引用失败") } refund.LatestAttemptID = attempt.ID refund.LatestApprovalInstanceID = instanceID return nil } func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byte, []byte, error) { submitterSnapshot, err := sonic.Marshal(map[string]any{ "account_id": account.ID, "account_name": account.Username, "user_type": account.UserType, }) if err != nil { return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款提交人快照失败") } requestSnapshot, err := sonic.Marshal(map[string]any{ constants.ApprovalFieldRefundNo: refund.RefundNo, constants.ApprovalFieldOrderID: refund.OrderID, constants.ApprovalFieldOrderNo: refund.OrderNo, constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier, constants.ApprovalFieldAssetType: refund.OrderType, constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.FrozenActualReceivedAmount), constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount), constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey), constants.ApprovalFieldRefundReason: refund.RefundReason, constants.ApprovalFieldPackageUsageID: refund.PackageUsageID, constants.ApprovalFieldSubmitterID: account.ID, constants.ApprovalFieldSubmitterName: account.Username, }) if err != nil { return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款审批业务快照失败") } return submitterSnapshot, requestSnapshot, nil } func formatCentAmount(amount int64) string { return fmt.Sprintf("%d.%02d", amount/100, amount%100) }