完善退款审批材料与恢复流程
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:
@@ -3,11 +3,14 @@ package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 审批恢复适配器方法见文件末尾。
|
||||
|
||||
// PrepareRequest 是业务写入前执行审批渠道可用性检查的请求。
|
||||
type PrepareRequest struct {
|
||||
BusinessType string
|
||||
@@ -79,3 +82,35 @@ type SubmissionRequestedEvent struct {
|
||||
type SubmissionEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event SubmissionRequestedEvent) error
|
||||
}
|
||||
|
||||
type RecoveryPort interface {
|
||||
EnqueueSubmittedSync(ctx context.Context, tx *gorm.DB, instanceID uint) error
|
||||
EnqueueUnknownConfirm(ctx context.Context, tx *gorm.DB, instanceID uint) error
|
||||
RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB, instanceID uint) (bool, error)
|
||||
}
|
||||
|
||||
// RecoveryAdapter 将恢复动作委托给基础设施实现。
|
||||
type RecoveryAdapter struct {
|
||||
SyncFunc func(context.Context, *gorm.DB, uint) error
|
||||
ConfirmFunc func(context.Context, *gorm.DB, uint) error
|
||||
RecoverFunc func(context.Context, *gorm.DB, uint) (bool, error)
|
||||
}
|
||||
|
||||
func (a RecoveryAdapter) SyncSubmitted(ctx context.Context, tx *gorm.DB, id uint) error {
|
||||
if a.SyncFunc == nil {
|
||||
return stderrors.New("审批原实例同步接缝未配置")
|
||||
}
|
||||
return a.SyncFunc(ctx, tx, id)
|
||||
}
|
||||
func (a RecoveryAdapter) ConfirmUnknown(ctx context.Context, tx *gorm.DB, id uint) error {
|
||||
if a.ConfirmFunc == nil {
|
||||
return stderrors.New("审批结果确认接缝未配置")
|
||||
}
|
||||
return a.ConfirmFunc(ctx, tx, id)
|
||||
}
|
||||
func (a RecoveryAdapter) RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB, id uint) (bool, error) {
|
||||
if a.RecoverFunc == nil {
|
||||
return false, stderrors.New("审批提交事件恢复接缝未配置")
|
||||
}
|
||||
return a.RecoverFunc(ctx, tx, id)
|
||||
}
|
||||
|
||||
@@ -161,8 +161,7 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint,
|
||||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
|
||||
}
|
||||
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account, "", material)
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, attempt, account, material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -226,10 +225,7 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account, command.ApplicantRemark, command.Material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var submitterSnapshot, requestSnapshot []byte
|
||||
var approvalStatus int
|
||||
var attempt *model.RefundRequestAttempt
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
@@ -255,6 +251,10 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
|
||||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err = refundSnapshots(command.Refund, attempt, account, command.Material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
|
||||
BusinessID: attempt.ID, SubmitterAccountID: command.SubmitterAccountID,
|
||||
@@ -359,7 +359,7 @@ func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command R
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account, command.ApplicantRemark, command.Material)
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, attempt, account, command.Material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -478,6 +478,7 @@ func buildAttempt(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customerAccountInfo, customerVoucherKeys := refundCustomerMaterial(refund)
|
||||
return &model.RefundRequestAttempt{
|
||||
RefundID: refund.ID,
|
||||
AttemptNo: attemptNo,
|
||||
@@ -486,14 +487,22 @@ func buildAttempt(
|
||||
FrozenActualReceivedAmount: refund.FrozenActualReceivedAmount,
|
||||
RefundReason: refund.RefundReason,
|
||||
Remark: strings.TrimSpace(applicantRemark),
|
||||
CustomerAccountInfo: refund.CustomerAccountInfo,
|
||||
CustomerVoucherKeys: refund.RefundVoucherKey,
|
||||
CustomerAccountInfo: customerAccountInfo,
|
||||
CustomerVoucherKeys: customerVoucherKeys,
|
||||
PackageUsageSnapshot: snapshot,
|
||||
ChannelRefundRequestNo: strings.TrimSpace(channelRefundRequestNo),
|
||||
SubmittedByAccountID: refund.Creator,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// refundCustomerMaterial 只为客户收款信息退款冻结客户收款材料;其它退款方式保持空材料,避免伪造占位内容。
|
||||
func refundCustomerMaterial(refund *model.RefundRequest) (string, model.StringJSONBArray) {
|
||||
if refund == nil || refund.Method != constants.RefundMethodCustomerAccount {
|
||||
return "", model.StringJSONBArray{}
|
||||
}
|
||||
return strings.TrimSpace(refund.CustomerAccountInfo), append(model.StringJSONBArray(nil), refund.RefundVoucherKey...)
|
||||
}
|
||||
|
||||
// nextAttemptNo 返回该退款申请的下一条审批尝试序号;退款申请行已加锁,序号在同一事务内唯一。
|
||||
func nextAttemptNo(ctx context.Context, tx *gorm.DB, refundID uint) (int, error) {
|
||||
var row struct {
|
||||
@@ -593,36 +602,28 @@ func updateRefundLatest(ctx context.Context, tx *gorm.DB, refund *model.RefundRe
|
||||
return nil
|
||||
}
|
||||
|
||||
// refundSnapshots 冻结提交人与审批业务快照。
|
||||
// 审批业务快照是企微表单构建的数据源,因此本变更新增的审批材料字段必须在此出现,
|
||||
// 且即使不可解析也要以空值或零值写入:控件映射已配置时缺失键会被表单构建明确拒绝。
|
||||
func refundSnapshots(refund *model.RefundRequest, account *model.Account, applicantRemark string, material RefundMaterial) ([]byte, []byte, error) {
|
||||
submitterSnapshot, err := sonic.Marshal(map[string]any{
|
||||
"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType,
|
||||
})
|
||||
// refundSnapshots 冻结提交人与审批业务快照,业务可变字段全部取不可变审批尝试。
|
||||
func refundSnapshots(refund *model.RefundRequest, attempt *model.RefundRequestAttempt, account *model.Account, material RefundMaterial) ([]byte, []byte, error) {
|
||||
if refund == nil || attempt == nil || account == nil {
|
||||
return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批快照参数不完整")
|
||||
}
|
||||
submitterSnapshot, err := sonic.Marshal(map[string]any{"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType})
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款提交人快照失败")
|
||||
}
|
||||
customerAccountInfo, customerVoucherKeys := refundCustomerMaterialFromAttempt(attempt)
|
||||
requestSnapshot, err := sonic.Marshal(map[string]any{
|
||||
constants.ApprovalFieldRefundNo: refund.RefundNo,
|
||||
constants.ApprovalFieldOrderID: refund.OrderID,
|
||||
constants.ApprovalFieldOrderNo: refund.OrderNo,
|
||||
constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier,
|
||||
constants.ApprovalFieldAssetType: refund.OrderType,
|
||||
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.FrozenActualReceivedAmount),
|
||||
constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount),
|
||||
constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey),
|
||||
constants.ApprovalFieldRefundReason: refund.RefundReason,
|
||||
constants.ApprovalFieldPackageUsageID: refund.PackageUsageID,
|
||||
constants.ApprovalFieldRefundAssetType: material.AssetType,
|
||||
constants.ApprovalFieldRefundDeviceType: material.DeviceType,
|
||||
constants.ApprovalFieldRefundDeviceModel: material.DeviceModel,
|
||||
constants.ApprovalFieldRefundPackageUsedMB: material.PackageUsedMB,
|
||||
constants.ApprovalFieldRefundPackageTotalMB: material.PackageTotalMB,
|
||||
constants.ApprovalFieldRefundChannelTradeNo: material.OriginalChannelTradeNo,
|
||||
constants.ApprovalFieldRefundApplicantRemark: strings.TrimSpace(applicantRemark),
|
||||
constants.ApprovalFieldSubmitterID: account.ID,
|
||||
constants.ApprovalFieldSubmitterName: account.Username,
|
||||
constants.ApprovalFieldRefundNo: refund.RefundNo, constants.ApprovalFieldOrderID: refund.OrderID, constants.ApprovalFieldOrderNo: refund.OrderNo,
|
||||
constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier, constants.ApprovalFieldAssetType: refund.OrderType,
|
||||
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(attempt.FrozenActualReceivedAmount), constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(attempt.RefundAmount),
|
||||
constants.ApprovalFieldRefundVoucherKey: []string(attempt.CustomerVoucherKeys), constants.ApprovalFieldRefundReason: attempt.RefundReason,
|
||||
constants.ApprovalFieldPackageUsageID: refund.PackageUsageID, constants.ApprovalFieldRefundAssetType: material.AssetType,
|
||||
constants.ApprovalFieldRefundDeviceType: material.DeviceType, constants.ApprovalFieldRefundDeviceModel: material.DeviceModel,
|
||||
constants.ApprovalFieldRefundPackageUsedMB: material.PackageUsedMB, constants.ApprovalFieldRefundPackageTotalMB: material.PackageTotalMB,
|
||||
constants.ApprovalFieldRefundChannelTradeNo: material.OriginalChannelTradeNo, constants.ApprovalFieldRefundApplicantRemark: attempt.Remark,
|
||||
constants.ApprovalFieldRefundMethodCode: attempt.Method, constants.ApprovalFieldRefundMethod: constants.RefundMethodName(attempt.Method),
|
||||
constants.ApprovalFieldCustomerAccountInfo: customerAccountInfo, constants.ApprovalFieldCustomerVoucherKey: []string(customerVoucherKeys),
|
||||
constants.ApprovalFieldSubmitterID: account.ID, constants.ApprovalFieldSubmitterName: account.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款审批业务快照失败")
|
||||
@@ -630,6 +631,13 @@ func refundSnapshots(refund *model.RefundRequest, account *model.Account, applic
|
||||
return submitterSnapshot, requestSnapshot, nil
|
||||
}
|
||||
|
||||
func refundCustomerMaterialFromAttempt(attempt *model.RefundRequestAttempt) (string, model.StringJSONBArray) {
|
||||
if attempt == nil || attempt.Method != constants.RefundMethodCustomerAccount {
|
||||
return "", model.StringJSONBArray{}
|
||||
}
|
||||
return strings.TrimSpace(attempt.CustomerAccountInfo), append(model.StringJSONBArray(nil), attempt.CustomerVoucherKeys...)
|
||||
}
|
||||
|
||||
func formatCentAmount(amount int64) string {
|
||||
return fmt.Sprintf("%d.%02d", amount/100, amount%100)
|
||||
}
|
||||
|
||||
144
internal/application/refundapproval/recovery.go
Normal file
144
internal/application/refundapproval/recovery.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package refundapproval
|
||||
|
||||
import (
|
||||
"context"
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type RecoveryAuditWriter interface {
|
||||
WriteSnapshotRecovery(ctx context.Context, tx *gorm.DB, instance *model.ApprovalInstance, fields []string, actorID uint) error
|
||||
WriteRecoveryRequest(ctx context.Context, tx *gorm.DB, instance *model.ApprovalInstance, actorID uint, branch string) error
|
||||
}
|
||||
|
||||
type RecoveryService struct {
|
||||
db *gorm.DB
|
||||
recovery approvalapp.RecoveryPort
|
||||
audit RecoveryAuditWriter
|
||||
}
|
||||
|
||||
func NewRecoveryService(db *gorm.DB, recovery approvalapp.RecoveryPort, audit RecoveryAuditWriter) *RecoveryService {
|
||||
return &RecoveryService{db: db, recovery: recovery, audit: audit}
|
||||
}
|
||||
|
||||
func (s *RecoveryService) Execute(ctx context.Context, refundID uint) error {
|
||||
actorID := middleware.GetUserIDFromContext(ctx)
|
||||
if actorID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未认证的恢复操作")
|
||||
}
|
||||
if s == nil || s.db == nil || s.recovery == nil || refundID == 0 {
|
||||
return errors.New(errors.CodeServiceUnavailable, "退款审批恢复能力未配置")
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var refund model.RefundRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&refund, refundID).Error; err != nil {
|
||||
return errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
if refund.Status != model.RefundStatusPending || refund.LatestAttemptID == 0 || refund.LatestApprovalInstanceID == 0 || refund.ChannelRefundStatus == constants.RefundChannelStatusProcessing || refund.Status == model.RefundStatusChannelFailed {
|
||||
return errors.New(errors.CodeConflict, "当前退款状态不允许恢复审批")
|
||||
}
|
||||
var attempt model.RefundRequestAttempt
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND refund_id = ?", refund.LatestAttemptID, refund.ID).First(&attempt).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "退款审批尝试关联不一致")
|
||||
}
|
||||
if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != refund.LatestApprovalInstanceID {
|
||||
return errors.New(errors.CodeConflict, "退款审批实例关联不一致")
|
||||
}
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND business_type = ? AND business_id = ?", refund.LatestApprovalInstanceID, constants.ApprovalBusinessTypeRefund, attempt.ID).First(&instance).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "退款审批实例业务关联不一致")
|
||||
}
|
||||
if instance.Status != constants.ApprovalStatusSubmitting && instance.Status != constants.ApprovalStatusSubmissionFailed && instance.Status != constants.ApprovalStatusSubmissionUnknown && instance.Status != constants.ApprovalStatusPending {
|
||||
return errors.New(errors.CodeConflict, "审批终态不允许恢复")
|
||||
}
|
||||
fields, err := fillMissingSnapshot(&instance, &attempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
if err := tx.Model(&model.ApprovalInstance{}).Where("id = ?", instance.ID).Update("request_snapshot", datatypes.JSON(instance.RequestSnapshot)).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "补齐退款审批快照失败")
|
||||
}
|
||||
if s.audit != nil {
|
||||
if err := s.audit.WriteSnapshotRecovery(ctx, tx, &instance, fields, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
var context model.WeComApprovalContext
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("approval_instance_id = ? AND business_type = ?", instance.ID, constants.ApprovalBusinessTypeRefund).First(&context).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "企业微信审批上下文不存在或业务类型不一致")
|
||||
}
|
||||
branch := "active"
|
||||
if context.SPNo != "" {
|
||||
branch = "sp_no"
|
||||
if s.audit != nil {
|
||||
if err := s.audit.WriteRecoveryRequest(ctx, tx, &instance, actorID, branch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.recovery.EnqueueSubmittedSync(ctx, tx, instance.ID)
|
||||
}
|
||||
if context.SubmissionStatus == constants.WeComSubmissionStatusUnknown || instance.Status == constants.ApprovalStatusSubmissionUnknown {
|
||||
branch = "unknown"
|
||||
if s.audit != nil {
|
||||
if err := s.audit.WriteRecoveryRequest(ctx, tx, &instance, actorID, branch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.recovery.EnqueueUnknownConfirm(ctx, tx, instance.ID)
|
||||
}
|
||||
if instance.Status == constants.ApprovalStatusSubmitting || instance.Status == constants.ApprovalStatusPending || context.SubmissionStatus == constants.WeComSubmissionStatusReady || context.SubmissionStatus == constants.WeComSubmissionStatusSending {
|
||||
if s.audit != nil {
|
||||
if err := s.audit.WriteRecoveryRequest(ctx, tx, &instance, actorID, branch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if instance.Status != constants.ApprovalStatusSubmissionFailed || context.SubmissionStatus != constants.WeComSubmissionStatusFailed {
|
||||
return errors.New(errors.CodeConflict, "当前审批提交状态不允许恢复")
|
||||
}
|
||||
branch = "replay"
|
||||
if s.audit != nil {
|
||||
if err := s.audit.WriteRecoveryRequest(ctx, tx, &instance, actorID, branch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = s.recovery.RecoverSubmissionEvent(ctx, tx, instance.ID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
func fillMissingSnapshot(instance *model.ApprovalInstance, attempt *model.RefundRequestAttempt) ([]string, error) {
|
||||
var snapshot map[string]any
|
||||
if err := sonic.Unmarshal(instance.RequestSnapshot, &snapshot); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInvalidStatus, err, "退款审批快照无效")
|
||||
}
|
||||
if snapshot == nil {
|
||||
snapshot = map[string]any{}
|
||||
}
|
||||
values := map[string]any{constants.ApprovalFieldRefundMethodCode: attempt.Method, constants.ApprovalFieldRefundMethod: constants.RefundMethodName(attempt.Method), constants.ApprovalFieldCustomerAccountInfo: attempt.CustomerAccountInfo, constants.ApprovalFieldCustomerVoucherKey: []string(attempt.CustomerVoucherKeys)}
|
||||
fields := make([]string, 0, len(values))
|
||||
for key, value := range values {
|
||||
if _, ok := snapshot[key]; ok {
|
||||
continue
|
||||
}
|
||||
snapshot[key] = value
|
||||
fields = append(fields, key)
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
encoded, err := sonic.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "编码退款审批快照失败")
|
||||
}
|
||||
instance.RequestSnapshot = encoded
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
@@ -132,6 +132,10 @@ func (s *SceneService) Save(ctx context.Context, businessType string, request dt
|
||||
s.recordFailure(ctx, businessType, request, constants.AuditResultDenied, errors.CodeInvalidParam, "拒绝保存非法企业微信审批场景映射")
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRefundConditionalMappings(businessType, request.ControlMapping, definition.Controls); err != nil {
|
||||
s.recordFailure(ctx, businessType, request, constants.AuditResultDenied, errors.CodeInvalidParam, "拒绝保存非法退款条件材料控件映射")
|
||||
return nil, err
|
||||
}
|
||||
request.ControlMapping = normalizeSceneMapping(request.ControlMapping)
|
||||
mappingJSON, err := sonic.Marshal(request.ControlMapping)
|
||||
if err != nil {
|
||||
@@ -305,19 +309,14 @@ func validateSceneMapping(businessType string, mapping []dto.WeComControlMapping
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequiredSceneField 描述本变更新增、必须配置控件映射的业务字段。
|
||||
// RequiredSceneField 描述审批字段映射的固定或条件必需规则。
|
||||
type RequiredSceneField struct {
|
||||
// Code 是 control_mapping.business_field 应填写的稳定字段编码。
|
||||
Code string
|
||||
// Name 是字段中文名称,用于在缺失时指出具体缺哪个字段/控件。
|
||||
Name string
|
||||
Code string
|
||||
Name string
|
||||
RequiredMapping bool
|
||||
}
|
||||
|
||||
// RequiredSceneFields 返回指定业务类型下必须配置控件映射的业务字段,顺序与场景白名单一致。
|
||||
//
|
||||
// 这是「必须映射字段」的唯一判定来源:场景映射校验(保存时)与表单构建(提交时)都从
|
||||
// 这里取集合,MUST NOT 再维护第二份清单。既有未标注必须映射的可选字段不在返回结果中,
|
||||
// 因此其缺失映射仍保持既有静默跳过行为。
|
||||
// RequiredSceneFields 返回场景级固定必需字段。
|
||||
func RequiredSceneFields(businessType string) []RequiredSceneField {
|
||||
fields, ok := sceneBusinessFields(businessType)
|
||||
if !ok {
|
||||
@@ -326,16 +325,31 @@ func RequiredSceneFields(businessType string) []RequiredSceneField {
|
||||
result := make([]RequiredSceneField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if field.RequiredMapping {
|
||||
result = append(result, RequiredSceneField{Code: field.Code, Name: field.Name})
|
||||
result = append(result, RequiredSceneField{Code: field.Code, Name: field.Name, RequiredMapping: true})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RequiredRefundFields 返回退款提交固定与条件必需字段;快照方式编码缺失或未知时由表单构建拒绝。
|
||||
func RequiredRefundFields(snapshot map[string]any) []RequiredSceneField {
|
||||
methodCode, ok := snapshot[constants.ApprovalFieldRefundMethodCode].(string)
|
||||
methodCode = strings.TrimSpace(methodCode)
|
||||
if !ok || methodCode == "" || (methodCode != constants.RefundMethodOriginalRoute && methodCode != constants.RefundMethodCustomerAccount && methodCode != constants.RefundMethodAssetWallet && methodCode != constants.RefundMethodAgentWallet) {
|
||||
return []RequiredSceneField{{Code: constants.ApprovalFieldRefundMethodCode, Name: "退款方式编码"}}
|
||||
}
|
||||
result := []RequiredSceneField{{Code: constants.ApprovalFieldRefundMethod, Name: "退款方式", RequiredMapping: true}}
|
||||
if methodCode == constants.RefundMethodCustomerAccount {
|
||||
result = append(result, RequiredSceneField{Code: constants.ApprovalFieldCustomerAccountInfo, Name: "客户收款信息"}, RequiredSceneField{Code: constants.ApprovalFieldCustomerVoucherKey, Name: "客户收款凭证"})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// approvalBusinessTypes 返回全部已登记审批业务场景,供只读字段汇总复用。
|
||||
func approvalBusinessTypes() []string {
|
||||
return []string{
|
||||
constants.ApprovalBusinessTypeRefund,
|
||||
|
||||
constants.ApprovalBusinessTypeOfflineRecharge,
|
||||
constants.ApprovalBusinessTypeEmployeeCollection,
|
||||
constants.ApprovalBusinessTypeAgentDistribution,
|
||||
@@ -344,6 +358,30 @@ func approvalBusinessTypes() []string {
|
||||
}
|
||||
}
|
||||
|
||||
// validateRefundConditionalMappings 校验退款条件字段使用正确的企微控件类型。
|
||||
func validateRefundConditionalMappings(businessType string, mapping []dto.WeComControlMappingItem, controls []TemplateControl) error {
|
||||
if businessType != constants.ApprovalBusinessTypeRefund {
|
||||
return nil
|
||||
}
|
||||
types := make(map[string]string, len(controls))
|
||||
for _, control := range controls {
|
||||
types[control.ID] = strings.ToLower(strings.TrimSpace(control.Type))
|
||||
}
|
||||
for _, item := range mapping {
|
||||
switch item.BusinessField {
|
||||
case constants.ApprovalFieldCustomerAccountInfo:
|
||||
if types[item.ControlID] != "textarea" {
|
||||
return errors.New(errors.CodeInvalidParam, "客户收款信息必须映射到多行文本控件")
|
||||
}
|
||||
case constants.ApprovalFieldCustomerVoucherKey:
|
||||
if types[item.ControlID] != "file" {
|
||||
return errors.New(errors.CodeInvalidParam, "客户收款凭证必须映射到附件控件")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func allowedSceneBusinessField(businessType, businessField string) bool {
|
||||
fields, ok := sceneBusinessFields(businessType)
|
||||
if !ok {
|
||||
@@ -394,6 +432,10 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
|
||||
{Code: constants.ApprovalFieldRefundPackageTotalMB, Name: "当前退款套餐总量(MB)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "当前退款套餐总量(MB,真实流量);解析不到套餐时为 0", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundChannelTradeNo, Name: "原支付渠道交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "创建申请时冻结的原成功支付记录渠道交易流水号;线下订单为空字符串", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundApplicantRemark, Name: "申请人备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次提交填写的申请人备注快照;未填写时为空字符串。与审批备注不是同一字段", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundMethodCode, Name: "退款方式编码", ValueType: constants.ApprovalFieldValueTypeString, Description: "仅用于按本次审批快照计算条件材料,不要求配置模板控件"},
|
||||
{Code: constants.ApprovalFieldRefundMethod, Name: "退款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次审批尝试冻结的退款方式中文名称", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldCustomerAccountInfo, Name: "客户收款信息", ValueType: constants.ApprovalFieldValueTypeString, Description: "客户收款信息退款的多行收款文本;其它退款方式为空"},
|
||||
{Code: constants.ApprovalFieldCustomerVoucherKey, Name: "客户收款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "客户收款信息退款的凭证列表;其它退款方式为空"},
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
|
||||
@@ -406,9 +406,10 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
refundService.SetEmployeeCollectionRefundOffset(
|
||||
employeecollectionApp.NewRefundOffsetService(auditWriter),
|
||||
)
|
||||
refundService.SetRefundApprovalCreationService(
|
||||
refundapprovalApp.NewCreationService(deps.DB, approvalCreationService, auditWriter),
|
||||
)
|
||||
recoveryPort := approvalInfra.NewRecoveryPort(deps.DB, deps.QueueClient)
|
||||
refundApprovalUseCase := refundapprovalApp.NewRecoveryService(deps.DB, recoveryPort, auditWriter)
|
||||
refundService.SetRefundApprovalRecoveryService(recoveryPort)
|
||||
refundService.SetRefundApprovalRecoveryUseCase(refundApprovalUseCase)
|
||||
roleService := roleSvc.New(s.Role, s.Permission, s.RolePermission, s.AccountRole, s.ShopRole)
|
||||
roleService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
|
||||
permissionService := permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis)
|
||||
|
||||
@@ -83,6 +83,20 @@ func (h *RefundHandler) TriggerApproval(c *fiber.Ctx) error {
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// RecoverApproval 恢复退款最新审批实例的原提交事实。
|
||||
// POST /api/admin/refunds/:id/recover-approval
|
||||
func (h *RefundHandler) RecoverApproval(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的退款申请ID")
|
||||
}
|
||||
result, err := h.service.RecoverApproval(c.UserContext(), uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// Approve 审批通过退款申请
|
||||
// POST /api/admin/refunds/:id/approve
|
||||
func (h *RefundHandler) Approve(c *fiber.Ctx) error {
|
||||
|
||||
89
internal/infrastructure/approval/recovery.go
Normal file
89
internal/infrastructure/approval/recovery.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RecoveryPort 将恢复请求限制为本地任务入队与事件状态转换。
|
||||
type RecoveryPort struct{ outbox *outbox.Repository }
|
||||
|
||||
func NewRecoveryPort(_ *gorm.DB, _ ...any) *RecoveryPort {
|
||||
return &RecoveryPort{outbox: outbox.NewRepository()}
|
||||
}
|
||||
func (r *RecoveryPort) EnqueueSubmittedSync(ctx context.Context, tx *gorm.DB, instanceID uint) error {
|
||||
return r.enqueueSync(ctx, tx, instanceID)
|
||||
}
|
||||
func (r *RecoveryPort) EnqueueUnknownConfirm(ctx context.Context, tx *gorm.DB, instanceID uint) error {
|
||||
return r.enqueueUnknown(ctx, tx, instanceID)
|
||||
}
|
||||
func (r *RecoveryPort) enqueueSync(ctx context.Context, tx *gorm.DB, instanceID uint) error {
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).Where("id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&instance).Error; err != nil {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
var context model.WeComApprovalContext
|
||||
if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&context).Error; err != nil || context.SPNo == "" || context.SubmissionStatus != constants.WeComSubmissionStatusSubmitted {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
id := strconv.FormatUint(uint64(instanceID), 10)
|
||||
payload := map[string]any{"instance_id": instanceID, "application_id": context.ApplicationID, "sp_no": context.SPNo}
|
||||
_, err := r.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{EventID: "approval:" + id + ":manual-sync", EventType: constants.OutboxEventTypeApprovalManualSyncRequested, PayloadVersion: 1, AggregateType: "approval", AggregateID: id, ResourceType: instance.BusinessType, ResourceID: strconv.FormatUint(uint64(instance.BusinessID), 10), Payload: payload})
|
||||
return err
|
||||
}
|
||||
func (r *RecoveryPort) enqueueUnknown(ctx context.Context, tx *gorm.DB, instanceID uint) error {
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).Where("id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&instance).Error; err != nil || instance.Status != constants.ApprovalStatusSubmissionUnknown {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
var context model.WeComApprovalContext
|
||||
if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusUnknown || context.SPNo != "" {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
id := strconv.FormatUint(uint64(instanceID), 10)
|
||||
_, err := r.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{EventID: "approval:" + id + ":unknown-recovery", EventType: constants.OutboxEventTypeApprovalUnknownRecoveryRequested, PayloadVersion: 1, AggregateType: "approval", AggregateID: id, ResourceType: instance.BusinessType, ResourceID: strconv.FormatUint(uint64(instance.BusinessID), 10), Payload: map[string]any{"instance_id": instanceID}})
|
||||
return err
|
||||
}
|
||||
func (r *RecoveryPort) RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB, instanceID uint) (bool, error) {
|
||||
if tx == nil || instanceID == 0 {
|
||||
return false, gorm.ErrInvalidData
|
||||
}
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if instance.Status != constants.ApprovalStatusSubmissionFailed {
|
||||
return false, gorm.ErrInvalidData
|
||||
}
|
||||
var context model.WeComApprovalContext
|
||||
if err := tx.WithContext(ctx).Where("approval_instance_id = ?", instanceID).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusFailed || context.SPNo != "" {
|
||||
return false, gorm.ErrInvalidData
|
||||
}
|
||||
eventID := "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":submission"
|
||||
var event model.OutboxEvent
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("event_id = ?", eventID).First(&event).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
id := strconv.FormatUint(uint64(instanceID), 10)
|
||||
rid := strconv.FormatUint(uint64(instance.BusinessID), 10)
|
||||
if event.EventType != constants.OutboxEventTypeApprovalSubmissionRequested || event.AggregateType != "approval" || event.AggregateID != id || event.ResourceType != instance.BusinessType || event.ResourceID != rid {
|
||||
return false, gorm.ErrInvalidData
|
||||
}
|
||||
if event.Status == constants.OutboxStatusPending || event.Status == constants.OutboxStatusDelivering {
|
||||
return false, nil
|
||||
}
|
||||
if event.Status != constants.OutboxStatusFailed && event.Status != constants.OutboxStatusDelivered {
|
||||
return false, nil
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.OutboxEvent{}).Where("id = ? AND status IN ?", event.ID, []int{constants.OutboxStatusFailed, constants.OutboxStatusDelivered}).Updates(map[string]any{"status": constants.OutboxStatusPending, "next_attempt_at": time.Now().UTC(), "last_error_code": "", "last_error_summary": "", "lease_owner": nil, "lease_expires_at": nil, "delivered_at": nil, "updated_at": time.Now().UTC()})
|
||||
return result.RowsAffected == 1, result.Error
|
||||
}
|
||||
|
||||
var _ approvalapp.RecoveryPort = (*RecoveryPort)(nil)
|
||||
@@ -36,3 +36,17 @@ func (w *SubmissionEventWriter) Append(ctx context.Context, tx *gorm.DB, event a
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type UnknownRecoveryEventWriter struct{ outbox *outbox.Repository }
|
||||
|
||||
func NewUnknownRecoveryEventWriter(repository *outbox.Repository) *UnknownRecoveryEventWriter {
|
||||
return &UnknownRecoveryEventWriter{outbox: repository}
|
||||
}
|
||||
func (w *UnknownRecoveryEventWriter) Append(ctx context.Context, tx *gorm.DB, instanceID uint) error {
|
||||
if w == nil || w.outbox == nil || instanceID == 0 {
|
||||
return errors.New(errors.CodeInternalError, "审批未知恢复 Outbox Writer 未配置")
|
||||
}
|
||||
eventID := "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":unknown-recovery"
|
||||
_, err := w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{EventID: eventID, EventType: constants.OutboxEventTypeApprovalUnknownRecoveryRequested, PayloadVersion: 1, AggregateType: "approval", AggregateID: strconv.FormatUint(uint64(instanceID), 10), ResourceType: "approval", ResourceID: strconv.FormatUint(uint64(instanceID), 10), BusinessKey: eventID, Payload: map[string]any{"instance_id": instanceID}})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -37,6 +37,24 @@ func (w *Writer) WriteApproval(ctx context.Context, tx *gorm.DB, change approval
|
||||
})
|
||||
}
|
||||
|
||||
func (w *Writer) WriteSnapshotRecovery(ctx context.Context, tx *gorm.DB, instance *model.ApprovalInstance, fields []string, actorID uint) error {
|
||||
if instance == nil || instance.ID == 0 || len(fields) == 0 || actorID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "退款审批快照恢复审计资源不完整")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(instance.ID), 10)
|
||||
actor := strconv.FormatUint(uint64(actorID), 10)
|
||||
return w.Append(ctx, tx, AppendInput{EventID: "approval:" + id + ":audit:snapshot_recovered", ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "补齐退款审批快照字段", Actor: ActorInput{Kind: constants.AuditActorAccount, ID: actor}, Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, Metadata: map[string]any{"fields": fields}, Resources: []ResourceInput{{Type: constants.AuditResourceApprovalInstance, ID: &id, Key: id, DisplayName: "审批实例 " + id, Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleApprovalTarget, IdentitySnapshot: map[string]any{"id": instance.ID, "fields": fields}}}})
|
||||
}
|
||||
|
||||
func (w *Writer) WriteRecoveryRequest(ctx context.Context, tx *gorm.DB, instance *model.ApprovalInstance, actorID uint, branch string) error {
|
||||
if instance == nil || instance.ID == 0 || actorID == 0 || strings.TrimSpace(branch) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "退款审批恢复请求审计资源不完整")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(instance.ID), 10)
|
||||
actor := strconv.FormatUint(uint64(actorID), 10)
|
||||
return w.Append(ctx, tx, AppendInput{EventID: "approval:" + id + ":audit:recovery_request:" + branch, ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "请求恢复退款审批原提交", Actor: ActorInput{Kind: constants.AuditActorAccount, ID: actor}, Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, Metadata: map[string]any{"branch": branch}, Resources: []ResourceInput{{Type: constants.AuditResourceApprovalInstance, ID: &id, Key: id, DisplayName: "审批实例 " + id, Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleApprovalTarget, IdentitySnapshot: map[string]any{"id": instance.ID, "status": instance.Status}}}})
|
||||
}
|
||||
|
||||
func approvalResources(ctx context.Context, tx *gorm.DB, change approvalapp.AuditChange) ([]ResourceInput, error) {
|
||||
instanceID := strconv.FormatUint(uint64(change.InstanceID), 10)
|
||||
resources := []ResourceInput{{
|
||||
|
||||
@@ -375,6 +375,7 @@ func NewRegistry() *Registry {
|
||||
approvalSubmissionRecovered := approvalAction(constants.AuditActionApprovalSubmissionRecovered, "恢复审批提交结果", []ActionOrigin{
|
||||
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
|
||||
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
|
||||
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
|
||||
})
|
||||
approvalDecisionSynced := approvalAction(constants.AuditActionApprovalDecisionSynced, "同步审批权威终态", []ActionOrigin{
|
||||
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
|
||||
|
||||
@@ -371,6 +371,17 @@ func (r *ApprovalContextRepository) FindBySPNo(ctx context.Context, applicationI
|
||||
}
|
||||
return r.Get(ctx, channelContext.ApprovalInstanceID)
|
||||
}
|
||||
func (r *ApprovalContextRepository) GetRecoveryRecord(ctx context.Context, instanceID uint) (*ApprovalRecoveryRecord, error) {
|
||||
var record ApprovalRecoveryRecord
|
||||
err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc").Select("wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid, wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at").Where("wc.approval_instance_id = ?", instanceID).Scan(&record).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批恢复记录失败")
|
||||
}
|
||||
if record.InstanceID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// SaveLatestDetail 保存最近一次权威企微审批详情,供终态同步和读取投影复用。
|
||||
func (r *ApprovalContextRepository) SaveLatestDetail(ctx context.Context, instanceID uint, spStatus int, snapshot []byte) error {
|
||||
|
||||
@@ -51,32 +51,51 @@ func buildApprovalForm(ctx context.Context, businessType string, applicationID,
|
||||
for _, control := range template.Controls {
|
||||
requiredControls[control.ID] = control.Required
|
||||
}
|
||||
// 本变更新增字段必须配置控件映射:场景里没有该字段的映射条目时,下面的遍历永远不会看到它,
|
||||
// 该字段会被静默丢弃。因此在组装前先针对本次提交使用的映射逐一确认必须字段有条目,
|
||||
// 缺失即明确失败并指出缺哪个字段/控件;本判定与场景映射校验共用同一份 RequiredSceneFields。
|
||||
// 既有未标注必须映射的可选字段不在其中,缺失映射时保持既有静默跳过行为。
|
||||
mappingFields := make(map[string]struct{}, len(mappings))
|
||||
mappingFields := make(map[string]dto.WeComControlMappingItem, len(mappings))
|
||||
for _, item := range mappings {
|
||||
mappingFields[strings.TrimSpace(item.BusinessField)] = struct{}{}
|
||||
mappingFields[strings.TrimSpace(item.BusinessField)] = item
|
||||
}
|
||||
for _, field := range wecomapp.RequiredSceneFields(businessType) {
|
||||
if _, exists := mappingFields[field.Code]; !exists {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus,
|
||||
"企业微信审批场景缺少必须配置的控件映射: "+field.Name+"(业务字段 "+field.Code+"),请先配置该控件映射后再提交")
|
||||
requiredFields := wecomapp.RequiredSceneFields(businessType)
|
||||
if businessType == constants.ApprovalBusinessTypeRefund {
|
||||
requiredFields = append(requiredFields, wecomapp.RequiredRefundFields(snapshot)...)
|
||||
}
|
||||
for _, field := range requiredFields {
|
||||
mapping, exists := mappingFields[field.Code]
|
||||
if !exists {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批场景缺少必须配置的控件映射: "+field.Name+"(业务字段 "+field.Code+")")
|
||||
}
|
||||
}
|
||||
// 本变更新增字段缺失快照值时同样明确失败:映射已配置却拿不到值,静默跳过会让审批材料
|
||||
// 与本地事实不一致。该校验必须在循环之前完成——循环内的既有文件类控件会经
|
||||
// attachments.Upload 触达外部系统,若把校验留在循环内,失败可能晚于外部调用(ENG-TX-001
|
||||
// 要求事务内不得持有不可回滚的长外部 I/O,同理失败必须发生在任何外呼之前)。
|
||||
// 既有未映射(或映射到既有可选字段)的控件保持既有静默跳过行为。
|
||||
requiredFields := make(map[string]string)
|
||||
for _, field := range wecomapp.RequiredSceneFields(businessType) {
|
||||
requiredFields[field.Code] = field.Name
|
||||
if _, exists := lookupSnapshotValue(snapshot, field.Code); !exists {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus,
|
||||
"审批业务快照缺少必须提交的字段: "+field.Name+"(业务字段 "+field.Code+"),请确认已配置对应控件与业务字段映射")
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少必须提交的字段: "+field.Name)
|
||||
}
|
||||
if businessType == constants.ApprovalBusinessTypeRefund && field.Code == constants.ApprovalFieldRefundMethodCode {
|
||||
methodCode, _ := snapshot[field.Code].(string)
|
||||
methodCode = strings.TrimSpace(methodCode)
|
||||
if methodCode == "" || (methodCode != constants.RefundMethodOriginalRoute && methodCode != constants.RefundMethodCustomerAccount && methodCode != constants.RefundMethodAssetWallet && methodCode != constants.RefundMethodAgentWallet) {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少有效退款方式编码")
|
||||
}
|
||||
}
|
||||
if field.Code == constants.ApprovalFieldCustomerAccountInfo {
|
||||
value, _ := snapshot[field.Code].(string)
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少非空客户收款信息")
|
||||
}
|
||||
if !strings.EqualFold(mapping.ControlType, "textarea") {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "客户收款信息必须映射到多行文本控件")
|
||||
}
|
||||
}
|
||||
if field.Code == constants.ApprovalFieldCustomerVoucherKey {
|
||||
refs, err := parseApprovalFileReferences(snapshot[field.Code])
|
||||
if err != nil || len(refs) == 0 {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少客户收款凭证")
|
||||
}
|
||||
if !strings.EqualFold(mapping.ControlType, "file") {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "客户收款凭证必须映射到附件控件")
|
||||
}
|
||||
}
|
||||
}
|
||||
fieldNames := make(map[string]string, len(requiredFields))
|
||||
for _, field := range requiredFields {
|
||||
fieldNames[field.Code] = field.Name
|
||||
}
|
||||
contents := make([]map[string]any, 0, len(mappings))
|
||||
attachmentCount := 0
|
||||
@@ -86,7 +105,7 @@ func buildApprovalForm(ctx context.Context, businessType string, applicationID,
|
||||
if requiredControls[mapping.ControlID] {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少模板必填字段: "+mapping.BusinessField)
|
||||
}
|
||||
if name, required := requiredFields[strings.TrimSpace(mapping.BusinessField)]; required {
|
||||
if name, required := fieldNames[strings.TrimSpace(mapping.BusinessField)]; required {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少必须提交的字段: "+name+",请确认已配置对应控件与业务字段映射")
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -25,12 +26,21 @@ func NewApprovalRecoveryTaskHandler(contexts *ApprovalContextRepository, infos *
|
||||
return &ApprovalRecoveryTaskHandler{contexts: contexts, infos: infos, queue: queueClient, now: time.Now}
|
||||
}
|
||||
|
||||
// Handle 扫描未终态和结果未知记录;结果未知只查询关联,绝不重新调用 applyevent。
|
||||
func (h *ApprovalRecoveryTaskHandler) Handle(ctx context.Context, _ *asynq.Task) error {
|
||||
// Handle 支持全局扫描任务与按实例人工任务,二者共享同一 unknown 确认算法。
|
||||
func (h *ApprovalRecoveryTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
|
||||
if h == nil || h.contexts == nil || h.infos == nil || h.queue == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "企业微信审批主动恢复任务未配置")
|
||||
}
|
||||
var payload struct {
|
||||
InstanceID uint `json:"instance_id"`
|
||||
}
|
||||
if task != nil && len(task.Payload()) > 0 {
|
||||
_ = sonic.Unmarshal(task.Payload(), &payload)
|
||||
}
|
||||
now := h.now().UTC()
|
||||
if payload.InstanceID > 0 {
|
||||
return h.recoverUnknownInstance(ctx, payload.InstanceID, now)
|
||||
}
|
||||
if err := h.contexts.PromoteStaleSendingToUnknown(ctx, now.Add(-constants.WeComApprovalSendingLease)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -53,48 +63,58 @@ func (h *ApprovalRecoveryTaskHandler) enqueuePendingSync(ctx context.Context, no
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ApprovalRecoveryTaskHandler) recoverUnknownInstance(ctx context.Context, instanceID uint, now time.Time) error {
|
||||
record, err := h.contexts.GetRecoveryRecord(ctx, instanceID)
|
||||
if err != nil || record == nil {
|
||||
return err
|
||||
}
|
||||
claimed, err := h.contexts.ClaimUnknownRecovery(ctx, instanceID, now.Add(-constants.WeComApprovalPollingInterval))
|
||||
if err != nil || !claimed {
|
||||
return err
|
||||
}
|
||||
return h.recoverUnknownRecord(ctx, *record, now)
|
||||
}
|
||||
|
||||
func (h *ApprovalRecoveryTaskHandler) recoverUnknownRecord(ctx context.Context, record ApprovalRecoveryRecord, now time.Time) error {
|
||||
spNo, submissionIntegrationID, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
integrationIDs := []string{submissionIntegrationID}
|
||||
if spNo == "" {
|
||||
var ids []string
|
||||
spNo, ids, err = h.findUniqueSPNo(ctx, record, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
integrationIDs = append(integrationIDs, ids...)
|
||||
}
|
||||
if spNo == "" {
|
||||
return nil
|
||||
}
|
||||
recovered, err := h.contexts.RecoverSubmitted(ctx, record.InstanceID, spNo, integrationIDs, constants.AuditActorScheduledJob, constants.ApprovalAuditActorRecoveryJob, constants.AuditSourceScheduler)
|
||||
if err != nil || !recovered {
|
||||
return err
|
||||
}
|
||||
return h.enqueueDetailSync(ctx, record.ApplicationID, spNo)
|
||||
}
|
||||
|
||||
func (h *ApprovalRecoveryTaskHandler) recoverUnknown(ctx context.Context, now time.Time) error {
|
||||
cutoff := now.Add(-constants.WeComApprovalPollingInterval)
|
||||
records, err := h.contexts.ListUnknownRecovery(ctx, cutoff, constants.WeComApprovalUnknownRecoveryBatchSize)
|
||||
records, err := h.contexts.ListUnknownRecovery(ctx, now.Add(-constants.WeComApprovalPollingInterval), constants.WeComApprovalUnknownRecoveryBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
claimed, err := h.contexts.ClaimUnknownRecovery(ctx, record.InstanceID, cutoff)
|
||||
claimed, err := h.contexts.ClaimUnknownRecovery(ctx, record.InstanceID, now.Add(-constants.WeComApprovalPollingInterval))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
spNo, submissionIntegrationID, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID)
|
||||
if err != nil {
|
||||
if err := h.recoverUnknownRecord(ctx, record, now); err != nil {
|
||||
return err
|
||||
}
|
||||
integrationIDs := []string{submissionIntegrationID}
|
||||
if spNo == "" {
|
||||
var recoveryIntegrationIDs []string
|
||||
spNo, recoveryIntegrationIDs, err = h.findUniqueSPNo(ctx, record, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
integrationIDs = append(integrationIDs, recoveryIntegrationIDs...)
|
||||
}
|
||||
if spNo == "" {
|
||||
continue
|
||||
}
|
||||
recovered, err := h.contexts.RecoverSubmitted(
|
||||
ctx, record.InstanceID, spNo, integrationIDs,
|
||||
constants.AuditActorScheduledJob, constants.ApprovalAuditActorRecoveryJob, constants.AuditSourceScheduler,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if recovered {
|
||||
if err := h.enqueueDetailSync(ctx, record.ApplicationID, spNo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
53
internal/infrastructure/wecom/manual_sync_consumer.go
Normal file
53
internal/infrastructure/wecom/manual_sync_consumer.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
)
|
||||
|
||||
type ManualSyncConsumer struct {
|
||||
db *gorm.DB
|
||||
queue *queue.Client
|
||||
}
|
||||
|
||||
func NewManualSyncConsumer(db *gorm.DB, client *queue.Client) *ManualSyncConsumer {
|
||||
return &ManualSyncConsumer{db: db, queue: client}
|
||||
}
|
||||
|
||||
func (c *ManualSyncConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.db == nil || c.queue == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "审批手动同步消费者未配置")
|
||||
}
|
||||
var payload struct {
|
||||
InstanceID uint `json:"instance_id"`
|
||||
ApplicationID uint `json:"application_id"`
|
||||
SPNo string `json:"sp_no"`
|
||||
}
|
||||
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil || payload.InstanceID == 0 || payload.ApplicationID == 0 || payload.SPNo == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "审批手动同步事件载荷无效")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(payload.InstanceID), 10)
|
||||
var instance model.ApprovalInstance
|
||||
if err := c.db.WithContext(ctx).Where("id = ?", payload.InstanceID).First(&instance).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "审批手动同步实例不存在")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeApprovalManualSyncRequested || envelope.AggregateType != "approval" || envelope.AggregateID != id || envelope.ResourceType != instance.BusinessType || envelope.ResourceID != strconv.FormatUint(uint64(instance.BusinessID), 10) {
|
||||
return errors.New(errors.CodeInvalidParam, "审批手动同步事件身份无效")
|
||||
}
|
||||
var channelContext model.WeComApprovalContext
|
||||
if err := c.db.WithContext(ctx).Where("approval_instance_id = ? AND application_id = ? AND sp_no = ? AND submission_status = ?", payload.InstanceID, payload.ApplicationID, payload.SPNo, constants.WeComSubmissionStatusSubmitted).First(&channelContext).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "审批手动同步上下文不一致")
|
||||
}
|
||||
return c.queue.EnqueueTask(ctx, constants.TaskTypeWeComApprovalSync, ApprovalDetailSyncTask{ApplicationID: payload.ApplicationID, SPNo: payload.SPNo, Source: constants.ApprovalSyncSourceManual})
|
||||
}
|
||||
|
||||
var _ outbox.EventConsumer = (*ManualSyncConsumer)(nil)
|
||||
51
internal/infrastructure/wecom/unknown_recovery_consumer.go
Normal file
51
internal/infrastructure/wecom/unknown_recovery_consumer.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
)
|
||||
|
||||
type UnknownRecoveryConsumer struct {
|
||||
db *gorm.DB
|
||||
queue *queue.Client
|
||||
}
|
||||
|
||||
func NewUnknownRecoveryConsumer(db *gorm.DB, client *queue.Client) *UnknownRecoveryConsumer {
|
||||
return &UnknownRecoveryConsumer{db: db, queue: client}
|
||||
}
|
||||
|
||||
func (c *UnknownRecoveryConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.db == nil || c.queue == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "审批未知恢复消费者未配置")
|
||||
}
|
||||
var payload struct {
|
||||
InstanceID uint `json:"instance_id"`
|
||||
}
|
||||
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil || payload.InstanceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "审批未知恢复事件载荷无效")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(payload.InstanceID), 10)
|
||||
var instance model.ApprovalInstance
|
||||
if err := c.db.WithContext(ctx).Where("id = ?", payload.InstanceID).First(&instance).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "审批未知恢复实例不存在")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeApprovalUnknownRecoveryRequested || envelope.AggregateType != "approval" || envelope.AggregateID != id || envelope.ResourceType != instance.BusinessType || envelope.ResourceID != strconv.FormatUint(uint64(instance.BusinessID), 10) {
|
||||
return errors.New(errors.CodeInvalidParam, "审批未知恢复事件身份无效")
|
||||
}
|
||||
var approvalContext model.WeComApprovalContext
|
||||
if err := c.db.WithContext(ctx).Where("approval_instance_id = ? AND submission_status = ? AND sp_no = ''", payload.InstanceID, constants.WeComSubmissionStatusUnknown).First(&approvalContext).Error; err != nil || instance.Status != constants.ApprovalStatusSubmissionUnknown {
|
||||
return errors.New(errors.CodeConflict, "审批未知恢复上下文不一致")
|
||||
}
|
||||
return c.queue.EnqueueTask(ctx, constants.TaskTypeWeComApprovalRecovery, map[string]any{"instance_id": payload.InstanceID})
|
||||
}
|
||||
|
||||
var _ outbox.EventConsumer = (*UnknownRecoveryConsumer)(nil)
|
||||
@@ -97,35 +97,40 @@ type RefundResponse struct {
|
||||
ChannelRefundAmount int64 `json:"channel_refund_amount" description:"提交渠道的退款金额快照(分)"`
|
||||
ChannelRefundedAt string `json:"channel_refunded_at,omitempty" description:"渠道明确退款成功时间"`
|
||||
// 结算标识:前两项是冻结的来源支付事实,第三项是财务补录的线下处理流水号,语义互不相同。
|
||||
SourcePaymentNo string `json:"source_payment_no" description:"冻结的来源支付单号,取创建申请时的原成功支付记录;线下订单无线上支付记录时为空字符串"`
|
||||
OriginalChannelTradeNo string `json:"original_channel_trade_no" description:"冻结的原支付渠道交易流水号,取创建申请时的原成功支付记录;线下订单无线上支付记录时为空字符串"`
|
||||
OfflineSettlementNo string `json:"offline_settlement_no" description:"线下退款处理流水号或凭证编号,由授权账号补录或更正;与渠道退款流水号语义不同"`
|
||||
OfflineSettledAt string `json:"offline_settled_at,omitempty" description:"线下退款处理流水号最近一次登记或更正时间"`
|
||||
OfflineSettledBy uint `json:"offline_settled_by" description:"线下退款处理流水号最近一次登记或更正的操作账号ID,0 表示未登记"`
|
||||
FailureReason string `json:"failure_reason" description:"结构化失败分类稳定编码 (channel_rejected:渠道明确拒绝, credential_invalid:渠道凭证失效, insufficient_balance:渠道余额不足, timeout_unknown:超时或结果未知, approval_rejected:企业微信驳回或关闭, revoked_after_approved:企业微信通过后撤销, payment_fact_invalid:本地原支付事实不可用),空表示无失败"`
|
||||
FailureReasonName string `json:"failure_reason_name" description:"失败分类中文名称"`
|
||||
FailureMessage string `json:"failure_message" description:"失败安全摘要,供人工排查;不含渠道凭证等敏感内容"`
|
||||
AnomalyFlag int `json:"anomaly_flag" description:"异常标记 (0:无异常, 1:有异常,需人工处理)"`
|
||||
AnomalyReason string `json:"anomaly_reason" description:"异常原因说明,无异常时为空"`
|
||||
LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID,仅用于展示"`
|
||||
LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID,仅用于展示"`
|
||||
Attempts []RefundAttemptResponse `json:"attempts" description:"审批尝试记录,按提交顺序排列,历史材料不被覆盖;无尝试记录时为空数组"`
|
||||
ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"`
|
||||
ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"`
|
||||
RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"`
|
||||
Remark string `json:"remark,omitempty" description:"审批备注"`
|
||||
CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"`
|
||||
AssetReset bool `json:"asset_reset" description:"退款后资产处理是否完成"`
|
||||
SubmitterID uint `json:"submitter_id" description:"提交人账号ID"`
|
||||
SubmitterName string `json:"submitter_name" description:"提交人账号名称"`
|
||||
ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID"`
|
||||
ApprovalProvider string `json:"approval_provider,omitempty" description:"审批渠道,企业微信为wecom"`
|
||||
ApprovalStatus *int `json:"approval_status,omitempty" description:"审批状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
|
||||
ApprovalStatusName string `json:"approval_status_name,omitempty" description:"审批状态名称(中文)"`
|
||||
Creator uint `json:"creator" description:"创建人ID"`
|
||||
Updater uint `json:"updater" description:"更新人ID"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
SourcePaymentNo string `json:"source_payment_no" description:"冻结的来源支付单号,取创建申请时的原成功支付记录;线下订单无线上支付记录时为空字符串"`
|
||||
OriginalChannelTradeNo string `json:"original_channel_trade_no" description:"冻结的原支付渠道交易流水号,取创建申请时的原成功支付记录;线下订单无线上支付记录时为空字符串"`
|
||||
OfflineSettlementNo string `json:"offline_settlement_no" description:"线下退款处理流水号或凭证编号,由授权账号补录或更正;与渠道退款流水号语义不同"`
|
||||
OfflineSettledAt string `json:"offline_settled_at,omitempty" description:"线下退款处理流水号最近一次登记或更正时间"`
|
||||
OfflineSettledBy uint `json:"offline_settled_by" description:"线下退款处理流水号最近一次登记或更正的操作账号ID,0 表示未登记"`
|
||||
FailureReason string `json:"failure_reason" description:"结构化失败分类稳定编码 (channel_rejected:渠道明确拒绝, credential_invalid:渠道凭证失效, insufficient_balance:渠道余额不足, timeout_unknown:超时或结果未知, approval_rejected:企业微信驳回或关闭, revoked_after_approved:企业微信通过后撤销, payment_fact_invalid:本地原支付事实不可用),空表示无失败"`
|
||||
FailureReasonName string `json:"failure_reason_name" description:"失败分类中文名称"`
|
||||
FailureMessage string `json:"failure_message" description:"失败安全摘要,供人工排查;不含渠道凭证等敏感内容"`
|
||||
AnomalyFlag int `json:"anomaly_flag" description:"异常标记 (0:无异常, 1:有异常,需人工处理)"`
|
||||
AnomalyReason string `json:"anomaly_reason" description:"异常原因说明,无异常时为空"`
|
||||
LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID,仅用于展示"`
|
||||
LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID,仅用于展示"`
|
||||
Attempts []RefundAttemptResponse `json:"attempts" description:"审批尝试记录,按提交顺序排列,历史材料不被覆盖;无尝试记录时为空数组"`
|
||||
ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"`
|
||||
ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"`
|
||||
RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"`
|
||||
Remark string `json:"remark,omitempty" description:"审批备注"`
|
||||
CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"`
|
||||
AssetReset bool `json:"asset_reset" description:"退款后资产处理是否完成"`
|
||||
SubmitterID uint `json:"submitter_id" description:"提交人账号ID"`
|
||||
SubmitterName string `json:"submitter_name" description:"提交人账号名称"`
|
||||
ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID"`
|
||||
ApprovalProvider string `json:"approval_provider,omitempty" description:"审批渠道,企业微信为wecom"`
|
||||
ApprovalStatus *int `json:"approval_status,omitempty" description:"审批状态"`
|
||||
ApprovalStatusName string `json:"approval_status_name,omitempty" description:"审批状态名称(中文)"`
|
||||
ApprovalSubmissionStatus *int `json:"submission_status,omitempty" description:"企业微信审批提交状态"`
|
||||
ApprovalSubmissionStatusName string `json:"submission_status_name,omitempty" description:"企业微信审批提交状态名称(中文)"`
|
||||
ApprovalRecoverable bool `json:"approval_recoverable" description:"是否允许恢复原审批提交"`
|
||||
ApprovalFailureSummary string `json:"approval_failure_summary,omitempty" description:"脱敏审批失败摘要"`
|
||||
ApprovalLastRecoveryAt string `json:"approval_last_recovery_at,omitempty" description:"最近一次审批恢复时间"`
|
||||
Creator uint `json:"creator" description:"创建人ID"`
|
||||
Updater uint `json:"updater" description:"更新人ID"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// RefundListResponse 退款申请列表分页响应
|
||||
|
||||
@@ -70,6 +70,10 @@ func registerRefundRoutes(router fiber.Router, handler *admin.RefundHandler, doc
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(refund, doc, groupPath, "POST", "/:id/recover-approval", handler.RecoverApproval, RouteSpec{
|
||||
Summary: "恢复退款原审批提交", Description: "仅恢复最新退款审批实例的原提交事件,不创建新审批实例,不直接调用企业微信接口;审批提交结果未知时必须先由恢复任务确认。", Tags: []string{"退款管理"}, Input: new(dto.RefundIDRequest), Output: new(dto.RefundResponse), Auth: true,
|
||||
})
|
||||
|
||||
Register(refund, doc, groupPath, "POST", "/:id/approve", handler.Approve, RouteSpec{
|
||||
Summary: "审批通过退款申请",
|
||||
Tags: []string{"退款管理"},
|
||||
|
||||
@@ -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