暂存一下,防止丢失

This commit is contained in:
2026-07-24 16:07:18 +08:00
parent 5d6e23f1a5
commit a18ed8bc8d
180 changed files with 13597 additions and 1986 deletions

View 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, "当前环境没有可用审批渠道")
}

View 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
}

View 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
}

View 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
}