feat(退款): AUG26-006 退款方式选择与原路退款

按 PRD 2.3/2.4/2.5 落地套餐退款的方式矩阵与原路渠道退款:

- 退款申请派生并冻结权威实收金额(线上取原成功支付记录,钱包/线下取订单实际收款),
  提交人不可填写或修改;按来源支付方式生成可选方式矩阵并在创建、提交、执行前重复校验。
- 审批切换为「每次提交一条不可变审批尝试记录 + 独立企业微信审批实例」,业务标识取尝试
  记录主键;终态消费按尝试记录优先、退款申请兜底双读,兼容存量无实例与已关联实例申请。
  新增活动退款部分唯一索引 (order_id) WHERE status IN (1,5,6)。
- 本地人工终审保持既有开关,补齐通过入口的 approval_instance_id IS NULL 守卫,使三个
  入口一致拒绝已关联审批实例的申请;重提按尝试模式重写(仅已拒绝/已退回/原路失败且无异常)。
- 权益时点:企微通过事务写退款终态、按方式确定的订单态、钱包回款、员工账单冲销与可靠
  失效事实;套餐失效/接续/停机仍由既有可靠机制最终一致执行,不把外部调用放入资金事务。
  订单支付状态按方式置位:凭证退款与退回原钱包在企微通过时置已退款,原路须渠道明确成功。
- 按官方契约实现微信直连 v3、微信 v2(双向证书)、富友(/commonRefund 与 /refundQuery)、
  支付宝四类原路退款;能力只由服务商类型与退款必需凭证完整性决定,无人工开关。
  渠道请求号在提交时冻结到尝试记录,并以 channel_submitted_at 条件认领保证资金动作至多
  提交一次(重复投递只查询不二次提交);不向任何渠道传递退款结果通知地址。
- 新增 refund:channel:recovery 恢复任务只查询回填;本地查询窗口超期(富友 72 小时、
  微信 v2 7 天)转原路退款失败、渠道状态已失败、分类超时未知并置异常转人工,不放行自动
  重提以避免重复退款。
- 同步退款 DTO/导出/审计资源与审计查询关联、商户凭证文档,并修正 fuiou 集成契约文档。

迁移 000218(退款尝试与渠道退款事实)、000219(微信 v2 客户端证书凭证)成对提供,
未修改既有迁移;测试库 junhong_cmp_test 完成 up/down/up 与行为核对,未调用真实渠道。
This commit is contained in:
2026-09-14 11:55:16 +08:00
parent 48c85a4916
commit ba0855d9eb
51 changed files with 5995 additions and 986 deletions

View File

@@ -4,9 +4,12 @@ package refundapproval
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -21,6 +24,8 @@ type CreateCommand struct {
Refund *model.RefundRequest
Order *model.Order
SubmitterAccountID uint
// Attempt 是本次提交或重提新增的不可变审批尝试记录,其主键同时作为通用审批业务标识。
Attempt *model.RefundRequestAttempt
}
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
@@ -29,6 +34,12 @@ type ApplicationAudit struct {
Order *model.Order
Approval *model.ApprovalInstance
Submitter *model.Account
// Attempt 非空时表示本次写入新增了一条审批尝试记录。
Attempt *model.RefundRequestAttempt
// Action 与 EventID 为空时按「首次提交」写入;重提时由调用方显式指定,
// 使同一次重提的审计事件在该尝试上保持幂等。
Action string
EventID string
}
// AuditWriter 接收退款申请事务内审计事实。
@@ -39,11 +50,16 @@ type AuditWriter interface {
// CreateResult 返回原子保存后的退款申请和初始审批状态。
type CreateResult struct {
Refund *model.RefundRequest
Attempt *model.RefundRequestAttempt
SubmitterName string
ApprovalStatus int
}
// CreationService 原子创建退款申请、通用审批实例、企微上下文和提交 Outbox。
// CreationService 原子创建退款申请、审批尝试记录、通用审批实例和提交 Outbox。
//
// 每次提交或重提新增一条不可变审批尝试记录,并以尝试记录主键作为通用审批业务标识,
// 使同一退款单的每次提交各自持有独立审批实例;退款单只保存最新尝试与最新实例引用用于展示,
// 其既有 approval_instance_id 语义与唯一约束保持不变。
type CreationService struct {
db *gorm.DB
approval approvalapp.Port
@@ -55,8 +71,8 @@ func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWrite
return &CreationService{db: db, approval: approval, audit: audit}
}
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。
// 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, "退款审批能力未配置")
@@ -91,12 +107,8 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
if err != nil {
return nil, err
}
submitterSnapshot, requestSnapshot, err := refundSnapshots(&refund, account)
if err != nil {
return nil, err
}
var approvalStatus int
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(&current, refundID).Error; err != nil {
@@ -113,47 +125,61 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
if err := tx.WithContext(ctx).First(&currentOrder, current.OrderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
}
attempt, err := buildAttempt(ctx, tx, &current, &currentOrder)
if err != nil {
return err
}
attempt.SubmittedByAccountID = current.Creator
submitterSnapshot, requestSnapshot, err := refundSnapshots(&current, account)
if err != nil {
return err
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: current.ID, SubmitterAccountID: current.Creator,
BusinessID: attempt.ID, SubmitterAccountID: current.Creator,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: current.RefundNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ? AND approval_instance_id IS NULL", current.ID, model.RefundStatusPending).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
if err := updateRefundLatest(ctx, tx, &current, attempt, reference.InstanceID); err != nil {
return err
}
current.ApprovalInstanceID = &reference.InstanceID
refund = current
order = currentOrder
approvalStatus = reference.Status
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: &current, Order: &currentOrder, Approval: &instance, Submitter: account,
})
if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: &current, Order: &currentOrder, 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 &CreateResult{Refund: &refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
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.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
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)
}
@@ -179,33 +205,34 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
}
var activeCount int64
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("order_id = ? AND status IN ?", command.Refund.OrderID, []int{model.RefundStatusPending, model.RefundStatusApproved}).
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, "该订单已存在退款申请")
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.Refund.ID, SubmitterAccountID: command.SubmitterAccountID,
BusinessID: command.Attempt.ID, SubmitterAccountID: command.SubmitterAccountID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: command.Refund.RefundNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND approval_instance_id IS NULL", command.Refund.ID).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
if err := attachAttemptInstance(ctx, tx, command.Attempt, reference.InstanceID); err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
if err := updateRefundLatest(ctx, tx, command.Refund, command.Attempt, reference.InstanceID); err != nil {
return err
}
command.Refund.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
@@ -214,13 +241,165 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account,
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account, Attempt: command.Attempt,
})
})
if err != nil {
return nil, err
}
return &CreateResult{Refund: command.Refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
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(&current, refundID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "退款申请不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
}
if !isResubmittable(&current) {
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, &current, &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(&current, 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, &current, 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: &current, 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: &current, 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) {
@@ -234,6 +413,104 @@ func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*m
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,
@@ -247,7 +524,7 @@ func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byt
constants.ApprovalFieldOrderNo: refund.OrderNo,
constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier,
constants.ApprovalFieldAssetType: refund.OrderType,
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.ActualReceivedAmount),
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.FrozenActualReceivedAmount),
constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount),
constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey),
constants.ApprovalFieldRefundReason: refund.RefundReason,

