// 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" retentionquery "github.com/break/junhong_cmp_fiber/internal/query/retention" "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" description:"符合条件的外部交互总数"` Page int `json:"page" description:"当前页码"` PageSize int `json:"page_size" description:"每页数量"` Items []ListItem `json:"items" description:"按创建时间和主键稳定倒序的外部交互"` Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"` } // ListItem 是 Integration Log 列表投影。 type ListItem struct { IntegrationID string `json:"integration_id" description:"稳定外部集成记录ID,可传给详情接口"` Provider string `json:"provider" enum:"ctcc,cmcc,cucc,wechat_pay,alipay,fuiou,wecom,gateway" description:"外部服务提供方稳定编码"` ProviderName string `json:"provider_name" description:"provider对应的中文展示名称"` Direction string `json:"direction" enum:"inbound,outbound" description:"交互方向稳定编码"` DirectionName string `json:"direction_name" description:"direction对应的中文展示名称"` Operation string `json:"operation" enum:"realname_callback,realname_removal_callback,payment_precreate,payment_query,payment_callback,get_access_token,list_visible_members,list_visible_departments,get_template_detail,upload_approval_attachment,submit_approval,approval_callback,get_approval_detail,get_approval_info,query_realname_status,query_flow,query_card_status,query_device_info,set_speed_tier,stop_card,start_card,set_device_wifi,set_device_switch_mode,switch_device_card,reboot_device,reset_device" description:"外部操作稳定编码,可直接用于列表筛选"` OperationName string `json:"operation_name" description:"operation对应的中文展示名称"` Resource ResourceView `json:"resource" description:"外部交互直接关联的本地主要资源"` Result string `json:"result" enum:"pending,success,failed,unknown,not_found,invalid_payload,conflict,ignored,merged,rate_limited,completed,cancelled" description:"外部交互原始结果稳定编码"` ResultName string `json:"result_name" description:"result对应的中文展示名称"` ResultCategory string `json:"result_category" enum:"processing,succeeded,indeterminate,failed,not_sent" description:"由result派生的固定结果类别"` DurationMS int64 `json:"duration_ms" description:"交互耗时,单位毫秒"` StateChanged bool `json:"state_changed" description:"本次交互是否改变本地业务状态"` RequestID *string `json:"request_id" description:"来源HTTP请求ID,可跳转请求时间线"` CorrelationID *string `json:"correlation_id" description:"跨步骤业务链路ID,可跳转关联时间线"` CreatedAt time.Time `json:"created_at" description:"外部交互记录创建时间"` } // ResourceView 是外部交互直接主资源投影。 type ResourceView struct { Type *string `json:"type" description:"Resource Registry注册类型"` ID *string `json:"id" description:"资源内部稳定ID;type和id均有值时可跳转资源时间线"` Key *string `json:"key" description:"资源业务稳定Key"` } // Detail 是按稳定 integration_id 返回的结构化详情。 type Detail struct { Identity IdentityView `json:"identity" description:"提供方、方向、操作和外部标识"` Resource ResourceView `json:"resource" description:"本地主要资源引用"` Trigger TriggerView `json:"trigger" description:"触发来源、场景和显式尝试序列"` Result ResultView `json:"result" description:"原始结果、派生类别和本地状态变化"` Content ContentView `json:"content" description:"已脱敏的结构化请求、响应和元数据摘要"` Linkage LinkageView `json:"linkage" description:"可跳转请求、关联和审计视角的稳定字段"` Timestamps TimestampView `json:"timestamps" description:"调度、开始、创建和更新时间"` Attempts []AttemptView `json:"attempts" description:"同一trigger_series下按attempt排序的技术尝试"` Fidelity FidelityView `json:"fidelity" description:"历史记录字段完整度,false时禁止前端猜测关联"` } // DetailResponse 是外部交互详情及在线留存边界。 type DetailResponse struct { Detail Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"` } // AttemptView 是显式 trigger_series 下的单次技术尝试。 type AttemptView struct { IntegrationID string `json:"integration_id" description:"本次尝试的稳定外部集成记录ID"` Attempt int `json:"attempt" description:"同一显式序列内从1开始的尝试序号"` Operation string `json:"operation" enum:"realname_callback,realname_removal_callback,payment_precreate,payment_query,payment_callback,get_access_token,list_visible_members,list_visible_departments,get_template_detail,upload_approval_attachment,submit_approval,approval_callback,get_approval_detail,get_approval_info,query_realname_status,query_flow,query_card_status,query_device_info,set_speed_tier,stop_card,start_card,set_device_wifi,set_device_switch_mode,switch_device_card,reboot_device,reset_device" description:"外部操作稳定编码"` OperationName string `json:"operation_name" description:"operation对应的中文展示名称"` Sent bool `json:"sent" description:"是否实际向外部系统发送请求"` Result string `json:"result" enum:"pending,success,failed,unknown,not_found,invalid_payload,conflict,ignored,merged,rate_limited,completed,cancelled" description:"本次尝试原始结果"` ResultName string `json:"result_name" description:"result对应的中文展示名称"` ResultCategory string `json:"result_category" enum:"processing,succeeded,indeterminate,failed,not_sent" description:"本次尝试的派生结果类别"` DurationMS int64 `json:"duration_ms" description:"本次尝试耗时,单位毫秒"` StateChanged bool `json:"state_changed" description:"本次尝试是否改变本地业务状态"` CreatedAt time.Time `json:"created_at" description:"本次尝试记录创建时间"` } // FidelityView 明确历史记录可关联能力,不推断缺失字段。 type FidelityView struct { TriggerSeriesAvailable bool `json:"trigger_series_available" description:"是否存在显式trigger_series"` AttemptSequenceReliable bool `json:"attempt_sequence_reliable" description:"attempt是否连续且operation一致"` CorrelationAvailable bool `json:"correlation_available" description:"是否存在稳定correlation_id"` ResourceIDAvailable bool `json:"resource_id_available" description:"是否存在稳定本地resource.id"` ProviderMessageFidelity string `json:"provider_message_fidelity" description:"外部消息保真等级;受限时只展示已脱敏摘要"` } // IdentityView 是外部交互身份分组。 type IdentityView struct { IntegrationID string `json:"integration_id" description:"稳定外部集成记录ID"` Provider string `json:"provider" enum:"ctcc,cmcc,cucc,wechat_pay,alipay,fuiou,wecom,gateway" description:"外部服务提供方稳定编码"` ProviderName string `json:"provider_name" description:"provider对应的中文展示名称"` Direction string `json:"direction" enum:"inbound,outbound" description:"交互方向稳定编码"` DirectionName string `json:"direction_name" description:"direction对应的中文展示名称"` Operation string `json:"operation" enum:"realname_callback,realname_removal_callback,payment_precreate,payment_query,payment_callback,get_access_token,list_visible_members,list_visible_departments,get_template_detail,upload_approval_attachment,submit_approval,approval_callback,get_approval_detail,get_approval_info,query_realname_status,query_flow,query_card_status,query_device_info,set_speed_tier,stop_card,start_card,set_device_wifi,set_device_switch_mode,switch_device_card,reboot_device,reset_device" description:"外部操作稳定编码"` OperationName string `json:"operation_name" description:"operation对应的中文展示名称"` ExternalID *string `json:"external_id" description:"外部系统业务或请求标识"` } // TriggerView 是外部交互触发分组。 type TriggerView struct { Source *string `json:"source" description:"触发来源稳定编码"` Scene *string `json:"scene" description:"触发业务场景"` Series *string `json:"series" description:"显式技术尝试序列ID;为空时禁止按时间或资源猜测重试关系"` Attempt int `json:"attempt" description:"显式序列内的尝试序号"` } // ResultView 是外部交互结果分组。 type ResultView struct { Code string `json:"code" enum:"pending,success,failed,unknown,not_found,invalid_payload,conflict,ignored,merged,rate_limited,completed,cancelled" description:"原始结果稳定编码"` Name string `json:"name" description:"code对应的中文展示名称"` Category string `json:"category" enum:"processing,succeeded,indeterminate,failed,not_sent" description:"由code派生的固定结果类别"` HTTPStatus *int `json:"http_status" description:"外部HTTP响应状态码"` ProviderCode *string `json:"provider_code" description:"外部服务稳定结果码"` ProviderMessage *string `json:"provider_message" description:"已脱敏的外部结果摘要"` DurationMS int64 `json:"duration_ms" description:"交互耗时,单位毫秒"` StateChanged bool `json:"state_changed" description:"是否改变本地业务状态"` RecoveryStrategy *string `json:"recovery_strategy" description:"已脱敏的既有恢复策略说明;本接口不执行恢复"` } // ContentView 是已持久化安全摘要分组。 type ContentView struct { RequestSummary map[string]any `json:"request_summary" description:"按白名单重新清理的请求摘要"` ResponseSummary map[string]any `json:"response_summary" description:"按白名单重新清理的响应摘要"` Metadata map[string]any `json:"metadata" description:"按白名单重新清理的扩展元数据"` ContentHash string `json:"content_hash" description:"持久化内容摘要"` } // LinkageView 是外部交互关联分组。 type LinkageView struct { RequestID *string `json:"request_id" description:"传给GET /audit/requests/{request_id}/timeline"` CorrelationID *string `json:"correlation_id" description:"传给GET /audit/correlations/{correlation_id}/timeline"` AuditEventID *uint `json:"audit_event_id" description:"内部审计事件数据库引用;前端优先使用调查接口返回的稳定event_id"` } // TimestampView 是外部交互时间分组。 type TimestampView struct { ScheduledAt *time.Time `json:"scheduled_at" description:"计划发送时间"` StartedAt *time.Time `json:"started_at" description:"实际开始时间"` CreatedAt time.Time `json:"created_at" description:"记录创建时间"` UpdatedAt time.Time `json:"updated_at" description:"记录最后更新时间"` } // 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 } filter, retention, err := q.normalizeOnlineFilter(ctx, filter) if 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, err := q.loadListPage(ctx, query, filter.Page, filter.PageSize) if err != nil { return nil, 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, Retention: retention}, nil } // loadListPage 先分页主键,再批量读取列表字段,避免加载正文摘要 JSON。 func (q *Query) loadListPage(ctx context.Context, query *gorm.DB, page, pageSize int) ([]model.IntegrationLog, error) { ids := make([]uint, 0, pageSize) if err := query.Select("id").Order("created_at DESC, id DESC"). Offset((page-1)*pageSize).Limit(pageSize).Pluck("id", &ids).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互日志分页ID失败") } rows := make([]model.IntegrationLog, 0, len(ids)) if len(ids) == 0 { return rows, nil } if err := q.db.WithContext(ctx).Select( "id", "integration_id", "provider", "direction", "operation", "resource_type", "resource_id", "resource_key", "result", "duration_ms", "state_changed", "request_id", "correlation_id", "created_at", ).Where("id IN ?", ids).Order("created_at DESC, id DESC").Find(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量投影外部交互日志失败") } return rows, nil } // Get 使用稳定 integration_id 查询结构化详情。 func (q *Query) Get(ctx context.Context, integrationID string) (*DetailResponse, error) { if err := q.authorize(ctx); err != nil { return nil, err } if integrationID == "" { return nil, errors.New(errors.CodeInvalidParam) } retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceIntegration) if err != nil { return nil, err } var row model.IntegrationLog if err := q.db.WithContext(ctx).Where("integration_id = ? AND created_at >= ?", integrationID, retention.OnlineFrom.UTC()).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, attemptSequenceReliable, err := q.loadAttempts(ctx, row, retention.OnlineFrom) if err != nil { return nil, err } providerMessage, providerMessageFidelity := safeProviderMessage(row.ProviderMessage) return &DetailResponse{Detail: 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: sanitizedTextPointer(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 != "", AttemptSequenceReliable: attemptSequenceReliable, CorrelationAvailable: row.CorrelationID != nil && *row.CorrelationID != "", ResourceIDAvailable: row.ResourceID != nil && *row.ResourceID != "", ProviderMessageFidelity: providerMessageFidelity, }, }, Retention: retention}, nil } func (q *Query) loadAttempts(ctx context.Context, current model.IntegrationLog, onlineFrom time.Time) ([]AttemptView, bool, error) { rows := []model.IntegrationLog{current} if current.TriggerSeries != nil && *current.TriggerSeries != "" { if err := q.db.WithContext(ctx).Where("trigger_series = ? AND created_at >= ?", *current.TriggerSeries, onlineFrom.UTC()). Order("attempt ASC, created_at ASC, id ASC").Find(&rows).Error; err != nil { return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互尝试序列失败") } } items := make([]AttemptView, len(rows)) reliable := current.TriggerSeries != nil && *current.TriggerSeries != "" expectedAttempt := 1 for index, row := range rows { category := constants.IntegrationResultCategory(row.Result) items[index] = AttemptView{ IntegrationID: row.IntegrationID, Attempt: row.Attempt, Operation: row.Operation, OperationName: constants.IntegrationOperationName(row.Operation), Sent: category != constants.IntegrationResultCategoryNotSent, Result: row.Result, ResultName: constants.IntegrationResultName(row.Result), ResultCategory: category, DurationMS: row.DurationMS, StateChanged: row.StateChanged, CreatedAt: row.CreatedAt, } if row.Operation != current.Operation || row.Attempt != expectedAttempt { reliable = false } expectedAttempt = row.Attempt + 1 } return items, reliable, 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 (q *Query) normalizeOnlineFilter(ctx context.Context, filter ListFilter) (ListFilter, retentionquery.Info, error) { retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceIntegration) if err != nil { return filter, retention, err } filter.CreatedFrom, filter.CreatedTo, err = retentionquery.NormalizeRange(retention, filter.CreatedFrom, filter.CreatedTo, constants.IntegrationQueryMaxRange) return filter, retention, err } 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):] if sanitized := sanitizer.SanitizeText(message); sanitized != message { return &sanitized, "redacted" } return &message, "readable" } summary := sanitizer.TextSummary(*value) return &summary, "historical_redacted" } func sanitizedTextPointer(value *string) *string { if value == nil { return nil } sanitized := sanitizer.SanitizeText(*value) return &sanitized } func normalizePage(page, pageSize int) (int, int) { if page < 1 { page = constants.DefaultPage } if pageSize < 1 { pageSize = constants.DefaultPageSize } return page, pageSize }