Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕 - 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败 - 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影 - 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序 - 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列 - 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名 - 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
636 lines
29 KiB
Go
636 lines
29 KiB
Go
// Package refundapproval 收口退款申请与渠道无关审批的事务边界。
|
||
package refundapproval
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/bytedance/sonic"
|
||
"gorm.io/datatypes"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
|
||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
)
|
||
|
||
// RefundMaterial 承载本次提交由调用方按既有展示口径解析出的审批材料事实。
|
||
//
|
||
// 材料由服务层解析后传入,使审批材料与退款展示字段共用同一份解析规则(ENG-ARCH-002:
|
||
// 读取口径不复制);材料不可解析时以空值或零值传入,MUST NOT 阻断审批提交。
|
||
type RefundMaterial struct {
|
||
// AssetType 是退款关联资产类型:card 单卡、device 设备,沿用退款申请的下单快照推导。
|
||
AssetType string
|
||
// DeviceType 与 DeviceModel 取退款申请关联设备,无关联设备时为空串。
|
||
DeviceType string
|
||
DeviceModel string
|
||
// PackageUsedMB 与 PackageTotalMB 是当前退款套餐的已用量与总量(MB,真实流量),解析不到时为 0。
|
||
PackageUsedMB int64
|
||
PackageTotalMB int64
|
||
// OriginalChannelTradeNo 是创建申请时冻结的原支付渠道交易流水号,线下订单为空串。
|
||
OriginalChannelTradeNo string
|
||
}
|
||
|
||
// CreateCommand 描述已通过订单与金额校验的退款审批申请。
|
||
//
|
||
// 本次提交的审批尝试记录由本用例在退款申请落库后于同一事务内构造,调用方只提供
|
||
// 需要按冻结商户派生、应用层无法自行生成的渠道退款请求号,以及本次提交的申请人备注与审批材料。
|
||
type CreateCommand struct {
|
||
Refund *model.RefundRequest
|
||
Order *model.Order
|
||
SubmitterAccountID uint
|
||
// ChannelRefundRequestNo 是原路退款本次尝试冻结的渠道退款请求号;非原路方式为空。
|
||
ChannelRefundRequestNo string
|
||
// ApplicantRemark 是本次提交的申请人备注,冻结进当次审批尝试快照;不写入退款主表审批备注。
|
||
ApplicantRemark string
|
||
// Material 是本次提交的审批材料事实,冻结进当次尝试快照与审批业务快照。
|
||
Material RefundMaterial
|
||
}
|
||
|
||
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
|
||
type ApplicationAudit struct {
|
||
Refund *model.RefundRequest
|
||
Order *model.Order
|
||
Approval *model.ApprovalInstance
|
||
Submitter *model.Account
|
||
// Attempt 非空时表示本次写入新增了一条审批尝试记录。
|
||
Attempt *model.RefundRequestAttempt
|
||
// Action 与 EventID 为空时按「首次提交」写入;重提时由调用方显式指定,
|
||
// 使同一次重提的审计事件在该尝试上保持幂等。
|
||
Action string
|
||
EventID string
|
||
}
|
||
|
||
// AuditWriter 接收退款申请事务内审计事实。
|
||
type AuditWriter interface {
|
||
WriteRefundApplication(ctx context.Context, tx *gorm.DB, audit ApplicationAudit) error
|
||
}
|
||
|
||
// CreateResult 返回原子保存后的退款申请和初始审批状态。
|
||
type CreateResult struct {
|
||
Refund *model.RefundRequest
|
||
Attempt *model.RefundRequestAttempt
|
||
SubmitterName string
|
||
ApprovalStatus int
|
||
}
|
||
|
||
// CreationService 原子创建退款申请、审批尝试记录、通用审批实例和提交 Outbox。
|
||
//
|
||
// 每次提交或重提新增一条不可变审批尝试记录,并以尝试记录主键作为通用审批业务标识,
|
||
// 使同一退款单的每次提交各自持有独立审批实例;退款单只保存最新尝试与最新实例引用用于展示,
|
||
// 其既有 approval_instance_id 语义与唯一约束保持不变。
|
||
type CreationService struct {
|
||
db *gorm.DB
|
||
approval approvalapp.Port
|
||
audit AuditWriter
|
||
}
|
||
|
||
// NewCreationService 创建退款审批申请用例。
|
||
func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *CreationService {
|
||
return &CreationService{db: db, approval: approval, audit: audit}
|
||
}
|
||
|
||
// TriggerHistorical 为历史待审批退款补发一次企业微信审批。
|
||
// 历史申请尚未接入尝试模式,因此本次补发同时建立首条尝试记录并把业务标识切换到该记录。
|
||
//
|
||
// 补发不为历史申请追溯退款原因必填(该校验只作用于创建与重提用例),但材料仍按既有展示口径
|
||
// 解析后传入,使新增控件的审批材料在补发路径同样完整,避免模板缺字段导致提交失败。
|
||
func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint, material RefundMaterial) (*CreateResult, error) {
|
||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil || refundID == 0 {
|
||
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||
}
|
||
|
||
var refund model.RefundRequest
|
||
if err := s.db.WithContext(ctx).First(&refund, refundID).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史退款申请失败")
|
||
}
|
||
if refund.Status != model.RefundStatusPending || refund.ApprovalInstanceID != nil {
|
||
return nil, errors.New(errors.CodeConflict, "退款申请状态不允许补发审批")
|
||
}
|
||
|
||
account, err := s.loadSubmitter(ctx, refund.Creator)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var order model.Order
|
||
if err := s.db.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "退款关联订单不存在")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
|
||
}
|
||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||
BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: refund.Creator,
|
||
CorrelationID: refund.RefundNo,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var result *CreateResult
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var current model.RefundRequest
|
||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, refundID).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return errors.New(errors.CodeNotFound, "退款申请不存在")
|
||
}
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定历史退款申请失败")
|
||
}
|
||
if current.Status != model.RefundStatusPending || current.ApprovalInstanceID != nil {
|
||
return errors.New(errors.CodeConflict, "退款申请状态不允许补发审批")
|
||
}
|
||
|
||
var currentOrder model.Order
|
||
if err := tx.WithContext(ctx).First(¤tOrder, current.OrderID).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
|
||
}
|
||
|
||
attempt, err := buildAttempt(ctx, tx, ¤t, ¤tOrder, "", "", material)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
attempt.SubmittedByAccountID = current.Creator
|
||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
|
||
}
|
||
|
||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account, "", material)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
|
||
BusinessID: attempt.ID, SubmitterAccountID: current.Creator,
|
||
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
|
||
CorrelationID: current.RefundNo,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
|
||
return err
|
||
}
|
||
if err := attachRefundFirstInstance(ctx, tx, ¤t, reference.InstanceID); err != nil {
|
||
return err
|
||
}
|
||
if err := updateRefundLatest(ctx, tx, ¤t, attempt, reference.InstanceID); err != nil {
|
||
return err
|
||
}
|
||
|
||
refund = current
|
||
order = currentOrder
|
||
var instance model.ApprovalInstance
|
||
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
|
||
}
|
||
if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
|
||
Refund: ¤t, Order: ¤tOrder, Approval: &instance, Submitter: account, Attempt: attempt,
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
result = &CreateResult{Refund: &refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实、审批尝试事实和审批事实。
|
||
func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) {
|
||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
|
||
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||
}
|
||
if command.Refund == nil || command.Order == nil ||
|
||
command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
|
||
command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" {
|
||
return nil, errors.New(errors.CodeInvalidParam)
|
||
}
|
||
account, err := s.loadSubmitter(ctx, command.SubmitterAccountID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||
BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: command.SubmitterAccountID,
|
||
CorrelationID: command.Refund.RefundNo,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account, command.ApplicantRemark, command.Material)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var approvalStatus int
|
||
var attempt *model.RefundRequestAttempt
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(command.Refund.OrderID)).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款订单申请边界失败")
|
||
}
|
||
var activeCount int64
|
||
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||
Where("order_id = ? AND status IN ?", command.Refund.OrderID, model.RefundActiveStatuses()).
|
||
Count(&activeCount).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "复核订单活跃退款申请失败")
|
||
}
|
||
if activeCount > 0 {
|
||
return errors.New(errors.CodeConflict, "该订单已存在活动退款申请")
|
||
}
|
||
if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败")
|
||
}
|
||
attempt, err = buildAttempt(ctx, tx, command.Refund, command.Order, command.ChannelRefundRequestNo, command.ApplicantRemark, command.Material)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
|
||
}
|
||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
|
||
BusinessID: attempt.ID, SubmitterAccountID: command.SubmitterAccountID,
|
||
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
|
||
CorrelationID: command.Refund.RefundNo,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
|
||
return err
|
||
}
|
||
if err := attachRefundFirstInstance(ctx, tx, command.Refund, reference.InstanceID); err != nil {
|
||
return err
|
||
}
|
||
if err := updateRefundLatest(ctx, tx, command.Refund, attempt, reference.InstanceID); err != nil {
|
||
return err
|
||
}
|
||
approvalStatus = reference.Status
|
||
var approval model.ApprovalInstance
|
||
if err := tx.WithContext(ctx).First(&approval, reference.InstanceID).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
|
||
}
|
||
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
|
||
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account, Attempt: attempt,
|
||
})
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &CreateResult{Refund: command.Refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
|
||
}
|
||
|
||
// ResubmitCommand 描述重提时的材料变更。
|
||
// Refund 携带本次重提后的新值(方式、金额、原因、客户收款信息、凭证与冻结实收);
|
||
// 本次新增的不可变审批尝试记录由本用例在同一事务内构造。
|
||
type ResubmitCommand struct {
|
||
Refund *model.RefundRequest
|
||
// ChannelRefundRequestNo 是原路退款本次重提冻结的渠道退款请求号;非原路方式为空。
|
||
ChannelRefundRequestNo string
|
||
// ApplicantRemark 是本次重提的申请人备注,冻结进新的审批尝试快照;历史尝试备注不被改写。
|
||
ApplicantRemark string
|
||
// Material 是本次重提的审批材料事实,冻结进新的尝试快照与审批业务快照。
|
||
Material RefundMaterial
|
||
}
|
||
|
||
// 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.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, command.ChannelRefundRequestNo, command.ApplicantRemark, command.Material)
|
||
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, command.ApplicantRemark, command.Material)
|
||
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,
|
||
"source_payment_no": current.SourcePaymentNo,
|
||
"original_channel_trade_no": current.OriginalChannelTradeNo,
|
||
"failure_reason": "",
|
||
"failure_message": "",
|
||
"channel_refund_status": constants.RefundChannelStatusNone,
|
||
"reject_reason": "",
|
||
"processor_id": nil,
|
||
"processed_at": nil,
|
||
"updater": current.Creator,
|
||
"updated_at": time.Now().UTC(),
|
||
}
|
||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||
Where("id = ? AND status IN ?", refundID, model.RefundResubmittableStatuses()).
|
||
Updates(updates)
|
||
if result.Error != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新退款申请重提材料失败")
|
||
}
|
||
if result.RowsAffected != 1 {
|
||
return errors.New(errors.CodeConflict, "退款申请状态已变化")
|
||
}
|
||
current.Status = model.RefundStatusPending
|
||
|
||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
|
||
}
|
||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
|
||
BusinessID: attempt.ID, SubmitterAccountID: current.Creator,
|
||
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
|
||
CorrelationID: current.RefundNo,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
|
||
return err
|
||
}
|
||
if err := updateRefundLatest(ctx, tx, ¤t, attempt, reference.InstanceID); err != nil {
|
||
return err
|
||
}
|
||
var instance model.ApprovalInstance
|
||
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
|
||
}
|
||
if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
|
||
Refund: ¤t, Order: &order, Approval: &instance, Submitter: account, Attempt: attempt,
|
||
Action: constants.AuditActionRefundResubmitted,
|
||
EventID: "refund:" + strconv.FormatUint(uint64(refundID), 10) + ":attempt:" + strconv.FormatUint(uint64(attempt.ID), 10),
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
created = &CreateResult{Refund: ¤t, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return created, nil
|
||
}
|
||
|
||
// isResubmittable 判断退款申请是否处于可重提状态且不存在审批异常。
|
||
// 企业微信通过后撤销的申请标记异常并禁止自动重提,只能由人工线下处理。
|
||
func isResubmittable(refund *model.RefundRequest) bool {
|
||
if refund == nil || refund.AnomalyFlag != 0 {
|
||
return false
|
||
}
|
||
for _, status := range model.RefundResubmittableStatuses() {
|
||
if refund.Status == status {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*model.Account, error) {
|
||
var account model.Account
|
||
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", accountID, constants.StatusEnabled).First(&account).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeForbidden, "退款提交人账号不可用")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款提交人失败")
|
||
}
|
||
return &account, nil
|
||
}
|
||
|
||
// buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、申请人备注、客户收款信息与套餐使用快照。
|
||
// attempt_no 在退款申请行已加锁的前提下于同一事务内递增,因此申请内唯一。
|
||
// package_usage_snapshot 必须是非空 JSON 对象,因此快照只能在这里按订单事实生成,不能由调用方预置。
|
||
func buildAttempt(
|
||
ctx context.Context,
|
||
tx *gorm.DB,
|
||
refund *model.RefundRequest,
|
||
order *model.Order,
|
||
channelRefundRequestNo string,
|
||
applicantRemark string,
|
||
material RefundMaterial,
|
||
) (*model.RefundRequestAttempt, error) {
|
||
attemptNo, err := nextAttemptNo(ctx, tx, refund.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
snapshot, err := packageUsageSnapshot(ctx, tx, refund, order, material)
|
||
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,
|
||
Remark: strings.TrimSpace(applicantRemark),
|
||
CustomerAccountInfo: refund.CustomerAccountInfo,
|
||
CustomerVoucherKeys: refund.RefundVoucherKey,
|
||
PackageUsageSnapshot: snapshot,
|
||
ChannelRefundRequestNo: strings.TrimSpace(channelRefundRequestNo),
|
||
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 冻结本次申请关联的套餐使用情况与本次提交的审批材料,作为企业微信审批判断材料。
|
||
// 本期退款不按套餐已用流量计算金额,因此该快照只作审批与追溯材料,不参与金额校验。
|
||
//
|
||
// 用量、设备维度与原渠道交易流水号由调用方按既有展示口径解析后传入(material):材料不可解析时
|
||
// 以空值或零值写入,使快照键始终存在而 MUST NOT 阻断审批提交。
|
||
func packageUsageSnapshot(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, material RefundMaterial) (datatypes.JSON, error) {
|
||
snapshot := map[string]any{
|
||
"order_type": order.OrderType,
|
||
"asset_identifier": order.AssetIdentifier,
|
||
"asset_type": material.AssetType,
|
||
"device_type": material.DeviceType,
|
||
"device_model": material.DeviceModel,
|
||
"package_used_mb": material.PackageUsedMB,
|
||
"package_total_mb": material.PackageTotalMB,
|
||
"channel_trade_no": material.OriginalChannelTradeNo,
|
||
}
|
||
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
|
||
}
|
||
|
||
// attachRefundFirstInstance 把审批实例回写到退款申请的首次接入引用。
|
||
// 既有 approval_instance_id 保持「首次接入企业微信审批的实例」语义:条件更新在引用为空时才写入,
|
||
// 因此重提只新增尝试引用,不会改写首次接入事实,也不会破坏其部分唯一索引。
|
||
func attachRefundFirstInstance(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, instanceID uint) error {
|
||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||
Where("id = ? AND approval_instance_id IS NULL", refund.ID).
|
||
Update("approval_instance_id", instanceID)
|
||
if result.Error != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "回写退款申请首次审批实例失败")
|
||
}
|
||
if result.RowsAffected != 1 {
|
||
return errors.New(errors.CodeConflict, "退款申请首次审批实例已变化")
|
||
}
|
||
refund.ApprovalInstanceID = &instanceID
|
||
return nil
|
||
}
|
||
|
||
// updateRefundLatest 更新退款申请的最新审批尝试与最新审批实例引用,仅用于展示。
|
||
// 既有 approval_instance_id 由 attachRefundFirstInstance 单独回写,保持「首次接入企业微信审批的实例」语义不变。
|
||
func updateRefundLatest(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, instanceID uint) error {
|
||
updates := map[string]any{
|
||
"latest_attempt_id": attempt.ID,
|
||
"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
|
||
}
|
||
|
||
// refundSnapshots 冻结提交人与审批业务快照。
|
||
// 审批业务快照是企微表单构建的数据源,因此本变更新增的审批材料字段必须在此出现,
|
||
// 且即使不可解析也要以空值或零值写入:控件映射已配置时缺失键会被表单构建明确拒绝。
|
||
func refundSnapshots(refund *model.RefundRequest, account *model.Account, applicantRemark string, material RefundMaterial) ([]byte, []byte, error) {
|
||
submitterSnapshot, err := sonic.Marshal(map[string]any{
|
||
"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType,
|
||
})
|
||
if err != nil {
|
||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款提交人快照失败")
|
||
}
|
||
requestSnapshot, err := sonic.Marshal(map[string]any{
|
||
constants.ApprovalFieldRefundNo: refund.RefundNo,
|
||
constants.ApprovalFieldOrderID: refund.OrderID,
|
||
constants.ApprovalFieldOrderNo: refund.OrderNo,
|
||
constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier,
|
||
constants.ApprovalFieldAssetType: refund.OrderType,
|
||
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.FrozenActualReceivedAmount),
|
||
constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount),
|
||
constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey),
|
||
constants.ApprovalFieldRefundReason: refund.RefundReason,
|
||
constants.ApprovalFieldPackageUsageID: refund.PackageUsageID,
|
||
constants.ApprovalFieldRefundAssetType: material.AssetType,
|
||
constants.ApprovalFieldRefundDeviceType: material.DeviceType,
|
||
constants.ApprovalFieldRefundDeviceModel: material.DeviceModel,
|
||
constants.ApprovalFieldRefundPackageUsedMB: material.PackageUsedMB,
|
||
constants.ApprovalFieldRefundPackageTotalMB: material.PackageTotalMB,
|
||
constants.ApprovalFieldRefundChannelTradeNo: material.OriginalChannelTradeNo,
|
||
constants.ApprovalFieldRefundApplicantRemark: strings.TrimSpace(applicantRemark),
|
||
constants.ApprovalFieldSubmitterID: account.ID,
|
||
constants.ApprovalFieldSubmitterName: account.Username,
|
||
})
|
||
if err != nil {
|
||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款审批业务快照失败")
|
||
}
|
||
return submitterSnapshot, requestSnapshot, nil
|
||
}
|
||
|
||
func formatCentAmount(amount int64) string {
|
||
return fmt.Sprintf("%d.%02d", amount/100, amount%100)
|
||
}
|