package audit import ( "context" "time" "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" ) // RiskFilter 定义固定风险调查视角的时间范围与筛选条件。 type RiskFilter struct { CreatedFrom *time.Time CreatedTo *time.Time Risk string Result string Action string Source string Page int PageSize int } // RiskOverview 是风险信号、固定维度与时间趋势的只读聚合。 type RiskOverview struct { Total int64 `json:"total" description:"固定风险集合内的事件总数"` Bucket string `json:"bucket" enum:"hour,day" description:"服务端选择的趋势时间粒度"` Signals []RiskNamedCount `json:"signals" description:"固定信号分布,code为high_risk、finance、security、failed、denied、partial或unknown,name为中文展示名"` Risks []RiskNamedCount `json:"risks" description:"风险等级分布,code为low、normal、high或critical,name为中文展示名"` Results []RiskNamedCount `json:"results" description:"结果分布,code为success、failed、denied、partial或unknown,name为中文展示名"` Actions []RiskNamedCount `json:"actions" description:"动作分布,code为稳定action_code,name为中文action_name"` Sources []RiskNamedCount `json:"sources" description:"来源分布,code为admin_api、personal_api、openapi、worker、scheduler或callback,name为中文展示名"` Trend []RiskTrendPoint `json:"trend" description:"固定风险信号的时间趋势"` Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"` } // RiskNamedCount 是风险聚合维度的稳定编码、中文名称和数量。 type RiskNamedCount struct { Code string `json:"code" description:"当前聚合维度的稳定编码,具体枚举域由所属数组字段说明"` Name string `json:"name" description:"code对应的中文展示名称"` Count int64 `json:"count" description:"该编码的事件数量"` } // RiskTrendPoint 是固定时间桶内的风险信号趋势。 type RiskTrendPoint struct { BucketAt time.Time `json:"bucket_at" description:"时间桶起点"` Total int64 `json:"total" description:"桶内固定风险集合事件数"` HighRisk int64 `json:"high_risk" description:"桶内high或critical风险事件数"` Finance int64 `json:"finance" description:"桶内资金类风险事件数"` Security int64 `json:"security" description:"桶内安全类风险事件数"` Failed int64 `json:"failed" description:"桶内failed事件数"` Denied int64 `json:"denied" description:"桶内denied事件数"` Partial int64 `json:"partial" description:"桶内partial事件数"` Unknown int64 `json:"unknown" description:"桶内unknown事件数"` } // RiskEventPage 是风险事件的稳定分页结果。 type RiskEventPage struct { Total int64 `json:"total" description:"符合条件的风险事件总数"` Page int `json:"page" description:"当前页码"` PageSize int `json:"page_size" description:"每页数量"` Items []EventView `json:"items" description:"风险事件及稳定调查引用"` Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"` } // RiskOverview 查询指定时间范围内的固定风险调查总览。 func (q *Query) RiskOverview(ctx context.Context, filter RiskFilter) (*RiskOverview, error) { if err := q.authorize(ctx); err != nil { return nil, err } retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit) if err != nil { return nil, err } filter.CreatedFrom, filter.CreatedTo, err = retentionquery.NormalizeRange(retention, filter.CreatedFrom, filter.CreatedTo, constants.AuditRiskQueryMaxRange) if err != nil { return nil, err } if !validRiskFilter(filter) { return nil, errors.New(errors.CodeInvalidParam) } base := q.applyRiskFilters(q.db.WithContext(ctx).Model(&model.AuditEvent{}), filter) result := &RiskOverview{ Bucket: riskTrendBucket(*filter.CreatedFrom, *filter.CreatedTo), Signals: []RiskNamedCount{}, Risks: []RiskNamedCount{}, Results: []RiskNamedCount{}, Actions: []RiskNamedCount{}, Sources: []RiskNamedCount{}, Trend: []RiskTrendPoint{}, Retention: retention, } if err := loadRiskSignals(base, result); err != nil { return nil, err } if err := loadRiskDimension(base, "risk_level", result); err != nil { return nil, err } if err := loadRiskDimension(base, "result", result); err != nil { return nil, err } if err := loadRiskDimension(base, "action_code", result); err != nil { return nil, err } if err := loadRiskDimension(base, "source", result); err != nil { return nil, err } if err := loadRiskTrend(base, result); err != nil { return nil, err } return result, nil } // RiskEvents 查询指定时间范围内的风险事件明细。 func (q *Query) RiskEvents(ctx context.Context, filter RiskFilter) (*RiskEventPage, error) { if err := q.authorize(ctx); err != nil { return nil, err } retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit) if err != nil { return nil, err } filter.CreatedFrom, filter.CreatedTo, err = retentionquery.NormalizeRange(retention, filter.CreatedFrom, filter.CreatedTo, constants.AuditRiskQueryMaxRange) if err != nil { return nil, err } if !validRiskFilter(filter) { return nil, errors.New(errors.CodeInvalidParam) } filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize) query := q.applyRiskFilters(q.db.WithContext(ctx).Model(&model.AuditEvent{}), filter) var total int64 if err := query.Count(&total).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计风险事件失败") } rows, err := q.loadEventPage(ctx, query, filter.Page, filter.PageSize) if err != nil { return nil, err } items, err := q.project(ctx, rows) if err != nil { return nil, err } return &RiskEventPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items, Retention: retention}, nil } func validRiskFilter(filter RiskFilter) bool { if filter.CreatedFrom == nil || filter.CreatedTo == nil || !filter.CreatedFrom.Before(*filter.CreatedTo) || filter.CreatedTo.Sub(*filter.CreatedFrom) > constants.AuditRiskQueryMaxRange { return false } return validEventFilter(EventFilter{ Risk: filter.Risk, Result: filter.Result, Action: filter.Action, Source: filter.Source, Page: filter.Page, PageSize: filter.PageSize, }) } func (q *Query) applyRiskFilters(query *gorm.DB, filter RiskFilter) *gorm.DB { query = q.applyFilters(query, EventFilter{ CreatedFrom: filter.CreatedFrom, CreatedTo: filter.CreatedTo, Risk: filter.Risk, Result: filter.Result, Action: filter.Action, Source: filter.Source, }) return query.Where(riskScopeSQL(), riskLevels(), constants.AuditCategorySecurity, abnormalResults(), financeResourceTypes()) } func loadRiskSignals(query *gorm.DB, result *RiskOverview) error { var row struct { Total, HighRisk, Finance, Security, Failed, Denied, Partial, Unknown int64 } err := query.Select(`COUNT(*) AS total, COUNT(*) FILTER (WHERE risk_level IN ?) AS high_risk, COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM tb_audit_event_resource aer WHERE aer.audit_event_id = tb_audit_event.id AND aer.resource_type IN ?)) AS finance, COUNT(*) FILTER (WHERE category = ?) AS security, COUNT(*) FILTER (WHERE result = ?) AS failed, COUNT(*) FILTER (WHERE result = ?) AS denied, COUNT(*) FILTER (WHERE result = ?) AS partial, COUNT(*) FILTER (WHERE result = ?) AS unknown`, riskLevels(), financeResourceTypes(), constants.AuditCategorySecurity, constants.AuditResultFailed, constants.AuditResultDenied, constants.AuditResultPartial, constants.AuditResultUnknown).Scan(&row).Error if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "聚合风险信号失败") } result.Total = row.Total result.Signals = []RiskNamedCount{ {Code: constants.AuditRiskSignalHighRisk, Name: "高风险", Count: row.HighRisk}, {Code: constants.AuditRiskSignalFinance, Name: "资金", Count: row.Finance}, {Code: constants.AuditRiskSignalSecurity, Name: "安全", Count: row.Security}, {Code: constants.AuditRiskSignalFailed, Name: "失败", Count: row.Failed}, {Code: constants.AuditRiskSignalDenied, Name: "拒绝", Count: row.Denied}, {Code: constants.AuditRiskSignalPartial, Name: "部分成功", Count: row.Partial}, {Code: constants.AuditRiskSignalUnknown, Name: "结果未知", Count: row.Unknown}, } return nil } func loadRiskDimension(query *gorm.DB, column string, result *RiskOverview) error { var rows []struct { Code string Name string Count int64 } selectClause := column + " AS code, '' AS name, COUNT(*) AS count" groupClause := column if column == "action_code" { selectClause = "action_code AS code, action_name AS name, COUNT(*) AS count" groupClause = "action_code, action_name" } if err := query.Select(selectClause).Group(groupClause).Order(column + " ASC").Scan(&rows).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "聚合风险维度失败") } items := make([]RiskNamedCount, 0, len(rows)) for _, row := range rows { name := row.Name if name == "" { name = riskDimensionName(column, row.Code) } items = append(items, RiskNamedCount{Code: row.Code, Name: name, Count: row.Count}) } switch column { case "risk_level": result.Risks = items case "result": result.Results = items case "action_code": result.Actions = items case "source": result.Sources = items } return nil } func loadRiskTrend(query *gorm.DB, result *RiskOverview) error { err := query.Select(`date_trunc(?, occurred_at) AS bucket_at, COUNT(*) AS total, COUNT(*) FILTER (WHERE risk_level IN ?) AS high_risk, COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM tb_audit_event_resource aer WHERE aer.audit_event_id = tb_audit_event.id AND aer.resource_type IN ?)) AS finance, COUNT(*) FILTER (WHERE category = ?) AS security, COUNT(*) FILTER (WHERE result = ?) AS failed, COUNT(*) FILTER (WHERE result = ?) AS denied, COUNT(*) FILTER (WHERE result = ?) AS partial, COUNT(*) FILTER (WHERE result = ?) AS unknown`, result.Bucket, riskLevels(), financeResourceTypes(), constants.AuditCategorySecurity, constants.AuditResultFailed, constants.AuditResultDenied, constants.AuditResultPartial, constants.AuditResultUnknown). Group("bucket_at").Order("bucket_at ASC").Scan(&result.Trend).Error if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "聚合风险趋势失败") } return nil } func riskScopeSQL() string { return `(risk_level IN ? OR category = ? OR result IN ? OR EXISTS ( SELECT 1 FROM tb_audit_event_resource aer WHERE aer.audit_event_id = tb_audit_event.id AND aer.resource_type IN ? ))` } func riskLevels() []string { return []string{constants.AuditRiskHigh, constants.AuditRiskCritical} } func abnormalResults() []string { return []string{constants.AuditResultFailed, constants.AuditResultDenied, constants.AuditResultPartial, constants.AuditResultUnknown} } func financeResourceTypes() []string { return []string{ constants.AuditResourceOrder, constants.AuditResourceRefund, constants.AuditResourceAgentRecharge, constants.AuditResourceRechargeOrder, constants.AuditResourceAssetWallet, constants.AuditResourceAssetWalletTransaction, constants.AuditResourceAgentWallet, constants.AuditResourceAgentWalletTransaction, constants.AuditResourceAgentWalletReservation, constants.AuditResourcePayment, constants.AuditResourceCommissionRecord, constants.AuditResourceCommissionWithdrawal, } } func riskTrendBucket(from, to time.Time) string { if to.Sub(from) <= constants.AuditRiskHourlyTrendMaxRange { return "hour" } return "day" } func riskDimensionName(column, code string) string { names := map[string]map[string]string{ "risk_level": { constants.AuditRiskLow: "低", constants.AuditRiskNormal: "普通", constants.AuditRiskHigh: "高", constants.AuditRiskCritical: "严重", }, "result": { constants.AuditResultSuccess: "成功", constants.AuditResultFailed: "失败", constants.AuditResultDenied: "拒绝", constants.AuditResultPartial: "部分成功", constants.AuditResultUnknown: "结果未知", }, "source": { constants.AuditSourceAdminAPI: "后台管理 API", constants.AuditSourcePersonalAPI: "个人客户 API", constants.AuditSourceOpenAPI: "代理 OpenAPI", constants.AuditSourceWorker: "异步 Worker", constants.AuditSourceScheduler: "计划任务", constants.AuditSourceCallback: "外部系统回调", }, } return names[column][code] }