暂存一下,防止丢失
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
|
||||
}
|
||||
120
internal/application/cardobservation/apply.go
Normal file
120
internal/application/cardobservation/apply.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Package cardobservation 提供卡实名观测的复杂写用例。
|
||||
package cardobservation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// RealnameChangedEvent 是卡实名状态变化的可靠领域事实。
|
||||
type RealnameChangedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
CardID uint `json:"card_id"`
|
||||
BeforeStatus int `json:"before_status"`
|
||||
AfterStatus int `json:"after_status"`
|
||||
FirstVerified bool `json:"first_verified"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
Source string `json:"source"`
|
||||
Scene string `json:"scene"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// EventWriter 在卡状态事务中追加领域 Outbox 事件。
|
||||
type EventWriter interface {
|
||||
AppendRealname(ctx context.Context, tx *gorm.DB, event RealnameChangedEvent) error
|
||||
AppendTraffic(ctx context.Context, tx *gorm.DB, event TrafficIncrementedEvent) error
|
||||
AppendNetwork(ctx context.Context, tx *gorm.DB, event NetworkChangedEvent) error
|
||||
}
|
||||
|
||||
// CacheInvalidator 在业务事务提交后失效卡缓存。
|
||||
type CacheInvalidator interface {
|
||||
Invalidate(ctx context.Context, cardID uint)
|
||||
}
|
||||
|
||||
// Service 负责卡实名观测的锁定、规则应用和可靠事件写入。
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
eventWriter EventWriter
|
||||
cache CacheInvalidator
|
||||
}
|
||||
|
||||
// NewService 创建卡实名观测应用服务。
|
||||
func NewService(db *gorm.DB, eventWriter EventWriter, cache CacheInvalidator) *Service {
|
||||
return &Service{db: db, eventWriter: eventWriter, cache: cache}
|
||||
}
|
||||
|
||||
// ApplyCardObservation 在同一事务中应用实名状态、逆转窗口和状态变更事件。
|
||||
func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.RealnameObservation) (domain.RealnameDecision, error) {
|
||||
if s == nil || s.db == nil || s.eventWriter == nil {
|
||||
return domain.RealnameDecision{}, errors.New(errors.CodeInternalError, "卡实名观测能力未完整配置")
|
||||
}
|
||||
var decision domain.RealnameDecision
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var card model.IotCard
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "IoT卡不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败")
|
||||
}
|
||||
nextDecision, decisionErr := domain.ApplyRealname(domain.CardRealnameSnapshot{
|
||||
CardID: card.ID, Status: card.RealNameStatus, FirstRealnameAt: card.FirstRealnameAt,
|
||||
ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt,
|
||||
}, observation)
|
||||
if decisionErr != nil {
|
||||
return decisionErr
|
||||
}
|
||||
decision = nextDecision
|
||||
updates := map[string]any{
|
||||
"last_real_name_check_at": observation.Metadata.ObservedAt,
|
||||
"realname_reversal_count": decision.ReversalCount,
|
||||
"realname_reversal_started_at": decision.ReversalStartedAt,
|
||||
}
|
||||
if observation.Metadata.Source != constants.CardObservationSourceManualOverride {
|
||||
updates["last_sync_time"] = observation.Metadata.ObservedAt
|
||||
}
|
||||
if decision.StatusChanged {
|
||||
updates["real_name_status"] = decision.AfterStatus
|
||||
updates["activation_status"] = gorm.Expr(`CASE WHEN network_status = ? AND (card_category = ? OR ? = ?) THEN 1 ELSE 0 END`, constants.NetworkStatusOnline, constants.CardCategoryIndustry, decision.AfterStatus, constants.RealNameStatusVerified)
|
||||
updates["activated_at"] = gorm.Expr(`CASE WHEN activated_at IS NULL AND (network_status = ? AND (card_category = ? OR ? = ?)) THEN ? ELSE activated_at END`, constants.NetworkStatusOnline, constants.CardCategoryIndustry, decision.AfterStatus, constants.RealNameStatusVerified, observation.Metadata.ObservedAt)
|
||||
}
|
||||
if decision.FirstVerified {
|
||||
updates["first_realname_at"] = observation.Metadata.ObservedAt
|
||||
}
|
||||
result := tx.Model(&model.IotCard{}).Where("id = ? AND real_name_status = ?", card.ID, card.RealNameStatus).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡实名事实失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "卡实名状态已被其他请求更新")
|
||||
}
|
||||
if decision.StatusChanged {
|
||||
eventID := "card-realname:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":changed"
|
||||
if err := s.eventWriter.AppendRealname(ctx, tx, RealnameChangedEvent{
|
||||
EventID: eventID, CardID: card.ID, BeforeStatus: card.RealNameStatus, AfterStatus: decision.AfterStatus,
|
||||
FirstVerified: decision.FirstVerified, ObservedAt: observation.Metadata.ObservedAt,
|
||||
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
|
||||
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡实名 Outbox 事件失败")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.RealnameDecision{}, err
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.Invalidate(ctx, observation.CardID)
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
98
internal/application/cardobservation/network.go
Normal file
98
internal/application/cardobservation/network.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package cardobservation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// NetworkChangedEvent 是卡网络状态变化的可靠领域事实。
|
||||
type NetworkChangedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
CardID uint `json:"card_id"`
|
||||
BeforeStatus int `json:"before_status"`
|
||||
AfterStatus int `json:"after_status"`
|
||||
GatewayExtend string `json:"gateway_extend"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
Source string `json:"source"`
|
||||
Scene string `json:"scene"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// ApplyNetworkObservation 串行应用 Gateway 网络状态、扩展原因、IMEI 和风险轮询规则。
|
||||
func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domain.NetworkObservation) (domain.NetworkDecision, error) {
|
||||
if s == nil || s.db == nil || s.eventWriter == nil {
|
||||
return domain.NetworkDecision{}, errors.New(errors.CodeInternalError, "卡网络观测能力未完整配置")
|
||||
}
|
||||
var decision domain.NetworkDecision
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var card model.IotCard
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "IoT卡不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡网络事实失败")
|
||||
}
|
||||
nextDecision, decisionErr := domain.ApplyNetwork(domain.CardNetworkSnapshot{
|
||||
CardID: card.ID, NetworkStatus: card.NetworkStatus, StopReason: card.StopReason,
|
||||
IsStandalone: card.IsStandalone, EnablePolling: card.EnablePolling,
|
||||
}, observation)
|
||||
if decisionErr != nil {
|
||||
return decisionErr
|
||||
}
|
||||
decision = nextDecision
|
||||
updates := map[string]any{
|
||||
"last_card_status_check_at": observation.Metadata.ObservedAt,
|
||||
"last_sync_time": observation.Metadata.ObservedAt,
|
||||
"gateway_extend": decision.GatewayExtend,
|
||||
}
|
||||
if decision.UpdateIMEI {
|
||||
updates["gateway_card_imei"] = decision.GatewayIMEI
|
||||
}
|
||||
if decision.StatusChanged {
|
||||
updates["network_status"] = decision.AfterStatus
|
||||
}
|
||||
if decision.StopReasonChanged {
|
||||
updates["stop_reason"] = decision.StopReason
|
||||
}
|
||||
if decision.StopPolling {
|
||||
updates["enable_polling"] = false
|
||||
}
|
||||
result := tx.Model(&model.IotCard{}).
|
||||
Where("id = ? AND network_status = ? AND enable_polling = ?", card.ID, card.NetworkStatus, card.EnablePolling).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡网络事实失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "卡网络事实已被其他请求更新")
|
||||
}
|
||||
if !decision.StatusChanged {
|
||||
return nil
|
||||
}
|
||||
eventID := "card-network:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":changed"
|
||||
if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{
|
||||
EventID: eventID, CardID: card.ID, BeforeStatus: card.NetworkStatus, AfterStatus: decision.AfterStatus,
|
||||
GatewayExtend: decision.GatewayExtend, ObservedAt: observation.Metadata.ObservedAt,
|
||||
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
|
||||
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡网络 Outbox 事件失败")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.NetworkDecision{}, err
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.Invalidate(ctx, observation.CardID)
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
241
internal/application/cardobservation/series.go
Normal file
241
internal/application/cardobservation/series.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package cardobservation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// SeriesRequest 描述一次业务成功边界产生的观测序列请求。
|
||||
type SeriesRequest struct {
|
||||
Scene string `json:"scene"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
SyncType string `json:"sync_type"`
|
||||
ExpectedValue string `json:"expected_value,omitempty"`
|
||||
Source string `json:"source"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// SeriesTaskPayload 是固定三次 Asynq 任务的结构化载荷。
|
||||
type SeriesTaskPayload struct {
|
||||
SeriesID string `json:"series_id"`
|
||||
Attempt int `json:"attempt"`
|
||||
ScheduledAt time.Time `json:"scheduled_at"`
|
||||
Scene string `json:"scene"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
SyncType string `json:"sync_type"`
|
||||
ExpectedValue string `json:"expected_value,omitempty"`
|
||||
Source string `json:"source"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// RunResult 描述一次实际 Gateway 请求及公共观测应用结果。
|
||||
type RunResult struct {
|
||||
StateChanged bool
|
||||
RateLimited bool
|
||||
}
|
||||
|
||||
// SeriesCoordinator 管理活跃序列、尝试幂等和实际请求互斥。
|
||||
type SeriesCoordinator interface {
|
||||
Reserve(ctx context.Context, request SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (seriesID string, baseTime time.Time, merged bool, err error)
|
||||
IsCompleted(ctx context.Context, seriesID string) (bool, error)
|
||||
ClaimAttempt(ctx context.Context, seriesID string, attempt int) (bool, error)
|
||||
AcquireRequest(ctx context.Context, payload SeriesTaskPayload, provider string) (release func(), wait time.Duration, acquired bool, err error)
|
||||
FinishAttempt(ctx context.Context, payload SeriesTaskPayload) error
|
||||
CompleteSeries(ctx context.Context, payload SeriesTaskPayload) error
|
||||
CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error
|
||||
}
|
||||
|
||||
// SeriesScheduler 提交固定的零重试观测任务。
|
||||
type SeriesScheduler interface {
|
||||
Enqueue(ctx context.Context, payload SeriesTaskPayload) error
|
||||
}
|
||||
|
||||
// SeriesAttemptLogger 记录未访问 Gateway 的合并、互斥、限频和提前完成结果。
|
||||
type SeriesAttemptLogger interface {
|
||||
Record(ctx context.Context, payload SeriesTaskPayload, result, reason string) error
|
||||
RecordMerged(ctx context.Context, request SeriesRequest, seriesID string) error
|
||||
}
|
||||
|
||||
// SeriesRunner 查询本地快照并执行一次 Gateway 公共观测。
|
||||
type SeriesRunner interface {
|
||||
Provider(ctx context.Context, payload SeriesTaskPayload) (string, error)
|
||||
ExpectationMet(ctx context.Context, payload SeriesTaskPayload) (bool, error)
|
||||
Run(ctx context.Context, payload SeriesTaskPayload) (RunResult, error)
|
||||
}
|
||||
|
||||
// SeriesTrigger 创建或合并固定的立即、3 分钟、5 分钟任务序列。
|
||||
type SeriesTrigger struct {
|
||||
coordinator SeriesCoordinator
|
||||
scheduler SeriesScheduler
|
||||
logger SeriesAttemptLogger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewSeriesTrigger 创建观测序列触发器。
|
||||
func NewSeriesTrigger(coordinator SeriesCoordinator, scheduler SeriesScheduler, logger SeriesAttemptLogger) *SeriesTrigger {
|
||||
return &SeriesTrigger{coordinator: coordinator, scheduler: scheduler, logger: logger, now: time.Now}
|
||||
}
|
||||
|
||||
// Trigger 创建新序列;同场景未结束序列只留合并记录,不延长原序列。
|
||||
func (s *SeriesTrigger) Trigger(ctx context.Context, request SeriesRequest) (string, bool, error) {
|
||||
if s == nil || s.coordinator == nil || s.scheduler == nil || s.logger == nil {
|
||||
return "", false, errors.New(errors.CodeInternalError, "卡观测序列触发能力未完整配置")
|
||||
}
|
||||
if err := validateSeriesRequest(request); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
candidateBaseTime := s.now().UTC()
|
||||
seriesID, baseTime, merged, err := s.coordinator.Reserve(ctx, request, uuid.NewString(), candidateBaseTime)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
// 重复触发仍以稳定任务 ID 补齐首次入队的局部失败;已存在任务由 Asynq 去重,不会延长原序列。
|
||||
for attempt := 1; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
|
||||
scheduledAt := baseTime.Add(constants.CardObservationAttemptDelay(attempt))
|
||||
payload := SeriesTaskPayload{
|
||||
SeriesID: seriesID, Attempt: attempt, ScheduledAt: scheduledAt,
|
||||
Scene: request.Scene, ResourceType: request.ResourceType, ResourceID: request.ResourceID,
|
||||
SyncType: request.SyncType, ExpectedValue: request.ExpectedValue, Source: request.Source,
|
||||
RequestID: request.RequestID, CorrelationID: request.CorrelationID,
|
||||
}
|
||||
if err := s.scheduler.Enqueue(ctx, payload); err != nil {
|
||||
return seriesID, false, err
|
||||
}
|
||||
}
|
||||
if merged {
|
||||
if err := s.logger.RecordMerged(ctx, request, seriesID); err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
}
|
||||
return seriesID, merged, nil
|
||||
}
|
||||
|
||||
// SeriesAttemptService 执行单次事件观测,不改变后续阶梯任务。
|
||||
type SeriesAttemptService struct {
|
||||
coordinator SeriesCoordinator
|
||||
runner SeriesRunner
|
||||
logger SeriesAttemptLogger
|
||||
}
|
||||
|
||||
// NewSeriesAttemptService 创建序列尝试服务。
|
||||
func NewSeriesAttemptService(coordinator SeriesCoordinator, runner SeriesRunner, logger SeriesAttemptLogger) *SeriesAttemptService {
|
||||
return &SeriesAttemptService{coordinator: coordinator, runner: runner, logger: logger}
|
||||
}
|
||||
|
||||
// Execute 执行一次幂等尝试;失败只结束当前任务。
|
||||
func (s *SeriesAttemptService) Execute(ctx context.Context, payload SeriesTaskPayload) error {
|
||||
if s == nil || s.coordinator == nil || s.runner == nil || s.logger == nil {
|
||||
return errors.New(errors.CodeInternalError, "卡观测序列执行能力未完整配置")
|
||||
}
|
||||
if err := validateSeriesPayload(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
claimed, err := s.coordinator.ClaimAttempt(ctx, payload.SeriesID, payload.Attempt)
|
||||
if err != nil || !claimed {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = s.coordinator.FinishAttempt(context.Background(), payload) }()
|
||||
|
||||
completed, err := s.coordinator.IsCompleted(ctx, payload.SeriesID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if completed {
|
||||
return s.completeRemainingAttempts(ctx, payload, "序列已提前完成")
|
||||
}
|
||||
met, err := s.runner.ExpectationMet(ctx, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if met {
|
||||
return s.completeRemainingAttempts(ctx, payload, "本地快照已达到预期")
|
||||
}
|
||||
provider, err := s.runner.Provider(ctx, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
release, wait, acquired, err := s.coordinator.AcquireRequest(ctx, payload, provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !acquired {
|
||||
return s.logger.Record(ctx, payload, constants.IntegrationResultIgnored, "实际 Gateway 请求正在执行")
|
||||
}
|
||||
defer release()
|
||||
if wait > 0 {
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return s.logger.Record(ctx, payload, constants.IntegrationResultRateLimited, "最小请求间隔等待被取消")
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
result, err := s.runner.Run(ctx, payload)
|
||||
if result.RateLimited {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if payload.Attempt == constants.CardObservationSeriesAttemptCount {
|
||||
return s.coordinator.CompleteSeries(ctx, payload)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SeriesAttemptService) completeRemainingAttempts(ctx context.Context, payload SeriesTaskPayload, reason string) error {
|
||||
baseTime := payload.ScheduledAt.Add(-constants.CardObservationAttemptDelay(payload.Attempt))
|
||||
for attempt := payload.Attempt; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
|
||||
remaining := payload
|
||||
remaining.Attempt = attempt
|
||||
remaining.ScheduledAt = baseTime.Add(constants.CardObservationAttemptDelay(attempt))
|
||||
if err := s.logger.Record(ctx, remaining, constants.IntegrationResultCompleted, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.coordinator.CompleteSeries(ctx, payload)
|
||||
}
|
||||
|
||||
// CompleteResourceSeries 供可信回调在公共观测成功后提前完成同资源序列。
|
||||
func (s *SeriesAttemptService) CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error {
|
||||
if s == nil || s.coordinator == nil {
|
||||
return errors.New(errors.CodeInternalError, "卡观测序列协调器未配置")
|
||||
}
|
||||
return s.coordinator.CompleteResourceSeries(ctx, resourceType, resourceID, syncType)
|
||||
}
|
||||
|
||||
func validateSeriesRequest(request SeriesRequest) error {
|
||||
if strings.TrimSpace(request.Scene) == "" || strings.TrimSpace(request.ResourceType) == "" ||
|
||||
strings.TrimSpace(request.ResourceID) == "" || !validSyncType(request.SyncType) {
|
||||
return errors.New(errors.CodeInvalidParam, "卡观测序列参数不完整")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSeriesPayload(payload SeriesTaskPayload) error {
|
||||
if payload.SeriesID == "" || payload.Attempt < 1 || payload.Attempt > constants.CardObservationSeriesAttemptCount {
|
||||
return errors.New(errors.CodeInvalidParam, "卡观测序列任务载荷无效")
|
||||
}
|
||||
return validateSeriesRequest(SeriesRequest{Scene: payload.Scene, ResourceType: payload.ResourceType, ResourceID: payload.ResourceID, SyncType: payload.SyncType})
|
||||
}
|
||||
|
||||
func validSyncType(syncType string) bool {
|
||||
switch syncType {
|
||||
case constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic,
|
||||
constants.CardObservationSyncTypeNetwork, constants.CardObservationSyncTypeDeviceInfo:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
86
internal/application/cardobservation/traffic.go
Normal file
86
internal/application/cardobservation/traffic.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package cardobservation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// TrafficIncrementedEvent 是卡流量正增量的可靠领域事实。
|
||||
type TrafficIncrementedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
CardID uint `json:"card_id"`
|
||||
IncrementMB float64 `json:"increment_mb"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
Source string `json:"source"`
|
||||
Scene string `json:"scene"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// ApplyTrafficObservation 串行应用流量读数,并在正增量时同事务写 Outbox。
|
||||
func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domain.TrafficObservation) (domain.TrafficDecision, error) {
|
||||
if s == nil || s.db == nil || s.eventWriter == nil {
|
||||
return domain.TrafficDecision{}, errors.New(errors.CodeInternalError, "卡流量观测能力未完整配置")
|
||||
}
|
||||
var decision domain.TrafficDecision
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var card model.IotCard
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "IoT卡不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡流量事实失败")
|
||||
}
|
||||
nextDecision, decisionErr := domain.ApplyTraffic(domain.CardTrafficSnapshot{
|
||||
CardID: card.ID, DataUsageMB: card.DataUsageMB, CurrentMonthUsageMB: card.CurrentMonthUsageMB,
|
||||
CurrentMonthStartDate: card.CurrentMonthStartDate, LastMonthTotalMB: card.LastMonthTotalMB,
|
||||
LastGatewayReadingMB: card.LastGatewayReadingMB,
|
||||
}, observation)
|
||||
if decisionErr != nil {
|
||||
return decisionErr
|
||||
}
|
||||
decision = nextDecision
|
||||
updates := map[string]any{
|
||||
"last_data_check_at": observation.Metadata.ObservedAt, "last_sync_time": observation.Metadata.ObservedAt,
|
||||
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
|
||||
"current_month_usage_mb": decision.CurrentMonthUsageMB, "data_usage_mb": decision.DataUsageMB,
|
||||
}
|
||||
if decision.ReadingAccepted {
|
||||
updates["last_gateway_reading_mb"] = decision.LastGatewayReadingMB
|
||||
}
|
||||
result := tx.Model(&model.IotCard{}).
|
||||
Where("id = ? AND last_gateway_reading_mb = ?", card.ID, card.LastGatewayReadingMB).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡流量事实失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "卡流量基线已被其他请求更新")
|
||||
}
|
||||
if decision.IncrementMB <= 0 {
|
||||
return nil
|
||||
}
|
||||
eventID := "card-traffic:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":incremented"
|
||||
return s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
|
||||
EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB,
|
||||
ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source,
|
||||
Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID,
|
||||
CorrelationID: observation.Metadata.CorrelationID,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return domain.TrafficDecision{}, err
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.Invalidate(ctx, observation.CardID)
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
252
internal/application/notification/delivery.go
Normal file
252
internal/application/notification/delivery.go
Normal file
@@ -0,0 +1,252 @@
|
||||
// Package notification 提供站内通知简单写用例与 Outbox 消费边界。
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
notificationinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// AdminDirectPayload 是明确后台账号通知的结构化 Outbox 载荷。
|
||||
type AdminDirectPayload struct {
|
||||
RecipientID uint `json:"recipient_id"`
|
||||
NotificationType string `json:"notification_type"`
|
||||
TemplateData map[string]string `json:"template_data"`
|
||||
RefType string `json:"ref_type,omitempty"`
|
||||
RefID string `json:"ref_id,omitempty"`
|
||||
RefKey string `json:"ref_key,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// PersonalCustomerDirectPayload 是明确个人客户通知的结构化 Outbox 载荷。
|
||||
type PersonalCustomerDirectPayload = AdminDirectPayload
|
||||
|
||||
// AdminDynamicPayload 是按账号、平台角色或店铺动态解析后台接收人的结构化 Outbox 载荷。
|
||||
type AdminDynamicPayload struct {
|
||||
TargetKind string `json:"target_kind"`
|
||||
TargetID uint `json:"target_id"`
|
||||
NotificationType string `json:"notification_type"`
|
||||
TemplateData map[string]string `json:"template_data"`
|
||||
RefType string `json:"ref_type,omitempty"`
|
||||
RefID string `json:"ref_id,omitempty"`
|
||||
RefKey string `json:"ref_key,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type deliveryRequest struct {
|
||||
notificationType string
|
||||
templateData map[string]string
|
||||
refType string
|
||||
refID string
|
||||
refKey string
|
||||
expiresAt *time.Time
|
||||
}
|
||||
|
||||
// DeliveryService 校验接收人并幂等生成站内通知。
|
||||
type DeliveryService struct {
|
||||
repository *notificationinfra.Repository
|
||||
registry *notificationinfra.Registry
|
||||
resolver DynamicRecipientResolver
|
||||
logger *zap.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewDeliveryService 创建站内通知投递用例。
|
||||
func NewDeliveryService(repository *notificationinfra.Repository, registry *notificationinfra.Registry, resolver DynamicRecipientResolver, logger *zap.Logger) *DeliveryService {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &DeliveryService{repository: repository, registry: registry, resolver: resolver, logger: logger, now: time.Now}
|
||||
}
|
||||
|
||||
// Consume 消费明确或动态接收人通知事件;所有可恢复错误交给 Asynq 重试策略处理。
|
||||
func (s *DeliveryService) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if envelope.PayloadVersion != constants.NotificationPayloadVersionV1 {
|
||||
return errors.New(errors.CodeInvalidParam, "通知事件类型或载荷版本不受支持")
|
||||
}
|
||||
if envelope.EventType == constants.OutboxEventTypeAdminDynamicNotification {
|
||||
return s.consumeDynamic(ctx, envelope)
|
||||
}
|
||||
return s.consumeDirect(ctx, envelope)
|
||||
}
|
||||
|
||||
func (s *DeliveryService) consumeDirect(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
recipientKind, err := recipientKindForDirectEvent(envelope.EventType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var payload AdminDirectPayload
|
||||
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "通知事件载荷格式错误")
|
||||
}
|
||||
if payload.RecipientID == 0 || payload.NotificationType == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "通知事件载荷不完整")
|
||||
}
|
||||
request := deliveryRequest{
|
||||
notificationType: payload.NotificationType, templateData: payload.TemplateData,
|
||||
refType: payload.RefType, refID: payload.RefID, refKey: payload.RefKey, expiresAt: payload.ExpiresAt,
|
||||
}
|
||||
if err := validateDeliveryRequest(request); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deliver(ctx, envelope.EventID, recipientKind, []uint{payload.RecipientID}, request)
|
||||
}
|
||||
|
||||
func (s *DeliveryService) consumeDynamic(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
var payload AdminDynamicPayload
|
||||
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "通知事件载荷格式错误")
|
||||
}
|
||||
if payload.TargetKind == "" || payload.TargetID == 0 || payload.NotificationType == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "通知事件载荷不完整")
|
||||
}
|
||||
if s.resolver == nil {
|
||||
return errors.New(errors.CodeInternalError, "通知动态接收人解析器未配置")
|
||||
}
|
||||
request := deliveryRequest{
|
||||
notificationType: payload.NotificationType, templateData: payload.TemplateData,
|
||||
refType: payload.RefType, refID: payload.RefID, refKey: payload.RefKey, expiresAt: payload.ExpiresAt,
|
||||
}
|
||||
if err := validateDeliveryRequest(request); err != nil {
|
||||
return err
|
||||
}
|
||||
recipientIDs, err := s.resolver.Resolve(ctx, payload.TargetKind, payload.TargetID)
|
||||
if err != nil {
|
||||
s.logger.Error("站内通知接收人解析失败",
|
||||
zap.String("event_id", envelope.EventID), zap.String("notification_type", payload.NotificationType),
|
||||
zap.String("target_kind", payload.TargetKind), zap.Uint("target_id", payload.TargetID),
|
||||
zap.String("failure_category", "recipient_resolution"))
|
||||
return err
|
||||
}
|
||||
if len(recipientIDs) == 0 {
|
||||
s.logger.Info("站内通知暂无可用接收人,已正常结束",
|
||||
zap.String("event_id", envelope.EventID), zap.String("target_kind", payload.TargetKind), zap.Uint("target_id", payload.TargetID),
|
||||
zap.String("resolution", "no_recipient"))
|
||||
return nil
|
||||
}
|
||||
return s.deliver(ctx, envelope.EventID, constants.NotificationRecipientKindAccount, recipientIDs, request)
|
||||
}
|
||||
|
||||
func validateDeliveryRequest(request deliveryRequest) error {
|
||||
if strings.Contains(request.refID, "://") || strings.Contains(request.refKey, "://") {
|
||||
return errors.New(errors.CodeInvalidParam, "通知资源引用禁止包含任意 URL")
|
||||
}
|
||||
if request.refType == "" && (request.refID != "" || request.refKey != "") {
|
||||
return errors.New(errors.CodeInvalidParam, "通知资源引用缺少受控类型")
|
||||
}
|
||||
if request.refType != "" && request.refID == "" && request.refKey == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "通知资源引用缺少定位值")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
|
||||
rendered, err := s.registry.Render(request.notificationType, request.templateData, request.refType, recipientKind)
|
||||
if err != nil {
|
||||
s.logger.Error("站内通知模板校验失败",
|
||||
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
|
||||
zap.String("failure_category", "template"))
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "站内通知模板校验失败")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
expiresAt, err := notificationDisplayExpiry(rendered.Category, request.expiresAt, now)
|
||||
if err != nil {
|
||||
s.logger.Error("站内通知展示期限校验失败",
|
||||
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
|
||||
zap.String("failure_category", "display_policy"))
|
||||
return err
|
||||
}
|
||||
for _, recipientID := range recipientIDs {
|
||||
active, err := s.isActiveRecipient(ctx, recipientKind, recipientID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验通知接收人失败")
|
||||
}
|
||||
if !active {
|
||||
s.logger.Info("站内通知接收人不可用,已跳过",
|
||||
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
|
||||
continue
|
||||
}
|
||||
notification := &model.Notification{
|
||||
EventID: eventID, RecipientKind: recipientKind,
|
||||
RecipientID: recipientID, Category: rendered.Category, Type: rendered.Type,
|
||||
Severity: rendered.Severity, Title: rendered.Title, Body: rendered.Body,
|
||||
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
|
||||
ExpiresAt: expiresAt, CreatedAt: now,
|
||||
}
|
||||
created, err := s.repository.CreateIdempotent(ctx, notification)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
|
||||
}
|
||||
if !created {
|
||||
s.logger.Info("站内通知重复事件已幂等忽略",
|
||||
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func notificationDisplayExpiry(category string, requested *time.Time, now time.Time) (*time.Time, error) {
|
||||
switch category {
|
||||
case constants.NotificationCategoryApproval:
|
||||
return nil, nil
|
||||
case constants.NotificationCategoryExpiry:
|
||||
if requested == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "临期通知缺少业务到期时间")
|
||||
}
|
||||
expiresAt := requested.UTC()
|
||||
return &expiresAt, nil
|
||||
case constants.NotificationCategorySync:
|
||||
return cappedNotificationExpiry(requested, now, constants.NotificationSyncDisplayDays), nil
|
||||
case constants.NotificationCategorySystem:
|
||||
return cappedNotificationExpiry(requested, now, constants.NotificationSystemMaxDisplayDays), nil
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "通知类别不支持展示期限策略")
|
||||
}
|
||||
}
|
||||
|
||||
func cappedNotificationExpiry(requested *time.Time, now time.Time, maxDays int) *time.Time {
|
||||
maximum := now.AddDate(0, 0, maxDays)
|
||||
if requested == nil {
|
||||
if maxDays == constants.NotificationSystemMaxDisplayDays {
|
||||
defaultExpiry := now.AddDate(0, 0, constants.NotificationSystemDefaultDisplayDays)
|
||||
return &defaultExpiry
|
||||
}
|
||||
return &maximum
|
||||
}
|
||||
expiresAt := requested.UTC()
|
||||
if expiresAt.After(maximum) {
|
||||
expiresAt = maximum
|
||||
}
|
||||
return &expiresAt
|
||||
}
|
||||
|
||||
func recipientKindForDirectEvent(eventType string) (string, error) {
|
||||
switch eventType {
|
||||
case constants.OutboxEventTypeAdminDirectNotification:
|
||||
return constants.NotificationRecipientKindAccount, nil
|
||||
case constants.OutboxEventTypePersonalCustomerDirectNotification:
|
||||
return constants.NotificationRecipientKindPersonalCustomer, nil
|
||||
default:
|
||||
return "", errors.New(errors.CodeInvalidParam, "通知事件类型或载荷版本不受支持")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DeliveryService) isActiveRecipient(ctx context.Context, recipientKind string, recipientID uint) (bool, error) {
|
||||
switch recipientKind {
|
||||
case constants.NotificationRecipientKindAccount:
|
||||
return s.repository.IsActiveAccount(ctx, recipientID)
|
||||
case constants.NotificationRecipientKindPersonalCustomer:
|
||||
return s.repository.IsActivePersonalCustomer(ctx, recipientID)
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
110
internal/application/notification/read.go
Normal file
110
internal/application/notification/read.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ReadService 执行后台账号与个人客户的幂等已读事务脚本。
|
||||
type ReadService struct {
|
||||
db *gorm.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewReadService 创建单条已读用例。
|
||||
func NewReadService(db *gorm.DB) *ReadService {
|
||||
return &ReadService{db: db, now: time.Now}
|
||||
}
|
||||
|
||||
// MarkRead 仅首次更新当前接收人的未过期未读通知。
|
||||
func (s *ReadService) MarkRead(ctx context.Context, recipientID, notificationID uint) error {
|
||||
if recipientID == 0 || notificationID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
result := s.db.WithContext(ctx).Model(&model.Notification{}).
|
||||
Where("id = ? AND recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
|
||||
notificationID, constants.NotificationRecipientKindAccount, recipientID, false, now).
|
||||
Updates(map[string]any{"is_read": true, "read_at": now})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新通知已读状态失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllRead 将当前后台账号全部或指定类别的未过期通知幂等标记为已读。
|
||||
func (s *ReadService) MarkAllRead(ctx context.Context, recipientID uint, request dto.NotificationReadAllRequest) (*dto.NotificationReadAllResponse, error) {
|
||||
if recipientID == 0 || !isReadAllCategory(request.Category) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
db := s.db.WithContext(ctx).Model(&model.Notification{}).
|
||||
Where("recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
|
||||
constants.NotificationRecipientKindAccount, recipientID, false, now)
|
||||
if request.Category != "" {
|
||||
db = db.Where("category = ?", request.Category)
|
||||
}
|
||||
result := db.Updates(map[string]any{"is_read": true, "read_at": now})
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败")
|
||||
}
|
||||
return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil
|
||||
}
|
||||
|
||||
func isReadAllCategory(category string) bool {
|
||||
switch category {
|
||||
case "", constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry,
|
||||
constants.NotificationCategorySync, constants.NotificationCategorySystem:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MarkPersonalRead 仅首次更新当前个人客户可见的未过期未读通知。
|
||||
func (s *ReadService) MarkPersonalRead(ctx context.Context, customerID, notificationID uint) error {
|
||||
if customerID == 0 || notificationID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now).
|
||||
Where("id = ? AND is_read = ?", notificationID, false).
|
||||
Updates(map[string]any{"is_read": true, "read_at": now})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新个人客户通知已读状态失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读。
|
||||
func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint) (*dto.NotificationReadAllResponse, error) {
|
||||
if customerID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now).
|
||||
Where("is_read = ?", false).
|
||||
Updates(map[string]any{"is_read": true, "read_at": now})
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新个人客户通知已读状态失败")
|
||||
}
|
||||
return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil
|
||||
}
|
||||
|
||||
func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {
|
||||
return db.Where(`recipient_kind = ? AND recipient_id = ?
|
||||
AND category IN ? AND type IN ? AND (expires_at IS NULL OR expires_at > ?)`,
|
||||
constants.NotificationRecipientKindPersonalCustomer,
|
||||
customerID,
|
||||
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry},
|
||||
[]string{constants.NotificationTypePackageExpiring},
|
||||
now,
|
||||
)
|
||||
}
|
||||
8
internal/application/notification/recipient.go
Normal file
8
internal/application/notification/recipient.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package notification
|
||||
|
||||
import "context"
|
||||
|
||||
// DynamicRecipientResolver 定义后台通知动态接收人解析 Port。
|
||||
type DynamicRecipientResolver interface {
|
||||
Resolve(ctx context.Context, targetKind string, targetID uint) ([]uint, error)
|
||||
}
|
||||
108
internal/application/role/default_credit.go
Normal file
108
internal/application/role/default_credit.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Package role 提供角色默认信用模板的应用用例。
|
||||
package role
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PermissionChecker 检查平台账号是否拥有独立信用模板权限。
|
||||
type PermissionChecker interface {
|
||||
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
|
||||
}
|
||||
|
||||
// DefaultCreditService 更新客户角色的新建代理默认信用模板。
|
||||
type DefaultCreditService struct {
|
||||
db *gorm.DB
|
||||
permissionChecker PermissionChecker
|
||||
}
|
||||
|
||||
// NewDefaultCreditService 创建角色默认信用模板服务。
|
||||
func NewDefaultCreditService(db *gorm.DB, permissionChecker PermissionChecker) *DefaultCreditService {
|
||||
return &DefaultCreditService{db: db, permissionChecker: permissionChecker}
|
||||
}
|
||||
|
||||
// Update 更新模板;该操作不扫描或修改任何既有钱包。
|
||||
func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled bool, limit int64) (*model.Role, error) {
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
if err := s.authorize(ctx, operatorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDefaultCredit(enabled, limit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var role model.Role
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses().First(&role, roleID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeRoleNotFound)
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "读取角色失败")
|
||||
}
|
||||
if role.RoleType != constants.RoleTypeCustomer {
|
||||
return errors.New(errors.CodeInvalidParam, "只有客户角色可以配置新建代理默认信用")
|
||||
}
|
||||
|
||||
result := tx.Model(&model.Role{}).
|
||||
Where("id = ? AND role_type = ?", roleID, constants.RoleTypeCustomer).
|
||||
Updates(map[string]any{
|
||||
"default_credit_enabled": enabled,
|
||||
"default_credit_limit": limit,
|
||||
"updater": operatorID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, result.Error, "更新角色默认信用失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "角色默认信用已发生变化,请刷新后重试")
|
||||
}
|
||||
role.DefaultCreditEnabled = enabled
|
||||
role.DefaultCreditLimit = limit
|
||||
role.Updater = operatorID
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
func (s *DefaultCreditService) authorize(ctx context.Context, operatorID uint) error {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType == constants.UserTypeSuperAdmin {
|
||||
return nil
|
||||
}
|
||||
if userType != constants.UserTypePlatform || s.permissionChecker == nil {
|
||||
return errors.New(errors.CodeForbidden, "无权限配置角色默认信用")
|
||||
}
|
||||
hasPermission, err := s.permissionChecker.CheckPermission(ctx, operatorID, constants.PermissionRoleDefaultCreditManage, constants.PlatformWeb)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "检查角色默认信用权限失败")
|
||||
}
|
||||
if !hasPermission {
|
||||
return errors.New(errors.CodeForbidden, "无权限配置角色默认信用")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDefaultCredit(enabled bool, limit int64) error {
|
||||
if limit < 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "默认信用额度不能为负数")
|
||||
}
|
||||
if enabled && limit == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "启用默认信用时额度必须大于零")
|
||||
}
|
||||
if !enabled && limit != 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "关闭默认信用时额度必须为零")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
251
internal/application/shop/create.go
Normal file
251
internal/application/shop/create.go
Normal file
@@ -0,0 +1,251 @@
|
||||
// Package shop 提供店铺创建与业务员归属的简单写事务脚本。
|
||||
package shop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// CreateService 收口平台与代理创建店铺的完整事务。
|
||||
type CreateService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewCreateService 创建店铺创建事务脚本。
|
||||
func NewCreateService(db *gorm.DB) *CreateService {
|
||||
return &CreateService{db: db}
|
||||
}
|
||||
|
||||
// Create 按操作者类型执行平台显式归属或代理安全继承。
|
||||
func (s *CreateService) Create(ctx context.Context, request *dto.CreateShopRequest) (*dto.ShopResponse, error) {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
resolver := resolvePlatformBusinessOwner
|
||||
switch userType {
|
||||
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
|
||||
case constants.UserTypeAgent:
|
||||
if request.BusinessOwnerAccountIDSet {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员")
|
||||
}
|
||||
if request.ParentID == nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
if err := middleware.CanManageShop(ctx, *request.ParentID); err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
resolver = resolveInheritedBusinessOwner
|
||||
default:
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.InitPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
|
||||
}
|
||||
|
||||
var response *dto.ShopResponse
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
created, createErr := createShop(tx, request, operatorID, string(hashedPassword), resolver)
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
response = created
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type businessOwnerResolver func(*gorm.DB, *dto.CreateShopRequest, *model.Shop) (*uint, error)
|
||||
|
||||
func createShop(tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, hashedPassword string, resolveOwner businessOwnerResolver) (*dto.ShopResponse, error) {
|
||||
if exists, err := recordExists(tx, &model.Shop{}, "shop_code = ?", request.ShopCode); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验店铺编号失败")
|
||||
} else if exists {
|
||||
return nil, errors.New(errors.CodeShopCodeExists, "店铺编号已存在")
|
||||
}
|
||||
if exists, err := recordExists(tx, &model.Account{}, "username = ?", request.InitUsername); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验初始账号用户名失败")
|
||||
} else if exists {
|
||||
return nil, errors.New(errors.CodeUsernameExists, "初始账号用户名已存在")
|
||||
}
|
||||
if exists, err := recordExists(tx, &model.Account{}, "phone = ?", request.InitPhone); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验初始账号手机号失败")
|
||||
} else if exists {
|
||||
return nil, errors.New(errors.CodePhoneExists, "初始账号手机号已存在")
|
||||
}
|
||||
|
||||
parent, level, err := resolveParent(tx, request.ParentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ownerID, err := resolveOwner(tx, request, parent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var role model.Role
|
||||
if err := tx.Where("id = ? AND role_type = ? AND status = ?", request.DefaultRoleID, constants.RoleTypeCustomer, constants.StatusEnabled).First(&role).Error; err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "请选择启用的客户角色")
|
||||
}
|
||||
|
||||
shop := &model.Shop{
|
||||
ShopName: request.ShopName, ShopCode: request.ShopCode, ParentID: request.ParentID,
|
||||
BusinessOwnerAccountID: ownerID, Level: level, ContactName: request.ContactName,
|
||||
ContactPhone: request.ContactPhone, Province: request.Province, City: request.City,
|
||||
District: request.District, Address: request.Address, Status: constants.ShopStatusEnabled,
|
||||
}
|
||||
shop.Creator = operatorID
|
||||
shop.Updater = operatorID
|
||||
if err := tx.Create(shop).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建店铺失败")
|
||||
}
|
||||
|
||||
account := &model.Account{
|
||||
Username: request.InitUsername, Phone: request.InitPhone, Password: hashedPassword,
|
||||
UserType: constants.UserTypeAgent, ShopID: &shop.ID, Status: constants.StatusEnabled, IsPrimary: true,
|
||||
}
|
||||
account.Creator = operatorID
|
||||
account.Updater = operatorID
|
||||
if err := tx.Create(account).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建初始账号失败")
|
||||
}
|
||||
if err := tx.Create(&model.AccountRole{
|
||||
AccountID: account.ID, RoleID: request.DefaultRoleID, Status: constants.StatusEnabled,
|
||||
Creator: operatorID, Updater: operatorID,
|
||||
}).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "为初始账号分配角色失败")
|
||||
}
|
||||
if err := tx.Create(&model.ShopRole{
|
||||
ShopID: shop.ID, RoleID: request.DefaultRoleID, Status: constants.StatusEnabled,
|
||||
Creator: operatorID, Updater: operatorID,
|
||||
}).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "设置店铺默认角色失败")
|
||||
}
|
||||
if err := tx.Create([]*model.AgentWallet{
|
||||
{
|
||||
ShopID: shop.ID, WalletType: constants.AgentWalletTypeMain,
|
||||
CreditEnabled: role.DefaultCreditEnabled, CreditLimit: role.DefaultCreditLimit,
|
||||
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
|
||||
},
|
||||
{
|
||||
ShopID: shop.ID, WalletType: constants.AgentWalletTypeCommission,
|
||||
CreditEnabled: false, CreditLimit: 0,
|
||||
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
|
||||
},
|
||||
}).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "初始化店铺钱包失败")
|
||||
}
|
||||
|
||||
parentName := ""
|
||||
if parent != nil {
|
||||
parentName = parent.ShopName
|
||||
}
|
||||
response := newShopResponse(shop, parentName)
|
||||
if err := fillBusinessOwnerResponse(tx, shop, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func resolveParent(tx *gorm.DB, parentID *uint) (*model.Shop, int, error) {
|
||||
if parentID == nil {
|
||||
return nil, 1, nil
|
||||
}
|
||||
var parent model.Shop
|
||||
if err := tx.First(&parent, *parentID).Error; err != nil {
|
||||
return nil, 0, errors.New(errors.CodeInvalidParentID, "上级店铺不存在或无效")
|
||||
}
|
||||
level := parent.Level + 1
|
||||
if level > constants.ShopMaxLevel {
|
||||
return nil, 0, errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级")
|
||||
}
|
||||
return &parent, level, nil
|
||||
}
|
||||
|
||||
func resolvePlatformBusinessOwner(tx *gorm.DB, request *dto.CreateShopRequest, parent *model.Shop) (*uint, error) {
|
||||
if !request.BusinessOwnerAccountIDSet {
|
||||
if parent == nil || parent.BusinessOwnerAccountID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
ownerID := *parent.BusinessOwnerAccountID
|
||||
return &ownerID, nil
|
||||
}
|
||||
if request.BusinessOwnerAccountID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if *request.BusinessOwnerAccountID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效")
|
||||
}
|
||||
var account model.Account
|
||||
if err := tx.Where("id = ? AND user_type = ? AND status = ?", *request.BusinessOwnerAccountID, constants.UserTypePlatform, constants.StatusEnabled).
|
||||
First(&account).Error; err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效或不可用")
|
||||
}
|
||||
ownerID := account.ID
|
||||
return &ownerID, nil
|
||||
}
|
||||
|
||||
func resolveInheritedBusinessOwner(_ *gorm.DB, _ *dto.CreateShopRequest, parent *model.Shop) (*uint, error) {
|
||||
if parent == nil || parent.BusinessOwnerAccountID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
ownerID := *parent.BusinessOwnerAccountID
|
||||
return &ownerID, nil
|
||||
}
|
||||
|
||||
func recordExists(tx *gorm.DB, target any, query string, value any) (bool, error) {
|
||||
var count int64
|
||||
err := tx.Model(target).Where(query, value).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func newShopResponse(shop *model.Shop, parentName string) *dto.ShopResponse {
|
||||
return &dto.ShopResponse{
|
||||
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, ParentID: shop.ParentID,
|
||||
BusinessOwnerAccountID: shop.BusinessOwnerAccountID,
|
||||
ParentShopName: parentName, Level: shop.Level, ContactName: shop.ContactName,
|
||||
ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City,
|
||||
District: shop.District, Address: shop.Address, Status: shop.Status,
|
||||
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
func fillBusinessOwnerResponse(tx *gorm.DB, shop *model.Shop, response *dto.ShopResponse) error {
|
||||
if shop.BusinessOwnerAccountID == nil {
|
||||
return nil
|
||||
}
|
||||
var account model.Account
|
||||
err := tx.Unscoped().Where("id = ?", *shop.BusinessOwnerAccountID).First(&account).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询业务员摘要失败")
|
||||
}
|
||||
response.BusinessOwnerUsername = account.Username
|
||||
response.BusinessOwnerPhoneSummary = maskBusinessOwnerPhone(account.Phone)
|
||||
response.BusinessOwnerAvailable = account.UserType == constants.UserTypePlatform && account.Status == constants.StatusEnabled && !account.DeletedAt.Valid
|
||||
return nil
|
||||
}
|
||||
|
||||
func maskBusinessOwnerPhone(phone string) string {
|
||||
phone = strings.TrimSpace(phone)
|
||||
if len(phone) < 7 {
|
||||
return ""
|
||||
}
|
||||
return phone[:3] + "****" + phone[len(phone)-4:]
|
||||
}
|
||||
10
internal/application/shop/recipient.go
Normal file
10
internal/application/shop/recipient.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package shop
|
||||
|
||||
import "context"
|
||||
|
||||
// NotificationRecipientResolver 定义按店铺解析当前可用后台通知接收人的 Port。
|
||||
//
|
||||
// 实现只返回稳定账号 ID;无可用接收人是正常结果,不应触发无限重试。
|
||||
type NotificationRecipientResolver interface {
|
||||
ResolveNotificationRecipients(ctx context.Context, shopID uint) ([]uint, error)
|
||||
}
|
||||
108
internal/application/shop/update.go
Normal file
108
internal/application/shop/update.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package shop
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// UpdateService 收口店铺资料与业务员归属的简单写事务脚本。
|
||||
type UpdateService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewUpdateService 创建店铺更新事务脚本。
|
||||
func NewUpdateService(db *gorm.DB) *UpdateService {
|
||||
return &UpdateService{db: db}
|
||||
}
|
||||
|
||||
// Update 更新单个店铺;业务员归属变化不会传播到其他店铺。
|
||||
func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.UpdateShopRequest) (*dto.ShopResponse, error) {
|
||||
if shopID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
if err := middleware.CanManageShop(ctx, shopID); err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
if userType == constants.UserTypeAgent && request.BusinessOwnerAccountIDSet {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员")
|
||||
}
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
var response *dto.ShopResponse
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var shop model.Shop
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shop, shopID).Error; err != nil {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
if request.BusinessOwnerAccountIDSet {
|
||||
ownerID, err := validateUpdatedBusinessOwner(tx, request.BusinessOwnerAccountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shop.BusinessOwnerAccountID = ownerID
|
||||
}
|
||||
shop.ShopName = request.ShopName
|
||||
shop.ContactName = request.ContactName
|
||||
shop.ContactPhone = request.ContactPhone
|
||||
shop.Province = request.Province
|
||||
shop.City = request.City
|
||||
shop.District = request.District
|
||||
shop.Address = request.Address
|
||||
shop.Status = request.Status
|
||||
shop.Updater = operatorID
|
||||
if err := tx.Save(&shop).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新店铺失败")
|
||||
}
|
||||
parentName := ""
|
||||
if shop.ParentID != nil {
|
||||
var parent model.Shop
|
||||
if err := tx.Select("shop_name").First(&parent, *shop.ParentID).Error; err == nil {
|
||||
parentName = parent.ShopName
|
||||
}
|
||||
}
|
||||
response = newShopResponse(&shop, parentName)
|
||||
if err := fillBusinessOwnerResponse(tx, &shop, response); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func validateUpdatedBusinessOwner(tx *gorm.DB, requestedID *uint) (*uint, error) {
|
||||
if requestedID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if *requestedID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效")
|
||||
}
|
||||
var account model.Account
|
||||
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).
|
||||
Where("id = ? AND user_type = ? AND status = ?", *requestedID, constants.UserTypePlatform, constants.StatusEnabled).
|
||||
First(&account).Error; err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效或不可用")
|
||||
}
|
||||
ownerID := account.ID
|
||||
return &ownerID, nil
|
||||
}
|
||||
58
internal/application/wallet/change_credit.go
Normal file
58
internal/application/wallet/change_credit.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Package wallet 提供代理主钱包复杂写用例。
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ChangeCreditService 调整既有店铺主钱包实际信用额度。
|
||||
type ChangeCreditService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewChangeCreditService 创建实际信用额度调整服务。
|
||||
func NewChangeCreditService(db *gorm.DB) *ChangeCreditService {
|
||||
return &ChangeCreditService{db: db}
|
||||
}
|
||||
|
||||
// Execute 按主钱包类型和版本条件更新,不修改余额、冻结金额或钱包流水。
|
||||
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64, version int) (*dto.ShopCreditLimitResponse, error) {
|
||||
var result *dto.ShopCreditLimitResponse
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var stored model.AgentWallet
|
||||
if err := tx.Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&stored).Error; err != nil {
|
||||
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
|
||||
}
|
||||
aggregate := domainwallet.AgentWallet{ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit, Status: stored.Status, Version: stored.Version}
|
||||
if err := aggregate.ChangeCredit(enabled, limit); err != nil {
|
||||
return err
|
||||
}
|
||||
update := tx.Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, version, aggregate.EffectiveCredit()).
|
||||
Updates(map[string]any{"credit_enabled": enabled, "credit_limit": limit, "version": gorm.Expr("version + 1")})
|
||||
if update.Error != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, update.Error, "更新店铺信用额度失败")
|
||||
}
|
||||
if update.RowsAffected != 1 {
|
||||
var current model.AgentWallet
|
||||
if err := tx.Where("id = ? AND wallet_type = ?", stored.ID, constants.AgentWalletTypeMain).First(¤t).Error; err != nil {
|
||||
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
|
||||
}
|
||||
if current.Version != version {
|
||||
return errors.New(errors.CodeConflict, "钱包版本已变化,请刷新后重试")
|
||||
}
|
||||
return errors.New(errors.CodeInsufficientQuota, "当前资金占用无法降低或关闭信用额度")
|
||||
}
|
||||
available, _ := aggregate.AvailableBalance()
|
||||
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: version + 1}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
202
internal/application/wallet/debit.go
Normal file
202
internal/application/wallet/debit.go
Normal file
@@ -0,0 +1,202 @@
|
||||
// Package wallet 提供代理主钱包复杂写用例。
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// DebitCommand 描述一次具有稳定业务引用的代理主钱包扣款。
|
||||
type DebitCommand struct {
|
||||
ShopID uint
|
||||
Amount int64
|
||||
ReferenceType string
|
||||
ReferenceID uint
|
||||
UserID uint
|
||||
Creator uint
|
||||
TransactionSubtype string
|
||||
RelatedShopID *uint
|
||||
AssetType string
|
||||
AssetID uint
|
||||
AssetIdentifier string
|
||||
Remark string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// DebitResult 返回统一扣款后的资金快照。
|
||||
type DebitResult struct {
|
||||
WalletID uint
|
||||
BalanceBefore int64
|
||||
BalanceAfter int64
|
||||
Version int
|
||||
AlreadyApplied bool
|
||||
}
|
||||
|
||||
// DebitedEvent 是代理主钱包扣款成功后的可靠领域事实。
|
||||
type DebitedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
WalletID uint `json:"wallet_id"`
|
||||
ShopID uint `json:"shop_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
BalanceBefore int64 `json:"balance_before"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
Version int `json:"version"`
|
||||
ReferenceType string `json:"reference_type"`
|
||||
ReferenceID uint `json:"reference_id"`
|
||||
TransactionType string `json:"transaction_type"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// DebitEventWriter 在调用方事务内追加扣款成功事件。
|
||||
type DebitEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event DebitedEvent) error
|
||||
}
|
||||
|
||||
// DebitService 统一代理主钱包扣款、流水与可靠事件。
|
||||
type DebitService struct {
|
||||
eventWriter DebitEventWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewDebitService 创建统一代理主钱包扣款服务。
|
||||
func NewDebitService(eventWriter DebitEventWriter, now func() time.Time) *DebitService {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &DebitService{eventWriter: eventWriter, now: now}
|
||||
}
|
||||
|
||||
// DebitInTx 在调用方事务内完成锁定、扣款、唯一流水和 Outbox 事件。
|
||||
func (s *DebitService) DebitInTx(ctx context.Context, tx *gorm.DB, command DebitCommand) (DebitResult, error) {
|
||||
if s == nil || s.eventWriter == nil || tx == nil {
|
||||
return DebitResult{}, errors.New(errors.CodeInternalError, "代理主钱包扣款能力未完整配置")
|
||||
}
|
||||
if err := validateDebitCommand(command); err != nil {
|
||||
return DebitResult{}, err
|
||||
}
|
||||
|
||||
var stored model.AgentWallet
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND wallet_type = ?", command.ShopID, constants.AgentWalletTypeMain).
|
||||
First(&stored).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return DebitResult{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
|
||||
}
|
||||
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
|
||||
}
|
||||
|
||||
existing, err := findExistingDebit(ctx, tx, command.ReferenceType, command.ReferenceID)
|
||||
if err != nil {
|
||||
return DebitResult{}, err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.AgentWalletID != stored.ID || existing.Amount != -command.Amount {
|
||||
return DebitResult{}, errors.New(errors.CodeConflict, "业务单已存在不一致的钱包扣款流水")
|
||||
}
|
||||
return DebitResult{
|
||||
WalletID: stored.ID, BalanceBefore: existing.BalanceBefore, BalanceAfter: existing.BalanceAfter,
|
||||
Version: stored.Version, AlreadyApplied: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
aggregate := domainwallet.AgentWallet{
|
||||
ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType,
|
||||
Balance: stored.Balance, FrozenBalance: stored.FrozenBalance,
|
||||
CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit,
|
||||
Status: stored.Status, Version: stored.Version,
|
||||
}
|
||||
if err := aggregate.Debit(command.Amount); err != nil {
|
||||
return DebitResult{}, err
|
||||
}
|
||||
|
||||
updatedAt := s.now().UTC()
|
||||
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where(`id = ? AND wallet_type = ? AND status = ? AND version = ?
|
||||
AND balance::numeric - frozen_balance::numeric
|
||||
+ CASE WHEN credit_enabled THEN credit_limit::numeric ELSE 0 END >= ?::numeric`,
|
||||
stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version, command.Amount).
|
||||
Updates(map[string]any{
|
||||
"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": updatedAt,
|
||||
})
|
||||
if update.Error != nil {
|
||||
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "扣减代理主钱包失败")
|
||||
}
|
||||
if update.RowsAffected != 1 {
|
||||
return DebitResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
|
||||
}
|
||||
|
||||
transaction := buildDebitTransaction(stored, aggregate.Balance, command)
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包扣款流水失败")
|
||||
}
|
||||
event := DebitedEvent{
|
||||
EventID: "agent-wallet:order:" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":debited",
|
||||
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
|
||||
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
|
||||
ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID,
|
||||
TransactionType: constants.AgentTransactionTypeDeduct, OccurredAt: updatedAt,
|
||||
RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
}
|
||||
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
|
||||
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包扣款事件失败")
|
||||
}
|
||||
return DebitResult{
|
||||
WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
|
||||
Version: stored.Version + 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateDebitCommand(command DebitCommand) error {
|
||||
if command.ShopID == 0 || command.Amount <= 0 || command.ReferenceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包扣款参数无效")
|
||||
}
|
||||
if strings.TrimSpace(command.ReferenceType) != constants.ReferenceTypeOrder {
|
||||
return errors.New(errors.CodeInvalidParam, "当前统一扣款仅支持订单业务")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findExistingDebit(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint) (*model.AgentWalletTransaction, error) {
|
||||
var transaction model.AgentWalletTransaction
|
||||
err := tx.WithContext(ctx).Unscoped().
|
||||
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
||||
referenceType, referenceID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
|
||||
First(&transaction).Error
|
||||
if err == nil {
|
||||
return &transaction, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包扣款流水失败")
|
||||
}
|
||||
|
||||
func buildDebitTransaction(stored model.AgentWallet, balanceAfter int64, command DebitCommand) *model.AgentWalletTransaction {
|
||||
referenceType := strings.TrimSpace(command.ReferenceType)
|
||||
remark := strings.TrimSpace(command.Remark)
|
||||
var subtype *string
|
||||
if value := strings.TrimSpace(command.TransactionSubtype); value != "" {
|
||||
subtype = &value
|
||||
}
|
||||
return &model.AgentWalletTransaction{
|
||||
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
|
||||
TransactionType: constants.AgentTransactionTypeDeduct, TransactionSubtype: subtype,
|
||||
Amount: -command.Amount, BalanceBefore: stored.Balance, BalanceAfter: balanceAfter,
|
||||
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.ReferenceID,
|
||||
RelatedShopID: command.RelatedShopID, AssetType: command.AssetType, AssetID: command.AssetID,
|
||||
AssetIdentifier: command.AssetIdentifier, Remark: &remark, Creator: command.Creator,
|
||||
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
|
||||
}
|
||||
}
|
||||
182
internal/application/wallet/posting.go
Normal file
182
internal/application/wallet/posting.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// PostingCommand 描述一次具有稳定业务引用的代理主钱包正向入账。
|
||||
type PostingCommand struct {
|
||||
ShopID uint
|
||||
WalletID uint
|
||||
Amount int64
|
||||
ReferenceType string
|
||||
ReferenceID uint
|
||||
TransactionType string
|
||||
UserID uint
|
||||
Creator uint
|
||||
Remark string
|
||||
Metadata *string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// PostingResult 返回统一入账后的资金快照。
|
||||
type PostingResult struct {
|
||||
WalletID uint
|
||||
BalanceBefore int64
|
||||
BalanceAfter int64
|
||||
Version int
|
||||
AlreadyApplied bool
|
||||
}
|
||||
|
||||
// CreditedEvent 是代理主钱包正向入账成功后的可靠领域事实。
|
||||
type CreditedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
WalletID uint `json:"wallet_id"`
|
||||
ShopID uint `json:"shop_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
BalanceBefore int64 `json:"balance_before"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
Version int `json:"version"`
|
||||
ReferenceType string `json:"reference_type"`
|
||||
ReferenceID uint `json:"reference_id"`
|
||||
TransactionType string `json:"transaction_type"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// CreditEventWriter 在调用方事务内追加正向入账事件。
|
||||
type CreditEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event CreditedEvent) error
|
||||
}
|
||||
|
||||
// PostingService 统一代理主钱包充值与人工调整入账。
|
||||
type PostingService struct {
|
||||
eventWriter CreditEventWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewPostingService 创建统一代理主钱包入账服务。
|
||||
func NewPostingService(eventWriter CreditEventWriter, now func() time.Time) *PostingService {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &PostingService{eventWriter: eventWriter, now: now}
|
||||
}
|
||||
|
||||
// PostInTx 在调用方事务内完成锁定、入账、唯一流水和 Outbox 事件。
|
||||
func (s *PostingService) PostInTx(ctx context.Context, tx *gorm.DB, command PostingCommand) (PostingResult, error) {
|
||||
if s == nil || s.eventWriter == nil || tx == nil {
|
||||
return PostingResult{}, errors.New(errors.CodeInternalError, "代理主钱包入账能力未完整配置")
|
||||
}
|
||||
if err := validatePostingCommand(command); err != nil {
|
||||
return PostingResult{}, err
|
||||
}
|
||||
|
||||
var stored model.AgentWallet
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND wallet_type = ?", command.ShopID, constants.AgentWalletTypeMain).
|
||||
First(&stored).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return PostingResult{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
|
||||
}
|
||||
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
|
||||
}
|
||||
if command.WalletID > 0 && command.WalletID != stored.ID {
|
||||
return PostingResult{}, errors.New(errors.CodeConflict, "入账业务单与代理主钱包归属不一致")
|
||||
}
|
||||
|
||||
existing, err := findExistingPosting(ctx, tx, command.ReferenceType, command.ReferenceID)
|
||||
if err != nil {
|
||||
return PostingResult{}, err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.AgentWalletID != stored.ID || existing.Amount != command.Amount || existing.TransactionType != command.TransactionType {
|
||||
return PostingResult{}, errors.New(errors.CodeConflict, "业务单已存在不一致的钱包入账流水")
|
||||
}
|
||||
return PostingResult{WalletID: stored.ID, BalanceBefore: existing.BalanceBefore, BalanceAfter: existing.BalanceAfter, Version: stored.Version, AlreadyApplied: true}, nil
|
||||
}
|
||||
|
||||
aggregate := domainwallet.AgentWallet{
|
||||
ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType,
|
||||
Balance: stored.Balance, FrozenBalance: stored.FrozenBalance,
|
||||
CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit,
|
||||
Status: stored.Status, Version: stored.Version,
|
||||
}
|
||||
if err := aggregate.Credit(command.Amount); err != nil {
|
||||
return PostingResult{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND status = ? AND version = ?", stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version).
|
||||
Updates(map[string]any{"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": now})
|
||||
if update.Error != nil {
|
||||
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "增加代理主钱包余额失败")
|
||||
}
|
||||
if update.RowsAffected != 1 {
|
||||
return PostingResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
|
||||
}
|
||||
|
||||
referenceType := strings.TrimSpace(command.ReferenceType)
|
||||
remark := strings.TrimSpace(command.Remark)
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
|
||||
TransactionType: command.TransactionType, Amount: command.Amount,
|
||||
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Status: constants.TransactionStatusSuccess,
|
||||
ReferenceType: &referenceType, ReferenceID: &command.ReferenceID, Remark: &remark, Metadata: command.Metadata,
|
||||
Creator: command.Creator, ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包入账流水失败")
|
||||
}
|
||||
event := CreditedEvent{
|
||||
EventID: "agent-wallet:" + referenceType + ":" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":credited",
|
||||
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
|
||||
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
|
||||
ReferenceType: referenceType, ReferenceID: command.ReferenceID, TransactionType: command.TransactionType,
|
||||
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
}
|
||||
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
|
||||
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包入账事件失败")
|
||||
}
|
||||
return PostingResult{WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1}, nil
|
||||
}
|
||||
|
||||
func validatePostingCommand(command PostingCommand) error {
|
||||
if command.ShopID == 0 || command.Amount <= 0 || command.ReferenceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包入账参数无效")
|
||||
}
|
||||
referenceType := strings.TrimSpace(command.ReferenceType)
|
||||
validRecharge := referenceType == constants.ReferenceTypeTopup && command.TransactionType == constants.AgentTransactionTypeRecharge
|
||||
validAdjustment := referenceType == constants.ReferenceTypeManualAdjustment && command.TransactionType == constants.AgentTransactionTypeAdjustment
|
||||
if !validRecharge && !validAdjustment {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包入账业务类型无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findExistingPosting(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint) (*model.AgentWalletTransaction, error) {
|
||||
var transaction model.AgentWalletTransaction
|
||||
err := tx.WithContext(ctx).Unscoped().
|
||||
Where("reference_type = ? AND reference_id = ? AND transaction_type IN ? AND status = ?",
|
||||
strings.TrimSpace(referenceType), referenceID, []string{constants.AgentTransactionTypeRecharge, constants.AgentTransactionTypeAdjustment}, constants.TransactionStatusSuccess).
|
||||
First(&transaction).Error
|
||||
if err == nil {
|
||||
return &transaction, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包入账流水失败")
|
||||
}
|
||||
289
internal/application/wallet/refund.go
Normal file
289
internal/application/wallet/refund.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// RefundCommand 描述一次沿订单原扣款回溯的代理主钱包退款。
|
||||
type RefundCommand struct {
|
||||
OrderID uint
|
||||
RefundID uint
|
||||
Amount int64
|
||||
LegacyPayerShopID uint
|
||||
LegacyDeductAmount int64
|
||||
LegacyRelatedShopID *uint
|
||||
AssetType string
|
||||
AssetID uint
|
||||
AssetIdentifier string
|
||||
UserID uint
|
||||
Creator uint
|
||||
Remark string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// RefundResult 返回代理主钱包退款后的资金快照。
|
||||
type RefundResult struct {
|
||||
WalletID uint
|
||||
BalanceBefore int64
|
||||
BalanceAfter int64
|
||||
Version int
|
||||
AlreadyApplied bool
|
||||
}
|
||||
|
||||
// RefundedEvent 是代理主钱包订单退款成功后的可靠资金事实。
|
||||
type RefundedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
WalletID uint `json:"wallet_id"`
|
||||
ShopID uint `json:"shop_id"`
|
||||
OrderID uint `json:"order_id"`
|
||||
RefundID uint `json:"refund_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
BalanceBefore int64 `json:"balance_before"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
Version int `json:"version"`
|
||||
OriginalDebitTransactionID uint `json:"original_debit_transaction_id,omitempty"`
|
||||
OriginalDeductAmount int64 `json:"original_deduct_amount"`
|
||||
RelatedShopID *uint `json:"related_shop_id,omitempty"`
|
||||
TransactionSubtype *string `json:"transaction_subtype,omitempty"`
|
||||
AssetType string `json:"asset_type,omitempty"`
|
||||
AssetID uint `json:"asset_id,omitempty"`
|
||||
AssetIdentifier string `json:"asset_identifier,omitempty"`
|
||||
Legacy bool `json:"legacy"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// RefundEventWriter 在调用方事务内追加代理主钱包退款事件。
|
||||
type RefundEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event RefundedEvent) error
|
||||
}
|
||||
|
||||
// RefundService 统一代理订单退款回充、真实流水和可靠事件。
|
||||
type RefundService struct {
|
||||
eventWriter RefundEventWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewRefundService 创建统一代理主钱包退款服务。
|
||||
func NewRefundService(eventWriter RefundEventWriter, now func() time.Time) *RefundService {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &RefundService{eventWriter: eventWriter, now: now}
|
||||
}
|
||||
|
||||
// RefundInTx 在调用方事务内按原扣款事实完成退款回充、版本递增、唯一流水和 Outbox。
|
||||
func (s *RefundService) RefundInTx(ctx context.Context, tx *gorm.DB, command RefundCommand) (RefundResult, error) {
|
||||
if s == nil || s.eventWriter == nil || tx == nil {
|
||||
return RefundResult{}, errors.New(errors.CodeInternalError, "代理主钱包退款能力未完整配置")
|
||||
}
|
||||
if err := validateRefundCommand(command); err != nil {
|
||||
return RefundResult{}, err
|
||||
}
|
||||
|
||||
origin, err := findOriginalOrderDebit(ctx, tx, command.OrderID)
|
||||
if err != nil {
|
||||
return RefundResult{}, err
|
||||
}
|
||||
resolution, err := resolveRefundTarget(command, origin)
|
||||
if err != nil {
|
||||
return RefundResult{}, err
|
||||
}
|
||||
stored, err := lockRefundWallet(ctx, tx, resolution)
|
||||
if err != nil {
|
||||
return RefundResult{}, err
|
||||
}
|
||||
|
||||
existing, err := findExistingRefund(ctx, tx, command.RefundID)
|
||||
if err != nil {
|
||||
return RefundResult{}, err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.AgentWalletID != stored.ID || existing.Amount != command.Amount ||
|
||||
!sameOptionalUint(existing.RelatedShopID, resolution.relatedShopID) ||
|
||||
!sameOptionalString(existing.TransactionSubtype, resolution.subtype) ||
|
||||
existing.AssetType != resolution.assetType || existing.AssetID != resolution.assetID ||
|
||||
existing.AssetIdentifier != resolution.assetIdentifier {
|
||||
return RefundResult{}, errors.New(errors.CodeConflict, "退款单已存在不一致的钱包回充流水")
|
||||
}
|
||||
return RefundResult{
|
||||
WalletID: stored.ID, BalanceBefore: existing.BalanceBefore, BalanceAfter: existing.BalanceAfter,
|
||||
Version: stored.Version, AlreadyApplied: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
aggregate := domainwallet.AgentWallet{
|
||||
ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType,
|
||||
Balance: stored.Balance, FrozenBalance: stored.FrozenBalance,
|
||||
CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit,
|
||||
Status: stored.Status, Version: stored.Version,
|
||||
}
|
||||
if err := aggregate.Credit(command.Amount); err != nil {
|
||||
return RefundResult{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND status = ? AND version = ?",
|
||||
stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version).
|
||||
Updates(map[string]any{"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": now})
|
||||
if update.Error != nil {
|
||||
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "退回代理主钱包余额失败")
|
||||
}
|
||||
if update.RowsAffected != 1 {
|
||||
return RefundResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
|
||||
}
|
||||
|
||||
transaction := buildRefundTransaction(stored, aggregate.Balance, command, resolution)
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包退款流水失败")
|
||||
}
|
||||
event := RefundedEvent{
|
||||
EventID: "agent-wallet:refund:" + strconv.FormatUint(uint64(command.RefundID), 10) + ":refunded",
|
||||
WalletID: stored.ID, ShopID: stored.ShopID, OrderID: command.OrderID, RefundID: command.RefundID,
|
||||
Amount: command.Amount, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
|
||||
Version: stored.Version + 1, OriginalDebitTransactionID: resolution.originalDebitID,
|
||||
OriginalDeductAmount: resolution.deductAmount,
|
||||
RelatedShopID: resolution.relatedShopID, TransactionSubtype: resolution.subtype, Legacy: resolution.legacy,
|
||||
AssetType: resolution.assetType, AssetID: resolution.assetID, AssetIdentifier: resolution.assetIdentifier,
|
||||
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
}
|
||||
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
|
||||
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包退款事件失败")
|
||||
}
|
||||
return RefundResult{
|
||||
WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
|
||||
Version: stored.Version + 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type refundResolution struct {
|
||||
walletID uint
|
||||
shopID uint
|
||||
originalDebitID uint
|
||||
deductAmount int64
|
||||
relatedShopID *uint
|
||||
subtype *string
|
||||
assetType string
|
||||
assetID uint
|
||||
assetIdentifier string
|
||||
legacy bool
|
||||
}
|
||||
|
||||
func validateRefundCommand(command RefundCommand) error {
|
||||
if command.OrderID == 0 || command.RefundID == 0 || command.Amount <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包退款参数无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findOriginalOrderDebit(ctx context.Context, tx *gorm.DB, orderID uint) (*model.AgentWalletTransaction, error) {
|
||||
var transaction model.AgentWalletTransaction
|
||||
err := tx.WithContext(ctx).Unscoped().
|
||||
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
||||
constants.ReferenceTypeOrder, orderID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
|
||||
Order("id ASC").First(&transaction).Error
|
||||
if err == nil {
|
||||
return &transaction, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原代理主钱包扣款流水失败")
|
||||
}
|
||||
|
||||
func resolveRefundTarget(command RefundCommand, origin *model.AgentWalletTransaction) (refundResolution, error) {
|
||||
if origin != nil {
|
||||
if origin.Amount >= 0 || origin.Amount == math.MinInt64 {
|
||||
return refundResolution{}, errors.New(errors.CodeInternalError, "原代理主钱包扣款流水金额非法")
|
||||
}
|
||||
maxAmount := -origin.Amount
|
||||
if command.Amount > maxAmount {
|
||||
return refundResolution{}, errors.New(errors.CodeInvalidParam, "退款金额不能大于原钱包扣款金额")
|
||||
}
|
||||
return refundResolution{
|
||||
walletID: origin.AgentWalletID, originalDebitID: origin.ID, deductAmount: maxAmount,
|
||||
relatedShopID: origin.RelatedShopID, subtype: origin.TransactionSubtype,
|
||||
assetType: origin.AssetType, assetID: origin.AssetID, assetIdentifier: origin.AssetIdentifier,
|
||||
}, nil
|
||||
}
|
||||
if command.LegacyPayerShopID == 0 || command.LegacyDeductAmount <= 0 {
|
||||
return refundResolution{}, errors.New(errors.CodeInternalError, "历史订单缺少原扣款流水和兼容付款快照")
|
||||
}
|
||||
if command.Amount > command.LegacyDeductAmount {
|
||||
return refundResolution{}, errors.New(errors.CodeInvalidParam, "退款金额不能大于历史订单实付金额")
|
||||
}
|
||||
return refundResolution{
|
||||
shopID: command.LegacyPayerShopID, relatedShopID: command.LegacyRelatedShopID,
|
||||
deductAmount: command.LegacyDeductAmount,
|
||||
assetType: command.AssetType, assetID: command.AssetID, assetIdentifier: command.AssetIdentifier,
|
||||
legacy: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func lockRefundWallet(ctx context.Context, tx *gorm.DB, resolution refundResolution) (model.AgentWallet, error) {
|
||||
var stored model.AgentWallet
|
||||
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("wallet_type = ?", constants.AgentWalletTypeMain)
|
||||
if resolution.walletID > 0 {
|
||||
query = query.Where("id = ?", resolution.walletID)
|
||||
} else {
|
||||
query = query.Where("shop_id = ?", resolution.shopID)
|
||||
}
|
||||
if err := query.First(&stored).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return model.AgentWallet{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
|
||||
}
|
||||
return model.AgentWallet{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
func findExistingRefund(ctx context.Context, tx *gorm.DB, refundID uint) (*model.AgentWalletTransaction, error) {
|
||||
var transaction model.AgentWalletTransaction
|
||||
err := tx.WithContext(ctx).Unscoped().
|
||||
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
||||
constants.ReferenceTypeRefund, refundID, constants.AgentTransactionTypeRefund, constants.TransactionStatusSuccess).
|
||||
First(&transaction).Error
|
||||
if err == nil {
|
||||
return &transaction, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包退款流水失败")
|
||||
}
|
||||
|
||||
func buildRefundTransaction(stored model.AgentWallet, balanceAfter int64, command RefundCommand, resolution refundResolution) *model.AgentWalletTransaction {
|
||||
referenceType := constants.ReferenceTypeRefund
|
||||
remark := strings.TrimSpace(command.Remark)
|
||||
return &model.AgentWalletTransaction{
|
||||
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
|
||||
TransactionType: constants.AgentTransactionTypeRefund, TransactionSubtype: resolution.subtype,
|
||||
Amount: command.Amount, BalanceBefore: stored.Balance, BalanceAfter: balanceAfter,
|
||||
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.RefundID,
|
||||
RelatedShopID: resolution.relatedShopID, AssetType: resolution.assetType, AssetID: resolution.assetID,
|
||||
AssetIdentifier: resolution.assetIdentifier, Remark: &remark, Creator: command.Creator,
|
||||
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
|
||||
}
|
||||
}
|
||||
|
||||
func sameOptionalUint(left, right *uint) bool {
|
||||
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
|
||||
}
|
||||
|
||||
func sameOptionalString(left, right *string) bool {
|
||||
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
|
||||
}
|
||||
297
internal/application/wallet/reservation.go
Normal file
297
internal/application/wallet/reservation.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ReservationCommand 描述一次具有稳定业务引用的代理主钱包预占操作。
|
||||
type ReservationCommand struct {
|
||||
ShopID uint
|
||||
Amount int64
|
||||
ReferenceType string
|
||||
ReferenceID uint
|
||||
UserID uint
|
||||
Creator uint
|
||||
Subtype string
|
||||
RelatedShopID *uint
|
||||
AssetType string
|
||||
AssetID uint
|
||||
AssetIdentifier string
|
||||
Remark string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// ReservationEvent 是代理主钱包预占状态变化事件。
|
||||
type ReservationEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
ReservationID uint `json:"reservation_id"`
|
||||
WalletID uint `json:"wallet_id"`
|
||||
ShopID uint `json:"shop_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Status int `json:"status"`
|
||||
ReferenceType string `json:"reference_type"`
|
||||
ReferenceID uint `json:"reference_id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// ReservationEventWriter 在业务事务内追加预占状态事件。
|
||||
type ReservationEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event ReservationEvent) error
|
||||
}
|
||||
|
||||
// ReservationService 统一代理主钱包冻结、释放与完成扣除。
|
||||
type ReservationService struct {
|
||||
reservationEvents ReservationEventWriter
|
||||
debitEvents DebitEventWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewReservationService 创建代理主钱包预占服务。
|
||||
func NewReservationService(reservationEvents ReservationEventWriter, debitEvents DebitEventWriter, now func() time.Time) *ReservationService {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &ReservationService{reservationEvents: reservationEvents, debitEvents: debitEvents, now: now}
|
||||
}
|
||||
|
||||
// FreezeInTx 在调用方事务内冻结资金并创建唯一预占事实。
|
||||
func (s *ReservationService) FreezeInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
|
||||
if err := s.validateFreeze(tx, command); err != nil {
|
||||
return err
|
||||
}
|
||||
wallet, aggregate, err := lockMainWallet(ctx, tx, command.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := findReservation(ctx, tx, command.ReferenceType, command.ReferenceID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.AgentWalletID == wallet.ID && existing.Amount == command.Amount && existing.Status == constants.AgentWalletReservationStatusFrozen {
|
||||
return nil
|
||||
}
|
||||
return errors.New(errors.CodeConflict, "业务引用已存在不一致的钱包预占")
|
||||
}
|
||||
if err := aggregate.Freeze(command.Amount); err != nil {
|
||||
return err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
if err := updateWalletFunds(ctx, tx, wallet, aggregate, now); err != nil {
|
||||
return err
|
||||
}
|
||||
reservation := &model.AgentWalletReservation{
|
||||
AgentWalletID: wallet.ID, ShopID: wallet.ShopID, Amount: command.Amount,
|
||||
Status: constants.AgentWalletReservationStatusFrozen,
|
||||
ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID, Creator: command.Creator,
|
||||
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(reservation).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包预占事实失败")
|
||||
}
|
||||
return s.appendReservationEvent(ctx, tx, reservation, now, command)
|
||||
}
|
||||
|
||||
// ReleaseInTx 幂等释放处于冻结状态的资金预占。
|
||||
func (s *ReservationService) ReleaseInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
|
||||
return s.finish(ctx, tx, command, constants.AgentWalletReservationStatusReleased)
|
||||
}
|
||||
|
||||
// CompleteInTx 完成冻结资金扣除并创建真实扣款流水与扣款事件。
|
||||
func (s *ReservationService) CompleteInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
|
||||
return s.finish(ctx, tx, command, constants.AgentWalletReservationStatusCompleted)
|
||||
}
|
||||
|
||||
func (s *ReservationService) finish(ctx context.Context, tx *gorm.DB, command ReservationCommand, targetStatus int) error {
|
||||
if err := s.validateFinish(tx, command); err != nil {
|
||||
return err
|
||||
}
|
||||
reservation, err := findReservation(ctx, tx, command.ReferenceType, command.ReferenceID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reservation == nil {
|
||||
return errors.New(errors.CodeNotFound, "代理主钱包预占事实不存在")
|
||||
}
|
||||
if (command.ShopID > 0 && reservation.ShopID != command.ShopID) || (command.Amount > 0 && reservation.Amount != command.Amount) {
|
||||
return errors.New(errors.CodeConflict, "钱包预占事实与请求不一致")
|
||||
}
|
||||
command.ShopID = reservation.ShopID
|
||||
command.Amount = reservation.Amount
|
||||
if reservation.Status == targetStatus {
|
||||
return nil
|
||||
}
|
||||
if reservation.Status != constants.AgentWalletReservationStatusFrozen {
|
||||
return errors.New(errors.CodeInvalidStatus, "钱包预占已经进入其他终态")
|
||||
}
|
||||
wallet, aggregate, err := lockMainWallet(ctx, tx, command.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wallet.ID != reservation.AgentWalletID {
|
||||
return errors.New(errors.CodeConflict, "钱包预占归属不一致")
|
||||
}
|
||||
if targetStatus == constants.AgentWalletReservationStatusReleased {
|
||||
err = aggregate.Release(command.Amount)
|
||||
} else {
|
||||
err = aggregate.CompleteReserved(command.Amount)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
if err := updateWalletFunds(ctx, tx, wallet, aggregate, now); err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.AgentWalletReservation{}).
|
||||
Where("id = ? AND status = ?", reservation.ID, constants.AgentWalletReservationStatusFrozen).
|
||||
Updates(map[string]any{"status": targetStatus, "completed_at": now, "updated_at": now})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新代理主钱包预占状态失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "钱包预占状态已变化")
|
||||
}
|
||||
reservation.Status = targetStatus
|
||||
reservation.CompletedAt = &now
|
||||
if targetStatus == constants.AgentWalletReservationStatusCompleted {
|
||||
if err := s.createCompletedDebit(ctx, tx, wallet, aggregate, command, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.appendReservationEvent(ctx, tx, reservation, now, command)
|
||||
}
|
||||
|
||||
func (s *ReservationService) validateConfigured(tx *gorm.DB) error {
|
||||
if s == nil || s.reservationEvents == nil || s.debitEvents == nil || tx == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包预占能力未完整配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ReservationService) validateFreeze(tx *gorm.DB, command ReservationCommand) error {
|
||||
if err := s.validateConfigured(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if command.ShopID == 0 || command.ReferenceType != constants.ReferenceTypeOrder || command.ReferenceID == 0 || command.Amount <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包预占参数无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ReservationService) validateFinish(tx *gorm.DB, command ReservationCommand) error {
|
||||
if err := s.validateConfigured(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if command.ReferenceType != constants.ReferenceTypeOrder || command.ReferenceID == 0 || command.Amount < 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包预占参数无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lockMainWallet(ctx context.Context, tx *gorm.DB, shopID uint) (model.AgentWallet, *domainwallet.AgentWallet, error) {
|
||||
var wallet model.AgentWallet
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&wallet).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return wallet, nil, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
|
||||
}
|
||||
return wallet, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
|
||||
}
|
||||
aggregate := &domainwallet.AgentWallet{
|
||||
ID: wallet.ID, ShopID: wallet.ShopID, WalletType: wallet.WalletType,
|
||||
Balance: wallet.Balance, FrozenBalance: wallet.FrozenBalance,
|
||||
CreditEnabled: wallet.CreditEnabled, CreditLimit: wallet.CreditLimit,
|
||||
Status: wallet.Status, Version: wallet.Version,
|
||||
}
|
||||
return wallet, aggregate, nil
|
||||
}
|
||||
|
||||
func findReservation(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint, lock bool) (*model.AgentWalletReservation, error) {
|
||||
var reservation model.AgentWalletReservation
|
||||
query := tx.WithContext(ctx)
|
||||
if lock {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
err := query.Where("reference_type = ? AND reference_id = ?", referenceType, referenceID).First(&reservation).Error
|
||||
if err == nil {
|
||||
return &reservation, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包预占事实失败")
|
||||
}
|
||||
|
||||
func updateWalletFunds(ctx context.Context, tx *gorm.DB, stored model.AgentWallet, aggregate *domainwallet.AgentWallet, now time.Time) error {
|
||||
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND status = ? AND version = ?", stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version).
|
||||
Updates(map[string]any{
|
||||
"balance": aggregate.Balance, "frozen_balance": aggregate.FrozenBalance,
|
||||
"version": gorm.Expr("version + 1"), "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新代理主钱包预占资金失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ReservationService) createCompletedDebit(ctx context.Context, tx *gorm.DB, stored model.AgentWallet, aggregate *domainwallet.AgentWallet, command ReservationCommand, now time.Time) error {
|
||||
referenceType := command.ReferenceType
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
|
||||
TransactionType: constants.AgentTransactionTypeDeduct, Amount: -command.Amount,
|
||||
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
|
||||
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.ReferenceID,
|
||||
RelatedShopID: command.RelatedShopID, AssetType: command.AssetType, AssetID: command.AssetID,
|
||||
AssetIdentifier: command.AssetIdentifier, Remark: &command.Remark, Creator: command.Creator,
|
||||
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
|
||||
}
|
||||
if command.Subtype != "" {
|
||||
transaction.TransactionSubtype = &command.Subtype
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建冻结资金扣款流水失败")
|
||||
}
|
||||
event := DebitedEvent{
|
||||
EventID: "agent-wallet:order:" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":debited",
|
||||
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
|
||||
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
|
||||
ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID,
|
||||
TransactionType: constants.AgentTransactionTypeDeduct, OccurredAt: now,
|
||||
RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
}
|
||||
if err := s.debitEvents.Append(ctx, tx, event); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入冻结资金扣款事件失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ReservationService) appendReservationEvent(ctx context.Context, tx *gorm.DB, reservation *model.AgentWalletReservation, now time.Time, command ReservationCommand) error {
|
||||
event := ReservationEvent{
|
||||
EventID: "agent-wallet-reservation:" + strconv.FormatUint(uint64(reservation.ID), 10) + ":" + strconv.Itoa(reservation.Status),
|
||||
ReservationID: reservation.ID, WalletID: reservation.AgentWalletID, ShopID: reservation.ShopID,
|
||||
Amount: reservation.Amount, Status: reservation.Status,
|
||||
ReferenceType: reservation.ReferenceType, ReferenceID: reservation.ReferenceID,
|
||||
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
}
|
||||
if err := s.reservationEvents.Append(ctx, tx, event); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包预占事件失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user