// Package integration 提供 Integration Log 只读调查投影。 package integration import ( "context" "crypto/sha256" "encoding/hex" "time" "github.com/bytedance/sonic" "gorm.io/datatypes" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/sanitizer" pkgvalidator "github.com/break/junhong_cmp_fiber/pkg/validator" ) // ListFilter 定义 Integration Log 组合筛选。 type ListFilter struct { CreatedFrom, CreatedTo *time.Time IntegrationID, Provider, Direction string Operation, Result, ResultCategory string ExternalID, ResourceType, ResourceID string ResourceKey, TriggerSource, TriggerScene string TriggerSeries, ProviderCode string RequestID, CorrelationID string StateChanged *bool HTTPStatus *int Page, PageSize int } // ListPage 是按创建时间和主键稳定倒序的分页结果。 type ListPage struct { Total int64 `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` Items []ListItem `json:"items"` } // ListItem 是 Integration Log 列表投影。 type ListItem struct { IntegrationID string `json:"integration_id"` Provider string `json:"provider"` ProviderName string `json:"provider_name"` Direction string `json:"direction"` DirectionName string `json:"direction_name"` Operation string `json:"operation"` OperationName string `json:"operation_name"` Resource ResourceView `json:"resource"` Result string `json:"result"` ResultName string `json:"result_name"` ResultCategory string `json:"result_category"` DurationMS int64 `json:"duration_ms"` StateChanged bool `json:"state_changed"` RequestID *string `json:"request_id"` CorrelationID *string `json:"correlation_id"` CreatedAt time.Time `json:"created_at"` } // ResourceView 是外部交互直接主资源投影。 type ResourceView struct { Type *string `json:"type"` ID *string `json:"id"` Key *string `json:"key"` } // Detail 是按稳定 integration_id 返回的结构化详情。 type Detail struct { Identity IdentityView `json:"identity"` Resource ResourceView `json:"resource"` Trigger TriggerView `json:"trigger"` Result ResultView `json:"result"` Content ContentView `json:"content"` Linkage LinkageView `json:"linkage"` Timestamps TimestampView `json:"timestamps"` Attempts []AttemptView `json:"attempts"` Fidelity FidelityView `json:"fidelity"` } // AttemptView 是显式 trigger_series 下的单次技术尝试。 type AttemptView struct { IntegrationID string `json:"integration_id"` Attempt int `json:"attempt"` Sent bool `json:"sent"` Result string `json:"result"` ResultName string `json:"result_name"` ResultCategory string `json:"result_category"` DurationMS int64 `json:"duration_ms"` StateChanged bool `json:"state_changed"` CreatedAt time.Time `json:"created_at"` } // FidelityView 明确历史记录可关联能力,不推断缺失字段。 type FidelityView struct { TriggerSeriesAvailable bool `json:"trigger_series_available"` CorrelationAvailable bool `json:"correlation_available"` ResourceIDAvailable bool `json:"resource_id_available"` ProviderMessageFidelity string `json:"provider_message_fidelity"` } // IdentityView 是外部交互身份分组。 type IdentityView struct { IntegrationID string `json:"integration_id"` Provider string `json:"provider"` ProviderName string `json:"provider_name"` Direction string `json:"direction"` DirectionName string `json:"direction_name"` Operation string `json:"operation"` OperationName string `json:"operation_name"` ExternalID *string `json:"external_id"` } // TriggerView 是外部交互触发分组。 type TriggerView struct { Source *string `json:"source"` Scene *string `json:"scene"` Series *string `json:"series"` Attempt int `json:"attempt"` } // ResultView 是外部交互结果分组。 type ResultView struct { Code string `json:"code"` Name string `json:"name"` Category string `json:"category"` HTTPStatus *int `json:"http_status"` ProviderCode *string `json:"provider_code"` ProviderMessage *string `json:"provider_message"` DurationMS int64 `json:"duration_ms"` StateChanged bool `json:"state_changed"` RecoveryStrategy *string `json:"recovery_strategy"` } // ContentView 是已持久化安全摘要分组。 type ContentView struct { RequestSummary map[string]any `json:"request_summary"` ResponseSummary map[string]any `json:"response_summary"` Metadata map[string]any `json:"metadata"` ContentHash string `json:"content_hash"` } // LinkageView 是外部交互关联分组。 type LinkageView struct { RequestID *string `json:"request_id"` CorrelationID *string `json:"correlation_id"` AuditEventID *uint `json:"audit_event_id"` } // TimestampView 是外部交互时间分组。 type TimestampView struct { ScheduledAt *time.Time `json:"scheduled_at"` StartedAt *time.Time `json:"started_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Query 提供平台 Integration Log 列表和详情读取。 type Query struct{ db *gorm.DB } // New 创建 Integration Log 调查 Query。 func New(db *gorm.DB) *Query { return &Query{db: db} } // List 查询受时间范围约束的 Integration Log 列表。 func (q *Query) List(ctx context.Context, filter ListFilter) (*ListPage, error) { if err := q.authorize(ctx); err != nil { return nil, err } if !validFilter(filter) { return nil, errors.New(errors.CodeInvalidParam) } filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize) query := applyFilters(q.db.WithContext(ctx).Model(&model.IntegrationLog{}), filter) var total int64 if err := query.Count(&total).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计外部交互日志失败") } rows := make([]model.IntegrationLog, 0, filter.PageSize) if err := query.Order("created_at DESC, id DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互日志失败") } items := make([]ListItem, len(rows)) for i, row := range rows { items[i] = projectListItem(row) } return &ListPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil } // Get 使用稳定 integration_id 查询结构化详情。 func (q *Query) Get(ctx context.Context, integrationID string) (*Detail, error) { if err := q.authorize(ctx); err != nil { return nil, err } if integrationID == "" { return nil, errors.New(errors.CodeInvalidParam) } var row model.IntegrationLog if err := q.db.WithContext(ctx).Where("integration_id = ?", integrationID).First(&row).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeNotFound, "外部交互日志不存在") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互日志详情失败") } requestSummary, err := decodeObject(row.RequestSummary) if err != nil { return nil, err } responseSummary, err := decodeObject(row.ResponseSummary) if err != nil { return nil, err } metadata, err := decodeObject(row.Metadata) if err != nil { return nil, err } attempts, err := q.loadAttempts(ctx, row) if err != nil { return nil, err } providerMessage, providerMessageFidelity := safeProviderMessage(row.ProviderMessage) return &Detail{ Identity: IdentityView{IntegrationID: row.IntegrationID, Provider: row.Provider, ProviderName: constants.IntegrationProviderName(row.Provider), Direction: row.Direction, DirectionName: constants.IntegrationDirectionName(row.Direction), Operation: row.Operation, OperationName: constants.IntegrationOperationName(row.Operation), ExternalID: row.ExternalID}, Resource: resourceView(row), Trigger: TriggerView{Source: row.TriggerSource, Scene: row.TriggerScene, Series: row.TriggerSeries, Attempt: row.Attempt}, Result: ResultView{Code: row.Result, Name: constants.IntegrationResultName(row.Result), Category: constants.IntegrationResultCategory(row.Result), HTTPStatus: row.HTTPStatus, ProviderCode: row.ProviderCode, ProviderMessage: providerMessage, DurationMS: row.DurationMS, StateChanged: row.StateChanged, RecoveryStrategy: row.RecoveryStrategy}, Content: ContentView{RequestSummary: requestSummary, ResponseSummary: responseSummary, Metadata: metadata, ContentHash: row.ContentHash}, Linkage: LinkageView{RequestID: row.RequestID, CorrelationID: row.CorrelationID, AuditEventID: row.AuditEventID}, Timestamps: TimestampView{ScheduledAt: row.ScheduledAt, StartedAt: row.StartedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, Attempts: attempts, Fidelity: FidelityView{ TriggerSeriesAvailable: row.TriggerSeries != nil && *row.TriggerSeries != "", CorrelationAvailable: row.CorrelationID != nil && *row.CorrelationID != "", ResourceIDAvailable: row.ResourceID != nil && *row.ResourceID != "", ProviderMessageFidelity: providerMessageFidelity, }, }, nil } func (q *Query) loadAttempts(ctx context.Context, current model.IntegrationLog) ([]AttemptView, error) { rows := []model.IntegrationLog{current} if current.TriggerSeries != nil && *current.TriggerSeries != "" { if err := q.db.WithContext(ctx).Where("trigger_series = ?", *current.TriggerSeries). Order("attempt ASC, created_at ASC, id ASC").Find(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互尝试序列失败") } } items := make([]AttemptView, len(rows)) for index, row := range rows { category := constants.IntegrationResultCategory(row.Result) items[index] = AttemptView{ IntegrationID: row.IntegrationID, Attempt: row.Attempt, Sent: category != constants.IntegrationResultCategoryNotSent, Result: row.Result, ResultName: constants.IntegrationResultName(row.Result), ResultCategory: category, DurationMS: row.DurationMS, StateChanged: row.StateChanged, CreatedAt: row.CreatedAt, } } return items, nil } func (q *Query) authorize(ctx context.Context) error { if q == nil || q.db == nil { return errors.New(errors.CodeServiceUnavailable, "外部交互调查能力未配置") } userType := middleware.GetUserTypeFromContext(ctx) if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } return nil } func validFilter(filter ListFilter) bool { if filter.CreatedFrom == nil || filter.CreatedTo == nil || !filter.CreatedFrom.Before(*filter.CreatedTo) || filter.CreatedTo.Sub(*filter.CreatedFrom) > constants.IntegrationQueryMaxRange { return false } if filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize { return false } if filter.Direction != "" && filter.Direction != constants.IntegrationDirectionInbound && filter.Direction != constants.IntegrationDirectionOutbound { return false } if filter.Result != "" && constants.IntegrationResultName(filter.Result) == "" { return false } if filter.ResultCategory != "" && len(categoryResults(filter.ResultCategory)) == 0 { return false } return filter.HTTPStatus == nil || (*filter.HTTPStatus >= 100 && *filter.HTTPStatus <= 599) } func applyFilters(query *gorm.DB, filter ListFilter) *gorm.DB { query = query.Where("created_at >= ? AND created_at < ?", filter.CreatedFrom.UTC(), filter.CreatedTo.UTC()) for column, value := range map[string]string{ "integration_id": filter.IntegrationID, "provider": filter.Provider, "direction": filter.Direction, "operation": filter.Operation, "result": filter.Result, "external_id": filter.ExternalID, "resource_type": filter.ResourceType, "resource_id": filter.ResourceID, "trigger_source": filter.TriggerSource, "trigger_scene": filter.TriggerScene, "trigger_series": filter.TriggerSeries, "provider_code": filter.ProviderCode, "request_id": filter.RequestID, "correlation_id": filter.CorrelationID, } { if value != "" { query = query.Where(column+" = ?", value) } } if filter.ResourceKey != "" { query = query.Where("resource_key IN ?", compatibleResourceKeys(filter.ResourceType, filter.ResourceKey)) } if filter.ResultCategory != "" { query = query.Where("result IN ?", categoryResults(filter.ResultCategory)) } if filter.StateChanged != nil { query = query.Where("state_changed = ?", *filter.StateChanged) } if filter.HTTPStatus != nil { query = query.Where("http_status = ?", *filter.HTTPStatus) } return query } func compatibleResourceKeys(resourceType, resourceKey string) []string { keys := []string{resourceKey} if resourceType != constants.AssetTypeIotCard || !pkgvalidator.ValidateICCIDWithoutCarrier(resourceKey).Valid { return keys } sum := sha256.Sum256([]byte(resourceKey)) return append(keys, "iccid-sha256:"+hex.EncodeToString(sum[:])[:32]) } func categoryResults(category string) []string { switch category { case constants.IntegrationResultCategoryProcessing: return []string{constants.IntegrationResultPending} case constants.IntegrationResultCategorySucceeded: return []string{constants.IntegrationResultSuccess} case constants.IntegrationResultCategoryIndeterminate: return []string{constants.IntegrationResultUnknown} case constants.IntegrationResultCategoryFailed: return []string{constants.IntegrationResultFailed, constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultConflict} case constants.IntegrationResultCategoryNotSent: return []string{constants.IntegrationResultIgnored, constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted, constants.IntegrationResultCancelled} default: return nil } } func projectListItem(row model.IntegrationLog) ListItem { return ListItem{IntegrationID: row.IntegrationID, Provider: row.Provider, ProviderName: constants.IntegrationProviderName(row.Provider), Direction: row.Direction, DirectionName: constants.IntegrationDirectionName(row.Direction), Operation: row.Operation, OperationName: constants.IntegrationOperationName(row.Operation), Resource: resourceView(row), Result: row.Result, ResultName: constants.IntegrationResultName(row.Result), ResultCategory: constants.IntegrationResultCategory(row.Result), DurationMS: row.DurationMS, StateChanged: row.StateChanged, RequestID: row.RequestID, CorrelationID: row.CorrelationID, CreatedAt: row.CreatedAt} } func resourceView(row model.IntegrationLog) ResourceView { return ResourceView{Type: row.ResourceType, ID: row.ResourceID, Key: row.ResourceKey} } func decodeObject(value datatypes.JSON) (map[string]any, error) { result := map[string]any{} if len(value) == 0 { return result, nil } if err := sonic.Unmarshal(value, &result); err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "解析外部交互结构化摘要失败") } sanitizer.RemoveForbiddenFields(result) return result, nil } func safeProviderMessage(value *string) (*string, string) { if value == nil || *value == "" { return nil, "missing" } if len(*value) >= len("外部文本摘要") && (*value)[:len("外部文本摘要")] == "外部文本摘要" { return value, "historical_summary" } if len(*value) >= len(constants.IntegrationSafeMessagePrefix) && (*value)[:len(constants.IntegrationSafeMessagePrefix)] == constants.IntegrationSafeMessagePrefix { message := (*value)[len(constants.IntegrationSafeMessagePrefix):] return &message, "readable" } summary := sanitizer.TextSummary(*value) return &summary, "historical_redacted" } func normalizePage(page, pageSize int) (int, int) { if page < 1 { page = constants.DefaultPage } if pageSize < 1 { pageSize = constants.DefaultPageSize } return page, pageSize }