完善退款审批材料与恢复流程
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s

This commit is contained in:
2026-09-20 17:59:52 +08:00
parent 4a6cec2730
commit b063617153
29 changed files with 1119 additions and 156 deletions

View File

@@ -15,6 +15,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
@@ -55,6 +56,8 @@ type Service struct {
deviceStore *postgres.DeviceStore
assetWalletStore *postgres.AssetWalletStore
agentWalletRefundService *walletapp.RefundService
refundApprovalRecovery approvalapp.RecoveryPort
refundApprovalUseCase *refundapprovalapp.RecoveryService
refundApprovalCreation *refundapprovalapp.CreationService
notificationOutbox *outbox.Repository
auditWriter *audit.Writer
@@ -122,6 +125,15 @@ func (s *Service) SetNotificationOutbox(repository *outbox.Repository) {
s.notificationOutbox = repository
}
// SetRefundApprovalRecoveryService 注入通用审批恢复接缝。
func (s *Service) SetRefundApprovalRecoveryService(recovery approvalapp.RecoveryPort) {
s.refundApprovalRecovery = recovery
}
func (s *Service) SetRefundApprovalRecoveryUseCase(recovery *refundapprovalapp.RecoveryService) {
s.refundApprovalUseCase = recovery
}
// SetLifecycleAudit 注入退款完整业务链统一审计 Writer。
func (s *Service) SetLifecycleAudit(writer *audit.Writer) {
s.auditWriter = writer
@@ -314,6 +326,21 @@ func (s *Service) TriggerApproval(ctx context.Context, id uint) (*dto.RefundResp
return resp, nil
}
// RecoverApproval 只负责按退款列表/详情可见范围定位并委托退款审批恢复用例HTTP 不执行企微外呼。
func (s *Service) RecoverApproval(ctx context.Context, id uint) (*dto.RefundResponse, error) {
if s == nil || s.refundApprovalUseCase == nil || id == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批恢复能力未配置")
}
// 恢复是对原可见退款的管理操作:沿用详情读取 scope不能使用按 creator 限制的写 scope。
if _, err := s.refundStore.GetByID(ctx, id); err != nil {
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
}
if err := s.refundApprovalUseCase.Execute(ctx, id); err != nil {
return nil, err
}
return s.GetByID(ctx, id)
}
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, error) {
refund, err := s.refundStore.GetByID(ctx, id)
if err != nil {
@@ -1364,47 +1391,106 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
}
type approvalSummary struct {
Provider string
Status int
Provider string
Status int
SubmissionStatus int
FailureSummary string
LastRecoveryAt *time.Time
Recoverable bool
SPNo string
EventStatus int
}
func appendUniqueUint(values []uint, seen map[uint]struct{}, value uint) []uint {
if value == 0 {
return values
}
if _, ok := seen[value]; ok {
return values
}
seen[value] = struct{}{}
return append(values, value)
}
func (s *Service) loadApprovalSummaries(ctx context.Context, refunds []*model.RefundRequest) (map[uint]approvalSummary, error) {
ids := make([]uint, 0, len(refunds))
seen := make(map[uint]struct{}, len(refunds))
refundIDs := make([]uint, 0, len(refunds))
for _, refund := range refunds {
if refund == nil || refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID == 0 {
if refund == nil {
continue
}
id := *refund.ApprovalInstanceID
if _, exists := seen[id]; exists {
continue
refundIDs = append(refundIDs, refund.ID)
ids = appendUniqueUint(ids, seen, refund.LatestApprovalInstanceID)
if refund.ApprovalInstanceID != nil {
ids = appendUniqueUint(ids, seen, *refund.ApprovalInstanceID)
}
seen[id] = struct{}{}
ids = append(ids, id)
}
summaries := make(map[uint]approvalSummary, len(ids))
var latest, first []struct {
RefundID uint
ApprovalInstanceID uint
}
if len(refundIDs) > 0 {
if err := s.db.WithContext(ctx).Raw(`SELECT DISTINCT ON (refund_id) refund_id, approval_instance_id FROM tb_refund_request_attempt WHERE refund_id IN ? AND approval_instance_id IS NOT NULL ORDER BY refund_id, attempt_no DESC, id DESC`, refundIDs).Scan(&latest).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款最新审批实例失败")
}
if err := s.db.WithContext(ctx).Raw(`SELECT DISTINCT ON (refund_id) refund_id, approval_instance_id FROM tb_refund_request_attempt WHERE refund_id IN ? AND approval_instance_id IS NOT NULL ORDER BY refund_id, attempt_no ASC, id ASC`, refundIDs).Scan(&first).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款首个审批实例失败")
}
}
for _, row := range latest {
ids = appendUniqueUint(ids, seen, row.ApprovalInstanceID)
}
for _, row := range first {
ids = appendUniqueUint(ids, seen, row.ApprovalInstanceID)
}
result := make(map[uint]approvalSummary, len(ids))
if len(ids) == 0 {
return summaries, nil
return result, nil
}
var instances []model.ApprovalInstance
if err := s.db.WithContext(ctx).Select("id", "provider", "status").Where("id IN ?", ids).Find(&instances).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款审批状态失败")
var rows []struct {
ID uint
Provider string
Status int
LastError string
LastRecoveryAt *time.Time
SPNo string
SubmissionStatus int
EventStatus int
}
for _, instance := range instances {
summaries[instance.ID] = approvalSummary{Provider: instance.Provider, Status: instance.Status}
query := `SELECT ai.id, ai.provider, ai.status, COALESCE(wc.last_error, '') AS last_error, wc.last_recovery_at, COALESCE(wc.sp_no, '') AS sp_no, COALESCE(wc.submission_status, 0) AS submission_status, COALESCE(oe.status, 0) AS event_status FROM tb_approval_instance ai LEFT JOIN tb_wecom_approval_context wc ON wc.approval_instance_id = ai.id LEFT JOIN tb_outbox_event oe ON oe.event_id = ('approval:' || ai.id::text || ':submission') WHERE ai.id IN ?`
if err := s.db.WithContext(ctx).Raw(query, ids).Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款审批投影失败")
}
return summaries, nil
for _, row := range rows {
summary := approvalSummary{Provider: row.Provider, Status: row.Status, SubmissionStatus: row.SubmissionStatus, FailureSummary: sanitizeApprovalFailure(row.LastError), LastRecoveryAt: row.LastRecoveryAt, SPNo: row.SPNo, EventStatus: row.EventStatus}
summary.Recoverable = row.Status == constants.ApprovalStatusSubmissionFailed && row.SubmissionStatus == constants.WeComSubmissionStatusFailed && row.SPNo == "" && (row.EventStatus == constants.OutboxStatusFailed || row.EventStatus == constants.OutboxStatusDelivered)
result[row.ID] = summary
}
return result, nil
}
func sanitizeApprovalFailure(message string) string {
if strings.TrimSpace(message) == "" {
return ""
}
// 失败摘要只能返回稳定白名单文案,禁止透传渠道原文、内部事件键或任意外部文本。
return "审批提交失败,请检查审批模板与渠道状态"
}
// applyApprovalSummary 用审批实例摘要回填响应的审批状态。
// 优先取最新审批尝试关联的实例;最新实例摘要缺失或存量数据未写入时回退主表关联实例。
func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]approvalSummary, refund *model.RefundRequest) {
if response == nil || refund == nil {
return
}
summary, exists := summaries[refund.LatestApprovalInstanceID]
id := refund.LatestApprovalInstanceID
if id == 0 && refund.ApprovalInstanceID != nil {
id = *refund.ApprovalInstanceID
}
summary, exists := summaries[id]
if !exists && refund.ApprovalInstanceID != nil {
summary, exists = summaries[*refund.ApprovalInstanceID]
id = *refund.ApprovalInstanceID
summary, exists = summaries[id]
}
if !exists {
return
@@ -1413,6 +1499,30 @@ func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]appro
response.ApprovalProvider = summary.Provider
response.ApprovalStatus = &status
response.ApprovalStatusName = constants.GetApprovalStatusName(status)
response.ApprovalSubmissionStatus = &summary.SubmissionStatus
response.ApprovalSubmissionStatusName = wecomSubmissionStatusName(summary.SubmissionStatus)
response.ApprovalRecoverable = summary.Recoverable && refund.Status == model.RefundStatusPending && refund.ChannelRefundStatus != constants.RefundChannelStatusProcessing && refund.Status != model.RefundStatusChannelFailed
response.ApprovalFailureSummary = summary.FailureSummary
if summary.LastRecoveryAt != nil {
response.ApprovalLastRecoveryAt = summary.LastRecoveryAt.UTC().Format(time.RFC3339)
}
}
func wecomSubmissionStatusName(status int) string {
switch status {
case constants.WeComSubmissionStatusReady:
return "待提交"
case constants.WeComSubmissionStatusSending:
return "提交中"
case constants.WeComSubmissionStatusSubmitted:
return "已提交"
case constants.WeComSubmissionStatusFailed:
return "提交失败"
case constants.WeComSubmissionStatusUnknown:
return "结果未知"
default:
return "未知"
}
}
func refundSubmitterIDs(requests []*model.RefundRequest) []uint {
@@ -1436,7 +1546,6 @@ func (s *Service) loadSubmitterNames(ctx context.Context, ids []uint) (map[uint]
}
return names, nil
}
func (s *Service) loadSubmitterNameBestEffort(ctx context.Context, id uint) string {
names, err := s.loadSubmitterNames(ctx, []uint{id})
if err != nil {