完善退款审批材料与恢复流程
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user