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:
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
|
||||
refundapproval "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
@@ -30,28 +31,76 @@ func (s *Service) Handle(ctx context.Context, event approvalapp.TerminalDecision
|
||||
case constants.ApprovalDecisionRejected, constants.ApprovalDecisionCancelled, constants.ApprovalDecisionDeleted:
|
||||
return s.applyClosedDecision(ctx, event)
|
||||
case constants.ApprovalDecisionRevokedAfterApproved:
|
||||
return nil
|
||||
return s.applyRevokedAfterApproved(ctx, event)
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "不支持的退款审批终态")
|
||||
}
|
||||
}
|
||||
|
||||
// applyRevokedAfterApproved 处理企业微信通过后撤销。
|
||||
//
|
||||
// 不回滚已失效的套餐权益、不取消已提交的渠道退款、也不恢复订单退款状态:这些事实在通过时
|
||||
// 已经成立。系统只标记审批异常并禁止后续自动重提,交由超级管理员线下处理。
|
||||
func (s *Service) applyRevokedAfterApproved(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
|
||||
var refund model.RefundRequest
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
target, _, err := refundapproval.ResolveRefundInTx(ctx, tx, event.BusinessID, event.InstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&refund, target.ID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
|
||||
}
|
||||
if refund.AnomalyFlag == 1 {
|
||||
return nil
|
||||
}
|
||||
beforeRefund := refundAuditState(&refund)
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND anomaly_flag = 0", refund.ID).
|
||||
Updates(map[string]any{
|
||||
"anomaly_flag": 1,
|
||||
"anomaly_reason": "企业微信通过后撤销,需超级管理员线下处理",
|
||||
"failure_reason": constants.RefundFailureRevokedAfterApproved,
|
||||
"updated_at": event.OccurredAt,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记退款审批异常失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return nil
|
||||
}
|
||||
refund.AnomalyFlag = 1
|
||||
refund.AnomalyReason = "企业微信通过后撤销,需超级管理员线下处理"
|
||||
refund.FailureReason = constants.RefundFailureRevokedAfterApproved
|
||||
return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundAnomalyFlagged, "标记退款审批异常",
|
||||
"refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":revoked", beforeRefund, nil, "退款审批通过后撤销")
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
|
||||
var refund model.RefundRequest
|
||||
var order model.Order
|
||||
changed := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.BusinessID).First(&refund).Error; err != nil {
|
||||
// 业务标识解析:审批尝试记录优先,退款申请兜底(兼容尚未接入尝试模式的存量申请)。
|
||||
target, attempt, err := refundapproval.ResolveRefundInTx(ctx, tx, event.BusinessID, event.InstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&refund, target.ID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
|
||||
}
|
||||
if refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID != event.InstanceID {
|
||||
return errors.New(errors.CodeConflict, "退款申请关联的审批实例不一致")
|
||||
}
|
||||
if refund.Status != model.RefundStatusPending && refund.Status != model.RefundStatusApproved {
|
||||
if refund.Status != model.RefundStatusPending && refund.Status != model.RefundStatusApproved &&
|
||||
refund.Status != model.RefundStatusChannelProcessing {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款申请状态不允许审批通过")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
|
||||
@@ -66,13 +115,21 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
|
||||
if err := s.preparePaymentRefundCredentials(ctx, tx, order.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
// 原路退款只登记待执行事实:退款单转入原路处理中,订单在渠道明确成功前保持已支付。
|
||||
// 客户收款信息与退回原钱包在审批通过时即完成,订单同时置为已退款。
|
||||
originalRoute := refund.Method == constants.RefundMethodOriginalRoute
|
||||
if refund.Status == model.RefundStatusPending {
|
||||
changed = true
|
||||
targetStatus := model.RefundStatusApproved
|
||||
if originalRoute {
|
||||
targetStatus = model.RefundStatusChannelProcessing
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": model.RefundStatusApproved, "processed_at": event.OccurredAt,
|
||||
"status": targetStatus, "processed_at": event.OccurredAt,
|
||||
"approved_refund_amount": approvedAmount, "remark": "企业微信审批通过",
|
||||
"failure_reason": "", "failure_message": "",
|
||||
"updated_at": event.OccurredAt,
|
||||
})
|
||||
if result.Error != nil {
|
||||
@@ -81,23 +138,33 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款申请状态已变化")
|
||||
}
|
||||
refund.Status = targetStatus
|
||||
}
|
||||
switch order.PaymentStatus {
|
||||
case model.PaymentStatusPaid:
|
||||
changed = true
|
||||
result := tx.WithContext(ctx).Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPaid).
|
||||
Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": event.OccurredAt})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单退款状态失败")
|
||||
if !originalRoute {
|
||||
switch order.PaymentStatus {
|
||||
case model.PaymentStatusPaid:
|
||||
changed = true
|
||||
result := tx.WithContext(ctx).Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPaid).
|
||||
Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": event.OccurredAt})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单退款状态失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "订单退款状态已变化")
|
||||
}
|
||||
order.PaymentStatus = model.PaymentStatusRefunded
|
||||
case model.PaymentStatusRefunded:
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidStatus, "订单状态不允许完成退款")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "订单退款状态已变化")
|
||||
} else {
|
||||
if order.PaymentStatus != model.PaymentStatusPaid && order.PaymentStatus != model.PaymentStatusRefunded {
|
||||
return errors.New(errors.CodeInvalidStatus, "订单状态不允许原路退款")
|
||||
}
|
||||
if err := s.prepareChannelRefundInTx(ctx, tx, &refund, attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
order.PaymentStatus = model.PaymentStatusRefunded
|
||||
case model.PaymentStatusRefunded:
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidStatus, "订单状态不允许完成退款")
|
||||
}
|
||||
if err := s.refundWalletPayment(ctx, tx, &refund, &order, approvedAmount, event.SubmitterAccountID); err != nil {
|
||||
return err
|
||||
@@ -112,8 +179,10 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.appendCompletedNotification(ctx, tx, &refund); err != nil {
|
||||
return err
|
||||
if !originalRoute {
|
||||
if err := s.appendCompletedNotification(ctx, tx, &refund); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := commissiondelivery.AppendRefundCommissionDeduct(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil {
|
||||
return err
|
||||
@@ -134,6 +203,17 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareChannelRefundInTx 在企微通过事务内登记原路退款的待执行事实。
|
||||
//
|
||||
// 只写本地事实与可靠事件:请求号在提交时已冻结到审批尝试记录,这里把它落到退款单并转入
|
||||
// 原路处理中;真正的渠道调用由可靠事件驱动的事务外消费者执行(ENG-TX-001)。
|
||||
func (s *Service) prepareChannelRefundInTx(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt) error {
|
||||
if s.channelRefund == nil {
|
||||
return errors.New(errors.CodeInternalError, "渠道原路退款能力未配置")
|
||||
}
|
||||
return s.channelRefund.PrepareInTx(ctx, tx, refund, attempt)
|
||||
}
|
||||
|
||||
func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
|
||||
reason := map[string]string{
|
||||
|
||||
87
internal/service/refund/attempt_query.go
Normal file
87
internal/service/refund/attempt_query.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package refund
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// loadAttemptResponses 批量读取退款申请的审批尝试历史,并补齐每次尝试的审批实例状态。
|
||||
//
|
||||
// 尝试记录按提交次序升序返回,历史材料与审批结果不被覆盖;未接入尝试模式的存量申请返回空切片。
|
||||
func (s *Service) loadAttemptResponses(ctx context.Context, refunds []*model.RefundRequest) (map[uint][]dto.RefundAttemptResponse, error) {
|
||||
result := make(map[uint][]dto.RefundAttemptResponse, len(refunds))
|
||||
refundIDs := make([]uint, 0, len(refunds))
|
||||
for _, refund := range refunds {
|
||||
if refund == nil || refund.ID == 0 {
|
||||
continue
|
||||
}
|
||||
refundIDs = append(refundIDs, refund.ID)
|
||||
result[refund.ID] = []dto.RefundAttemptResponse{}
|
||||
}
|
||||
if len(refundIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var attempts []model.RefundRequestAttempt
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("refund_id IN ?", refundIDs).
|
||||
Order("refund_id ASC, attempt_no ASC, id ASC").
|
||||
Find(&attempts).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试记录失败")
|
||||
}
|
||||
if len(attempts) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
instanceIDs := make([]uint, 0, len(attempts))
|
||||
for index := range attempts {
|
||||
if attempts[index].ApprovalInstanceID != nil && *attempts[index].ApprovalInstanceID > 0 {
|
||||
instanceIDs = append(instanceIDs, *attempts[index].ApprovalInstanceID)
|
||||
}
|
||||
}
|
||||
statuses := map[uint]int{}
|
||||
if len(instanceIDs) > 0 {
|
||||
var instances []model.ApprovalInstance
|
||||
if err := s.db.WithContext(ctx).Select("id", "status").Where("id IN ?", instanceIDs).Find(&instances).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款审批尝试实例状态失败")
|
||||
}
|
||||
for _, instance := range instances {
|
||||
statuses[instance.ID] = instance.Status
|
||||
}
|
||||
}
|
||||
|
||||
for index := range attempts {
|
||||
attempt := &attempts[index]
|
||||
item := dto.RefundAttemptResponse{
|
||||
ID: attempt.ID,
|
||||
AttemptNo: attempt.AttemptNo,
|
||||
Method: attempt.Method,
|
||||
MethodName: constants.RefundMethodName(attempt.Method),
|
||||
RefundAmount: attempt.RefundAmount,
|
||||
FrozenActualReceivedAmount: attempt.FrozenActualReceivedAmount,
|
||||
RefundReason: attempt.RefundReason,
|
||||
CustomerAccountInfo: attempt.CustomerAccountInfo,
|
||||
CustomerVoucherKey: []string(attempt.CustomerVoucherKeys),
|
||||
ChannelRefundRequestNo: attempt.ChannelRefundRequestNo,
|
||||
SubmittedByAccountID: attempt.SubmittedByAccountID,
|
||||
CreatedAt: attempt.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if len(item.CustomerVoucherKey) == 0 {
|
||||
item.CustomerVoucherKey = []string{}
|
||||
}
|
||||
if attempt.ApprovalInstanceID != nil && *attempt.ApprovalInstanceID > 0 {
|
||||
item.ApprovalInstanceID = *attempt.ApprovalInstanceID
|
||||
if status, exists := statuses[*attempt.ApprovalInstanceID]; exists {
|
||||
value := status
|
||||
item.ApprovalStatus = &value
|
||||
item.ApprovalStatusName = constants.GetApprovalStatusName(status)
|
||||
}
|
||||
}
|
||||
result[attempt.RefundID] = append(result[attempt.RefundID], item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
273
internal/service/refund/method.go
Normal file
273
internal/service/refund/method.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package refund
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
refundchannel "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// refundMethodOption 描述一种可选退款方式及其不可用的原因。
|
||||
type refundMethodOption struct {
|
||||
Method string
|
||||
Name string
|
||||
Available bool
|
||||
// Reason 在方式不可用时说明具体原因,供前端禁用并展示。
|
||||
Reason string
|
||||
}
|
||||
|
||||
// refundMethodDecision 是一次退款方式判定的完整结果。
|
||||
type refundMethodDecision struct {
|
||||
Options []refundMethodOption
|
||||
// FrozenActualReceivedAmount 是系统派生的权威实收金额(分),提交人不可填写或修改。
|
||||
FrozenActualReceivedAmount int64
|
||||
// Payment 是派生实收金额时使用的原成功支付记录,线上订单才有值。
|
||||
Payment *model.Payment
|
||||
// ChannelConfig 是原路退款实际使用的冻结商户当前凭证,非线上订单或原路不可用时为 nil。
|
||||
ChannelConfig *model.WechatConfig
|
||||
}
|
||||
|
||||
// decideRefundMethods 派生权威实收金额并按来源实际支付方式生成可选退款方式。
|
||||
//
|
||||
// 派生来源:线上支付取该订单原成功支付记录金额;资产钱包、代理主钱包与后台线下套餐订单
|
||||
// 取订单实际收款或实际扣款金额。无法确定或金额非正时拒绝,绝不允许提交人以自填金额替代。
|
||||
func (s *Service) decideRefundMethods(ctx context.Context, order *model.Order) (*refundMethodDecision, error) {
|
||||
if order == nil || order.ID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "退款来源订单无效")
|
||||
}
|
||||
|
||||
decision := &refundMethodDecision{}
|
||||
switch order.PaymentMethod {
|
||||
case model.PaymentMethodWechat, model.PaymentMethodAlipay:
|
||||
payment, err := s.loadPaidPackagePayment(ctx, order.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payment.Amount <= 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "原成功支付记录金额非正,无法确定权威实收金额")
|
||||
}
|
||||
decision.Payment = payment
|
||||
decision.FrozenActualReceivedAmount = payment.Amount
|
||||
originalRoute, reason, config := s.originalRouteAvailability(ctx, order, payment)
|
||||
if originalRoute {
|
||||
decision.ChannelConfig = config
|
||||
}
|
||||
decision.Options = []refundMethodOption{
|
||||
{Method: constants.RefundMethodOriginalRoute, Name: constants.RefundMethodName(constants.RefundMethodOriginalRoute), Available: originalRoute, Reason: reason},
|
||||
{Method: constants.RefundMethodCustomerAccount, Name: constants.RefundMethodName(constants.RefundMethodCustomerAccount), Available: true},
|
||||
}
|
||||
case model.PaymentMethodWallet:
|
||||
amount, err := orderActualPaidAmount(order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decision.FrozenActualReceivedAmount = amount
|
||||
switch order.BuyerType {
|
||||
case model.BuyerTypeAgent:
|
||||
decision.Options = []refundMethodOption{
|
||||
{Method: constants.RefundMethodAgentWallet, Name: constants.RefundMethodName(constants.RefundMethodAgentWallet), Available: true},
|
||||
}
|
||||
case model.BuyerTypePersonal:
|
||||
decision.Options = []refundMethodOption{
|
||||
{Method: constants.RefundMethodAssetWallet, Name: constants.RefundMethodName(constants.RefundMethodAssetWallet), Available: true},
|
||||
}
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "钱包支付订单的买家类型不支持退款")
|
||||
}
|
||||
case model.PaymentMethodOffline:
|
||||
amount, err := orderActualPaidAmount(order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decision.FrozenActualReceivedAmount = amount
|
||||
decision.Options = []refundMethodOption{
|
||||
{Method: constants.RefundMethodCustomerAccount, Name: constants.RefundMethodName(constants.RefundMethodCustomerAccount), Available: true},
|
||||
}
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "该订单的支付方式不支持退款")
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// orderActualPaidAmount 取订单实际收款或实际扣款金额作为权威实收金额。
|
||||
func orderActualPaidAmount(order *model.Order) (int64, error) {
|
||||
if order.ActualPaidAmount == nil || *order.ActualPaidAmount <= 0 {
|
||||
return 0, errors.New(errors.CodeInvalidParam, "订单实际收款金额缺失或非正,无法确定权威实收金额")
|
||||
}
|
||||
return *order.ActualPaidAmount, nil
|
||||
}
|
||||
|
||||
// loadPaidPackagePayment 读取该订单原成功的线上支付记录。
|
||||
func (s *Service) loadPaidPackagePayment(ctx context.Context, orderID uint) (*model.Payment, error) {
|
||||
var payment model.Payment
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
|
||||
Order("id DESC").
|
||||
First(&payment).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "未找到该订单的原成功支付记录,无法确定权威实收金额")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联支付记录失败")
|
||||
}
|
||||
return &payment, nil
|
||||
}
|
||||
|
||||
// originalRouteAvailability 预检线上订单的原路退款可退性,并返回实际使用的商户凭证。
|
||||
//
|
||||
// 条件全部本地可判定:存在成功支付记录、可定位实际收款商户、该商户具备其服务商类型的
|
||||
// 退款能力与必需凭证、且原交易未超出渠道可退时限。实际调用渠道后的结果才是退款资格的
|
||||
// 最终判断;预检只用于在申请阶段禁用原路并说明原因。
|
||||
func (s *Service) originalRouteAvailability(ctx context.Context, order *model.Order, payment *model.Payment) (bool, string, *model.WechatConfig) {
|
||||
if payment == nil {
|
||||
return false, "缺少原成功支付记录", nil
|
||||
}
|
||||
if strings.TrimSpace(payment.ThirdPartyTradeNo) == "" {
|
||||
return false, "原支付记录缺少渠道交易流水号", nil
|
||||
}
|
||||
if s.paymentMerchantRuntime == nil {
|
||||
return false, "商户凭证加载能力未配置", nil
|
||||
}
|
||||
|
||||
var config *model.WechatConfig
|
||||
if payment.MerchantID != nil {
|
||||
merchant, err := s.paymentMerchantRuntime.LoadMerchant(ctx, *payment.MerchantID)
|
||||
if err != nil {
|
||||
return false, "实际收款商户不可用", nil
|
||||
}
|
||||
// 商户停用不阻断历史单的原路退款,因此只校验凭证完整性,不校验商户状态。
|
||||
config, err = merchantRefundConfig(merchant)
|
||||
if err != nil {
|
||||
return false, err.Error(), nil
|
||||
}
|
||||
} else {
|
||||
if payment.PaymentConfigID == nil {
|
||||
return false, "历史支付单缺少支付配置", nil
|
||||
}
|
||||
legacy, err := loadLegacyPaymentConfig(ctx, s.db, *payment.PaymentConfigID)
|
||||
if err != nil {
|
||||
return false, err.Error(), nil
|
||||
}
|
||||
config = legacy
|
||||
}
|
||||
|
||||
if reason := channelRefundCredentialIssue(config); reason != "" {
|
||||
return false, reason, nil
|
||||
}
|
||||
if reason := channelRefundWindowIssue(config.ProviderType, payment.PaidAt); reason != "" {
|
||||
return false, reason, nil
|
||||
}
|
||||
return true, "", config
|
||||
}
|
||||
|
||||
// channelRefundWindowIssue 判定原交易是否超出渠道可退时限。
|
||||
// 富友按是否回传原交易日期区分:不回传仅支持 30 天内,回传可退 360 天内。
|
||||
// 系统始终回传原交易日期,因此按 360 天判定;微信与支付宝不设本地时限。
|
||||
func channelRefundWindowIssue(providerType string, paidAt *time.Time) string {
|
||||
if providerType != model.ProviderTypeFuiou {
|
||||
return ""
|
||||
}
|
||||
if paidAt == nil {
|
||||
return "富友原交易缺少支付时间,无法回传原交易日期"
|
||||
}
|
||||
if time.Since(*paidAt) > 360*24*time.Hour {
|
||||
return "富友原交易已超出渠道可退时限(360 天)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// requestChannelRefundNo 按冻结商户生成渠道退款请求号,并在提交时冻结到审批尝试记录。
|
||||
// 重提会生成新值;同一尝试的渠道重试复用该值,因此渠道侧以它作为幂等标识。
|
||||
func requestChannelRefundNo(config *model.WechatConfig, now time.Time) string {
|
||||
return refundchannel.BuildChannelRefundRequestNo(merchantChannelPrefix(config), now)
|
||||
}
|
||||
|
||||
// merchantChannelPrefix 取商户标识中的数字段作为请求号前缀。
|
||||
// 富友要求前缀为其机构码;其余渠道只要求请求号在其长度与字符集约束内唯一。
|
||||
func merchantChannelPrefix(config *model.WechatConfig) string {
|
||||
if config == nil {
|
||||
return "0000"
|
||||
}
|
||||
if config.ProviderType == model.ProviderTypeFuiou && strings.TrimSpace(config.FyInsCd) != "" {
|
||||
return config.FyInsCd
|
||||
}
|
||||
return config.WxMchID + config.AliAppID
|
||||
}
|
||||
|
||||
// validateRefundMethod 校验提交的退款方式属于该订单的可选方式集合。
|
||||
func validateRefundMethod(decision *refundMethodDecision, method string) error {
|
||||
method = strings.TrimSpace(method)
|
||||
if method == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "退款方式不能为空")
|
||||
}
|
||||
for _, option := range decision.Options {
|
||||
if option.Method != method {
|
||||
continue
|
||||
}
|
||||
if !option.Available {
|
||||
return errors.New(errors.CodeInvalidParam, "该订单当前不支持此退款方式:"+option.Reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New(errors.CodeInvalidParam, "该订单不支持此退款方式")
|
||||
}
|
||||
|
||||
// validateCustomerAccountMaterial 校验客户收款信息方式的材料完整性。
|
||||
// 客户收款信息与至少一个客户凭证附件必须同时存在;不得复用公司员工收款方式字典。
|
||||
func validateCustomerAccountMaterial(method, customerAccountInfo string, voucherKeys []string) error {
|
||||
if method != constants.RefundMethodCustomerAccount {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(customerAccountInfo) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "客户收款信息退款必须填写客户收款信息")
|
||||
}
|
||||
hasVoucher := false
|
||||
for _, key := range voucherKeys {
|
||||
if strings.TrimSpace(key) != "" {
|
||||
hasVoucher = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasVoucher {
|
||||
return errors.New(errors.CodeInvalidParam, "客户收款信息退款必须至少上传一个客户收款凭证")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFrozenRefundAmount 校验退款金额为正分且不超过冻结实收金额。
|
||||
func validateFrozenRefundAmount(amount, frozenActualReceivedAmount int64) error {
|
||||
if amount <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "退款金额必须为正分")
|
||||
}
|
||||
if frozenActualReceivedAmount <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "冻结实收金额缺失或非正,无法受理退款申请")
|
||||
}
|
||||
if amount > frozenActualReceivedAmount {
|
||||
return errors.New(errors.CodeInvalidParam, "退款金额不能超过冻结实收金额")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAttemptFromDecision 依据方式判定结果构造一条不可变审批尝试记录。
|
||||
func buildAttemptFromDecision(decision *refundMethodDecision, refund *model.RefundRequest, config *model.WechatConfig, now time.Time) *model.RefundRequestAttempt {
|
||||
attempt := &model.RefundRequestAttempt{
|
||||
RefundID: refund.ID,
|
||||
Method: refund.Method,
|
||||
RefundAmount: refund.RequestedRefundAmount,
|
||||
FrozenActualReceivedAmount: decision.FrozenActualReceivedAmount,
|
||||
RefundReason: refund.RefundReason,
|
||||
CustomerAccountInfo: refund.CustomerAccountInfo,
|
||||
CustomerVoucherKeys: refund.RefundVoucherKey,
|
||||
SubmittedByAccountID: refund.Creator,
|
||||
}
|
||||
if refund.Method == constants.RefundMethodOriginalRoute {
|
||||
attempt.ChannelRefundRequestNo = requestChannelRefundNo(config, now)
|
||||
}
|
||||
return attempt
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
refundchannelapp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
@@ -16,10 +17,10 @@ func (s *Service) SetPaymentMerchantRuntime(runtime *merchantpayment.RuntimeLoad
|
||||
s.paymentMerchantRuntime = runtime
|
||||
}
|
||||
|
||||
// preparePaymentRefundCredentials 只为既有套餐订单退款流程装载和校验支付凭证;本 Change
|
||||
// 不发起任何渠道退款请求。钱包支付和线下支付没有收款商户凭证,直接放行。
|
||||
// merchant_id 为空仅是留存期内的历史线上支付,独立 Change 清理后才可删除按
|
||||
// payment_config_id 读取的兼容路径,绝不能按当前商户池推断商户。
|
||||
// preparePaymentRefundCredentials 在企微通过事务内装载并校验原路退款所需凭证。
|
||||
//
|
||||
// 钱包支付和线下支付没有收款商户凭证,直接放行。merchant_id 为空仅表示留存期内的历史
|
||||
// 线上支付,仍按 payment_config_id 读取兼容配置;绝不能按当前商户池推断历史商户。
|
||||
func (s *Service) preparePaymentRefundCredentials(ctx context.Context, tx *gorm.DB, orderID uint) error {
|
||||
var payment model.Payment
|
||||
err := tx.WithContext(ctx).
|
||||
@@ -38,7 +39,7 @@ func (s *Service) preparePaymentRefundCredentials(ctx context.Context, tx *gorm.
|
||||
}
|
||||
if payment.MerchantID != nil {
|
||||
if s.paymentMerchantRuntime == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "支付商户加载能力未配置")
|
||||
return errors.New(errors.CodeServiceUnavailable, "商户凭证加载能力未配置")
|
||||
}
|
||||
merchant, loadErr := s.paymentMerchantRuntime.LoadMerchant(ctx, *payment.MerchantID)
|
||||
if loadErr != nil {
|
||||
@@ -59,36 +60,58 @@ func (s *Service) preparePaymentRefundCredentials(ctx context.Context, tx *gorm.
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFrozenMerchantRefundCredentials 只判断既有渠道流程所需凭证是否完整。
|
||||
// 当前系统没有任何渠道原路退款 Adapter,因此返回前不调用微信、支付宝或富友。
|
||||
// validateFrozenMerchantRefundCredentials 判断冻结商户是否具备原路退款能力。
|
||||
//
|
||||
// 能力只由服务商类型与该服务商类型退款所需凭证的完整性决定,不提供人工开关:
|
||||
// - 微信直连 v3:具备 v3 密钥、证书、私钥与证书序列号时可调用 v3 退款接口;
|
||||
// - 微信 v2:其退款接口需要 API 客户端证书,而商户凭证键集合不含该证书,故判定不可用;
|
||||
// - 富友:凭证键集合已含退款与退款查询所需参数,能力可用;
|
||||
// - 支付宝:具备 AppID、应用私钥与支付宝公钥时可签名退款请求。
|
||||
func validateFrozenMerchantRefundCredentials(merchant *model.PaymentMerchant) error {
|
||||
config, err := merchantpayment.MerchantConfig(merchant, nil)
|
||||
config, err := merchantRefundConfig(merchant)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch config.ProviderType {
|
||||
case model.ProviderTypeWechat:
|
||||
if blank(config.WxMchID, config.WxAPIV3Key, config.WxCertContent, config.WxKeyContent, config.WxSerialNo, config.WxNotifyURL) {
|
||||
return errors.New(errors.CodeNoPaymentConfig, "冻结微信商户退款凭证不完整")
|
||||
}
|
||||
case model.ProviderTypeWechatV2:
|
||||
if blank(config.WxMchID, config.WxAPIV2Key, config.WxNotifyURL) {
|
||||
return errors.New(errors.CodeNoPaymentConfig, "冻结微信商户退款凭证不完整")
|
||||
}
|
||||
case model.ProviderTypeFuiou:
|
||||
if blank(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyPrivateKey, config.FyPublicKey, config.FyAPIURL, config.FyNotifyURL) {
|
||||
return errors.New(errors.CodeNoPaymentConfig, "冻结富友商户退款凭证不完整")
|
||||
}
|
||||
case "alipay":
|
||||
if blank(config.AliAppID, config.AliPrivateKey, config.AliPublicKey, config.AliNotifyURL, config.AliReturnURL) {
|
||||
return errors.New(errors.CodeNoPaymentConfig, "冻结支付宝商户退款凭证不完整")
|
||||
}
|
||||
default:
|
||||
return errors.New(errors.CodeNoPaymentConfig, "冻结商户不支持现有退款流程")
|
||||
if reason := channelRefundCredentialIssue(config); reason != "" {
|
||||
return errors.New(errors.CodeNoPaymentConfig, reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// merchantRefundConfig 把冻结商户凭证适配为渠道配置,供退款能力判定与渠道调用使用。
|
||||
func merchantRefundConfig(merchant *model.PaymentMerchant) (*model.WechatConfig, error) {
|
||||
if merchant == nil {
|
||||
return nil, errors.New(errors.CodeNoPaymentConfig, "支付商户不存在")
|
||||
}
|
||||
return merchantpayment.MerchantConfig(merchant, nil)
|
||||
}
|
||||
|
||||
// loadLegacyPaymentConfig 按历史支付配置标识读取兼容配置。
|
||||
func loadLegacyPaymentConfig(ctx context.Context, db *gorm.DB, configID uint) (*model.WechatConfig, error) {
|
||||
if db == nil || configID == 0 {
|
||||
return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用")
|
||||
}
|
||||
var legacy model.WechatConfig
|
||||
if err := db.WithContext(ctx).Unscoped().First(&legacy, configID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取历史支付配置失败")
|
||||
}
|
||||
return &legacy, nil
|
||||
}
|
||||
|
||||
// channelRefundCredentialIssue 返回该商户凭证不满足原路退款要求的原因;凭证完整时返回空串。
|
||||
//
|
||||
// 判定规则只有一处来源:refundchannel.RefundCredentialIssue。申请阶段的方式预检与执行阶段的
|
||||
// 凭证校验必须完全一致,否则会出现「申请时可选原路、执行时凭证不完整」的不一致。
|
||||
func channelRefundCredentialIssue(config *model.WechatConfig) string {
|
||||
if config == nil {
|
||||
return "商户支付凭证不可用"
|
||||
}
|
||||
return refundchannelapp.RefundCredentialIssue(config.ProviderType, config)
|
||||
}
|
||||
|
||||
func blank(values ...string) bool {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
|
||||
refundchannelapp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
|
||||
@@ -60,6 +61,12 @@ type Service struct {
|
||||
logger *zap.Logger
|
||||
paymentMerchantRuntime *merchantpayment.RuntimeLoader
|
||||
refundOffset *employeecollectionapp.RefundOffsetService
|
||||
channelRefund *refundchannelapp.Service
|
||||
}
|
||||
|
||||
// SetChannelRefundService 注入渠道原路退款用例。
|
||||
func (s *Service) SetChannelRefundService(service *refundchannelapp.Service) {
|
||||
s.channelRefund = service
|
||||
}
|
||||
|
||||
// SetEmployeeCollectionRefundOffset 注入员工代收款账单退款冲销用例。
|
||||
@@ -121,7 +128,8 @@ func (s *Service) SetLifecycleAudit(writer *audit.Writer) {
|
||||
}
|
||||
|
||||
// Create 创建退款申请
|
||||
// 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录
|
||||
// 校验订单存在且已支付、派生并冻结权威实收金额、判定可选退款方式,随后原子创建退款申请、
|
||||
// 审批尝试记录与企业微信审批实例。实收金额由系统派生,提交人填写无效。
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) {
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
@@ -139,13 +147,24 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
|
||||
}
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "仅已支付订单可申请退款")
|
||||
}
|
||||
if err := validateRequestedRefundAmountByOrder(req.RequestedRefundAmount, order); err != nil {
|
||||
|
||||
decision, err := s.decideRefundMethods(ctx, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRefundMethod(decision, req.Method); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateFrozenRefundAmount(req.RequestedRefundAmount, decision.FrozenActualReceivedAmount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refundVoucherKey, err := normalizeRefundVoucherKey(req.RefundVoucherKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateCustomerAccountMaterial(req.Method, req.CustomerAccountInfo, refundVoucherKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 从订单获取 shop_id:优先使用 SellerShopID,代理商买家使用 BuyerID
|
||||
var shopID *uint
|
||||
@@ -156,20 +175,24 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
|
||||
}
|
||||
|
||||
refund := &model.RefundRequest{
|
||||
RefundNo: generateRefundNo(),
|
||||
OrderID: req.OrderID,
|
||||
OrderNo: order.OrderNo,
|
||||
OrderType: order.OrderType,
|
||||
AssetIdentifier: order.AssetIdentifier,
|
||||
IotCardID: order.IotCardID,
|
||||
DeviceID: order.DeviceID,
|
||||
PackageUsageID: req.PackageUsageID,
|
||||
ShopID: shopID,
|
||||
ActualReceivedAmount: req.ActualReceivedAmount,
|
||||
RequestedRefundAmount: req.RequestedRefundAmount,
|
||||
RefundVoucherKey: refundVoucherKey,
|
||||
RefundReason: req.RefundReason,
|
||||
Status: model.RefundStatusPending,
|
||||
RefundNo: generateRefundNo(),
|
||||
OrderID: req.OrderID,
|
||||
OrderNo: order.OrderNo,
|
||||
OrderType: order.OrderType,
|
||||
AssetIdentifier: order.AssetIdentifier,
|
||||
IotCardID: order.IotCardID,
|
||||
DeviceID: order.DeviceID,
|
||||
PackageUsageID: req.PackageUsageID,
|
||||
ShopID: shopID,
|
||||
// 实收金额与冻结额一律取系统派生值,忽略提交人传入的金额。
|
||||
ActualReceivedAmount: decision.FrozenActualReceivedAmount,
|
||||
FrozenActualReceivedAmount: decision.FrozenActualReceivedAmount,
|
||||
RequestedRefundAmount: req.RequestedRefundAmount,
|
||||
Method: req.Method,
|
||||
CustomerAccountInfo: req.CustomerAccountInfo,
|
||||
RefundVoucherKey: refundVoucherKey,
|
||||
RefundReason: req.RefundReason,
|
||||
Status: model.RefundStatusPending,
|
||||
}
|
||||
refund.Creator = userID
|
||||
refund.Updater = userID
|
||||
@@ -177,8 +200,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
|
||||
if s.refundApprovalCreation == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||||
}
|
||||
attempt := buildAttemptFromDecision(decision, refund, decision.ChannelConfig, time.Now())
|
||||
result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{
|
||||
Refund: refund, Order: order, SubmitterAccountID: userID,
|
||||
Refund: refund, Order: order, SubmitterAccountID: userID, Attempt: attempt,
|
||||
})
|
||||
if err != nil {
|
||||
failedRefund := *refund
|
||||
@@ -229,12 +253,17 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attemptResponses, err := s.loadAttemptResponses(ctx, requests)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]dto.RefundResponse, 0, len(requests))
|
||||
for _, r := range requests {
|
||||
item := buildRefundResponse(r)
|
||||
item.SubmitterName = submitterNames[r.Creator]
|
||||
applyApprovalSummary(item, approvalSummaries, r.ApprovalInstanceID)
|
||||
applyApprovalSummary(item, approvalSummaries, r)
|
||||
item.Attempts = attemptResponses[r.ID]
|
||||
items = append(items, *item)
|
||||
}
|
||||
|
||||
@@ -279,7 +308,12 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, er
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyApprovalSummary(resp, approvalSummaries, refund.ApprovalInstanceID)
|
||||
applyApprovalSummary(resp, approvalSummaries, refund)
|
||||
attemptResponses, err := s.loadAttemptResponses(ctx, []*model.RefundRequest{refund})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.Attempts = attemptResponses[refund.ID]
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -334,8 +368,10 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
|
||||
beforeRefund := refundAuditState(refund)
|
||||
beforeOrder := map[string]any{"payment_status": order.PaymentStatus}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 条件更新同时约束 approval_instance_id IS NULL:已关联审批实例的申请由企业微信决定,
|
||||
// 本地人工通过一律拒绝,并与 Reject/Return 的守卫保持一致(ENG-CONC-001)。
|
||||
result := tx.Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", id, model.RefundStatusPending).
|
||||
Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": model.RefundStatusApproved,
|
||||
"processor_id": userID,
|
||||
@@ -349,7 +385,7 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
|
||||
return errors.Wrap(errors.CodeInternalError, result.Error, "审批退款申请失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
|
||||
return errors.New(errors.CodeInvalidStatus, "该退款申请由企业微信审批决定,或状态已变更,不能人工审批")
|
||||
}
|
||||
|
||||
orderResult := tx.Model(&model.Order{}).
|
||||
@@ -391,6 +427,12 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppendCompletedNotification 在同一事务内补写退款完成通知事实。
|
||||
// 供渠道原路退款在渠道明确成功时调用:该方式的退款完成时点晚于企微通过。
|
||||
func (s *Service) AppendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error {
|
||||
return s.appendCompletedNotification(ctx, tx, refund)
|
||||
}
|
||||
|
||||
// appendCompletedNotification 在退款业务事务内幂等写入目标店铺通知。
|
||||
func (s *Service) appendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error {
|
||||
if refund == nil || refund.ShopID == nil {
|
||||
@@ -717,6 +759,11 @@ func ensureRefundProcessor(ctx context.Context) error {
|
||||
|
||||
// Resubmit 重新提交退款申请
|
||||
// 条件更新 WHERE status=4(已退回),修改部分字段后重新进入待审批状态
|
||||
// Resubmit 修改并重提未成功退款申请
|
||||
// POST /api/admin/refunds/:id/resubmit
|
||||
// 仅已拒绝、已退回或原路退款失败且不存在审批异常的申请可修改重提;每次重提新增一条不可变
|
||||
// 审批尝试记录与一个新的企业微信审批实例,历史材料与审批结果不被覆盖。金额与方式的变更
|
||||
// 必须重新审批,不得沿用旧实例。
|
||||
func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefundRequest) error {
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
@@ -725,19 +772,43 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
|
||||
refund, err := s.refundStore.GetByIDForOperation(ctx, id)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
|
||||
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo})
|
||||
if refund.Status != model.RefundStatusReturned {
|
||||
businessErr := errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
|
||||
if err := s.resubmitPrecheck(refund); err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err)
|
||||
return err
|
||||
}
|
||||
|
||||
order, err := s.orderStore.GetByID(ctx, refund.OrderID)
|
||||
if err != nil {
|
||||
businessErr := errors.New(errors.CodeNotFound, "订单不存在")
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, businessErr)
|
||||
return businessErr
|
||||
}
|
||||
decision, err := s.decideRefundMethods(ctx, order)
|
||||
if err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 方式缺省沿用原方式,金额缺省沿用原金额。
|
||||
method := refund.Method
|
||||
if req.Method != nil && strings.TrimSpace(*req.Method) != "" {
|
||||
method = *req.Method
|
||||
}
|
||||
if err := validateRefundMethod(decision, method); err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
|
||||
return err
|
||||
}
|
||||
requestedRefundAmount := refund.RequestedRefundAmount
|
||||
if req.RequestedRefundAmount != nil {
|
||||
requestedRefundAmount = *req.RequestedRefundAmount
|
||||
}
|
||||
if err := validateFrozenRefundAmount(requestedRefundAmount, decision.FrozenActualReceivedAmount); err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
|
||||
return err
|
||||
}
|
||||
refundVoucherKey := refund.RefundVoucherKey
|
||||
if req.RefundVoucherKey != nil {
|
||||
normalized, normErr := normalizeRefundVoucherKey(*req.RefundVoucherKey)
|
||||
@@ -747,54 +818,58 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
}
|
||||
refundVoucherKey = normalized
|
||||
}
|
||||
|
||||
order, err := s.orderStore.GetByID(ctx, refund.OrderID)
|
||||
if err != nil {
|
||||
businessErr := errors.New(errors.CodeNotFound, "订单不存在")
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, businessErr)
|
||||
return businessErr
|
||||
refundReason := refund.RefundReason
|
||||
if req.RefundReason != nil {
|
||||
refundReason = *req.RefundReason
|
||||
}
|
||||
if err := validateRequestedRefundAmountByOrder(requestedRefundAmount, order); err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
|
||||
customerAccountInfo := refund.CustomerAccountInfo
|
||||
if req.CustomerAccountInfo != nil {
|
||||
customerAccountInfo = *req.CustomerAccountInfo
|
||||
}
|
||||
if err := validateCustomerAccountMaterial(method, customerAccountInfo, refundVoucherKey); err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err)
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
updates := map[string]any{
|
||||
"status": model.RefundStatusPending,
|
||||
"updater": userID,
|
||||
"updated_at": now,
|
||||
}
|
||||
if req.ActualReceivedAmount != nil {
|
||||
updates["actual_received_amount"] = *req.ActualReceivedAmount
|
||||
}
|
||||
if req.RequestedRefundAmount != nil {
|
||||
updates["requested_refund_amount"] = *req.RequestedRefundAmount
|
||||
}
|
||||
if req.RefundVoucherKey != nil {
|
||||
updates["refund_voucher_key"] = refundVoucherKey
|
||||
}
|
||||
if req.RefundReason != nil {
|
||||
updates["refund_reason"] = *req.RefundReason
|
||||
}
|
||||
// 重提的实收金额重新派生并冻结;提交人传入的实收金额一律忽略。
|
||||
updated := *refund
|
||||
updated.Method = method
|
||||
updated.RequestedRefundAmount = requestedRefundAmount
|
||||
updated.FrozenActualReceivedAmount = decision.FrozenActualReceivedAmount
|
||||
updated.ActualReceivedAmount = decision.FrozenActualReceivedAmount
|
||||
updated.RefundVoucherKey = refundVoucherKey
|
||||
updated.RefundReason = refundReason
|
||||
updated.CustomerAccountInfo = customerAccountInfo
|
||||
updated.Creator = userID
|
||||
|
||||
beforeRefund := refundAuditState(refund)
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", id, model.RefundStatusReturned).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, result.Error, "重新提交退款申请失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
|
||||
}
|
||||
return s.appendRefundAudit(ctx, tx, id, constants.AuditActionRefundResubmitted, "重新提交退款申请", "", beforeRefund, nil, "退款申请已重新提交")
|
||||
})
|
||||
if err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
|
||||
attempt := buildAttemptFromDecision(decision, &updated, decision.ChannelConfig, time.Now())
|
||||
if s.refundApprovalCreation == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||||
}
|
||||
return err
|
||||
if _, err := s.refundApprovalCreation.Resubmit(ctx, id, refundapprovalapp.ResubmitCommand{
|
||||
Refund: &updated, Attempt: attempt,
|
||||
}); err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resubmitPrecheck 校验退款申请是否处于可重提状态且不存在审批异常。
|
||||
// 企业微信通过后撤销的申请标记异常并禁止自动重提,只能由超级管理员线下处理。
|
||||
func (s *Service) resubmitPrecheck(refund *model.RefundRequest) error {
|
||||
if refund == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
|
||||
}
|
||||
if refund.AnomalyFlag != 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "该退款申请存在审批异常,需超级管理员线下处理,不支持重提")
|
||||
}
|
||||
for _, status := range model.RefundResubmittableStatuses() {
|
||||
if refund.Status == status {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
|
||||
}
|
||||
|
||||
// deductAllCommission 幂等回扣该订单所有已入账佣金。
|
||||
@@ -1340,40 +1415,60 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
|
||||
}
|
||||
|
||||
resp := &dto.RefundResponse{
|
||||
ID: r.ID,
|
||||
RefundNo: r.RefundNo,
|
||||
OrderID: r.OrderID,
|
||||
OrderNo: r.OrderNo,
|
||||
AssetIdentifier: r.AssetIdentifier,
|
||||
AssetType: assetType,
|
||||
IotCardID: r.IotCardID,
|
||||
DeviceID: r.DeviceID,
|
||||
PackageUsageID: r.PackageUsageID,
|
||||
ShopID: r.ShopID,
|
||||
ShopName: r.ShopName,
|
||||
ActualReceivedAmount: r.ActualReceivedAmount,
|
||||
RequestedRefundAmount: r.RequestedRefundAmount,
|
||||
ApprovedRefundAmount: r.ApprovedRefundAmount,
|
||||
RefundVoucherKey: []string(r.RefundVoucherKey),
|
||||
RefundReason: r.RefundReason,
|
||||
Status: r.Status,
|
||||
StatusName: constants.GetRefundStatusName(r.Status),
|
||||
ProcessorID: r.ProcessorID,
|
||||
RejectReason: r.RejectReason,
|
||||
Remark: r.Remark,
|
||||
CommissionDeducted: r.CommissionDeducted,
|
||||
AssetReset: r.AssetReset,
|
||||
SubmitterID: r.Creator,
|
||||
ApprovalInstanceID: r.ApprovalInstanceID,
|
||||
Creator: r.Creator,
|
||||
Updater: r.Updater,
|
||||
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
ID: r.ID,
|
||||
RefundNo: r.RefundNo,
|
||||
OrderID: r.OrderID,
|
||||
OrderNo: r.OrderNo,
|
||||
AssetIdentifier: r.AssetIdentifier,
|
||||
AssetType: assetType,
|
||||
IotCardID: r.IotCardID,
|
||||
DeviceID: r.DeviceID,
|
||||
PackageUsageID: r.PackageUsageID,
|
||||
ShopID: r.ShopID,
|
||||
ShopName: r.ShopName,
|
||||
ActualReceivedAmount: r.ActualReceivedAmount,
|
||||
RequestedRefundAmount: r.RequestedRefundAmount,
|
||||
ApprovedRefundAmount: r.ApprovedRefundAmount,
|
||||
RefundVoucherKey: []string(r.RefundVoucherKey),
|
||||
RefundReason: r.RefundReason,
|
||||
Status: r.Status,
|
||||
StatusName: constants.GetRefundStatusName(r.Status),
|
||||
Method: r.Method,
|
||||
MethodName: constants.RefundMethodName(r.Method),
|
||||
FrozenActualReceivedAmount: r.FrozenActualReceivedAmount,
|
||||
CustomerAccountInfo: r.CustomerAccountInfo,
|
||||
ChannelRefundStatus: r.ChannelRefundStatus,
|
||||
ChannelRefundStatusName: constants.RefundChannelStatusName(r.ChannelRefundStatus),
|
||||
ChannelRefundNo: r.ChannelRefundNo,
|
||||
ChannelRefundRequestNo: r.ChannelRefundRequestNo,
|
||||
ChannelRefundAmount: r.ChannelRefundAmount,
|
||||
FailureReason: r.FailureReason,
|
||||
FailureReasonName: constants.RefundFailureReasonName(r.FailureReason),
|
||||
FailureMessage: r.FailureMessage,
|
||||
AnomalyFlag: r.AnomalyFlag,
|
||||
AnomalyReason: r.AnomalyReason,
|
||||
LatestAttemptID: r.LatestAttemptID,
|
||||
LatestApprovalInstanceID: r.LatestApprovalInstanceID,
|
||||
Attempts: []dto.RefundAttemptResponse{},
|
||||
ProcessorID: r.ProcessorID,
|
||||
RejectReason: r.RejectReason,
|
||||
Remark: r.Remark,
|
||||
CommissionDeducted: r.CommissionDeducted,
|
||||
AssetReset: r.AssetReset,
|
||||
SubmitterID: r.Creator,
|
||||
ApprovalInstanceID: r.ApprovalInstanceID,
|
||||
Creator: r.Creator,
|
||||
Updater: r.Updater,
|
||||
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
if r.ProcessedAt != nil {
|
||||
resp.ProcessedAt = r.ProcessedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if r.ChannelRefundedAt != nil {
|
||||
resp.ChannelRefundedAt = r.ChannelRefundedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
@@ -1411,11 +1506,16 @@ func (s *Service) loadApprovalSummaries(ctx context.Context, refunds []*model.Re
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]approvalSummary, instanceID *uint) {
|
||||
if response == nil || instanceID == nil {
|
||||
// applyApprovalSummary 用审批实例摘要回填响应的审批状态。
|
||||
// 优先取最新审批尝试关联的实例;最新实例摘要缺失或存量数据未写入时回退主表关联实例。
|
||||
func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]approvalSummary, refund *model.RefundRequest) {
|
||||
if response == nil || refund == nil {
|
||||
return
|
||||
}
|
||||
summary, exists := summaries[*instanceID]
|
||||
summary, exists := summaries[refund.LatestApprovalInstanceID]
|
||||
if !exists && refund.ApprovalInstanceID != nil {
|
||||
summary, exists = summaries[*refund.ApprovalInstanceID]
|
||||
}
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user