Files
junhong_cmp_fiber/internal/infrastructure/cardobservation/series_runner.go
2026-07-24 16:07:18 +08:00

260 lines
11 KiB
Go

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)