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:
@@ -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(¤t, refundID).Error; err != nil {
|
||||
@@ -113,47 +125,61 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
|
||||
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: 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, ¤t, 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: ¤t, Order: ¤tOrder, Approval: &instance, Submitter: account,
|
||||
})
|
||||
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 &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(¤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) {
|
||||
@@ -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,
|
||||
|
||||
101
internal/application/refundapproval/resolve.go
Normal file
101
internal/application/refundapproval/resolve.go
Normal 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
|
||||
}
|
||||
32
internal/application/refundchannel/audit.go
Normal file
32
internal/application/refundchannel/audit.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// AuditWriter 写退款渠道调用与恢复的可审计事实。
|
||||
// 实现必须与业务更新在同一事务内写入,且摘要不得包含凭证或渠道报文原文。
|
||||
type AuditWriter interface {
|
||||
WriteRefundChannelResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, action string, message string) error
|
||||
}
|
||||
|
||||
// CompletionNotifier 在渠道明确退款成功时补写退款完成通知事实。
|
||||
// 通知载荷由退款能力拥有,本包只负责在正确的时点与事务内触发。
|
||||
type CompletionNotifier interface {
|
||||
AppendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error
|
||||
}
|
||||
|
||||
// appErrorCode 读取应用错误码;非应用错误返回 0。
|
||||
func appErrorCode(err error) int {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return appErr.Code
|
||||
}
|
||||
return 0
|
||||
}
|
||||
75
internal/application/refundchannel/event.go
Normal file
75
internal/application/refundchannel/event.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
|
||||
)
|
||||
|
||||
// EventRefundChannelRefund 是退款进入渠道原路处理中后的执行事件。
|
||||
const EventRefundChannelRefund = "refund.channel.refund.requested"
|
||||
|
||||
// refundChannelPayloadVersion 是渠道原路退款事件的载荷版本。
|
||||
const refundChannelPayloadVersion = 1
|
||||
|
||||
// Payload 是渠道原路退款事件的载荷。
|
||||
type Payload struct {
|
||||
RefundID uint `json:"refund_id"`
|
||||
OrderID uint `json:"order_id"`
|
||||
}
|
||||
|
||||
// AppendRefundChannelRefund 在企微通过事务内幂等写入渠道原路退款执行事件。
|
||||
// 同一退款申请使用稳定事件 ID,重复投递不会重复创建事实。
|
||||
func AppendRefundChannelRefund(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, refundID, orderID uint) error {
|
||||
if repository == nil {
|
||||
return gorm.ErrInvalidDB
|
||||
}
|
||||
value := strconv.FormatUint(uint64(refundID), 10)
|
||||
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: outboxid.Stable(EventRefundChannelRefund+":", value),
|
||||
EventType: EventRefundChannelRefund,
|
||||
PayloadVersion: refundChannelPayloadVersion,
|
||||
AggregateType: "refund", AggregateID: value,
|
||||
ResourceType: "refund", ResourceID: value,
|
||||
BusinessKey: EventRefundChannelRefund + ":" + value,
|
||||
Payload: Payload{RefundID: refundID, OrderID: orderID},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Consumer 把渠道原路退款事件转成一次性资金动作。
|
||||
type Consumer struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewConsumer 创建渠道原路退款事件消费者。
|
||||
func NewConsumer(service *Service) *Consumer {
|
||||
return &Consumer{service: service}
|
||||
}
|
||||
|
||||
// Consume 幂等执行渠道原路退款;重复投递由退款申请状态与渠道请求号共同兜住。
|
||||
func (c *Consumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
var payload Payload
|
||||
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return outbox.Permanent(err)
|
||||
}
|
||||
if envelope.EventType != EventRefundChannelRefund ||
|
||||
envelope.PayloadVersion != refundChannelPayloadVersion || payload.RefundID == 0 {
|
||||
return outbox.Permanent(gorm.ErrInvalidData)
|
||||
}
|
||||
if c == nil || c.service == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "渠道原路退款执行能力未配置")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID})
|
||||
return c.service.Execute(ctx, payload.RefundID)
|
||||
}
|
||||
|
||||
// 编译期断言:渠道原路退款消费者满足公共 Outbox 的消费边界。
|
||||
var _ outbox.EventConsumer = (*Consumer)(nil)
|
||||
71
internal/application/refundchannel/number.go
Normal file
71
internal/application/refundchannel/number.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 渠道退款请求号生成规则参数。
|
||||
const (
|
||||
// channelRefundRequestNoAlphabet 随机段字符集:大写字母与数字。
|
||||
channelRefundRequestNoAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
// channelRefundRequestNoRandomLen 随机段长度,取渠道规则上限 18 位。
|
||||
channelRefundRequestNoRandomLen = 18
|
||||
// channelRefundRequestNoLength 请求号总长:前缀 4 + 日期 8 + 随机段 18。
|
||||
channelRefundRequestNoLength = 30
|
||||
// channelRefundRequestNoPrefixLen 前缀固定长度,不足左侧补 0,超过取前 4 位。
|
||||
channelRefundRequestNoPrefixLen = 4
|
||||
)
|
||||
|
||||
// shanghaiLocation 上海时区(东八区),用于按渠道规则生成日期段。
|
||||
var shanghaiLocation = time.FixedZone("CST", 8*3600)
|
||||
|
||||
// BuildChannelRefundRequestNo 按三渠道共性规则生成渠道退款请求号。
|
||||
//
|
||||
// 规则与富友流水号完全一致(本包不引入渠道 SDK,因此在此独立实现同一规则):
|
||||
// 前缀规整为 4 位(不足左侧补 0,超过取前 4 位)+ 上海时区日期 yyyyMMdd + 18 位大写字母
|
||||
// 数字随机段,总长 30。prefix 由调用方按冻结服务商类型传入:富友传机构码,其余渠道传
|
||||
// 商户标识数字段。生成结果一经写入审批尝试记录即不可变,作为渠道幂等标识复用。
|
||||
func BuildChannelRefundRequestNo(prefix string, now time.Time) string {
|
||||
var builder strings.Builder
|
||||
builder.Grow(channelRefundRequestNoLength)
|
||||
builder.WriteString(normalizeChannelRefundPrefix(prefix))
|
||||
builder.WriteString(now.In(shanghaiLocation).Format("20060102"))
|
||||
|
||||
buffer := make([]byte, channelRefundRequestNoRandomLen)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
// 随机源不可用时退回时间派生的同字符集随机段,保证结果仍满足格式与长度约束。
|
||||
builder.WriteString(fallbackRandomSegment(now))
|
||||
return builder.String()
|
||||
}
|
||||
for _, value := range buffer {
|
||||
builder.WriteByte(channelRefundRequestNoAlphabet[int(value)%len(channelRefundRequestNoAlphabet)])
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// normalizeChannelRefundPrefix 将前缀规整为 4 位:不足左侧补 0,超过取前 4 位。
|
||||
func normalizeChannelRefundPrefix(prefix string) string {
|
||||
normalized := strings.TrimSpace(prefix)
|
||||
if len(normalized) >= channelRefundRequestNoPrefixLen {
|
||||
return normalized[:channelRefundRequestNoPrefixLen]
|
||||
}
|
||||
return strings.Repeat("0", channelRefundRequestNoPrefixLen-len(normalized)) + normalized
|
||||
}
|
||||
|
||||
// fallbackRandomSegment 生成 18 位大写字母数字随机段,仅用于随机源不可用时的兜底。
|
||||
func fallbackRandomSegment(now time.Time) string {
|
||||
segment := strings.ToUpper(strconv.FormatInt(now.UnixNano(), 36))
|
||||
segment = strings.Map(func(char rune) rune {
|
||||
if (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') {
|
||||
return char
|
||||
}
|
||||
return 'X'
|
||||
}, segment)
|
||||
if len(segment) >= channelRefundRequestNoRandomLen {
|
||||
return segment[:channelRefundRequestNoRandomLen]
|
||||
}
|
||||
return segment + strings.Repeat("0", channelRefundRequestNoRandomLen-len(segment))
|
||||
}
|
||||
193
internal/application/refundchannel/recover.go
Normal file
193
internal/application/refundchannel/recover.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// Stats 是一次恢复扫描的可观察结果。
|
||||
//
|
||||
// Scanned 为本次扫描到的申请数;Confirmed 为回填为渠道明确成功的申请数;
|
||||
// Failed 为回填为渠道失败终态的申请数(含渠道明确失败,以及富友与微信 v2 的本地查询窗口
|
||||
// 超期后终止本次渠道执行);Pending 为结果仍未知、等待下次扫描的申请数(含查询调用失败);
|
||||
// Skipped 为本地事实不可用或已被并发推进而未由本次扫描改动状态的申请数。
|
||||
type Stats struct{ Scanned, Confirmed, Failed, Pending, Skipped int }
|
||||
|
||||
// ProcessBatch 扫描原路处理中的退款并只查询渠道回填结果,绝不重复发起资金动作。
|
||||
func (s *Service) ProcessBatch(ctx context.Context) (Stats, error) {
|
||||
stats := Stats{}
|
||||
if err := s.requireReady(); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
var refunds []model.RefundRequest
|
||||
if err := s.db.WithContext(ctx).
|
||||
// 已置异常标记的申请转人工处理,必须退出轮询:否则每次扫描都会重复查询同一笔未知结果。
|
||||
Where("deleted_at IS NULL AND status = ? AND channel_refund_status = ? AND channel_refund_request_no <> ? AND anomaly_flag = ?",
|
||||
model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing, "", 0).
|
||||
Order("id ASC").Limit(recoveryBatchSize).Find(&refunds).Error; err != nil {
|
||||
return stats, errors.Wrap(errors.CodeDatabaseError, err, "扫描原路处理中的退款申请失败")
|
||||
}
|
||||
stats.Scanned = len(refunds)
|
||||
if len(refunds) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
payments, err := s.loadPaidPayments(ctx, refunds)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
var firstErr error
|
||||
for index := range refunds {
|
||||
if err := s.recoverOne(ctx, &refunds[index], payments, now, &stats); err != nil {
|
||||
stats.Skipped++
|
||||
s.logger.Warn("渠道原路退款恢复单条处理失败",
|
||||
zap.Uint("refund_id", refunds[index].ID), zap.Error(err))
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return stats, firstErr
|
||||
}
|
||||
|
||||
// recoverOne 只查询该申请对应的渠道退款状态并按结果回填,不发起任何资金动作。
|
||||
func (s *Service) recoverOne(ctx context.Context, refund *model.RefundRequest, payments map[uint]*model.Payment, now time.Time, stats *Stats) error {
|
||||
target, failureReason, _, err := s.buildTarget(ctx, refund, nil, payments[refund.OrderID])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if failureReason != "" {
|
||||
// 恢复阶段绝不改写为明确失败:渠道可能已受理资金动作,只能留待人工与环境修复。
|
||||
stats.Pending++
|
||||
s.logger.Warn("渠道原路退款恢复缺少本地事实,跳过本次查询",
|
||||
zap.Uint("refund_id", refund.ID), zap.String("failure_reason", failureReason))
|
||||
return nil
|
||||
}
|
||||
if window, reason := queryWindowPolicy(target.ProviderType); window > 0 && now.Sub(refundWindowStart(refund)) > window {
|
||||
return s.flagQueryWindowExpired(ctx, refund, reason, now, stats)
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout)
|
||||
defer cancel()
|
||||
result, callErr := s.refunder.Query(callCtx, target)
|
||||
if callErr != nil {
|
||||
// 查询失败不能推断渠道结果,保持原路处理中等待下次扫描。
|
||||
stats.Pending++
|
||||
return nil
|
||||
}
|
||||
applied, err := s.writeback(ctx, refund, target, result, constants.AuditActionRefundChannelRecovered, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !applied {
|
||||
stats.Skipped++
|
||||
return nil
|
||||
}
|
||||
switch result.State {
|
||||
case StateSuccess:
|
||||
stats.Confirmed++
|
||||
case StateFailed:
|
||||
stats.Failed++
|
||||
default:
|
||||
stats.Pending++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryWindowPolicy 返回该服务商类型的本地查询窗口与其超期原因。
|
||||
// 返回 0 表示不设本地窗口,持续查询直到渠道给出终态。
|
||||
//
|
||||
// - 富友:退款查询接口只支持 3 日内的退款交易,超期后渠道侧已无法查询,属渠道硬约束;
|
||||
// - 微信 v2:受理响应不含退款状态、渠道侧无查询时限,此处按本地阈值放弃轮询并转人工,
|
||||
// 避免一笔未知结果被无限重试。
|
||||
func queryWindowPolicy(providerType string) (time.Duration, string) {
|
||||
switch providerType {
|
||||
case model.ProviderTypeFuiou:
|
||||
return fuiouQueryWindow, anomalyReasonFuiouQueryWindow
|
||||
case model.ProviderTypeWechatV2:
|
||||
return wechatV2QueryWindow, anomalyReasonWechatV2QueryWindow
|
||||
default:
|
||||
return 0, ""
|
||||
}
|
||||
}
|
||||
|
||||
// flagQueryWindowExpired 在本地查询窗口超期且结果仍未知时终止本次渠道执行并转人工处理。
|
||||
//
|
||||
// 生效后果:退款申请转「原路退款失败」、渠道退款状态转「已失败」、写入稳定的
|
||||
// timeout_unknown 分类与异常标记,并写一次审计;重复扫描不重复写入。
|
||||
// 「结果未确认」这一性质由 failure_reason 承载(它不是明确失败,因此不进入后续回溯判定),
|
||||
// 而 status 只表达该尝试的渠道路径已终止。
|
||||
//
|
||||
// 为何不放行自动重提:本次渠道请求可能已被受理但结果未知,放行重提会以新的请求号再次
|
||||
// 提交资金动作,存在重复退款风险。因此保留异常标记,由人工先向渠道核对再决定处置。
|
||||
func (s *Service) flagQueryWindowExpired(ctx context.Context, refund *model.RefundRequest, reason string, now time.Time, stats *Stats) error {
|
||||
stats.Failed++
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ? AND channel_refund_status = ? AND anomaly_flag = 0",
|
||||
refund.ID, model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing).
|
||||
// UpdateColumns 不隐式推进 updated_at:窗口起算点必须保留在进入原路处理中的时刻,
|
||||
// 否则置标记会把窗口重置,下一轮扫描将重新查询同一笔未知结果。
|
||||
UpdateColumns(map[string]any{
|
||||
"status": model.RefundStatusChannelFailed,
|
||||
"channel_refund_status": constants.RefundChannelStatusFailed,
|
||||
"failure_reason": constants.RefundFailureTimeoutUnknown,
|
||||
"anomaly_flag": 1,
|
||||
"anomaly_reason": reason,
|
||||
})
|
||||
if updated.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "标记退款查询窗口超期失败")
|
||||
}
|
||||
if updated.RowsAffected != 1 {
|
||||
return nil
|
||||
}
|
||||
refund.Status = model.RefundStatusChannelFailed
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusFailed
|
||||
refund.FailureReason = constants.RefundFailureTimeoutUnknown
|
||||
refund.AnomalyFlag = 1
|
||||
refund.AnomalyReason = reason
|
||||
return s.audit.WriteRefundChannelResult(ctx, tx, refund,
|
||||
constants.AuditActionRefundAnomalyFlagged, reason+",结果未知,已终止渠道执行并转人工核对")
|
||||
})
|
||||
if err != nil {
|
||||
if appErrorCode(err) != 0 {
|
||||
return err
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "标记退款查询窗口超期失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refundWindowStart 返回查询窗口的起算时点:渠道明确成功时间优先,否则取最后一次实质性状态变更时间。
|
||||
// 结果未知的回写不会推进 updated_at,因此窗口始终从进入原路处理中的时点起算。
|
||||
func refundWindowStart(refund *model.RefundRequest) time.Time {
|
||||
if refund.ChannelRefundedAt != nil {
|
||||
return refund.ChannelRefundedAt.UTC()
|
||||
}
|
||||
return refund.UpdatedAt.UTC()
|
||||
}
|
||||
|
||||
// loadPaidPayments 批量读取该批订单最近一笔已支付的套餐支付单。
|
||||
func (s *Service) loadPaidPayments(ctx context.Context, refunds []model.RefundRequest) (map[uint]*model.Payment, error) {
|
||||
orderIDs := make([]uint, 0, len(refunds))
|
||||
for index := range refunds {
|
||||
orderIDs = append(orderIDs, refunds[index].OrderID)
|
||||
}
|
||||
var payments []model.Payment
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("order_id IN ? AND order_type = ? AND status = ?", orderIDs, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
|
||||
Order("id ASC").Find(&payments).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量读取原支付单失败")
|
||||
}
|
||||
latest := make(map[uint]*model.Payment, len(payments))
|
||||
for index := range payments {
|
||||
latest[payments[index].OrderID] = &payments[index]
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
701
internal/application/refundchannel/service.go
Normal file
701
internal/application/refundchannel/service.go
Normal file
@@ -0,0 +1,701 @@
|
||||
// Package refundchannel 执行与恢复渠道原路退款。
|
||||
//
|
||||
// 本包只编排渠道退款的资金动作与本地状态流转:请求号决定执行幂等、结果按条件更新回写、
|
||||
// 失败按稳定分类终结、未知结果交由恢复扫描查询收敛。具体渠道协议由按服务商类型注入的
|
||||
// Refunder 实现,本包不依赖任何渠道 SDK,也绝不在数据库事务内发起渠道调用。
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// State 是渠道调用的稳定结果状态。
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateSuccess State = "success" // 渠道明确成功
|
||||
StateFailed State = "failed" // 渠道明确失败
|
||||
StateUnknown State = "unknown" // 超时或结果未确认,可恢复
|
||||
)
|
||||
|
||||
// 渠道原路退款的固定运行参数。
|
||||
const (
|
||||
// recoveryBatchSize 是恢复扫描的单批上限,与既有批次扫描用例保持一致。
|
||||
recoveryBatchSize = 50
|
||||
// fuiouQueryWindow 是富友退款查询窗口:其退款查询接口只支持 3 日内的退款交易。
|
||||
fuiouQueryWindow = 72 * time.Hour
|
||||
// wechatV2QueryWindow 是微信 v2 退款结果的本地确认上限。
|
||||
// 微信 v2 退款接口的受理响应不含退款状态,终态只能由退款查询确认;渠道侧没有查询时限,
|
||||
// 因此这里只设本地的放弃阈值:超过该期限仍未确认即停止轮询并转人工核对,避免无限查询。
|
||||
wechatV2QueryWindow = 7 * 24 * time.Hour
|
||||
// channelCallTimeout 是单次渠道退款申请或查询调用的最长等待时间。
|
||||
channelCallTimeout = 30 * time.Second
|
||||
// fuiouOrderTypeWechat 是富友原交易的 order_type 当前唯一可达值(富友微信主扫)。
|
||||
// 与 pkg/fuiou.OrderTypeWechat 取值一致;本包不引入渠道 SDK,因此在此固定回传该冻结值。
|
||||
fuiouOrderTypeWechat = "WECHAT"
|
||||
// anomalyReasonFuiouQueryWindow 是富友退款查询窗口超期的异常原因。
|
||||
anomalyReasonFuiouQueryWindow = "富友退款查询窗口已过,需人工核对"
|
||||
// anomalyReasonWechatV2QueryWindow 是微信 v2 退款结果超过本地确认上限的异常原因。
|
||||
anomalyReasonWechatV2QueryWindow = "微信 v2 退款超过 7 天未确认结果,需人工核对"
|
||||
// failureMessageUnknown 是渠道退款调用结果未确认时的安全摘要。
|
||||
failureMessageUnknown = "渠道退款调用结果未确认,等待查询恢复"
|
||||
// failureMessagePaymentFact 是本地原支付事实不可用时的安全摘要。
|
||||
failureMessagePaymentFact = "本地原支付事实不可用,未能发起渠道退款"
|
||||
// failureMessageCredential 是商户退款必需凭证不完整时的安全摘要。
|
||||
failureMessageCredential = "商户退款必需凭证不完整,未发起渠道退款"
|
||||
// failureMessageNoRequestNo 是退款申请缺少渠道退款请求号时的安全摘要。
|
||||
failureMessageNoRequestNo = "退款申请缺少渠道退款请求号,未发起渠道退款"
|
||||
// failureMessageMaxRunes 是失败安全摘要的字符上限,与 failure_message 列宽约束一致。
|
||||
failureMessageMaxRunes = 480
|
||||
// providerTypeAlipay 是支付宝商户的 provider_type 取值;model 未定义该常量,
|
||||
// 取值与商户凭证管理保持的 "alipay" 完全一致。
|
||||
providerTypeAlipay = "alipay"
|
||||
)
|
||||
|
||||
// Target 是执行一次渠道原路退款所需的全部冻结事实。
|
||||
type Target struct {
|
||||
RefundID uint
|
||||
RefundNo string
|
||||
OrderID uint
|
||||
OrderNo string
|
||||
ProviderType string // model.ProviderType*
|
||||
Config *model.WechatConfig // 商户当前凭证,绝不落库或记日志
|
||||
PaymentNo string // 原支付单商户订单号(微信/支付宝 out_trade_no、富友 mchnt_order_no)
|
||||
ChannelTradeNo string // 原支付单渠道交易流水
|
||||
ChannelOrderType string // 富友原交易 order_type
|
||||
PaidAt *time.Time
|
||||
PaidAmount int64 // 原支付单渠道订单总金额(分),渠道退款请求的 total_amt 必须回传该值
|
||||
RefundAmount int64
|
||||
FrozenActualReceivedAmount int64
|
||||
ChannelRefundRequestNo string
|
||||
}
|
||||
|
||||
// Result 是渠道调用或查询的映射结果。
|
||||
type Result struct {
|
||||
State State
|
||||
ChannelRefundNo string // 渠道退款流水号
|
||||
ChannelRefundAmount int64 // 渠道退款金额(分)
|
||||
SettledAt string // 渠道结算日期原文,可空
|
||||
FailureReason string // pkg/constants.RefundFailure* 稳定编码,仅 State!=StateSuccess 时有值
|
||||
FailureMessage string // 安全摘要,不得含凭证或报文原文
|
||||
}
|
||||
|
||||
// Refunder 是渠道原路退款 Port,由基础设施层按服务商类型实现。
|
||||
type Refunder interface {
|
||||
// Refund 至多提交一次可确认的退款请求;请求号由 Target.ChannelRefundRequestNo 提供。
|
||||
Refund(ctx context.Context, target Target) (Result, error)
|
||||
// Query 只查询渠道退款状态,不得发起资金动作。
|
||||
Query(ctx context.Context, target Target) (Result, error)
|
||||
}
|
||||
|
||||
// MerchantLoader 按冻结商户 ID 加载商户当前凭证与渠道所需的全局授权配置。
|
||||
type MerchantLoader interface {
|
||||
LoadMerchant(ctx context.Context, id uint) (*model.PaymentMerchant, error)
|
||||
LoadAuthorization(ctx context.Context) (*model.WechatAuthorization, error)
|
||||
}
|
||||
|
||||
// Service 执行与恢复原路退款。
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
loader MerchantLoader
|
||||
refunder Refunder
|
||||
audit AuditWriter
|
||||
notifier CompletionNotifier
|
||||
logger *zap.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService 创建渠道原路退款用例。
|
||||
func NewService(db *gorm.DB, loader MerchantLoader, refunder Refunder, audit AuditWriter) *Service {
|
||||
return &Service{db: db, loader: loader, refunder: refunder, audit: audit, logger: zap.NewNop(), now: time.Now}
|
||||
}
|
||||
|
||||
// SetCompletionNotifier 注入退款完成通知写入能力;未注入时成功路径不写通知事实。
|
||||
func (s *Service) SetCompletionNotifier(notifier CompletionNotifier) *Service {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
s.notifier = notifier
|
||||
return s
|
||||
}
|
||||
|
||||
// SetLogger 注入渠道原路退款运行日志。
|
||||
func (s *Service) SetLogger(logger *zap.Logger) *Service {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
s.logger = logger
|
||||
return s
|
||||
}
|
||||
|
||||
// PrepareInTx 在企微通过事务内为原路方式生成请求号并把退款申请置为原路处理中。
|
||||
//
|
||||
// 请求号由提交或重提在不可变审批尝试记录上生成并冻结;尝试记录已带请求号时直接复用,
|
||||
// 仅在缺失时防御性补生成。条件更新要求申请仍处于待审批,否则视为并发冲突。
|
||||
func (s *Service) PrepareInTx(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt) error {
|
||||
if s == nil || tx == nil || refund == nil || refund.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "渠道原路退款准备参数无效")
|
||||
}
|
||||
if refund.Method != constants.RefundMethodOriginalRoute {
|
||||
return nil
|
||||
}
|
||||
requestNo := ""
|
||||
if attempt != nil {
|
||||
requestNo = strings.TrimSpace(attempt.ChannelRefundRequestNo)
|
||||
}
|
||||
if requestNo == "" {
|
||||
// 正常运行不会走到这里:请求号在提交/重提时已冻结到尝试记录上。
|
||||
requestNo = BuildChannelRefundRequestNo(strconv.FormatUint(uint64(refund.ID), 10), s.now())
|
||||
}
|
||||
now := s.now().UTC()
|
||||
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": model.RefundStatusChannelProcessing,
|
||||
"channel_refund_status": constants.RefundChannelStatusProcessing,
|
||||
"channel_refund_request_no": requestNo,
|
||||
"updated_at": now,
|
||||
})
|
||||
if updated.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "进入渠道原路退款处理中失败")
|
||||
}
|
||||
if updated.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款申请状态不允许进入渠道原路退款处理中")
|
||||
}
|
||||
if attempt != nil && attempt.ID != 0 {
|
||||
write := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
|
||||
Where("id = ?", attempt.ID).
|
||||
Update("channel_refund_request_no", requestNo)
|
||||
if write.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, write.Error, "写入退款尝试渠道退款请求号失败")
|
||||
}
|
||||
if write.RowsAffected != 1 {
|
||||
s.logger.Warn("退款尝试渠道退款请求号未写入", zap.Uint("refund_id", refund.ID), zap.Uint("attempt_id", attempt.ID))
|
||||
}
|
||||
attempt.ChannelRefundRequestNo = requestNo
|
||||
}
|
||||
if err := AppendRefundChannelRefund(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款事件失败")
|
||||
}
|
||||
refund.Status = model.RefundStatusChannelProcessing
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing
|
||||
refund.ChannelRefundRequestNo = requestNo
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute 幂等执行一次原路退款;已明确成功或已失败终结的申请直接返回 nil。
|
||||
//
|
||||
// 本地事实在只读事务内锁定读取。资金动作「至多提交一次」由提交认领保证:
|
||||
// 提交前先以 channel_submitted_at IS NULL 条件认领,只有认领成功的执行才调用 Refund;
|
||||
// 认领失败表示该尝试已提交过渠道退款请求(例如 Outbox 事件被重复投递或人工重放),
|
||||
// 此时只查询渠道结果并回填,绝不再次提交资金动作。
|
||||
func (s *Service) Execute(ctx context.Context, refundID uint) error {
|
||||
if err := s.requireReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
if refundID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "渠道原路退款缺少退款申请标识")
|
||||
}
|
||||
facts, proceed, err := s.loadExecutionFacts(ctx, refundID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !proceed {
|
||||
return nil
|
||||
}
|
||||
payment, err := s.loadPaidPayment(ctx, facts.refund.OrderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, failureReason, failureMessage, err := s.buildTarget(ctx, facts.refund, facts.attempt, payment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
if failureReason != "" {
|
||||
// 本地事实不可用时绝不调用渠道,按稳定失败分类终结本次原路退款。
|
||||
if _, err := s.writeback(ctx, facts.refund, target, Result{
|
||||
State: StateFailed, FailureReason: failureReason, FailureMessage: failureMessage,
|
||||
}, constants.AuditActionRefundChannelCalled, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 认领本次提交:认领成功才拥有提交权,失败则本次只做查询。
|
||||
claimed, err := s.claimChannelSubmission(ctx, refundID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout)
|
||||
defer cancel()
|
||||
if !claimed {
|
||||
// 已提交过:只查询渠道结果,绝不再次提交资金动作。
|
||||
result, callErr := s.refunder.Query(callCtx, target)
|
||||
if callErr != nil {
|
||||
// 查询失败不能推断渠道结果,保持原路处理中等待恢复扫描。
|
||||
return nil
|
||||
}
|
||||
s.logger.Info("渠道退款请求已提交过,本次仅查询结果",
|
||||
zap.Uint("refund_id", refundID), zap.String("channel_refund_request_no", target.ChannelRefundRequestNo))
|
||||
_, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelRecovered, now)
|
||||
return err
|
||||
}
|
||||
result, callErr := s.refunder.Refund(callCtx, target)
|
||||
if callErr != nil {
|
||||
// 传输层错误不能推断渠道未受理,一律按结果未知保持可恢复。
|
||||
result = Result{State: StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: failureMessageUnknown}
|
||||
}
|
||||
_, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelCalled, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// claimChannelSubmission 以条件更新认领本次渠道退款提交权。
|
||||
//
|
||||
// 返回 true 表示调用方获得提交权、可以调用渠道退款接口;false 表示该尝试在此之前
|
||||
// 已提交过(重复投递或人工重放),调用方只能查询。认领与回写同以 status = 原路处理中
|
||||
// 为谓词,因此并发执行也至多有一次认领成功。
|
||||
func (s *Service) claimChannelSubmission(ctx context.Context, refundID uint, now time.Time) (bool, error) {
|
||||
claimed := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ? AND channel_submitted_at IS NULL",
|
||||
refundID, model.RefundStatusChannelProcessing).
|
||||
UpdateColumn("channel_submitted_at", now)
|
||||
if claimed.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领渠道退款提交权失败")
|
||||
}
|
||||
return claimed.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// executionFacts 是一次渠道执行所需的本地冻结事实。
|
||||
type executionFacts struct {
|
||||
refund *model.RefundRequest
|
||||
attempt *model.RefundRequestAttempt
|
||||
}
|
||||
|
||||
// loadExecutionFacts 在只读事务内锁定退款申请并读取本次执行所需的尝试记录。
|
||||
// proceed 为 false 表示申请已终结、方式不符或已由并发执行推进,调用方必须直接结束本次执行。
|
||||
func (s *Service) loadExecutionFacts(ctx context.Context, refundID uint) (*executionFacts, bool, error) {
|
||||
facts := &executionFacts{}
|
||||
proceed := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var refund model.RefundRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refundID).First(&refund).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
|
||||
}
|
||||
facts.refund = &refund
|
||||
if refund.Method != constants.RefundMethodOriginalRoute {
|
||||
s.logger.Warn("退款方式不是原路,跳过渠道退款", zap.Uint("refund_id", refund.ID), zap.String("method", refund.Method))
|
||||
return nil
|
||||
}
|
||||
// 已通过或已失败终结的申请直接返回;渠道已明确成功的申请也不得再次调用渠道。
|
||||
if refund.Status != model.RefundStatusChannelProcessing ||
|
||||
refund.ChannelRefundStatus == constants.RefundChannelStatusSucceeded {
|
||||
return nil
|
||||
}
|
||||
attempt, err := loadAttempt(ctx, tx, &refund)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
facts.attempt = attempt
|
||||
proceed = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return facts, proceed, nil
|
||||
}
|
||||
|
||||
// loadAttempt 按申请冻结的最新尝试引用读取尝试记录;引用缺失时退回该申请的最大尝试序号。
|
||||
func loadAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) (*model.RefundRequestAttempt, error) {
|
||||
var attempt model.RefundRequestAttempt
|
||||
query := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{})
|
||||
if refund.LatestAttemptID != 0 {
|
||||
if err := query.Where("id = ?", refund.LatestAttemptID).First(&attempt).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败")
|
||||
}
|
||||
return &attempt, nil
|
||||
}
|
||||
if err := query.Where("refund_id = ?", refund.ID).Order("attempt_no DESC").First(&attempt).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败")
|
||||
}
|
||||
return &attempt, nil
|
||||
}
|
||||
|
||||
// loadPaidPayment 读取订单最近一笔已支付的套餐支付单,作为原路退款的原支付事实。
|
||||
func (s *Service) loadPaidPayment(ctx context.Context, orderID uint) (*model.Payment, error) {
|
||||
var payment model.Payment
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
|
||||
Order("id DESC").First(&payment).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取原支付单失败")
|
||||
}
|
||||
return &payment, nil
|
||||
}
|
||||
|
||||
// buildTarget 在事务外组装渠道调用目标。
|
||||
// 返回非空 failureReason 表示本地事实不可用:调用方必须按该分类回写且绝不调用渠道。
|
||||
func (s *Service) buildTarget(ctx context.Context, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, payment *model.Payment) (Target, string, string, error) {
|
||||
target := Target{
|
||||
RefundID: refund.ID, RefundNo: refund.RefundNo, OrderID: refund.OrderID, OrderNo: refund.OrderNo,
|
||||
RefundAmount: resolveRefundAmount(refund, attempt),
|
||||
FrozenActualReceivedAmount: resolveFrozenAmount(refund, attempt),
|
||||
ChannelRefundRequestNo: resolveChannelRefundRequestNo(refund, attempt),
|
||||
}
|
||||
if target.ChannelRefundRequestNo == "" {
|
||||
// 没有请求号就没有渠道幂等标识:本次尝试从未提交过资金动作,可按明确失败终结。
|
||||
return target, constants.RefundFailurePaymentFactInvalid, failureMessageNoRequestNo, nil
|
||||
}
|
||||
if payment == nil {
|
||||
return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil
|
||||
}
|
||||
target.PaymentNo = strings.TrimSpace(payment.PaymentNo)
|
||||
target.ChannelTradeNo = strings.TrimSpace(payment.ThirdPartyTradeNo)
|
||||
target.PaidAt = payment.PaidAt
|
||||
target.PaidAmount = payment.Amount
|
||||
if target.RefundAmount <= 0 || target.FrozenActualReceivedAmount <= 0 ||
|
||||
target.RefundAmount > target.FrozenActualReceivedAmount {
|
||||
return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil
|
||||
}
|
||||
config, providerType, err := s.loadChannelConfig(ctx, payment)
|
||||
if err != nil {
|
||||
if !credentialFailure(err) {
|
||||
return target, "", "", err
|
||||
}
|
||||
return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil
|
||||
}
|
||||
if !credentialComplete(providerType, config) {
|
||||
return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil
|
||||
}
|
||||
target.ProviderType = providerType
|
||||
target.Config = config
|
||||
if providerType == model.ProviderTypeFuiou {
|
||||
target.ChannelOrderType = fuiouOrderTypeWechat
|
||||
}
|
||||
return target, "", "", nil
|
||||
}
|
||||
|
||||
// loadChannelConfig 加载原支付单实际收款商户的当前凭证。
|
||||
// 新支付按冻结商户标识加载该商户当前凭证与全局微信授权;merchant_id 为空仅表示数据留存期内的
|
||||
// 历史支付,按其原支付配置读取,禁止按当前启用商户池推断历史商户。
|
||||
func (s *Service) loadChannelConfig(ctx context.Context, payment *model.Payment) (*model.WechatConfig, string, error) {
|
||||
if payment.MerchantID != nil {
|
||||
merchant, err := s.loader.LoadMerchant(ctx, *payment.MerchantID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if merchant == nil {
|
||||
return nil, "", errors.New(errors.CodeNoPaymentConfig, "原支付收款商户不存在")
|
||||
}
|
||||
// 仅微信直连(v3/v2)需要全局微信授权配置中的 AppID;其他服务商传 nil 避免无谓失败。
|
||||
var authorization *model.WechatAuthorization
|
||||
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
|
||||
authorization, err = s.loader.LoadAuthorization(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
config, err := merchantpayment.MerchantConfig(merchant, authorization)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return config, merchant.ProviderType, nil
|
||||
}
|
||||
if payment.PaymentConfigID == nil {
|
||||
return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置")
|
||||
}
|
||||
var legacy model.WechatConfig
|
||||
if err := s.db.WithContext(ctx).Unscoped().Where("id = ?", *payment.PaymentConfigID).First(&legacy).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用")
|
||||
}
|
||||
return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "读取历史支付配置失败")
|
||||
}
|
||||
return &legacy, legacy.ProviderType, nil
|
||||
}
|
||||
|
||||
// credentialComplete 判断该服务商类型发起原路退款所需的凭证是否完整。
|
||||
// 规则与本 Change 冻结的商户退款凭证要求一致,只判断必需字段非空,不新增任何凭证键。
|
||||
// RefundCredentialIssue 返回该服务商类型的退款必需凭证缺失原因;凭证完整时返回空串。
|
||||
//
|
||||
// 这是退款能力的唯一判定入口:退款请求不向渠道传递任何通知地址,因此支付通知地址与
|
||||
// 支付跳转地址都不是退款必需凭证。微信 v2 退款接口(/secapi/pay/refund)请求需要双向
|
||||
// 证书,因此其必需凭证包含 API 客户端证书;缺少该证书的 v2 商户按其凭证完整性判定为
|
||||
// 不可用,补录证书后即可用。判定结果不提供人工开关。
|
||||
func RefundCredentialIssue(providerType string, config *model.WechatConfig) string {
|
||||
if config == nil {
|
||||
return failureMessageCredential
|
||||
}
|
||||
switch providerType {
|
||||
case model.ProviderTypeWechat:
|
||||
if !completeFields(config.WxMchID, config.WxAPIV3Key, config.WxCertContent,
|
||||
config.WxKeyContent, config.WxSerialNo) {
|
||||
return "冻结微信商户退款凭证不完整"
|
||||
}
|
||||
case model.ProviderTypeWechatV2:
|
||||
// v2 退款接口为双向证书接口:缺少 API 客户端证书时按其凭证完整性判定为不可用。
|
||||
if !completeFields(config.WxMchID, config.WxAPIV2Key, config.WxClientCertContent, config.WxClientKeyContent) {
|
||||
return "冻结微信 v2 商户退款凭证不完整(缺少 API 客户端证书)"
|
||||
}
|
||||
case model.ProviderTypeFuiou:
|
||||
if !completeFields(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyPrivateKey,
|
||||
config.FyPublicKey, config.FyAPIURL) {
|
||||
return "冻结富友商户退款凭证不完整"
|
||||
}
|
||||
case providerTypeAlipay:
|
||||
if !completeFields(config.AliAppID, config.AliPrivateKey, config.AliPublicKey) {
|
||||
return "冻结支付宝商户退款凭证不完整"
|
||||
}
|
||||
default:
|
||||
return "冻结商户不支持原路退款"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// credentialComplete 判断该服务商类型的退款必需凭证是否完整。
|
||||
func credentialComplete(providerType string, config *model.WechatConfig) bool {
|
||||
return RefundCredentialIssue(providerType, config) == ""
|
||||
}
|
||||
|
||||
func completeFields(values ...string) bool {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// credentialFailure 判断凭证加载错误属于渠道侧不可执行的凭证问题,而不是可重试的基础设施错误。
|
||||
func credentialFailure(err error) bool {
|
||||
switch appErrorCode(err) {
|
||||
case errors.CodeNoPaymentConfig, errors.CodeNotFound, errors.CodeInvalidParam, errors.CodeWechatConfigUnavailable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resolveChannelRefundRequestNo 取本次执行的渠道幂等标识。
|
||||
// 尝试记录持有本次提交冻结的请求号,优先级高于退款单上的展示快照:重提会生成新请求号,
|
||||
// 沿用旧快照会让渠道按旧请求号再次受理;两者一致时结果相同。
|
||||
func resolveChannelRefundRequestNo(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) string {
|
||||
if attempt != nil {
|
||||
if requestNo := strings.TrimSpace(attempt.ChannelRefundRequestNo); requestNo != "" {
|
||||
return requestNo
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(refund.ChannelRefundRequestNo)
|
||||
}
|
||||
|
||||
// resolveRefundAmount 取本次原路退款的权威金额:优先审批实际退款金额,其次尝试记录冻结金额。
|
||||
func resolveRefundAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 {
|
||||
if refund.ApprovedRefundAmount != nil && *refund.ApprovedRefundAmount > 0 {
|
||||
return *refund.ApprovedRefundAmount
|
||||
}
|
||||
if refund.RequestedRefundAmount > 0 {
|
||||
return refund.RequestedRefundAmount
|
||||
}
|
||||
if attempt != nil {
|
||||
return attempt.RefundAmount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// resolveFrozenAmount 取本次原路退款的冻结实收金额。
|
||||
func resolveFrozenAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 {
|
||||
if refund.FrozenActualReceivedAmount > 0 {
|
||||
return refund.FrozenActualReceivedAmount
|
||||
}
|
||||
if attempt != nil {
|
||||
return attempt.FrozenActualReceivedAmount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// writeback 在独立事务内按渠道结果条件更新退款申请、订单与审计事实。
|
||||
// applied 为 false 表示记录已被并发推进,本次不改动任何状态。
|
||||
func (s *Service) writeback(ctx context.Context, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) {
|
||||
applied := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
applied, err = s.applyResult(ctx, tx, refund, target, result, action, now)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !applied {
|
||||
s.logger.Warn("渠道原路退款结果未回写,记录已被并发推进",
|
||||
zap.Uint("refund_id", refund.ID), zap.String("action", action), zap.String("state", string(result.State)))
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
// applyResult 按结果状态把渠道事实条件回写到退款申请,成功时同步把订单置为已退款。
|
||||
// 所有状态流转都以 status = 原路处理中 为谓词,RowsAffected 为 0 表示并发已推进该记录。
|
||||
func (s *Service) applyResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) {
|
||||
update := map[string]any{}
|
||||
syncRefund := func() {}
|
||||
orderRefunded := false
|
||||
reason := result.FailureReason
|
||||
message := ""
|
||||
switch result.State {
|
||||
case StateSuccess:
|
||||
amount := result.ChannelRefundAmount
|
||||
if amount <= 0 {
|
||||
amount = target.RefundAmount
|
||||
}
|
||||
update["status"] = model.RefundStatusApproved
|
||||
update["channel_refund_status"] = constants.RefundChannelStatusSucceeded
|
||||
update["channel_refund_no"] = result.ChannelRefundNo
|
||||
update["channel_refund_amount"] = amount
|
||||
update["channel_refunded_at"] = now
|
||||
update["processed_at"] = now
|
||||
update["failure_reason"] = ""
|
||||
update["failure_message"] = ""
|
||||
update["updated_at"] = now
|
||||
orderRefunded = true
|
||||
message = "渠道原路退款明确成功"
|
||||
syncRefund = func() {
|
||||
refund.Status = model.RefundStatusApproved
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusSucceeded
|
||||
refund.ChannelRefundNo = result.ChannelRefundNo
|
||||
refund.ChannelRefundAmount = amount
|
||||
refund.ChannelRefundedAt = &now
|
||||
refund.ProcessedAt = &now
|
||||
refund.FailureReason = ""
|
||||
refund.FailureMessage = ""
|
||||
}
|
||||
case StateFailed:
|
||||
if reason == "" {
|
||||
reason = constants.RefundFailureChannelRejected
|
||||
}
|
||||
message = "渠道原路退款明确失败:" + constants.RefundFailureReasonName(reason)
|
||||
failureMessage := safeMessage(result.FailureMessage, message)
|
||||
update["status"] = model.RefundStatusChannelFailed
|
||||
update["channel_refund_status"] = constants.RefundChannelStatusFailed
|
||||
update["failure_reason"] = reason
|
||||
update["failure_message"] = failureMessage
|
||||
update["updated_at"] = now
|
||||
syncRefund = func() {
|
||||
refund.Status = model.RefundStatusChannelFailed
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusFailed
|
||||
refund.FailureReason = reason
|
||||
refund.FailureMessage = failureMessage
|
||||
}
|
||||
default:
|
||||
// 超时或结果未确认:保持原路处理中,等待恢复扫描查询收敛。
|
||||
// 不修改 updated_at,使富友查询窗口从进入原路处理中的时点起算。
|
||||
reason = constants.RefundFailureTimeoutUnknown
|
||||
message = "渠道原路退款结果未确认,保持处理中"
|
||||
failureMessage := safeMessage(result.FailureMessage, failureMessageUnknown)
|
||||
update["channel_refund_status"] = constants.RefundChannelStatusProcessing
|
||||
update["failure_reason"] = reason
|
||||
update["failure_message"] = failureMessage
|
||||
syncRefund = func() {
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing
|
||||
refund.FailureReason = reason
|
||||
refund.FailureMessage = failureMessage
|
||||
}
|
||||
}
|
||||
// UpdateColumns 不会隐式推进 updated_at:结果未知时必须保留进入原路处理中的时点,
|
||||
// 富友 72 小时查询窗口正是以该时点起算;需要推进的分支已在 update 中显式写入。
|
||||
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", refund.ID, model.RefundStatusChannelProcessing).
|
||||
UpdateColumns(update)
|
||||
if updated.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, updated.Error, "回写渠道原路退款结果失败")
|
||||
}
|
||||
if updated.RowsAffected != 1 {
|
||||
return false, nil
|
||||
}
|
||||
syncRefund()
|
||||
if orderRefunded {
|
||||
if err := s.markOrderRefunded(ctx, tx, refund, now); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 原路退款的完成时点是渠道明确成功,与客户收款信息退款在企微通过时完成的语义不同:
|
||||
// 退款完成通知必须在同一事务内补写,否则该方式的店铺通知永远不会发出。
|
||||
if s.notifier != nil {
|
||||
if err := s.notifier.AppendCompletedNotification(ctx, tx, refund); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.audit.WriteRefundChannelResult(ctx, tx, refund, action, message); err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款审计失败")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// markOrderRefunded 在渠道明确成功后按方式把订单置为已退款。
|
||||
// 条件更新命中 0 行时容忍订单已是已退款;其他状态只记录告警,不覆盖业务事实。
|
||||
func (s *Service) markOrderRefunded(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, now time.Time) error {
|
||||
updated := tx.WithContext(ctx).Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", refund.OrderID, model.PaymentStatusPaid).
|
||||
Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": now})
|
||||
if updated.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "更新订单退款状态失败")
|
||||
}
|
||||
if updated.RowsAffected == 1 {
|
||||
return nil
|
||||
}
|
||||
var order model.Order
|
||||
if err := tx.WithContext(ctx).Select("id", "payment_status").Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "读取退款关联订单状态失败")
|
||||
}
|
||||
if order.PaymentStatus != model.PaymentStatusRefunded {
|
||||
s.logger.Warn("订单支付状态未置为已退款",
|
||||
zap.Uint("refund_id", refund.ID), zap.Uint("order_id", refund.OrderID), zap.Int("payment_status", order.PaymentStatus))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeMessage 生成失败安全摘要:裁剪空白、限定字符数,空值退回该状态的固定摘要。
|
||||
func safeMessage(message, fallback string) string {
|
||||
text := strings.TrimSpace(message)
|
||||
if text == "" {
|
||||
text = fallback
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) > failureMessageMaxRunes {
|
||||
text = string(runes[:failureMessageMaxRunes])
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// requireReady 校验渠道原路退款的全部依赖已配置。
|
||||
func (s *Service) requireReady() error {
|
||||
if s == nil || s.db == nil || s.loader == nil || s.refunder == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "渠道原路退款能力未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user