暂存一下,防止丢失
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user