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

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

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

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

View File

@@ -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
}