This commit is contained in:
299
internal/query/audit/risks.go
Normal file
299
internal/query/audit/risks.go
Normal file
@@ -0,0 +1,299 @@
|
||||
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"`
|
||||
Bucket string `json:"bucket"`
|
||||
Signals []RiskNamedCount `json:"signals"`
|
||||
Risks []RiskNamedCount `json:"risks"`
|
||||
Results []RiskNamedCount `json:"results"`
|
||||
Actions []RiskNamedCount `json:"actions"`
|
||||
Sources []RiskNamedCount `json:"sources"`
|
||||
Trend []RiskTrendPoint `json:"trend"`
|
||||
Retention retentionquery.Info `json:"retention"`
|
||||
}
|
||||
|
||||
// RiskNamedCount 是风险聚合维度的稳定编码、中文名称和数量。
|
||||
type RiskNamedCount struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// RiskTrendPoint 是固定时间桶内的风险信号趋势。
|
||||
type RiskTrendPoint struct {
|
||||
BucketAt time.Time `json:"bucket_at"`
|
||||
Total int64 `json:"total"`
|
||||
HighRisk int64 `json:"high_risk"`
|
||||
Finance int64 `json:"finance"`
|
||||
Security int64 `json:"security"`
|
||||
Failed int64 `json:"failed"`
|
||||
Denied int64 `json:"denied"`
|
||||
Partial int64 `json:"partial"`
|
||||
Unknown int64 `json:"unknown"`
|
||||
}
|
||||
|
||||
// RiskEventPage 是风险事件的稳定分页结果。
|
||||
type RiskEventPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []EventView `json:"items"`
|
||||
Retention retentionquery.Info `json:"retention"`
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
Reference in New Issue
Block a user