暂存一下,防止丢失
This commit is contained in:
127
internal/application/approval/create.go
Normal file
127
internal/application/approval/create.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
approvaldomain "github.com/break/junhong_cmp_fiber/internal/domain/approval"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// CreationService 实现业务侧 Approval Port,并保持渠道前置检查与业务事务分离。
|
||||
type CreationService struct {
|
||||
providers ProviderPort
|
||||
repositories RepositoryProvider
|
||||
eventWriter SubmissionEventWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewCreationService 创建通用审批申请创建用例。
|
||||
func NewCreationService(
|
||||
providers ProviderPort,
|
||||
repositories RepositoryProvider,
|
||||
eventWriter SubmissionEventWriter,
|
||||
now func() time.Time,
|
||||
) *CreationService {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &CreationService{providers: providers, repositories: repositories, eventWriter: eventWriter, now: now}
|
||||
}
|
||||
|
||||
// Prepare 在任何业务事实写入前确认 Adapter、场景和真实发起身份可用。
|
||||
func (s *CreationService) Prepare(ctx context.Context, request PrepareRequest) (Preparation, error) {
|
||||
if s == nil || s.providers == nil || s.repositories == nil || s.eventWriter == nil {
|
||||
return Preparation{}, errors.New(errors.CodeServiceUnavailable, "审批能力尚未配置")
|
||||
}
|
||||
request.BusinessType = strings.TrimSpace(request.BusinessType)
|
||||
request.CorrelationID = strings.TrimSpace(request.CorrelationID)
|
||||
if request.BusinessType == "" || request.SubmitterAccountID == 0 || request.CorrelationID == "" {
|
||||
return Preparation{}, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
providerContext, err := s.providers.Prepare(ctx, request)
|
||||
if err != nil {
|
||||
return Preparation{}, err
|
||||
}
|
||||
providerContext.Provider = strings.TrimSpace(providerContext.Provider)
|
||||
if providerContext.Provider == "" {
|
||||
return Preparation{}, errors.New(errors.CodeServiceUnavailable, "审批渠道未返回有效 provider")
|
||||
}
|
||||
return Preparation{
|
||||
provider: providerContext.Provider, businessType: request.BusinessType,
|
||||
submitterAccountID: request.SubmitterAccountID, correlationID: request.CorrelationID,
|
||||
expiresAt: s.now().UTC().Add(constants.ApprovalPreparationTTL), issuer: s,
|
||||
providerContext: providerContext,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateInTx 使用调用方业务事务原子创建通用实例、渠道上下文和提交 Outbox。
|
||||
func (s *CreationService) CreateInTx(ctx context.Context, tx *gorm.DB, request CreateRequest) (Reference, error) {
|
||||
if s == nil || tx == nil || s.repositories == nil || s.providers == nil || s.eventWriter == nil {
|
||||
return Reference{}, errors.New(errors.CodeInternalError, "通用审批创建用例未完整配置")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
if err := s.validatePreparation(request, now); err != nil {
|
||||
return Reference{}, err
|
||||
}
|
||||
instance, err := approvaldomain.NewInstance(approvaldomain.NewInstanceParams{
|
||||
BusinessType: request.BusinessType, BusinessID: request.BusinessID,
|
||||
SubmitterAccountID: request.SubmitterAccountID, SubmitterSnapshot: request.SubmitterSnapshot,
|
||||
Provider: request.Preparation.provider, RequestSnapshot: request.RequestSnapshot,
|
||||
CorrelationID: request.CorrelationID,
|
||||
}, now)
|
||||
if err != nil {
|
||||
return Reference{}, err
|
||||
}
|
||||
repository := s.repositories.ForDB(tx)
|
||||
if repository == nil {
|
||||
return Reference{}, errors.New(errors.CodeInternalError, "通用审批 Repository 未配置")
|
||||
}
|
||||
if err := repository.Create(ctx, instance); err != nil {
|
||||
return Reference{}, err
|
||||
}
|
||||
if err := s.providers.CreateContextInTx(ctx, tx, request.Preparation.providerContext, instance.ID); err != nil {
|
||||
return Reference{}, err
|
||||
}
|
||||
event := SubmissionRequestedEvent{
|
||||
EventID: "approval:" + strconv.FormatUint(uint64(instance.ID), 10) + ":submission",
|
||||
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
|
||||
SubmitterAccountID: instance.SubmitterAccountID, Provider: instance.Provider,
|
||||
CorrelationID: instance.CorrelationID, OccurredAt: instance.CreatedAt,
|
||||
}
|
||||
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
|
||||
return Reference{}, err
|
||||
}
|
||||
return Reference{InstanceID: instance.ID, Status: instance.Status}, nil
|
||||
}
|
||||
|
||||
func (s *CreationService) validatePreparation(request CreateRequest, now time.Time) error {
|
||||
preparation := request.Preparation
|
||||
if preparation.issuer != s || preparation.expiresAt.IsZero() || !preparation.expiresAt.After(now) {
|
||||
return errors.New(errors.CodeServiceUnavailable, "审批可用性检查已失效,请重新提交")
|
||||
}
|
||||
if request.BusinessType != preparation.businessType ||
|
||||
request.SubmitterAccountID != preparation.submitterAccountID ||
|
||||
request.CorrelationID != preparation.correlationID {
|
||||
return errors.New(errors.CodeInvalidParam, "审批准备结果与业务申请不匹配")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnavailableProviderPort 在当前环境未装配有效审批 Adapter 时失败关闭。
|
||||
type UnavailableProviderPort struct{}
|
||||
|
||||
// Prepare 拒绝在缺少有效 Adapter、场景或发起身份时创建业务审批。
|
||||
func (UnavailableProviderPort) Prepare(_ context.Context, _ PrepareRequest) (ProviderPreparation, error) {
|
||||
return ProviderPreparation{}, errors.New(errors.CodeServiceUnavailable, "当前环境没有可用审批渠道")
|
||||
}
|
||||
|
||||
// CreateContextInTx 防止未配置渠道上下文时误写审批事实。
|
||||
func (UnavailableProviderPort) CreateContextInTx(_ context.Context, _ *gorm.DB, _ ProviderPreparation, _ uint) error {
|
||||
return errors.New(errors.CodeServiceUnavailable, "当前环境没有可用审批渠道")
|
||||
}
|
||||
85
internal/application/approval/dispatch_decision.go
Normal file
85
internal/application/approval/dispatch_decision.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// DecisionProcessingStore 管理标准决策交给业务消费者时的处理租约。
|
||||
type DecisionProcessingStore interface {
|
||||
Claim(ctx context.Context, eventID string, owner string, now time.Time, duration time.Duration) (bool, error)
|
||||
MarkSucceeded(ctx context.Context, eventID string, owner string, now time.Time) (bool, error)
|
||||
MarkFailed(ctx context.Context, eventID string, owner string, now time.Time, errorSummary string) (bool, error)
|
||||
}
|
||||
|
||||
// BusinessDecisionHandler 消费渠道无关标准决策。
|
||||
// 实现必须以审批实例 ID 和决策作为业务幂等键,并且不得依赖任何渠道 SDK、DTO 或状态码。
|
||||
type BusinessDecisionHandler interface {
|
||||
Handle(ctx context.Context, event TerminalDecisionEvent) error
|
||||
}
|
||||
|
||||
// DecisionDispatcher 使用处理租约把标准决策交给对应业务消费者。
|
||||
type DecisionDispatcher struct {
|
||||
store DecisionProcessingStore
|
||||
handlers map[string]BusinessDecisionHandler
|
||||
owner string
|
||||
logger *zap.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewDecisionDispatcher 创建通用审批标准决策分发器。
|
||||
func NewDecisionDispatcher(
|
||||
store DecisionProcessingStore,
|
||||
handlers map[string]BusinessDecisionHandler,
|
||||
owner string,
|
||||
logger *zap.Logger,
|
||||
now func() time.Time,
|
||||
) *DecisionDispatcher {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &DecisionDispatcher{store: store, handlers: handlers, owner: owner, logger: logger, now: now}
|
||||
}
|
||||
|
||||
// Consume 幂等消费一条标准决策;重复投递或其他有效租约正在处理时正常结束。
|
||||
func (d *DecisionDispatcher) Consume(ctx context.Context, event TerminalDecisionEvent) error {
|
||||
if d == nil || d.store == nil || d.owner == "" || event.EventID == "" || event.InstanceID == 0 || event.BusinessType == "" {
|
||||
return errors.New(errors.CodeInternalError, "通用审批决策分发器未完整配置")
|
||||
}
|
||||
handler := d.handlers[event.BusinessType]
|
||||
if handler == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "审批业务消费者尚未注册")
|
||||
}
|
||||
now := d.now().UTC()
|
||||
claimed, err := d.store.Claim(ctx, event.EventID, d.owner, now, constants.ApprovalDecisionDeliveryLeaseDuration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
return nil
|
||||
}
|
||||
if err := handler.Handle(ctx, event); err != nil {
|
||||
if _, markErr := d.store.MarkFailed(ctx, event.EventID, d.owner, d.now().UTC(), "业务消费者处理失败"); markErr != nil {
|
||||
d.logger.Error("审批标准决策失败状态保存失败",
|
||||
zap.String("event_id", event.EventID), zap.Uint("approval_instance_id", event.InstanceID),
|
||||
zap.String("business_type", event.BusinessType), zap.Error(markErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
marked, err := d.store.MarkSucceeded(ctx, event.EventID, d.owner, d.now().UTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !marked {
|
||||
return errors.New(errors.CodeConflict, "审批标准决策处理租约已失效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
81
internal/application/approval/port.go
Normal file
81
internal/application/approval/port.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Package approval 定义业务用例依赖的渠道无关审批接缝。
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PrepareRequest 是业务写入前执行审批渠道可用性检查的请求。
|
||||
type PrepareRequest struct {
|
||||
BusinessType string
|
||||
SubmitterAccountID uint
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// Preparation 是渠道可用性检查返回的短期、不透明准备凭据。
|
||||
type Preparation struct {
|
||||
provider string
|
||||
businessType string
|
||||
submitterAccountID uint
|
||||
correlationID string
|
||||
expiresAt time.Time
|
||||
issuer *CreationService
|
||||
providerContext ProviderPreparation
|
||||
}
|
||||
|
||||
// CreateRequest 是业务事务内创建通用审批实例的请求。
|
||||
type CreateRequest struct {
|
||||
Preparation Preparation
|
||||
BusinessType string
|
||||
BusinessID uint
|
||||
SubmitterAccountID uint
|
||||
SubmitterSnapshot []byte
|
||||
RequestSnapshot []byte
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// Reference 是业务表保存的通用审批实例引用。
|
||||
type Reference struct {
|
||||
InstanceID uint
|
||||
Status int
|
||||
}
|
||||
|
||||
// Port 是退款、线下充值等业务用例唯一依赖的审批创建接缝。
|
||||
// Prepare 必须在业务事务前确认 Adapter、场景和发起身份可用;CreateInTx 必须复核准备凭据并使用调用方事务写入审批事实。
|
||||
type Port interface {
|
||||
Prepare(ctx context.Context, request PrepareRequest) (Preparation, error)
|
||||
CreateInTx(ctx context.Context, tx *gorm.DB, request CreateRequest) (Reference, error)
|
||||
}
|
||||
|
||||
// ProviderPreparation 是渠道 Adapter 在事务前完成场景和发起身份检查后返回的内部准备结果。
|
||||
// ChannelContext 只能包含后续写入渠道专属表所需的安全快照,不得包含密钥或访问令牌。
|
||||
type ProviderPreparation struct {
|
||||
Provider string
|
||||
ChannelContext []byte
|
||||
}
|
||||
|
||||
// ProviderPort 定义具体审批渠道对通用创建用例提供的防腐接缝。
|
||||
type ProviderPort interface {
|
||||
Prepare(ctx context.Context, request PrepareRequest) (ProviderPreparation, error)
|
||||
CreateContextInTx(ctx context.Context, tx *gorm.DB, preparation ProviderPreparation, instanceID uint) error
|
||||
}
|
||||
|
||||
// SubmissionRequestedEvent 是渠道提交 Worker 接收的通用申请事件。
|
||||
type SubmissionRequestedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
InstanceID uint `json:"instance_id"`
|
||||
BusinessType string `json:"business_type"`
|
||||
BusinessID uint `json:"business_id"`
|
||||
SubmitterAccountID uint `json:"submitter_account_id"`
|
||||
Provider string `json:"provider"`
|
||||
CorrelationID string `json:"correlation_id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
// SubmissionEventWriter 在调用方业务事务中追加渠道提交 Outbox。
|
||||
type SubmissionEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event SubmissionRequestedEvent) error
|
||||
}
|
||||
145
internal/application/approval/sync_decision.go
Normal file
145
internal/application/approval/sync_decision.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
approvaldomain "github.com/break/junhong_cmp_fiber/internal/domain/approval"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RepositoryProvider 为当前 GORM 事务提供纯领域 Repository。
|
||||
type RepositoryProvider interface {
|
||||
ForDB(db *gorm.DB) approvaldomain.Repository
|
||||
}
|
||||
|
||||
// TerminalDecisionEvent 是业务消费者接收的渠道无关标准决策事件。
|
||||
type TerminalDecisionEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
InstanceID uint `json:"instance_id"`
|
||||
BusinessType string `json:"business_type"`
|
||||
BusinessID uint `json:"business_id"`
|
||||
SubmitterAccountID uint `json:"submitter_account_id"`
|
||||
Decision string `json:"decision"`
|
||||
Source string `json:"source"`
|
||||
CorrelationID string `json:"correlation_id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
// TerminalEventWriter 在审批状态事务中追加标准决策 Outbox。
|
||||
type TerminalEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event TerminalDecisionEvent) error
|
||||
}
|
||||
|
||||
// DecisionDeliveryWriter 在审批状态事务中创建业务消费租约事实。
|
||||
type DecisionDeliveryWriter interface {
|
||||
Create(ctx context.Context, tx *gorm.DB, event TerminalDecisionEvent) error
|
||||
}
|
||||
|
||||
// SyncDecisionCommand 是回调、兜底轮询和受控人工同步共用的标准决策命令。
|
||||
type SyncDecisionCommand struct {
|
||||
InstanceID uint
|
||||
Decision string
|
||||
DecisionSnapshot []byte
|
||||
Source string
|
||||
}
|
||||
|
||||
// SyncDecisionResult 返回本次是否首次记录该标准终态。
|
||||
type SyncDecisionResult struct {
|
||||
Status int
|
||||
FirstTerminal bool
|
||||
}
|
||||
|
||||
// SyncDecisionService 统一处理各审批渠道回传的标准决策。
|
||||
type SyncDecisionService struct {
|
||||
db *gorm.DB
|
||||
repositories RepositoryProvider
|
||||
eventWriter TerminalEventWriter
|
||||
deliveryWriter DecisionDeliveryWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewSyncDecisionService 创建标准决策同步用例。
|
||||
func NewSyncDecisionService(
|
||||
db *gorm.DB,
|
||||
repositories RepositoryProvider,
|
||||
eventWriter TerminalEventWriter,
|
||||
deliveryWriter DecisionDeliveryWriter,
|
||||
now func() time.Time,
|
||||
) *SyncDecisionService {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &SyncDecisionService{
|
||||
db: db, repositories: repositories, eventWriter: eventWriter, deliveryWriter: deliveryWriter, now: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute 将回调或轮询取得的权威渠道状态原子转换为通用审批终态和可靠业务事件。
|
||||
func (s *SyncDecisionService) Execute(ctx context.Context, command SyncDecisionCommand) (*SyncDecisionResult, error) {
|
||||
if s == nil || s.db == nil || s.repositories == nil || s.eventWriter == nil || s.deliveryWriter == nil {
|
||||
return nil, errors.New(errors.CodeInternalError, "通用审批决策同步用例未完整配置")
|
||||
}
|
||||
if command.InstanceID == 0 || !isSupportedSyncSource(command.Source) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result := &SyncDecisionResult{}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
repository := s.repositories.ForDB(tx)
|
||||
if repository == nil {
|
||||
return errors.New(errors.CodeInternalError, "通用审批 Repository 未配置")
|
||||
}
|
||||
instance, err := repository.GetForUpdate(ctx, command.InstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expectedStatus, expectedVersion := instance.Status, instance.Version
|
||||
changed, err := instance.ApplyDecision(command.Decision, command.DecisionSnapshot, s.now().UTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.Status = instance.Status
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
saved, err := repository.SaveDecision(ctx, instance, expectedStatus, expectedVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !saved {
|
||||
return errors.New(errors.CodeConflict, "审批状态已被其他同步任务更新")
|
||||
}
|
||||
event := TerminalDecisionEvent{
|
||||
EventID: terminalDecisionEventID(instance.ID, command.Decision),
|
||||
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
|
||||
SubmitterAccountID: instance.SubmitterAccountID, Decision: command.Decision, Source: command.Source,
|
||||
CorrelationID: instance.CorrelationID, OccurredAt: instance.StatusChangedAt,
|
||||
}
|
||||
if err := s.deliveryWriter.Create(ctx, tx, event); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
|
||||
return err
|
||||
}
|
||||
result.FirstTerminal = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func terminalDecisionEventID(instanceID uint, decision string) string {
|
||||
return "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":" + decision
|
||||
}
|
||||
|
||||
func isSupportedSyncSource(source string) bool {
|
||||
return source == constants.ApprovalSyncSourceCallback ||
|
||||
source == constants.ApprovalSyncSourcePolling ||
|
||||
source == constants.ApprovalSyncSourceManual
|
||||
}
|
||||
Reference in New Issue
Block a user