暂存一下,防止丢失
This commit is contained in:
104
internal/infrastructure/approval/decision_delivery.go
Normal file
104
internal/infrastructure/approval/decision_delivery.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// DecisionDeliveryStore 持久化标准决策消费事实并管理处理租约。
|
||||
type DecisionDeliveryStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDecisionDeliveryStore 创建标准决策投递 Store。
|
||||
func NewDecisionDeliveryStore(db *gorm.DB) *DecisionDeliveryStore {
|
||||
return &DecisionDeliveryStore{db: db}
|
||||
}
|
||||
|
||||
// Create 在审批状态事务中创建唯一标准决策投递事实。
|
||||
func (s *DecisionDeliveryStore) Create(ctx context.Context, tx *gorm.DB, event approvalapp.TerminalDecisionEvent) error {
|
||||
if tx == nil {
|
||||
return errors.New(errors.CodeInternalError, "审批决策投递必须使用审批状态事务")
|
||||
}
|
||||
record := model.ApprovalDecisionDelivery{
|
||||
InstanceID: event.InstanceID, Decision: event.Decision, EventID: event.EventID,
|
||||
Status: constants.ApprovalDecisionDeliveryPending, CreatedAt: event.OccurredAt, UpdatedAt: event.OccurredAt,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(&record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建审批决策投递事实失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Claim 领取待处理、失败或租约已过期的标准决策。
|
||||
func (s *DecisionDeliveryStore) Claim(
|
||||
ctx context.Context,
|
||||
eventID string,
|
||||
owner string,
|
||||
now time.Time,
|
||||
duration time.Duration,
|
||||
) (bool, error) {
|
||||
if s == nil || s.db == nil || eventID == "" || owner == "" || duration <= 0 {
|
||||
return false, errors.New(errors.CodeInternalError, "审批决策处理租约 Store 未完整配置")
|
||||
}
|
||||
result := s.db.WithContext(ctx).Model(&model.ApprovalDecisionDelivery{}).
|
||||
Where("event_id = ? AND (status IN ? OR (status = ? AND lease_expires_at <= ?))",
|
||||
eventID,
|
||||
[]int{constants.ApprovalDecisionDeliveryPending, constants.ApprovalDecisionDeliveryFailed},
|
||||
constants.ApprovalDecisionDeliveryProcessing, now).
|
||||
Updates(map[string]any{
|
||||
"status": constants.ApprovalDecisionDeliveryProcessing,
|
||||
"lease_owner": owner, "lease_expires_at": now.Add(duration), "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "领取审批决策处理租约失败")
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// MarkSucceeded 标记当前租约的业务消费者已幂等处理成功。
|
||||
func (s *DecisionDeliveryStore) MarkSucceeded(ctx context.Context, eventID string, owner string, now time.Time) (bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return false, errors.New(errors.CodeInternalError, "审批决策处理租约 Store 未配置")
|
||||
}
|
||||
result := s.db.WithContext(ctx).Model(&model.ApprovalDecisionDelivery{}).
|
||||
Where("event_id = ? AND status = ? AND lease_owner = ?", eventID, constants.ApprovalDecisionDeliveryProcessing, owner).
|
||||
Updates(map[string]any{
|
||||
"status": constants.ApprovalDecisionDeliverySucceeded, "processed_at": now,
|
||||
"lease_owner": nil, "lease_expires_at": nil, "last_error": "", "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "完成审批决策业务处理失败")
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// MarkFailed 记录当前租约的安全失败摘要并释放租约等待重试。
|
||||
func (s *DecisionDeliveryStore) MarkFailed(
|
||||
ctx context.Context,
|
||||
eventID string,
|
||||
owner string,
|
||||
now time.Time,
|
||||
errorSummary string,
|
||||
) (bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return false, errors.New(errors.CodeInternalError, "审批决策处理租约 Store 未配置")
|
||||
}
|
||||
result := s.db.WithContext(ctx).Model(&model.ApprovalDecisionDelivery{}).
|
||||
Where("event_id = ? AND status = ? AND lease_owner = ?", eventID, constants.ApprovalDecisionDeliveryProcessing, owner).
|
||||
Updates(map[string]any{
|
||||
"status": constants.ApprovalDecisionDeliveryFailed, "retry_count": gorm.Expr("retry_count + 1"),
|
||||
"lease_owner": nil, "lease_expires_at": nil, "last_error": errorSummary, "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "记录审批决策业务处理失败")
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
112
internal/infrastructure/approval/repository.go
Normal file
112
internal/infrastructure/approval/repository.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Package approval 实现通用审批实例的 PostgreSQL 持久化 Adapter。
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvaldomain "github.com/break/junhong_cmp_fiber/internal/domain/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// Repository 实现通用审批实例写侧仓储。
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// RepositoryProvider 为 Application 用例创建事务作用域 Repository。
|
||||
type RepositoryProvider struct{}
|
||||
|
||||
// NewRepositoryProvider 创建通用审批 Repository Provider。
|
||||
func NewRepositoryProvider() *RepositoryProvider {
|
||||
return &RepositoryProvider{}
|
||||
}
|
||||
|
||||
// ForDB 使用指定数据库会话创建领域 Repository。
|
||||
func (p *RepositoryProvider) ForDB(db *gorm.DB) approvaldomain.Repository {
|
||||
return NewRepository(db)
|
||||
}
|
||||
|
||||
// GetForUpdate 在当前事务中锁定并读取通用审批实例。
|
||||
func (r *Repository) GetForUpdate(ctx context.Context, instanceID uint) (*approvaldomain.Instance, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New(errors.CodeInternalError, "通用审批实例 Repository 未配置数据库会话")
|
||||
}
|
||||
var record model.ApprovalInstance
|
||||
err := r.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", instanceID).First(&record).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取通用审批实例失败")
|
||||
}
|
||||
return instanceFromModel(record), nil
|
||||
}
|
||||
|
||||
// SaveDecision 以旧状态和旧版本为条件保存标准决策,防止并发重复终态。
|
||||
func (r *Repository) SaveDecision(
|
||||
ctx context.Context,
|
||||
instance *approvaldomain.Instance,
|
||||
expectedStatus int,
|
||||
expectedVersion int,
|
||||
) (bool, error) {
|
||||
if r == nil || r.db == nil || instance == nil {
|
||||
return false, errors.New(errors.CodeInternalError, "通用审批实例 Repository 未完整配置")
|
||||
}
|
||||
result := r.db.WithContext(ctx).Model(&model.ApprovalInstance{}).
|
||||
Where("id = ? AND status = ? AND version = ?", instance.ID, expectedStatus, expectedVersion).
|
||||
Updates(map[string]any{
|
||||
"status": instance.Status, "decision_snapshot": datatypes.JSON(instance.DecisionSnapshot),
|
||||
"status_changed_at": instance.StatusChangedAt, "version": instance.Version, "updated_at": instance.UpdatedAt,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "保存通用审批决策失败")
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
func instanceFromModel(record model.ApprovalInstance) *approvaldomain.Instance {
|
||||
return &approvaldomain.Instance{
|
||||
ID: record.ID, BusinessType: record.BusinessType, BusinessID: record.BusinessID,
|
||||
SubmitterAccountID: record.SubmitterAccountID, SubmitterSnapshot: append([]byte(nil), record.SubmitterSnapshot...),
|
||||
Provider: record.Provider, ExternalRef: record.ExternalRef, Status: record.Status,
|
||||
RequestSnapshot: append([]byte(nil), record.RequestSnapshot...),
|
||||
DecisionSnapshot: append([]byte(nil), record.DecisionSnapshot...), CorrelationID: record.CorrelationID,
|
||||
Version: record.Version, StatusChangedAt: record.StatusChangedAt,
|
||||
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// NewRepository 创建通用审批实例 PostgreSQL Repository。
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
// Create 使用 Repository 持有的数据库会话创建一条唯一通用审批实例。
|
||||
// 需要原子写入业务事实时,调用方必须以当前 GORM 事务创建 Repository。
|
||||
func (r *Repository) Create(ctx context.Context, instance *approvaldomain.Instance) error {
|
||||
if r == nil || r.db == nil {
|
||||
return stderrors.New("通用审批实例 Repository 未配置数据库会话")
|
||||
}
|
||||
if instance == nil {
|
||||
return stderrors.New("通用审批实例不能为空")
|
||||
}
|
||||
record := model.ApprovalInstance{
|
||||
BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
|
||||
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: datatypes.JSON(instance.SubmitterSnapshot),
|
||||
Provider: instance.Provider, ExternalRef: instance.ExternalRef, Status: instance.Status,
|
||||
RequestSnapshot: datatypes.JSON(instance.RequestSnapshot), DecisionSnapshot: datatypes.JSON(instance.DecisionSnapshot),
|
||||
CorrelationID: instance.CorrelationID, Version: instance.Version, StatusChangedAt: instance.StatusChangedAt,
|
||||
CreatedAt: instance.CreatedAt, UpdatedAt: instance.UpdatedAt,
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(&record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
instance.ID = record.ID
|
||||
return nil
|
||||
}
|
||||
38
internal/infrastructure/approval/submission_event.go
Normal file
38
internal/infrastructure/approval/submission_event.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// SubmissionEventWriter 将通用审批申请写入公共 Outbox。
|
||||
type SubmissionEventWriter struct {
|
||||
outbox *outbox.Repository
|
||||
}
|
||||
|
||||
// NewSubmissionEventWriter 创建通用审批申请 Outbox Writer。
|
||||
func NewSubmissionEventWriter(repository *outbox.Repository) *SubmissionEventWriter {
|
||||
return &SubmissionEventWriter{outbox: repository}
|
||||
}
|
||||
|
||||
// Append 在调用方业务事务中追加渠道提交事件。
|
||||
func (w *SubmissionEventWriter) Append(ctx context.Context, tx *gorm.DB, event approvalapp.SubmissionRequestedEvent) error {
|
||||
if w == nil || w.outbox == nil {
|
||||
return errors.New(errors.CodeInternalError, "审批申请 Outbox Writer 未配置")
|
||||
}
|
||||
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
|
||||
EventID: event.EventID, EventType: constants.OutboxEventTypeApprovalSubmissionRequested,
|
||||
PayloadVersion: constants.ApprovalSubmissionPayloadVersionV1,
|
||||
AggregateType: "approval", AggregateID: strconv.FormatUint(uint64(event.InstanceID), 10),
|
||||
ResourceType: event.BusinessType, ResourceID: strconv.FormatUint(uint64(event.BusinessID), 10),
|
||||
BusinessKey: event.EventID, CorrelationID: event.CorrelationID, Payload: event,
|
||||
})
|
||||
return err
|
||||
}
|
||||
73
internal/infrastructure/approval/terminal_event.go
Normal file
73
internal/infrastructure/approval/terminal_event.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
approvaldomain "github.com/break/junhong_cmp_fiber/internal/domain/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// TerminalEventWriter 将通用审批标准决策写入公共 Outbox。
|
||||
type TerminalEventWriter struct {
|
||||
outbox *outbox.Repository
|
||||
}
|
||||
|
||||
// NewTerminalEventWriter 创建通用审批标准决策 Outbox Writer。
|
||||
func NewTerminalEventWriter(repository *outbox.Repository) *TerminalEventWriter {
|
||||
return &TerminalEventWriter{outbox: repository}
|
||||
}
|
||||
|
||||
// Append 在审批状态事务中追加结构化标准决策事件。
|
||||
func (w *TerminalEventWriter) Append(ctx context.Context, tx *gorm.DB, event approvalapp.TerminalDecisionEvent) error {
|
||||
if w == nil || w.outbox == nil {
|
||||
return errors.New(errors.CodeInternalError, "审批标准决策 Outbox Writer 未配置")
|
||||
}
|
||||
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
|
||||
EventID: event.EventID, EventType: constants.OutboxEventTypeApprovalTerminalDecision,
|
||||
PayloadVersion: constants.ApprovalTerminalDecisionPayloadVersionV1,
|
||||
AggregateType: "approval", AggregateID: strconv.FormatUint(uint64(event.InstanceID), 10),
|
||||
ResourceType: event.BusinessType, ResourceID: strconv.FormatUint(uint64(event.BusinessID), 10),
|
||||
BusinessKey: event.EventID, CorrelationID: event.CorrelationID, Payload: event,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// TerminalDecisionConsumer 将公共 Outbox 信封转换为渠道无关业务决策。
|
||||
type TerminalDecisionConsumer struct {
|
||||
dispatcher *approvalapp.DecisionDispatcher
|
||||
}
|
||||
|
||||
// NewTerminalDecisionConsumer 创建审批标准决策消费者 Adapter。
|
||||
func NewTerminalDecisionConsumer(dispatcher *approvalapp.DecisionDispatcher) *TerminalDecisionConsumer {
|
||||
return &TerminalDecisionConsumer{dispatcher: dispatcher}
|
||||
}
|
||||
|
||||
// Consume 校验事件类型、版本和稳定身份后交给业务分发器。
|
||||
func (c *TerminalDecisionConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.dispatcher == nil {
|
||||
return errors.New(errors.CodeInternalError, "审批标准决策消费者未配置")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeApprovalTerminalDecision ||
|
||||
envelope.PayloadVersion != constants.ApprovalTerminalDecisionPayloadVersionV1 {
|
||||
return errors.New(errors.CodeInvalidParam, "审批标准决策事件类型或版本不受支持")
|
||||
}
|
||||
var event approvalapp.TerminalDecisionEvent
|
||||
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "审批标准决策事件载荷格式错误")
|
||||
}
|
||||
if event.EventID == "" || event.EventID != envelope.EventID || event.InstanceID == 0 ||
|
||||
event.BusinessType == "" || event.BusinessID == 0 || event.CorrelationID == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "审批标准决策事件载荷不完整")
|
||||
}
|
||||
if _, err := approvaldomain.StatusForDecision(event.Decision); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.dispatcher.Consume(ctx, event)
|
||||
}
|
||||
336
internal/infrastructure/cardobservation/event.go
Normal file
336
internal/infrastructure/cardobservation/event.go
Normal file
@@ -0,0 +1,336 @@
|
||||
// Package cardobservation 提供卡观测的 Outbox、缓存和消费者适配器。
|
||||
package cardobservation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
func uintString(value uint) string {
|
||||
return strconv.FormatUint(uint64(value), 10)
|
||||
}
|
||||
|
||||
// EventWriter 将卡实名状态变化写入公共 Outbox。
|
||||
type EventWriter struct {
|
||||
outbox *outbox.Repository
|
||||
}
|
||||
|
||||
// NewEventWriter 创建卡实名事件 Writer。
|
||||
func NewEventWriter(repository *outbox.Repository) *EventWriter {
|
||||
return &EventWriter{outbox: repository}
|
||||
}
|
||||
|
||||
// Append 在调用方事务中追加卡实名状态变化事件。
|
||||
func (w *EventWriter) AppendRealname(ctx context.Context, tx *gorm.DB, event cardapp.RealnameChangedEvent) error {
|
||||
if w == nil || w.outbox == nil {
|
||||
return errors.New(errors.CodeInternalError, "卡实名 Outbox Writer 未配置")
|
||||
}
|
||||
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
|
||||
EventID: event.EventID, EventType: constants.OutboxEventTypeCardRealnameChanged,
|
||||
PayloadVersion: constants.CardRealnameChangedPayloadVersionV1,
|
||||
AggregateType: "iot_card", AggregateID: uintString(event.CardID),
|
||||
ResourceType: constants.AssetTypeIotCard, ResourceID: uintString(event.CardID),
|
||||
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
|
||||
Payload: event,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// AppendTraffic 在调用方事务中追加卡流量正增量事件。
|
||||
func (w *EventWriter) AppendTraffic(ctx context.Context, tx *gorm.DB, event cardapp.TrafficIncrementedEvent) error {
|
||||
if w == nil || w.outbox == nil {
|
||||
return errors.New(errors.CodeInternalError, "卡流量 Outbox Writer 未配置")
|
||||
}
|
||||
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
|
||||
EventID: event.EventID, EventType: constants.OutboxEventTypeCardTrafficIncremented,
|
||||
PayloadVersion: constants.CardTrafficIncrementedPayloadVersionV1,
|
||||
AggregateType: "iot_card", AggregateID: uintString(event.CardID),
|
||||
ResourceType: constants.AssetTypeIotCard, ResourceID: uintString(event.CardID),
|
||||
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
|
||||
Payload: event,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// AppendNetwork 在调用方事务中追加卡网络状态变化事件。
|
||||
func (w *EventWriter) AppendNetwork(ctx context.Context, tx *gorm.DB, event cardapp.NetworkChangedEvent) error {
|
||||
if w == nil || w.outbox == nil {
|
||||
return errors.New(errors.CodeInternalError, "卡网络 Outbox Writer 未配置")
|
||||
}
|
||||
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
|
||||
EventID: event.EventID, EventType: constants.OutboxEventTypeCardNetworkChanged,
|
||||
PayloadVersion: constants.CardNetworkChangedPayloadVersionV1,
|
||||
AggregateType: "iot_card", AggregateID: uintString(event.CardID),
|
||||
ResourceType: constants.AssetTypeIotCard, ResourceID: uintString(event.CardID),
|
||||
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
|
||||
Payload: event,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// CacheInvalidator 在提交后删除轮询卡缓存和旧 Redis 逆转计数。
|
||||
type CacheInvalidator struct {
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewCacheInvalidator 创建卡观测缓存失效适配器。
|
||||
func NewCacheInvalidator(redisClient *redis.Client, logger *zap.Logger) *CacheInvalidator {
|
||||
return &CacheInvalidator{redis: redisClient, logger: logger}
|
||||
}
|
||||
|
||||
// Invalidate 删除可由数据库重建的缓存;失败只告警,不伪造事务回滚。
|
||||
func (i *CacheInvalidator) Invalidate(ctx context.Context, cardID uint) {
|
||||
if i == nil || i.redis == nil {
|
||||
return
|
||||
}
|
||||
if err := i.redis.Del(ctx, constants.RedisPollingCardInfoKey(cardID), constants.RedisPollingRealnameReversalCountKey(cardID)).Err(); err != nil && i.logger != nil {
|
||||
i.logger.Warn("卡实名事实已提交但缓存失效失败", zap.Uint("card_id", cardID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// RealnameActivator 激活卡或设备待实名套餐。
|
||||
type RealnameActivator interface {
|
||||
ActivateByRealname(ctx context.Context, carrierType string, carrierID uint) error
|
||||
}
|
||||
|
||||
// TrafficDeductor 按卡流量正增量扣减套餐流量。
|
||||
type TrafficDeductor interface {
|
||||
DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error
|
||||
}
|
||||
|
||||
// StopResumeEvaluator 根据卡的当前事实执行停复机评估。
|
||||
type StopResumeEvaluator interface {
|
||||
EvaluateAndAct(ctx context.Context, card *model.IotCard) error
|
||||
}
|
||||
|
||||
// DeviceBindingReader 查询卡当前绑定的设备。
|
||||
type DeviceBindingReader interface {
|
||||
GetActiveBindingByCardID(ctx context.Context, cardID uint) (*model.DeviceSimBinding, error)
|
||||
}
|
||||
|
||||
// RealnameChangedConsumer 消费实名变化事件并串联现有幂等业务副作用。
|
||||
type RealnameChangedConsumer struct {
|
||||
db *gorm.DB
|
||||
activator RealnameActivator
|
||||
binding DeviceBindingReader
|
||||
evaluator StopResumeEvaluator
|
||||
}
|
||||
|
||||
// NetworkChangedConsumer 消费网络状态变化并按当前权威事实执行停复机评估。
|
||||
type NetworkChangedConsumer struct {
|
||||
db *gorm.DB
|
||||
evaluator StopResumeEvaluator
|
||||
}
|
||||
|
||||
// NewNetworkChangedConsumer 创建卡网络状态变化事件消费者。
|
||||
func NewNetworkChangedConsumer(db *gorm.DB, evaluator StopResumeEvaluator) *NetworkChangedConsumer {
|
||||
return &NetworkChangedConsumer{db: db, evaluator: evaluator}
|
||||
}
|
||||
|
||||
// Consume 校验网络事件并按当前卡事实执行评估。
|
||||
func (c *NetworkChangedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.db == nil {
|
||||
return errors.New(errors.CodeInternalError, "卡网络事件消费者未配置")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeCardNetworkChanged || envelope.PayloadVersion != constants.CardNetworkChangedPayloadVersionV1 {
|
||||
return errors.New(errors.CodeInvalidParam, "卡网络事件类型或版本不受支持")
|
||||
}
|
||||
var event cardapp.NetworkChangedEvent
|
||||
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "卡网络事件载荷无法解析")
|
||||
}
|
||||
if event.EventID != envelope.EventID || event.CardID == 0 || event.BeforeStatus == event.AfterStatus {
|
||||
return errors.New(errors.CodeInvalidParam, "卡网络事件载荷不完整")
|
||||
}
|
||||
if c.evaluator == nil {
|
||||
return nil
|
||||
}
|
||||
var card model.IotCard
|
||||
if err := c.db.WithContext(ctx).Where("id = ?", event.CardID).First(&card).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询网络变化后的卡事实失败")
|
||||
}
|
||||
return c.evaluator.EvaluateAndAct(ctx, &card)
|
||||
}
|
||||
|
||||
// NewRealnameChangedConsumer 创建卡实名变化事件消费者。
|
||||
func NewRealnameChangedConsumer(db *gorm.DB, activator RealnameActivator, binding DeviceBindingReader, evaluator StopResumeEvaluator) *RealnameChangedConsumer {
|
||||
return &RealnameChangedConsumer{db: db, activator: activator, binding: binding, evaluator: evaluator}
|
||||
}
|
||||
|
||||
// Consume 校验载荷,并以当前权威卡事实执行可重入副作用。
|
||||
func (c *RealnameChangedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.db == nil {
|
||||
return errors.New(errors.CodeInternalError, "卡实名事件消费者未配置")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeCardRealnameChanged || envelope.PayloadVersion != constants.CardRealnameChangedPayloadVersionV1 {
|
||||
return errors.New(errors.CodeInvalidParam, "卡实名事件类型或版本不受支持")
|
||||
}
|
||||
var event cardapp.RealnameChangedEvent
|
||||
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "卡实名事件载荷无法解析")
|
||||
}
|
||||
if event.EventID != envelope.EventID || event.CardID == 0 || event.BeforeStatus == event.AfterStatus {
|
||||
return errors.New(errors.CodeInvalidParam, "卡实名事件载荷不完整")
|
||||
}
|
||||
if event.FirstVerified && c.activator != nil {
|
||||
if err := c.activator.ActivateByRealname(ctx, constants.AssetTypeIotCard, event.CardID); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.binding != nil {
|
||||
binding, err := c.binding.GetActiveBindingByCardID(ctx, event.CardID)
|
||||
if err == nil && binding != nil {
|
||||
if err := c.activator.ActivateByRealname(ctx, constants.AssetTypeDevice, binding.DeviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询实名卡绑定设备失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.evaluator == nil {
|
||||
return nil
|
||||
}
|
||||
var card model.IotCard
|
||||
if err := c.db.WithContext(ctx).Where("id = ?", event.CardID).First(&card).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询实名变化后的卡事实失败")
|
||||
}
|
||||
return c.evaluator.EvaluateAndAct(ctx, &card)
|
||||
}
|
||||
|
||||
// TrafficIncrementedConsumer 消费卡流量正增量并推进幂等副作用进度。
|
||||
type TrafficIncrementedConsumer struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
deductor TrafficDeductor
|
||||
evaluator StopResumeEvaluator
|
||||
}
|
||||
|
||||
// NewTrafficIncrementedConsumer 创建卡流量正增量事件消费者。
|
||||
func NewTrafficIncrementedConsumer(db *gorm.DB, redisClient *redis.Client, deductor TrafficDeductor, evaluator StopResumeEvaluator) *TrafficIncrementedConsumer {
|
||||
return &TrafficIncrementedConsumer{db: db, redis: redisClient, deductor: deductor, evaluator: evaluator}
|
||||
}
|
||||
|
||||
// Consume 按事件处理进度避免至少一次投递重复扣减套餐流量。
|
||||
func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.db == nil || c.redis == nil || c.deductor == nil {
|
||||
return errors.New(errors.CodeInternalError, "卡流量事件消费者未配置")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeCardTrafficIncremented || envelope.PayloadVersion != constants.CardTrafficIncrementedPayloadVersionV1 {
|
||||
return errors.New(errors.CodeInvalidParam, "卡流量事件类型或版本不受支持")
|
||||
}
|
||||
var event cardapp.TrafficIncrementedEvent
|
||||
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "卡流量事件载荷无法解析")
|
||||
}
|
||||
if event.EventID != envelope.EventID || event.CardID == 0 || event.IncrementMB <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "卡流量事件载荷不完整")
|
||||
}
|
||||
receipt, err := c.acquireTrafficEffect(ctx, event.EventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if receipt.Status == constants.CardObservationEffectStatusCompleted {
|
||||
return nil
|
||||
}
|
||||
if receipt.Status == constants.CardObservationEffectStatusProcessing {
|
||||
dailyKey := constants.RedisCardDailyTrafficKey(event.CardID, event.ObservedAt.Format("2006-01-02"))
|
||||
pipe := c.redis.TxPipeline()
|
||||
pipe.IncrByFloat(ctx, dailyKey, event.IncrementMB)
|
||||
pipe.Expire(ctx, dailyKey, 48*time.Hour)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "记录卡日流量缓冲失败,处理结果未知")
|
||||
}
|
||||
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusProcessing, constants.CardObservationEffectStatusDailySaved); err != nil {
|
||||
return err
|
||||
}
|
||||
receipt.Status = constants.CardObservationEffectStatusDailySaved
|
||||
}
|
||||
if receipt.Status == constants.CardObservationEffectStatusDailySaved {
|
||||
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusDailySaved, constants.CardObservationEffectStatusProcessing); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.deductor.DeductDataUsage(ctx, constants.AssetTypeIotCard, event.CardID, event.IncrementMB); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusProcessing, constants.CardObservationEffectStatusDeducted); err != nil {
|
||||
return err
|
||||
}
|
||||
receipt.Status = constants.CardObservationEffectStatusDeducted
|
||||
}
|
||||
if c.evaluator != nil {
|
||||
var card model.IotCard
|
||||
if err := c.db.WithContext(ctx).Where("id = ?", event.CardID).First(&card).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询流量变化后的卡事实失败")
|
||||
}
|
||||
if err := c.evaluator.EvaluateAndAct(ctx, &card); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusDeducted, constants.CardObservationEffectStatusCompleted); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TrafficIncrementedConsumer) acquireTrafficEffect(ctx context.Context, eventID string) (*model.CardObservationEffect, error) {
|
||||
var receipt model.CardObservationEffect
|
||||
err := c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("event_id = ?", eventID).First(&receipt).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
receipt = model.CardObservationEffect{
|
||||
EventID: eventID, EffectType: constants.CardObservationEffectTypeTrafficIncrement,
|
||||
Status: constants.CardObservationEffectStatusProcessing,
|
||||
}
|
||||
return tx.Create(&receipt).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch receipt.Status {
|
||||
case constants.CardObservationEffectStatusCompleted, constants.CardObservationEffectStatusDailySaved, constants.CardObservationEffectStatusDeducted:
|
||||
return nil
|
||||
case constants.CardObservationEffectStatusProcessing:
|
||||
return errors.New(errors.CodeConflict, "卡流量副作用仍在处理或结果未知")
|
||||
default:
|
||||
return tx.Model(&receipt).Where("status = ?", constants.CardObservationEffectStatusPending).
|
||||
Update("status", constants.CardObservationEffectStatusProcessing).Error
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "获取卡流量副作用处理权失败")
|
||||
}
|
||||
return &receipt, nil
|
||||
}
|
||||
|
||||
func (c *TrafficIncrementedConsumer) updateTrafficEffectStatus(ctx context.Context, id uint, beforeStatus, afterStatus int) error {
|
||||
result := c.db.WithContext(ctx).Model(&model.CardObservationEffect{}).
|
||||
Where("id = ? AND status = ?", id, beforeStatus).
|
||||
Update("status", afterStatus)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡流量副作用进度失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "卡流量副作用进度已被其他消费者更新")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
185
internal/infrastructure/cardobservation/series_coordinator.go
Normal file
185
internal/infrastructure/cardobservation/series_coordinator.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package cardobservation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// SeriesCoordinator 使用 Redis 原子协调活跃序列、幂等尝试和实际请求互斥。
|
||||
type SeriesCoordinator struct {
|
||||
redis *redis.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewSeriesCoordinator 创建观测序列 Redis 协调器。
|
||||
func NewSeriesCoordinator(redisClient *redis.Client) *SeriesCoordinator {
|
||||
return &SeriesCoordinator{redis: redisClient, now: time.Now}
|
||||
}
|
||||
|
||||
// Reserve 原子保留同场景活跃序列;重复触发不会刷新 TTL。
|
||||
func (c *SeriesCoordinator) Reserve(ctx context.Context, request cardapp.SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (string, time.Time, bool, error) {
|
||||
if c == nil || c.redis == nil {
|
||||
return "", time.Time{}, false, errors.New(errors.CodeInternalError, "卡观测序列 Redis 未配置")
|
||||
}
|
||||
seriesKey := constants.RedisCardObservationSeriesKey(request.Scene, request.ResourceType, request.ResourceID, request.SyncType)
|
||||
indexKey := constants.RedisCardObservationSeriesIndexKey(request.ResourceType, request.ResourceID, request.SyncType)
|
||||
result, err := c.redis.Eval(ctx, `
|
||||
local current = redis.call('HMGET', KEYS[1], 'series_id', 'base_time_ms')
|
||||
if current[1] then return {current[1], current[2], '1'} end
|
||||
redis.call('HSET', KEYS[1], 'series_id', ARGV[1], 'base_time_ms', ARGV[2])
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[3])
|
||||
redis.call('SADD', KEYS[2], KEYS[1])
|
||||
redis.call('EXPIRE', KEYS[2], ARGV[3])
|
||||
return {ARGV[1], ARGV[2], '0'}
|
||||
`, []string{seriesKey, indexKey}, candidateSeriesID, candidateBaseTime.UTC().UnixMilli(), int(constants.CardObservationSeriesTTL.Seconds())).StringSlice()
|
||||
if err != nil || len(result) != 3 {
|
||||
return "", time.Time{}, false, errors.Wrap(errors.CodeInternalError, err, "保留卡观测序列失败")
|
||||
}
|
||||
baseTimeMS, parseErr := strconv.ParseInt(result[1], 10, 64)
|
||||
if parseErr != nil {
|
||||
return "", time.Time{}, false, errors.Wrap(errors.CodeInternalError, parseErr, "解析卡观测序列基准时间失败")
|
||||
}
|
||||
return result[0], time.UnixMilli(baseTimeMS).UTC(), result[2] == "1", nil
|
||||
}
|
||||
|
||||
// IsCompleted 判断序列是否已由预期状态或回调提前完成。
|
||||
func (c *SeriesCoordinator) IsCompleted(ctx context.Context, seriesID string) (bool, error) {
|
||||
exists, err := c.redis.Exists(ctx, constants.RedisCardObservationSeriesCompletedKey(seriesID)).Result()
|
||||
if err != nil {
|
||||
return false, errors.Wrap(errors.CodeInternalError, err, "读取卡观测序列完成状态失败")
|
||||
}
|
||||
return exists > 0, nil
|
||||
}
|
||||
|
||||
// ClaimAttempt 以 series_id + attempt 原子保证任务只执行一次。
|
||||
func (c *SeriesCoordinator) ClaimAttempt(ctx context.Context, seriesID string, attempt int) (bool, error) {
|
||||
claimed, err := c.redis.SetNX(ctx, constants.RedisCardObservationAttemptKey(seriesID, attempt), "processing", constants.CardObservationSeriesResultTTL).Result()
|
||||
if err != nil {
|
||||
return false, errors.Wrap(errors.CodeInternalError, err, "领取卡观测序列尝试失败")
|
||||
}
|
||||
return claimed, nil
|
||||
}
|
||||
|
||||
// AcquireRequest 获取仅覆盖实际 Gateway 请求的互斥,并返回最小间隔剩余等待时间。
|
||||
func (c *SeriesCoordinator) AcquireRequest(ctx context.Context, payload cardapp.SeriesTaskPayload, provider string) (func(), time.Duration, bool, error) {
|
||||
token := uuid.NewString()
|
||||
lockKey := constants.RedisCardObservationInflightKey(provider, payload.SyncType, payload.ResourceID)
|
||||
// 流量观测复用现有卡级同步锁,避免事件、轮询和手动刷新并发读取同一上游读数。
|
||||
if payload.SyncType == constants.CardObservationSyncTypeTraffic {
|
||||
if cardID, parseErr := strconv.ParseUint(payload.ResourceID, 10, 64); parseErr == nil && cardID > 0 {
|
||||
lockKey = constants.RedisCardTrafficSyncLockKey(uint(cardID))
|
||||
}
|
||||
}
|
||||
lastKey := constants.RedisCardObservationLastRequestKey(provider, payload.SyncType, payload.ResourceID)
|
||||
nowMS := c.now().UTC().UnixMilli()
|
||||
result, err := c.redis.Eval(ctx, `
|
||||
if redis.call('EXISTS', KEYS[1]) == 1 then return {-1} end
|
||||
local now = tonumber(ARGV[2])
|
||||
local minimum = tonumber(ARGV[3])
|
||||
local last = tonumber(redis.call('GET', KEYS[2]) or '0')
|
||||
local wait = math.max(0, last + minimum - now)
|
||||
redis.call('PSETEX', KEYS[1], ARGV[4], ARGV[1])
|
||||
redis.call('PSETEX', KEYS[2], minimum * 3, now + wait)
|
||||
return {wait}
|
||||
`, []string{lockKey, lastKey}, token, nowMS, constants.CardObservationGatewayMinInterval.Milliseconds(), constants.CardObservationGatewayLockTTL.Milliseconds()).Int64Slice()
|
||||
if err != nil || len(result) != 1 {
|
||||
return nil, 0, false, errors.Wrap(errors.CodeInternalError, err, "获取卡观测 Gateway 请求互斥失败")
|
||||
}
|
||||
if result[0] < 0 {
|
||||
return func() {}, 0, false, nil
|
||||
}
|
||||
release := func() {
|
||||
_, _ = c.redis.Eval(context.Background(), `
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
|
||||
return 0
|
||||
`, []string{lockKey}, token).Result()
|
||||
}
|
||||
return release, time.Duration(result[0]) * time.Millisecond, true, nil
|
||||
}
|
||||
|
||||
// FinishAttempt 保存尝试终态,并在最后一次尝试后释放同场景活跃序列。
|
||||
func (c *SeriesCoordinator) FinishAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload) error {
|
||||
if err := c.redis.Set(ctx, constants.RedisCardObservationAttemptKey(payload.SeriesID, payload.Attempt), "completed", constants.CardObservationSeriesResultTTL).Err(); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "完成卡观测序列尝试失败")
|
||||
}
|
||||
if payload.Attempt < constants.CardObservationSeriesAttemptCount {
|
||||
return nil
|
||||
}
|
||||
return c.releaseActiveSeries(ctx, payload, false)
|
||||
}
|
||||
|
||||
// CompleteSeries 提前完成当前及剩余任务,并释放同场景合并键。
|
||||
func (c *SeriesCoordinator) CompleteSeries(ctx context.Context, payload cardapp.SeriesTaskPayload) error {
|
||||
if err := c.releaseActiveSeries(ctx, payload, true); err != nil {
|
||||
return err
|
||||
}
|
||||
pipe := c.redis.TxPipeline()
|
||||
for attempt := payload.Attempt; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
|
||||
pipe.Set(ctx, constants.RedisCardObservationAttemptKey(payload.SeriesID, attempt), "completed", constants.CardObservationSeriesResultTTL)
|
||||
}
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "标记卡观测剩余尝试完成失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *SeriesCoordinator) releaseActiveSeries(ctx context.Context, payload cardapp.SeriesTaskPayload, completed bool) error {
|
||||
seriesKey := constants.RedisCardObservationSeriesKey(payload.Scene, payload.ResourceType, payload.ResourceID, payload.SyncType)
|
||||
indexKey := constants.RedisCardObservationSeriesIndexKey(payload.ResourceType, payload.ResourceID, payload.SyncType)
|
||||
completedValue := "0"
|
||||
if completed {
|
||||
completedValue = "1"
|
||||
}
|
||||
if _, err := c.redis.Eval(ctx, `
|
||||
if ARGV[2] == '1' then redis.call('SET', KEYS[3], '1', 'EX', ARGV[3]) end
|
||||
if redis.call('HGET', KEYS[1], 'series_id') == ARGV[1] then
|
||||
redis.call('DEL', KEYS[1])
|
||||
redis.call('SREM', KEYS[2], KEYS[1])
|
||||
end
|
||||
return 1
|
||||
`, []string{seriesKey, indexKey, constants.RedisCardObservationSeriesCompletedKey(payload.SeriesID)}, payload.SeriesID, completedValue, int(constants.CardObservationSeriesResultTTL.Seconds())).Result(); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "释放卡观测活跃序列失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteResourceSeries 提前完成同资源、同步类型下所有场景的活跃序列。
|
||||
func (c *SeriesCoordinator) CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error {
|
||||
indexKey := constants.RedisCardObservationSeriesIndexKey(resourceType, resourceID, syncType)
|
||||
seriesKeys, err := c.redis.SMembers(ctx, indexKey).Result()
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "读取卡观测活跃序列索引失败")
|
||||
}
|
||||
for _, seriesKey := range seriesKeys {
|
||||
seriesID, getErr := c.redis.HGet(ctx, seriesKey, "series_id").Result()
|
||||
if getErr == redis.Nil {
|
||||
_ = c.redis.SRem(ctx, indexKey, seriesKey).Err()
|
||||
continue
|
||||
}
|
||||
if getErr != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, getErr, "读取卡观测活跃序列失败")
|
||||
}
|
||||
pipe := c.redis.TxPipeline()
|
||||
pipe.Set(ctx, constants.RedisCardObservationSeriesCompletedKey(seriesID), "1", constants.CardObservationSeriesResultTTL)
|
||||
pipe.Del(ctx, seriesKey)
|
||||
pipe.SRem(ctx, indexKey, seriesKey)
|
||||
if _, pipeErr := pipe.Exec(ctx); pipeErr != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, pipeErr, "提前完成卡观测资源序列失败")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ cardapp.SeriesCoordinator = (*SeriesCoordinator)(nil)
|
||||
|
||||
func formatCardID(cardID uint) string {
|
||||
return strconv.FormatUint(uint64(cardID), 10)
|
||||
}
|
||||
259
internal/infrastructure/cardobservation/series_runner.go
Normal file
259
internal/infrastructure/cardobservation/series_runner.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package cardobservation
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// SeriesAttemptLogger 将未发送的序列结果写入统一 Integration Log。
|
||||
type SeriesAttemptLogger struct {
|
||||
repository *integrationlog.Repository
|
||||
}
|
||||
|
||||
// NewSeriesAttemptLogger 创建未发送尝试日志适配器。
|
||||
func NewSeriesAttemptLogger(repository *integrationlog.Repository) *SeriesAttemptLogger {
|
||||
return &SeriesAttemptLogger{repository: repository}
|
||||
}
|
||||
|
||||
// Record 写入互斥、限频或提前完成结果。
|
||||
func (l *SeriesAttemptLogger) Record(ctx context.Context, payload cardapp.SeriesTaskPayload, result, reason string) error {
|
||||
if l == nil || l.repository == nil {
|
||||
return apperrors.New(apperrors.CodeInternalError, "卡观测 Integration Log 未配置")
|
||||
}
|
||||
resourceID := payload.ResourceID
|
||||
source, scene, seriesID := payload.Source, payload.Scene, payload.SeriesID
|
||||
requestID, correlationID := optionalText(payload.RequestID), optionalText(payload.CorrelationID)
|
||||
_, err := l.repository.Start(ctx, integrationlog.Attempt{
|
||||
IntegrationID: unsentIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
|
||||
Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType),
|
||||
ResourceType: payload.ResourceType, ResourceID: &resourceID, TriggerSource: &source,
|
||||
TriggerScene: &scene, TriggerSeries: &seriesID, ScheduledAt: &payload.ScheduledAt,
|
||||
Attempt: payload.Attempt, InitialResult: result, RequestID: requestID, CorrelationID: correlationID,
|
||||
Metadata: map[string]any{"reason": reason, "sync_type": payload.SyncType},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordMerged 记录同场景重复触发被合并,不创建第二组三任务。
|
||||
func (l *SeriesAttemptLogger) RecordMerged(ctx context.Context, request cardapp.SeriesRequest, seriesID string) error {
|
||||
resourceID := request.ResourceID
|
||||
source, scene := request.Source, request.Scene
|
||||
now := time.Now().UTC()
|
||||
_, err := l.repository.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: operationForSyncType(request.SyncType), ResourceType: request.ResourceType,
|
||||
ResourceID: &resourceID, TriggerSource: &source, TriggerScene: &scene, TriggerSeries: &seriesID,
|
||||
ScheduledAt: &now, Attempt: 1, InitialResult: constants.IntegrationResultMerged,
|
||||
RequestID: optionalText(request.RequestID), CorrelationID: optionalText(request.CorrelationID),
|
||||
Metadata: map[string]any{"reason": "同场景未结束序列已存在", "sync_type": request.SyncType},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SeriesRunner 负责卡本地预期判断、Gateway 查询和公共观测应用。
|
||||
type SeriesRunner struct {
|
||||
db *gorm.DB
|
||||
gateway *gateway.Client
|
||||
observation *cardapp.Service
|
||||
integration *integrationlog.Repository
|
||||
carrier *postgres.CarrierStore
|
||||
}
|
||||
|
||||
// NewSeriesRunner 创建卡观测序列 Gateway 执行器。
|
||||
func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository) *SeriesRunner {
|
||||
return &SeriesRunner{db: db, gateway: gatewayClient, observation: observation, integration: integration, carrier: postgres.NewCarrierStore(db)}
|
||||
}
|
||||
|
||||
// Provider 返回用于请求互斥的运营商接入标识。
|
||||
func (r *SeriesRunner) Provider(ctx context.Context, payload cardapp.SeriesTaskPayload) (string, error) {
|
||||
card, err := r.loadCard(ctx, payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
provider := strings.ToLower(strings.TrimSpace(card.CarrierType))
|
||||
if provider == "" {
|
||||
provider = "carrier-" + strconv.FormatUint(uint64(card.CarrierID), 10)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// ExpectationMet 在访问 Gateway 前读取本地权威快照。
|
||||
func (r *SeriesRunner) ExpectationMet(ctx context.Context, payload cardapp.SeriesTaskPayload) (bool, error) {
|
||||
expected := strings.ToLower(strings.TrimSpace(payload.ExpectedValue))
|
||||
if expected == "" {
|
||||
return false, nil
|
||||
}
|
||||
card, err := r.loadCard(ctx, payload)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch payload.SyncType {
|
||||
case constants.CardObservationSyncTypeRealname:
|
||||
return (expected == "verified" || expected == "1") && card.RealNameStatus == constants.RealNameStatusVerified, nil
|
||||
case constants.CardObservationSyncTypeNetwork:
|
||||
if expected == "online" || expected == "1" {
|
||||
return card.NetworkStatus == constants.NetworkStatusOnline, nil
|
||||
}
|
||||
if expected == "offline" || expected == "stopped" || expected == "0" {
|
||||
return card.NetworkStatus == constants.NetworkStatusOffline, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Run 执行一次 Gateway 查询,并将结果交给公共卡观测应用服务。
|
||||
func (r *SeriesRunner) Run(ctx context.Context, payload cardapp.SeriesTaskPayload) (cardapp.RunResult, error) {
|
||||
if r == nil || r.db == nil || r.gateway == nil || r.observation == nil || r.integration == nil {
|
||||
return cardapp.RunResult{}, apperrors.New(apperrors.CodeInternalError, "卡观测序列 Gateway 能力未完整配置")
|
||||
}
|
||||
card, err := r.loadCard(ctx, payload)
|
||||
if err != nil {
|
||||
return cardapp.RunResult{}, err
|
||||
}
|
||||
attempt, err := r.startAttempt(ctx, payload, card)
|
||||
if err != nil {
|
||||
return cardapp.RunResult{}, err
|
||||
}
|
||||
startedAt := time.Now()
|
||||
result, runErr := r.queryAndApply(ctx, payload, card, attempt.IntegrationID)
|
||||
completionResult := constants.IntegrationResultSuccess
|
||||
if runErr != nil {
|
||||
completionResult = constants.IntegrationResultFailed
|
||||
if isRateLimited(runErr) {
|
||||
completionResult = constants.IntegrationResultRateLimited
|
||||
result.RateLimited = true
|
||||
}
|
||||
}
|
||||
_, completeErr := r.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
|
||||
Result: completionResult, DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: result.StateChanged,
|
||||
ResponseSummary: map[string]any{"sync_type": payload.SyncType, "applied": runErr == nil},
|
||||
})
|
||||
if completeErr != nil {
|
||||
return result, completeErr
|
||||
}
|
||||
return result, runErr
|
||||
}
|
||||
|
||||
func (r *SeriesRunner) startAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload, card *model.IotCard) (*model.IntegrationLog, error) {
|
||||
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID
|
||||
resourceKey := "card:" + formatCardID(card.ID)
|
||||
return r.integration.Start(ctx, integrationlog.Attempt{
|
||||
IntegrationID: gatewayIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
|
||||
Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType),
|
||||
ResourceType: payload.ResourceType, ResourceID: &resourceID, ResourceKey: &resourceKey,
|
||||
TriggerSource: &source, TriggerScene: &scene, TriggerSeries: &seriesID,
|
||||
ScheduledAt: &payload.ScheduledAt, Attempt: payload.Attempt,
|
||||
RequestSummary: map[string]any{"card_id": card.ID, "sync_type": payload.SyncType},
|
||||
RequestID: optionalText(payload.RequestID), CorrelationID: optionalText(payload.CorrelationID),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *SeriesRunner) queryAndApply(ctx context.Context, payload cardapp.SeriesTaskPayload, card *model.IotCard, observationID string) (cardapp.RunResult, error) {
|
||||
metadata := carddomain.ObservationMetadata{
|
||||
ObservationID: observationID, Source: constants.CardObservationSourceBusinessEvent,
|
||||
Scene: payload.Scene, ObservedAt: time.Now().UTC(), RequestID: payload.RequestID,
|
||||
CorrelationID: payload.CorrelationID,
|
||||
}
|
||||
switch payload.SyncType {
|
||||
case constants.CardObservationSyncTypeRealname:
|
||||
response, err := r.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
|
||||
if err != nil {
|
||||
return cardapp.RunResult{}, err
|
||||
}
|
||||
decision, err := r.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{CardID: card.ID, Verified: response.RealStatus, Metadata: metadata})
|
||||
return cardapp.RunResult{StateChanged: decision.StatusChanged}, err
|
||||
case constants.CardObservationSyncTypeTraffic:
|
||||
response, err := r.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID})
|
||||
if err != nil {
|
||||
return cardapp.RunResult{}, err
|
||||
}
|
||||
decision, err := r.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
|
||||
CardID: card.ID, GatewayReadingMB: float64(response.Used), ResetDay: r.carrier.GetDataResetDay(ctx, card.CarrierID), Metadata: metadata,
|
||||
})
|
||||
return cardapp.RunResult{StateChanged: decision.IncrementMB > 0 || decision.CrossMonth}, err
|
||||
case constants.CardObservationSyncTypeNetwork:
|
||||
response, err := r.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
|
||||
if err != nil {
|
||||
return cardapp.RunResult{}, err
|
||||
}
|
||||
decision, err := r.observation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{
|
||||
CardID: card.ID, GatewayStatus: response.CardStatus, GatewayExtend: response.Extend,
|
||||
GatewayIMEI: response.IMEI, Metadata: metadata,
|
||||
})
|
||||
return cardapp.RunResult{StateChanged: decision.StatusChanged}, err
|
||||
default:
|
||||
return cardapp.RunResult{}, apperrors.New(apperrors.CodeInvalidParam, "设备信息观测执行器尚未注册")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *SeriesRunner) loadCard(ctx context.Context, payload cardapp.SeriesTaskPayload) (*model.IotCard, error) {
|
||||
if payload.ResourceType != constants.CardObservationResourceTypeCard {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "当前观测执行器仅支持 IoT 卡资源")
|
||||
}
|
||||
cardID, err := strconv.ParseUint(payload.ResourceID, 10, 64)
|
||||
if err != nil || cardID == 0 {
|
||||
return nil, apperrors.New(apperrors.CodeInvalidParam, "卡观测资源 ID 无效")
|
||||
}
|
||||
var card model.IotCard
|
||||
if err := r.db.WithContext(ctx).Where("id = ?", uint(cardID)).First(&card).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, apperrors.New(apperrors.CodeNotFound, "IoT卡不存在")
|
||||
}
|
||||
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询卡观测本地快照失败")
|
||||
}
|
||||
return &card, nil
|
||||
}
|
||||
|
||||
func isRateLimited(err error) bool {
|
||||
var appErr *apperrors.AppError
|
||||
if stderrors.As(err, &appErr) && appErr.Code == apperrors.CodeTooManyRequests {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(err.Error(), "429") || strings.Contains(strings.ToLower(err.Error()), "rate limit")
|
||||
}
|
||||
|
||||
func operationForSyncType(syncType string) string {
|
||||
switch syncType {
|
||||
case constants.CardObservationSyncTypeRealname:
|
||||
return constants.IntegrationOperationGatewayRealname
|
||||
case constants.CardObservationSyncTypeTraffic:
|
||||
return constants.IntegrationOperationGatewayTraffic
|
||||
case constants.CardObservationSyncTypeNetwork:
|
||||
return constants.IntegrationOperationGatewayNetwork
|
||||
default:
|
||||
return "query_device_info"
|
||||
}
|
||||
}
|
||||
|
||||
func gatewayIntegrationID(payload cardapp.SeriesTaskPayload) string {
|
||||
return "card-series:" + payload.SeriesID + ":" + strconv.Itoa(payload.Attempt)
|
||||
}
|
||||
|
||||
func unsentIntegrationID(payload cardapp.SeriesTaskPayload) string {
|
||||
return gatewayIntegrationID(payload) + ":unsent"
|
||||
}
|
||||
|
||||
func optionalText(value string) *string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
var _ cardapp.SeriesAttemptLogger = (*SeriesAttemptLogger)(nil)
|
||||
var _ cardapp.SeriesRunner = (*SeriesRunner)(nil)
|
||||
72
internal/infrastructure/notification/cleanup.go
Normal file
72
internal/infrastructure/notification/cleanup.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// CleanupService 按通知类别的保留期限分批删除过期通知事实。
|
||||
type CleanupService struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewCleanupService 创建通知保留清理服务。
|
||||
func NewCleanupService(db *gorm.DB, logger *zap.Logger) *CleanupService {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &CleanupService{db: db, logger: logger, now: time.Now}
|
||||
}
|
||||
|
||||
// Run 按类别、创建时间和稳定主键执行有界分批清理。
|
||||
func (s *CleanupService) Run(ctx context.Context) error {
|
||||
policies := []struct {
|
||||
category string
|
||||
days int
|
||||
}{
|
||||
{constants.NotificationCategoryApproval, constants.NotificationApprovalRetentionDays},
|
||||
{constants.NotificationCategoryExpiry, constants.NotificationExpiryRetentionDays},
|
||||
{constants.NotificationCategorySync, constants.NotificationSyncRetentionDays},
|
||||
{constants.NotificationCategorySystem, constants.NotificationSystemRetentionDays},
|
||||
}
|
||||
for _, policy := range policies {
|
||||
deleted, err := s.cleanupCategory(ctx, policy.category, s.now().UTC().AddDate(0, 0, -policy.days))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("站内通知保留清理完成",
|
||||
zap.String("category", policy.category), zap.Int64("deleted_count", deleted))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CleanupService) cleanupCategory(ctx context.Context, category string, cutoff time.Time) (int64, error) {
|
||||
var total int64
|
||||
for batch := 0; batch < constants.NotificationCleanupMaxBatches; batch++ {
|
||||
result := s.db.WithContext(ctx).Exec(`WITH candidates AS (
|
||||
SELECT id FROM tb_notification
|
||||
WHERE category = ? AND created_at < ?
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?
|
||||
)
|
||||
DELETE FROM tb_notification AS notification
|
||||
USING candidates
|
||||
WHERE notification.id = candidates.id`, category, cutoff, constants.NotificationCleanupBatchSize)
|
||||
if result.Error != nil {
|
||||
return total, errors.Wrap(errors.CodeDatabaseError, result.Error, "清理站内通知失败")
|
||||
}
|
||||
total += result.RowsAffected
|
||||
if result.RowsAffected < constants.NotificationCleanupBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
100
internal/infrastructure/notification/recipient_resolver.go
Normal file
100
internal/infrastructure/notification/recipient_resolver.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// DynamicRecipientResolver 按稳定账号、平台角色或店铺解析当前可用后台账号。
|
||||
type DynamicRecipientResolver struct {
|
||||
db *gorm.DB
|
||||
shopResolver shopapp.NotificationRecipientResolver
|
||||
}
|
||||
|
||||
// NewDynamicRecipientResolver 创建后台通知动态接收人解析 Adapter。
|
||||
func NewDynamicRecipientResolver(db *gorm.DB, shopResolver shopapp.NotificationRecipientResolver) *DynamicRecipientResolver {
|
||||
return &DynamicRecipientResolver{db: db, shopResolver: shopResolver}
|
||||
}
|
||||
|
||||
// Resolve 根据受控目标类型返回去重且按账号 ID 排序的当前可用接收人。
|
||||
func (r *DynamicRecipientResolver) Resolve(ctx context.Context, targetKind string, targetID uint) ([]uint, error) {
|
||||
if targetID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
var ids []uint
|
||||
var err error
|
||||
switch targetKind {
|
||||
case constants.NotificationTargetKindAccount:
|
||||
ids, err = r.resolveAccount(ctx, targetID)
|
||||
case constants.NotificationTargetKindPlatformRole:
|
||||
ids, err = r.resolvePlatformRole(ctx, targetID)
|
||||
case constants.NotificationTargetKindShop:
|
||||
if r.shopResolver == nil {
|
||||
return nil, errors.New(errors.CodeInternalError, "店铺通知接收人解析器未配置")
|
||||
}
|
||||
ids, err = r.shopResolver.ResolveNotificationRecipients(ctx, targetID)
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "通知接收目标类型不受支持")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uniqueSortedIDs(ids), nil
|
||||
}
|
||||
|
||||
func (r *DynamicRecipientResolver) resolveAccount(ctx context.Context, accountID uint) ([]uint, error) {
|
||||
var account model.Account
|
||||
err := r.db.WithContext(ctx).Select("id").
|
||||
Where("id = ? AND status = ?", accountID, constants.StatusEnabled).Take(&account).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return []uint{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知申请人失败")
|
||||
}
|
||||
return []uint{account.ID}, nil
|
||||
}
|
||||
|
||||
func (r *DynamicRecipientResolver) resolvePlatformRole(ctx context.Context, roleID uint) ([]uint, error) {
|
||||
var accounts []model.Account
|
||||
err := r.db.WithContext(ctx).Model(&model.Account{}).
|
||||
Select("tb_account.id").
|
||||
Joins("JOIN tb_account_role ar ON ar.account_id = tb_account.id AND ar.deleted_at IS NULL AND ar.status = ?", constants.StatusEnabled).
|
||||
Joins("JOIN tb_role r ON r.id = ar.role_id AND r.deleted_at IS NULL AND r.status = ? AND r.role_type = ?",
|
||||
constants.StatusEnabled, constants.RoleTypePlatform).
|
||||
Where("ar.role_id = ? AND tb_account.status = ? AND tb_account.user_type IN ?",
|
||||
roleID, constants.StatusEnabled, []int{constants.UserTypeSuperAdmin, constants.UserTypePlatform}).
|
||||
Find(&accounts).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询平台角色通知接收人失败")
|
||||
}
|
||||
ids := make([]uint, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
ids = append(ids, account.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func uniqueSortedIDs(ids []uint) []uint {
|
||||
seen := make(map[uint]struct{}, len(ids))
|
||||
result := make([]uint, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
result = append(result, id)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
||||
return result
|
||||
}
|
||||
154
internal/infrastructure/notification/registry.go
Normal file
154
internal/infrastructure/notification/registry.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Package notification 提供站内通知模板注册、渲染和 PostgreSQL 持久化 Adapter。
|
||||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
var (
|
||||
htmlTagPattern = regexp.MustCompile(`(?i)<\s*/?\s*[a-z][^>]*>`)
|
||||
sensitiveTextPattern = regexp.MustCompile(`(?i)(password|operation_password|token|secret|credential|media_id|密码|令牌|密钥)\s*[:=:]\s*\S+`)
|
||||
longURLPattern = regexp.MustCompile(`(?i)https?://\S+`)
|
||||
)
|
||||
|
||||
// Definition 定义一个受控通知类型的类别、级别、模板和允许资源引用。
|
||||
type Definition struct {
|
||||
Type string
|
||||
Category string
|
||||
Severity string
|
||||
TitleTemplate string
|
||||
BodyTemplate string
|
||||
TemplateFields map[string]struct{}
|
||||
RecipientKinds map[string]struct{}
|
||||
AllowedRefTypes map[string]struct{}
|
||||
}
|
||||
|
||||
// Rendered 是模板渲染后的纯文本快照。
|
||||
type Rendered struct {
|
||||
Category string
|
||||
Type string
|
||||
Severity string
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
// Registry 保存代码内受控通知类型注册关系。
|
||||
type Registry struct {
|
||||
definitions map[string]Definition
|
||||
}
|
||||
|
||||
// NewRegistry 创建内置受控通知类型注册表。
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{definitions: map[string]Definition{
|
||||
constants.NotificationTypeSystemNotice: {
|
||||
Type: constants.NotificationTypeSystemNotice, Category: constants.NotificationCategorySystem,
|
||||
Severity: constants.NotificationSeverityInfo,
|
||||
TitleTemplate: "系统通知",
|
||||
BodyTemplate: "有一项系统事项需要处理,请进入对应页面查看。",
|
||||
TemplateFields: map[string]struct{}{},
|
||||
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
|
||||
AllowedRefTypes: map[string]struct{}{
|
||||
constants.NotificationRefTypeSystemConfig: {},
|
||||
constants.NotificationRefTypeIntegrationLog: {},
|
||||
},
|
||||
},
|
||||
constants.NotificationTypePackageExpiring: {
|
||||
Type: constants.NotificationTypePackageExpiring, Category: constants.NotificationCategoryExpiry,
|
||||
Severity: constants.NotificationSeverityWarning,
|
||||
TitleTemplate: "套餐即将到期",
|
||||
BodyTemplate: "您的套餐即将到期,请及时查看并处理。",
|
||||
TemplateFields: map[string]struct{}{},
|
||||
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}},
|
||||
AllowedRefTypes: map[string]struct{}{
|
||||
constants.NotificationRefTypePackage: {},
|
||||
constants.NotificationRefTypeAsset: {},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// Render 校验注册类型、模板字段、资源引用与敏感内容后生成纯文本快照。
|
||||
func (r *Registry) Render(notificationType string, data map[string]string, refType, recipientKind string) (Rendered, error) {
|
||||
definition, exists := r.definitions[notificationType]
|
||||
if !exists {
|
||||
return Rendered{}, errors.New("通知类型未注册")
|
||||
}
|
||||
if _, allowed := definition.RecipientKinds[recipientKind]; !allowed {
|
||||
return Rendered{}, errors.New("通知类型未向当前接收人开放")
|
||||
}
|
||||
if err := validateTemplateData(data, definition.TemplateFields); err != nil {
|
||||
return Rendered{}, err
|
||||
}
|
||||
if refType != "" {
|
||||
if _, allowed := definition.AllowedRefTypes[refType]; !allowed {
|
||||
return Rendered{}, errors.New("通知资源类型未注册")
|
||||
}
|
||||
}
|
||||
title, err := executeTemplate("通知标题", definition.TitleTemplate, data)
|
||||
if err != nil {
|
||||
return Rendered{}, err
|
||||
}
|
||||
body, err := executeTemplate("通知正文", definition.BodyTemplate, data)
|
||||
if err != nil {
|
||||
return Rendered{}, err
|
||||
}
|
||||
if title == "" || body == "" {
|
||||
return Rendered{}, errors.New("通知模板字段为空")
|
||||
}
|
||||
if len([]rune(title)) > constants.NotificationMaxTitleLength {
|
||||
return Rendered{}, errors.New("通知标题超过长度限制")
|
||||
}
|
||||
if len([]rune(body)) > constants.NotificationMaxBodyLength {
|
||||
return Rendered{}, errors.New("通知正文超过长度限制")
|
||||
}
|
||||
if htmlTagPattern.MatchString(title) || htmlTagPattern.MatchString(body) {
|
||||
return Rendered{}, errors.New("通知正文禁止包含 HTML")
|
||||
}
|
||||
if sensitiveTextPattern.MatchString(title) || sensitiveTextPattern.MatchString(body) || longURLPattern.MatchString(title) || longURLPattern.MatchString(body) {
|
||||
return Rendered{}, errors.New("通知正文包含禁止的敏感内容")
|
||||
}
|
||||
return Rendered{
|
||||
Category: definition.Category, Type: definition.Type, Severity: definition.Severity,
|
||||
Title: title, Body: body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func executeTemplate(name, source string, data map[string]string) (string, error) {
|
||||
tmpl, err := template.New(name).Option("missingkey=error").Parse(source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
if err := tmpl.Execute(&buffer, data); err != nil {
|
||||
return "", errors.New("通知模板字段缺失")
|
||||
}
|
||||
return strings.TrimSpace(buffer.String()), nil
|
||||
}
|
||||
|
||||
func validateTemplateData(data map[string]string, fields map[string]struct{}) error {
|
||||
if data == nil && len(fields) > 0 {
|
||||
return errors.New("通知模板数据不能为空")
|
||||
}
|
||||
for key := range data {
|
||||
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||
switch normalized {
|
||||
case "password", "operation_password", "token", "secret", "credential", "id_card", "callback", "media_id", "url":
|
||||
return errors.New("通知模板数据包含禁止字段")
|
||||
}
|
||||
if _, allowed := fields[normalized]; !allowed {
|
||||
return errors.New("通知模板数据包含未注册字段")
|
||||
}
|
||||
}
|
||||
for field := range fields {
|
||||
if strings.TrimSpace(data[field]) == "" {
|
||||
return errors.New("通知模板必填字段缺失")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
internal/infrastructure/notification/repository.go
Normal file
48
internal/infrastructure/notification/repository.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// Repository 提供站内通知幂等写入能力。
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewRepository 创建站内通知持久化 Adapter。
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
// CreateIdempotent 以事件、接收人类型和接收人 ID 唯一键幂等写入通知。
|
||||
func (r *Repository) CreateIdempotent(ctx context.Context, notification *model.Notification) (bool, error) {
|
||||
result := r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "event_id"}, {Name: "recipient_kind"}, {Name: "recipient_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(notification)
|
||||
return result.RowsAffected == 1, result.Error
|
||||
}
|
||||
|
||||
// IsActiveAccount 判断明确后台账号是否仍启用且未软删除。
|
||||
func (r *Repository) IsActiveAccount(ctx context.Context, accountID uint) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&model.Account{}).
|
||||
Where("id = ? AND status = ?", accountID, constants.StatusEnabled).
|
||||
Count(&count).Error
|
||||
return count == 1, err
|
||||
}
|
||||
|
||||
// IsActivePersonalCustomer 判断明确个人客户是否仍启用且未软删除。
|
||||
func (r *Repository) IsActivePersonalCustomer(ctx context.Context, customerID uint) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&model.PersonalCustomer{}).
|
||||
Where("id = ? AND status = ?", customerID, constants.StatusEnabled).
|
||||
Count(&count).Error
|
||||
return count == 1, err
|
||||
}
|
||||
71
internal/infrastructure/shop/recipient_resolver.go
Normal file
71
internal/infrastructure/shop/recipient_resolver.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Package shop 提供店铺通知接收人的 PostgreSQL Adapter。
|
||||
package shop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RecipientResolver 按店铺当前独立归属解析主账号和可用平台业务员。
|
||||
type RecipientResolver struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
var _ shopapp.NotificationRecipientResolver = (*RecipientResolver)(nil)
|
||||
|
||||
// NewRecipientResolver 创建店铺通知接收人解析 Adapter。
|
||||
func NewRecipientResolver(db *gorm.DB) *RecipientResolver {
|
||||
return &RecipientResolver{db: db}
|
||||
}
|
||||
|
||||
// ResolveNotificationRecipients 返回启用的店铺主账号和当前可用业务员账号 ID。
|
||||
//
|
||||
// 解析只读取目标店铺当前保存的业务员 ID,不读取父店铺、祖先店铺或创建人。
|
||||
func (r *RecipientResolver) ResolveNotificationRecipients(ctx context.Context, shopID uint) ([]uint, error) {
|
||||
if shopID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
var target struct {
|
||||
BusinessOwnerAccountID *uint
|
||||
}
|
||||
err := r.db.WithContext(ctx).Model(&model.Shop{}).
|
||||
Select("business_owner_account_id").Where("id = ?", shopID).Take(&target).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return []uint{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺通知归属失败")
|
||||
}
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&model.Account{}).
|
||||
Select("id").
|
||||
Where("status = ? AND shop_id = ? AND is_primary = ? AND user_type = ?",
|
||||
constants.StatusEnabled, shopID, true, constants.UserTypeAgent)
|
||||
if target.BusinessOwnerAccountID != nil {
|
||||
query = query.Or("id = ? AND status = ? AND user_type = ?",
|
||||
*target.BusinessOwnerAccountID, constants.StatusEnabled, constants.UserTypePlatform)
|
||||
}
|
||||
var accounts []model.Account
|
||||
if err := query.Find(&accounts).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺通知接收人失败")
|
||||
}
|
||||
|
||||
ids := make([]uint, 0, len(accounts))
|
||||
seen := make(map[uint]struct{}, len(accounts))
|
||||
for _, account := range accounts {
|
||||
if _, exists := seen[account.ID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[account.ID] = struct{}{}
|
||||
ids = append(ids, account.ID)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids, nil
|
||||
}
|
||||
59
internal/infrastructure/wallet/credit_consumer.go
Normal file
59
internal/infrastructure/wallet/credit_consumer.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// CreditEventConsumer 校验已投递的代理主钱包入账事件具有对应权威资金流水。
|
||||
type CreditEventConsumer struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewCreditEventConsumer 创建代理主钱包入账事件消费者。
|
||||
func NewCreditEventConsumer(db *gorm.DB) *CreditEventConsumer {
|
||||
return &CreditEventConsumer{db: db}
|
||||
}
|
||||
|
||||
// Consume 校验事件载荷及不可变流水,拒绝确认未知或损坏事件。
|
||||
func (c *CreditEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.db == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包入账事件消费者未配置")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletCredited || envelope.PayloadVersion != constants.AgentMainWalletCreditedPayloadVersionV1 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包入账事件类型或版本不受支持")
|
||||
}
|
||||
var event walletapp.CreditedEvent
|
||||
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包入账事件载荷无法解析")
|
||||
}
|
||||
validRecharge := event.ReferenceType == constants.ReferenceTypeTopup && event.TransactionType == constants.AgentTransactionTypeRecharge
|
||||
validAdjustment := event.ReferenceType == constants.ReferenceTypeManualAdjustment && event.TransactionType == constants.AgentTransactionTypeAdjustment
|
||||
if event.EventID != envelope.EventID || event.WalletID == 0 || event.ReferenceID == 0 || event.Amount <= 0 || (!validRecharge && !validAdjustment) {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包入账事件载荷不完整")
|
||||
}
|
||||
|
||||
var transaction model.AgentWalletTransaction
|
||||
err := c.db.WithContext(ctx).Unscoped().
|
||||
Where("agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
||||
event.WalletID, event.ReferenceType, event.ReferenceID, event.TransactionType, constants.TransactionStatusSuccess).
|
||||
First(&transaction).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包入账事件缺少权威资金流水")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包入账流水失败")
|
||||
}
|
||||
if transaction.Amount != event.Amount || transaction.BalanceBefore != event.BalanceBefore || transaction.BalanceAfter != event.BalanceAfter {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包入账事件与权威资金流水不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
37
internal/infrastructure/wallet/credit_event.go
Normal file
37
internal/infrastructure/wallet/credit_event.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CreditEventWriter 将代理主钱包正向入账事实写入公共 Outbox。
|
||||
type CreditEventWriter struct {
|
||||
outbox *outbox.Repository
|
||||
}
|
||||
|
||||
// NewCreditEventWriter 创建代理主钱包入账 Outbox Writer。
|
||||
func NewCreditEventWriter(repository *outbox.Repository) *CreditEventWriter {
|
||||
return &CreditEventWriter{outbox: repository}
|
||||
}
|
||||
|
||||
// Append 在调用方业务事务中追加代理主钱包入账事件。
|
||||
func (w *CreditEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.CreditedEvent) error {
|
||||
if w == nil || w.outbox == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包入账 Outbox Writer 未配置")
|
||||
}
|
||||
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
|
||||
EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletCredited,
|
||||
PayloadVersion: constants.AgentMainWalletCreditedPayloadVersionV1,
|
||||
AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10),
|
||||
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
|
||||
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
|
||||
})
|
||||
return err
|
||||
}
|
||||
62
internal/infrastructure/wallet/debit_consumer.go
Normal file
62
internal/infrastructure/wallet/debit_consumer.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// DebitEventConsumer 校验已投递的代理主钱包扣款事件具有对应权威资金流水。
|
||||
// 后续余额预警等消费者在此稳定接缝上扩展,不需要回读或改写订单扣款事务。
|
||||
type DebitEventConsumer struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDebitEventConsumer 创建代理主钱包扣款事件消费者。
|
||||
func NewDebitEventConsumer(db *gorm.DB) *DebitEventConsumer {
|
||||
return &DebitEventConsumer{db: db}
|
||||
}
|
||||
|
||||
// Consume 校验事件载荷及不可变流水,保证未知或损坏事件不会被静默确认。
|
||||
func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.db == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件消费者未配置")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletDebited ||
|
||||
envelope.PayloadVersion != constants.AgentMainWalletDebitedPayloadVersionV1 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包扣款事件类型或版本不受支持")
|
||||
}
|
||||
var event walletapp.DebitedEvent
|
||||
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包扣款事件载荷无法解析")
|
||||
}
|
||||
if event.EventID != envelope.EventID || event.WalletID == 0 || event.ReferenceID == 0 ||
|
||||
event.ReferenceType != constants.ReferenceTypeOrder || event.TransactionType != constants.AgentTransactionTypeDeduct ||
|
||||
event.Amount <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包扣款事件载荷不完整")
|
||||
}
|
||||
|
||||
var transaction model.AgentWalletTransaction
|
||||
err := c.db.WithContext(ctx).Unscoped().
|
||||
Where("agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
||||
event.WalletID, event.ReferenceType, event.ReferenceID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
|
||||
First(&transaction).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件缺少权威资金流水")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包扣款流水失败")
|
||||
}
|
||||
if transaction.Amount != -event.Amount || transaction.BalanceBefore != event.BalanceBefore ||
|
||||
transaction.BalanceAfter != event.BalanceAfter {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件与权威资金流水不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
38
internal/infrastructure/wallet/debit_event.go
Normal file
38
internal/infrastructure/wallet/debit_event.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Package wallet 提供代理主钱包持久化与可靠事件 Adapter。
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DebitEventWriter 将代理主钱包扣款事实写入公共 Outbox。
|
||||
type DebitEventWriter struct {
|
||||
outbox *outbox.Repository
|
||||
}
|
||||
|
||||
// NewDebitEventWriter 创建代理主钱包扣款 Outbox Writer。
|
||||
func NewDebitEventWriter(repository *outbox.Repository) *DebitEventWriter {
|
||||
return &DebitEventWriter{outbox: repository}
|
||||
}
|
||||
|
||||
// Append 在调用方业务事务中追加代理主钱包扣款事件。
|
||||
func (w *DebitEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) error {
|
||||
if w == nil || w.outbox == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包扣款 Outbox Writer 未配置")
|
||||
}
|
||||
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
|
||||
EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletDebited,
|
||||
PayloadVersion: constants.AgentMainWalletDebitedPayloadVersionV1,
|
||||
AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10),
|
||||
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
|
||||
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
|
||||
})
|
||||
return err
|
||||
}
|
||||
98
internal/infrastructure/wallet/refund_consumer.go
Normal file
98
internal/infrastructure/wallet/refund_consumer.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RefundEventConsumer 校验已投递的代理主钱包退款事件具有对应权威资金流水。
|
||||
type RefundEventConsumer struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewRefundEventConsumer 创建代理主钱包退款事件消费者。
|
||||
func NewRefundEventConsumer(db *gorm.DB) *RefundEventConsumer {
|
||||
return &RefundEventConsumer{db: db}
|
||||
}
|
||||
|
||||
// Consume 复核退款流水及正常订单的原扣款流水,拒绝确认未知或损坏事件。
|
||||
func (c *RefundEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.db == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包退款事件消费者未配置")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletRefunded ||
|
||||
envelope.PayloadVersion != constants.AgentMainWalletRefundedPayloadVersionV1 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包退款事件类型或版本不受支持")
|
||||
}
|
||||
var event walletapp.RefundedEvent
|
||||
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包退款事件载荷无法解析")
|
||||
}
|
||||
if event.EventID != envelope.EventID || event.WalletID == 0 || event.ShopID == 0 ||
|
||||
event.OrderID == 0 || event.RefundID == 0 || event.Amount <= 0 || event.Version <= 0 ||
|
||||
event.OriginalDeductAmount <= 0 || event.Amount > event.OriginalDeductAmount {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包退款事件载荷不完整")
|
||||
}
|
||||
if event.Legacy == (event.OriginalDebitTransactionID > 0) {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包退款事件原扣款标识不一致")
|
||||
}
|
||||
|
||||
var refund model.AgentWalletTransaction
|
||||
err := c.db.WithContext(ctx).Unscoped().
|
||||
Where("agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
||||
event.WalletID, constants.ReferenceTypeRefund, event.RefundID, constants.AgentTransactionTypeRefund, constants.TransactionStatusSuccess).
|
||||
First(&refund).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包退款事件缺少权威资金流水")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包退款流水失败")
|
||||
}
|
||||
if refund.ShopID != event.ShopID || refund.Amount != event.Amount ||
|
||||
refund.BalanceBefore != event.BalanceBefore || refund.BalanceAfter != event.BalanceAfter ||
|
||||
!sameRefundUint(refund.RelatedShopID, event.RelatedShopID) ||
|
||||
!sameRefundString(refund.TransactionSubtype, event.TransactionSubtype) ||
|
||||
refund.AssetType != event.AssetType || refund.AssetID != event.AssetID || refund.AssetIdentifier != event.AssetIdentifier {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包退款事件与权威退款流水不一致")
|
||||
}
|
||||
if event.Legacy {
|
||||
return nil
|
||||
}
|
||||
|
||||
var debit model.AgentWalletTransaction
|
||||
err = c.db.WithContext(ctx).Unscoped().
|
||||
Where("id = ? AND agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
||||
event.OriginalDebitTransactionID, event.WalletID, constants.ReferenceTypeOrder, event.OrderID,
|
||||
constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
|
||||
First(&debit).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包退款事件缺少原扣款流水")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包原扣款流水失败")
|
||||
}
|
||||
if debit.Amount >= 0 || debit.Amount == math.MinInt64 || event.OriginalDeductAmount != -debit.Amount ||
|
||||
!sameRefundUint(debit.RelatedShopID, event.RelatedShopID) ||
|
||||
!sameRefundString(debit.TransactionSubtype, event.TransactionSubtype) ||
|
||||
debit.AssetType != event.AssetType || debit.AssetID != event.AssetID || debit.AssetIdentifier != event.AssetIdentifier {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包退款事件与原扣款流水不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameRefundUint(left, right *uint) bool {
|
||||
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
|
||||
}
|
||||
|
||||
func sameRefundString(left, right *string) bool {
|
||||
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
|
||||
}
|
||||
37
internal/infrastructure/wallet/refund_event.go
Normal file
37
internal/infrastructure/wallet/refund_event.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RefundEventWriter 将代理主钱包退款事实写入公共 Outbox。
|
||||
type RefundEventWriter struct {
|
||||
outbox *outbox.Repository
|
||||
}
|
||||
|
||||
// NewRefundEventWriter 创建代理主钱包退款 Outbox Writer。
|
||||
func NewRefundEventWriter(repository *outbox.Repository) *RefundEventWriter {
|
||||
return &RefundEventWriter{outbox: repository}
|
||||
}
|
||||
|
||||
// Append 在调用方业务事务中追加代理主钱包退款事件。
|
||||
func (w *RefundEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.RefundedEvent) error {
|
||||
if w == nil || w.outbox == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包退款 Outbox Writer 未配置")
|
||||
}
|
||||
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
|
||||
EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletRefunded,
|
||||
PayloadVersion: constants.AgentMainWalletRefundedPayloadVersionV1,
|
||||
AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10),
|
||||
ResourceType: constants.ReferenceTypeRefund, ResourceID: strconv.FormatUint(uint64(event.RefundID), 10),
|
||||
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
|
||||
})
|
||||
return err
|
||||
}
|
||||
64
internal/infrastructure/wallet/reservation_consumer.go
Normal file
64
internal/infrastructure/wallet/reservation_consumer.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ReservationEventConsumer 校验预占事件与本地权威事实一致。
|
||||
type ReservationEventConsumer struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewReservationEventConsumer 创建代理主钱包预占事件消费者。
|
||||
func NewReservationEventConsumer(db *gorm.DB) *ReservationEventConsumer {
|
||||
return &ReservationEventConsumer{db: db}
|
||||
}
|
||||
|
||||
// Consume 只确认具有匹配权威预占事实的事件。
|
||||
func (c *ReservationEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.db == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包预占事件消费者未配置")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletReservationChanged ||
|
||||
envelope.PayloadVersion != constants.AgentMainWalletReservationPayloadVersionV1 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包预占事件类型或版本不受支持")
|
||||
}
|
||||
var event walletapp.ReservationEvent
|
||||
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包预占事件载荷无法解析")
|
||||
}
|
||||
if event.EventID != envelope.EventID || event.ReservationID == 0 || event.WalletID == 0 ||
|
||||
event.Amount <= 0 || event.ReferenceType == "" || event.ReferenceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包预占事件载荷不完整")
|
||||
}
|
||||
var reservation model.AgentWalletReservation
|
||||
if err := c.db.WithContext(ctx).Where("id = ?", event.ReservationID).First(&reservation).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包预占事件缺少权威事实")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包预占事实失败")
|
||||
}
|
||||
if reservation.AgentWalletID != event.WalletID || reservation.Amount != event.Amount ||
|
||||
reservation.ReferenceType != event.ReferenceType || reservation.ReferenceID != event.ReferenceID ||
|
||||
!reservationStatusContainsEvent(reservation.Status, event.Status) {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包预占事件与权威事实不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reservationStatusContainsEvent(current, event int) bool {
|
||||
if current == event {
|
||||
return true
|
||||
}
|
||||
return event == constants.AgentWalletReservationStatusFrozen &&
|
||||
(current == constants.AgentWalletReservationStatusReleased || current == constants.AgentWalletReservationStatusCompleted)
|
||||
}
|
||||
37
internal/infrastructure/wallet/reservation_event.go
Normal file
37
internal/infrastructure/wallet/reservation_event.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ReservationEventWriter 将代理主钱包预占状态写入公共 Outbox。
|
||||
type ReservationEventWriter struct {
|
||||
outbox *outbox.Repository
|
||||
}
|
||||
|
||||
// NewReservationEventWriter 创建代理主钱包预占 Outbox Writer。
|
||||
func NewReservationEventWriter(repository *outbox.Repository) *ReservationEventWriter {
|
||||
return &ReservationEventWriter{outbox: repository}
|
||||
}
|
||||
|
||||
// Append 在调用方事务中追加预占状态事件。
|
||||
func (w *ReservationEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.ReservationEvent) error {
|
||||
if w == nil || w.outbox == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包预占 Outbox Writer 未配置")
|
||||
}
|
||||
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
|
||||
EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletReservationChanged,
|
||||
PayloadVersion: constants.AgentMainWalletReservationPayloadVersionV1,
|
||||
AggregateType: "agent_wallet_reservation", AggregateID: strconv.FormatUint(uint64(event.ReservationID), 10),
|
||||
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
|
||||
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
|
||||
})
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user