Files
junhong_cmp_fiber/internal/query/integration/logs.go
break 88cc5e96ec
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m32s
暂存
2026-08-06 09:35:00 +08:00

436 lines
18 KiB
Go

// 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"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []ListItem `json:"items"`
Retention retentionquery.Info `json:"retention"`
}
// 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"`
}
// DetailResponse 是外部交互详情及在线留存边界。
type DetailResponse struct {
Detail
Retention retentionquery.Info `json:"retention"`
}
// 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
}
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, 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: 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,
},
}, Retention: retention}, nil
}
func (q *Query) loadAttempts(ctx context.Context, current model.IntegrationLog, onlineFrom time.Time) ([]AttemptView, 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, 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 (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):]
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
}