All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m32s
436 lines
18 KiB
Go
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
|
|
}
|