Files
junhong_cmp_fiber/internal/query/audit/risks.go
break c64f3d8b80
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m31s
全局审计完成
2026-08-07 11:02:52 +08:00

300 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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或unknownname为中文展示名"`
Risks []RiskNamedCount `json:"risks" description:"风险等级分布code为low、normal、high或criticalname为中文展示名"`
Results []RiskNamedCount `json:"results" description:"结果分布code为success、failed、denied、partial或unknownname为中文展示名"`
Actions []RiskNamedCount `json:"actions" description:"动作分布code为稳定action_codename为中文action_name"`
Sources []RiskNamedCount `json:"sources" description:"来源分布code为admin_api、personal_api、openapi、worker、scheduler或callbackname为中文展示名"`
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]
}