Files
junhong_cmp_fiber/internal/service/refund/attempt_query.go
break ba0855d9eb 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 与行为核对,未调用真实渠道。
2026-09-14 11:55:16 +08:00

88 lines
3.3 KiB
Go

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
}