View File

@@ -0,0 +1,101 @@
package refundapproval
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ResolveRefundInTx 按审批业务标识解析出退款申请与本次审批尝试记录。
//
// 退款审批的业务标识在审批尝试模式下取尝试记录主键;本能力上线前的存量申请取退款申请主键。
// 尝试记录与退款申请来自两个独立序列,必然存在同值,因此不能只按 businessID 判定归属:
// 必须同时匹配 approval_instance_id才能唯一确定是尝试记录还是退款申请。
//
// 解析顺序固定为「尝试记录优先、退款申请兜底」:
// 1. tb_refund_request_attempt 中 id = businessID 且 approval_instance_id = instanceID
// 2. tb_refund_request 中 id = businessID 且 approval_instance_id = instanceID
// 3. 两者均不匹配返回稳定冲突错误,绝不回落到任一候选业务单。
//
// attempt 在存量兼容路径下为 nil。
func ResolveRefundInTx(ctx context.Context, tx *gorm.DB, businessID, instanceID uint) (*model.RefundRequest, *model.RefundRequestAttempt, error) {
if tx == nil || businessID == 0 || instanceID == 0 {
return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批业务标识参数无效")
}
var attempt model.RefundRequestAttempt
err := tx.WithContext(ctx).
Where("id = ? AND approval_instance_id = ?", businessID, instanceID).
First(&attempt).Error
switch {
case err == nil:
var refund model.RefundRequest
if err := tx.WithContext(ctx).First(&refund, attempt.RefundID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeConflict, "退款审批尝试记录所属退款申请不存在")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
}
return &refund, &attempt, nil
case err != gorm.ErrRecordNotFound:
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试记录失败")
}
var refund model.RefundRequest
err = tx.WithContext(ctx).
Where("id = ? AND approval_instance_id = ?", businessID, instanceID).
First(&refund).Error
switch {
case err == nil:
return &refund, nil, nil
case err == gorm.ErrRecordNotFound:
return nil, nil, errors.New(errors.CodeConflict, "退款申请的关联审批实例不一致")
default:
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
}
}
// ResolveRefundIDInTx 只解析退款申请标识,供审计资源构造与查询关联使用。
func ResolveRefundIDInTx(ctx context.Context, tx *gorm.DB, businessID, instanceID uint) (uint, error) {
refund, _, err := ResolveRefundInTx(ctx, tx, businessID, instanceID)
if err != nil {
return 0, err
}
return refund.ID, nil
}
// ResolveRefundForApprovalRequestInTx 解析「审批申请已建立但审批实例尚未回写到业务记录」时刻的业务归属。
//
// 通用审批创建用例在同一事务内先写审批实例并写审批申请审计,业务侧随后才把实例 ID 回写到
// 审批尝试记录。该审计时刻尝试记录已存在但其 approval_instance_id 仍为空,因此按实例一致性
// 校验的常规解析必然不命中。本函数只承认这一种在途形态:
//
// attempt.id = businessID AND attempt.approval_instance_id IS NULL
//
// 其余情况一律返回不存在,由调用方按常规解析的错误失败关闭,不得放宽为任意未回写记录。
func ResolveRefundForApprovalRequestInTx(ctx context.Context, tx *gorm.DB, businessID uint) (*model.RefundRequest, *model.RefundRequestAttempt, error) {
if tx == nil || businessID == 0 {
return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批业务标识参数无效")
}
var attempt model.RefundRequestAttempt
err := tx.WithContext(ctx).
Where("id = ? AND approval_instance_id IS NULL", businessID).
First(&attempt).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeNotFound, "退款审批尝试记录未回写审批实例")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询在途退款审批尝试记录失败")
}
var refund model.RefundRequest
if err := tx.WithContext(ctx).First(&refund, attempt.RefundID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeConflict, "退款审批尝试记录所属退款申请不存在")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
}
return &refund, &attempt, nil
}