Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。 Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。 Confidence: medium Scope-risk: broad Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。 Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。 Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
172 lines
6.5 KiB
Go
172 lines
6.5 KiB
Go
package integration
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// OverviewFilter 定义外部交互总览的受控筛选和时间粒度。
|
|
type OverviewFilter struct {
|
|
ListFilter
|
|
Bucket string
|
|
}
|
|
|
|
// Overview 是外部交互固定维度聚合结果。
|
|
type Overview struct {
|
|
Total int64 `json:"total"`
|
|
AnomalyCount int64 `json:"anomaly_count"`
|
|
UnknownCount int64 `json:"unknown_count"`
|
|
StalePendingCount int64 `json:"stale_pending_count"`
|
|
StateChangedCount int64 `json:"state_changed_count"`
|
|
AverageDurationMS float64 `json:"average_duration_ms"`
|
|
P95DurationMS float64 `json:"p95_duration_ms"`
|
|
Results []ResultCount `json:"results"`
|
|
Providers []NamedCount `json:"providers"`
|
|
Directions []NamedCount `json:"directions"`
|
|
Trend []TrendPoint `json:"trend"`
|
|
}
|
|
|
|
// ResultCount 是原始结果及其派生类别计数。
|
|
type ResultCount struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Category string `json:"category"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
|
|
// NamedCount 是稳定编码、中文名称和数量。
|
|
type NamedCount struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
|
|
// TrendPoint 是固定时间桶内的结果类别趋势。
|
|
type TrendPoint struct {
|
|
BucketAt time.Time `json:"bucket_at"`
|
|
Total int64 `json:"total"`
|
|
Succeeded int64 `json:"succeeded"`
|
|
Processing int64 `json:"processing"`
|
|
Indeterminate int64 `json:"indeterminate"`
|
|
Failed int64 `json:"failed"`
|
|
NotSent int64 `json:"not_sent"`
|
|
}
|
|
|
|
// Overview 查询指定时间范围的固定维度外部交互总览。
|
|
func (q *Query) Overview(ctx context.Context, filter OverviewFilter) (*Overview, error) {
|
|
if err := q.authorize(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if filter.Bucket == "" {
|
|
filter.Bucket = "hour"
|
|
}
|
|
if !validFilter(filter.ListFilter) || (filter.Bucket != "hour" && filter.Bucket != "day") {
|
|
return nil, errors.New(errors.CodeInvalidParam)
|
|
}
|
|
base := applyFilters(q.db.WithContext(ctx).Model(&model.IntegrationLog{}), filter.ListFilter)
|
|
result := &Overview{Results: []ResultCount{}, Providers: []NamedCount{}, Directions: []NamedCount{}, Trend: []TrendPoint{}}
|
|
if err := loadOverviewMetrics(base, result); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := loadResultCounts(base, result); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := loadNamedCounts(base, "provider", result); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := loadNamedCounts(base, "direction", result); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := loadTrend(base, filter.Bucket, result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func loadOverviewMetrics(query *gorm.DB, result *Overview) error {
|
|
failed := categoryResults(constants.IntegrationResultCategoryFailed)
|
|
var row struct {
|
|
Total, AnomalyCount, UnknownCount, StalePendingCount, StateChangedCount int64
|
|
AverageDurationMS, P95DurationMS float64
|
|
}
|
|
err := query.Select(`COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE result IN ? OR result = ?) AS anomaly_count,
|
|
COUNT(*) FILTER (WHERE result = ?) AS unknown_count,
|
|
COUNT(*) FILTER (WHERE result = ? AND created_at < ?) AS stale_pending_count,
|
|
COUNT(*) FILTER (WHERE state_changed) AS state_changed_count,
|
|
COALESCE(AVG(duration_ms), 0)::float8 AS average_duration_ms,
|
|
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms), 0)::float8 AS p95_duration_ms`,
|
|
failed, constants.IntegrationResultUnknown, constants.IntegrationResultUnknown,
|
|
constants.IntegrationResultPending, time.Now().UTC().Add(-constants.IntegrationPendingStaleAfter)).Scan(&row).Error
|
|
if err != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互总览失败")
|
|
}
|
|
result.Total, result.AnomalyCount, result.UnknownCount = row.Total, row.AnomalyCount, row.UnknownCount
|
|
result.StalePendingCount, result.StateChangedCount = row.StalePendingCount, row.StateChangedCount
|
|
result.AverageDurationMS, result.P95DurationMS = row.AverageDurationMS, row.P95DurationMS
|
|
return nil
|
|
}
|
|
|
|
func loadResultCounts(query *gorm.DB, result *Overview) error {
|
|
var rows []struct {
|
|
Code string
|
|
Count int64
|
|
}
|
|
if err := query.Select("result AS code, COUNT(*) AS count").Group("result").Order("result ASC").Scan(&rows).Error; err != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互结果分布失败")
|
|
}
|
|
for _, row := range rows {
|
|
result.Results = append(result.Results, ResultCount{Code: row.Code, Name: constants.IntegrationResultName(row.Code), Category: constants.IntegrationResultCategory(row.Code), Count: row.Count})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadNamedCounts(query *gorm.DB, column string, result *Overview) error {
|
|
var rows []struct {
|
|
Code string
|
|
Count int64
|
|
}
|
|
if err := query.Select(column + " AS code, COUNT(*) AS count").Group(column).Order(column + " ASC").Scan(&rows).Error; err != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互维度分布失败")
|
|
}
|
|
items := make([]NamedCount, 0, len(rows))
|
|
for _, row := range rows {
|
|
name := constants.IntegrationProviderName(row.Code)
|
|
if column == "direction" {
|
|
name = constants.IntegrationDirectionName(row.Code)
|
|
}
|
|
items = append(items, NamedCount{Code: row.Code, Name: name, Count: row.Count})
|
|
}
|
|
if column == "provider" {
|
|
result.Providers = items
|
|
} else {
|
|
result.Directions = items
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadTrend(query *gorm.DB, bucket string, result *Overview) error {
|
|
failed, notSent := categoryResults(constants.IntegrationResultCategoryFailed), categoryResults(constants.IntegrationResultCategoryNotSent)
|
|
return wrapTrendError(query.Select(`date_trunc(?, created_at) AS bucket_at, COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE result = ?) AS succeeded,
|
|
COUNT(*) FILTER (WHERE result = ?) AS processing,
|
|
COUNT(*) FILTER (WHERE result = ?) AS indeterminate,
|
|
COUNT(*) FILTER (WHERE result IN ?) AS failed,
|
|
COUNT(*) FILTER (WHERE result IN ?) AS not_sent`, bucket, constants.IntegrationResultSuccess,
|
|
constants.IntegrationResultPending, constants.IntegrationResultUnknown, failed, notSent).
|
|
Group("bucket_at").Order("bucket_at ASC").Scan(&result.Trend).Error)
|
|
}
|
|
|
|
func wrapTrendError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互趋势失败")
|
|
}
|