暂存
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m32s

This commit is contained in:
2026-08-06 09:35:00 +08:00
parent 8659dfc658
commit 88cc5e96ec
75 changed files with 6520 additions and 513 deletions

View File

@@ -10,6 +10,7 @@ import (
"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"
@@ -39,10 +40,17 @@ type EventFilter struct {
// EventPage 是平台全局事件稳定分页结果。
type EventPage struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []EventView `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []EventView `json:"items"`
Retention retentionquery.Info `json:"retention"`
}
// EventDetail 是单个审计事件及在线留存边界。
type EventDetail struct {
EventView
Retention retentionquery.Info `json:"retention"`
}
// EventView 是不暴露 GORM Model 的审计事件投影。
@@ -147,6 +155,14 @@ func (q *Query) List(ctx context.Context, filter EventFilter) (*EventPage, 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)
if err != nil {
return nil, err
}
if !validEventFilter(filter) {
return nil, errors.New(errors.CodeInvalidParam)
}
@@ -156,16 +172,33 @@ func (q *Query) List(ctx context.Context, filter EventFilter) (*EventPage, error
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计审计事件失败")
}
rows := make([]model.AuditEvent, 0, filter.PageSize)
if err := query.Order("occurred_at DESC, id DESC").
Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).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 &EventPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
return &EventPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items, Retention: retention}, nil
}
// loadEventPage 先分页主键,再批量读取事件宽行,避免排序阶段加载 JSON 字段。
func (q *Query) loadEventPage(ctx context.Context, query *gorm.DB, page, pageSize int) ([]model.AuditEvent, error) {
ids := make([]uint, 0, pageSize)
if err := query.Select("id").Order("occurred_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.AuditEvent, 0, len(ids))
if len(ids) == 0 {
return rows, nil
}
if err := q.db.WithContext(ctx).Where("id IN ?", ids).
Order("occurred_at DESC, id DESC").Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量投影审计事件失败")
}
return rows, nil
}
func validEventFilter(filter EventFilter) bool {
@@ -192,15 +225,19 @@ func validOptionalValue(value string, allowed ...string) bool {
}
// Get 查询平台范围的单个稳定审计事件详情。
func (q *Query) Get(ctx context.Context, eventID string) (*EventView, error) {
func (q *Query) Get(ctx context.Context, eventID string) (*EventDetail, error) {
if err := q.authorize(ctx); err != nil {
return nil, err
}
if eventID == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
if err != nil {
return nil, err
}
var row model.AuditEvent
if err := q.db.WithContext(ctx).Where("event_id = ?", eventID).First(&row).Error; err != nil {
if err := q.db.WithContext(ctx).Where("event_id = ? AND occurred_at >= ?", eventID, retention.OnlineFrom.UTC()).First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "审计事件不存在")
}
@@ -210,7 +247,7 @@ func (q *Query) Get(ctx context.Context, eventID string) (*EventView, error) {
if err != nil {
return nil, err
}
return &items[0], nil
return &EventDetail{EventView: items[0], Retention: retention}, nil
}
func (q *Query) authorize(ctx context.Context) error {

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import (
"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"
)
@@ -17,16 +18,18 @@ import (
type ResourceSearchFilter struct {
ResourceType string
Keyword string
OnlineFrom time.Time
Page int
PageSize int
}
// ResourceSearchPage 是资源候选稳定分页结果。
type ResourceSearchPage struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []ResourceCandidate `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []ResourceCandidate `json:"items"`
Retention retentionquery.Info `json:"retention"`
}
// ResourceCandidate 是当前业务表或历史事件快照解析出的稳定资源候选。
@@ -59,6 +62,11 @@ func (q *Query) SearchResources(ctx context.Context, filter ResourceSearchFilter
if filter.Keyword == "" || !searchableResourceType(filter.ResourceType) {
return nil, errors.New(errors.CodeInvalidParam)
}
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
if err != nil {
return nil, err
}
filter.OnlineFrom = retention.OnlineFrom
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
items, total, err := q.searchCurrent(ctx, filter)
if err != nil {
@@ -70,7 +78,7 @@ func (q *Query) SearchResources(ctx context.Context, filter ResourceSearchFilter
return nil, err
}
}
return &ResourceSearchPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
return &ResourceSearchPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items, Retention: retention}, nil
}
// ResourceTimeline 查询注册资源作为任意关系参与的统一事件时间线。
@@ -196,7 +204,7 @@ func (q *Query) searchHistorical(ctx context.Context, filter ResourceSearchFilte
func (q *Query) historicalIdentifierQuery(ctx context.Context, filter ResourceSearchFilter) *gorm.DB {
query := q.db.WithContext(ctx).Model(&model.AuditEventResource{}).
Where("resource_type = ? AND resource_id IS NOT NULL", filter.ResourceType)
Where("resource_type = ? AND resource_id IS NOT NULL AND created_at >= ?", filter.ResourceType, filter.OnlineFrom.UTC())
switch filter.ResourceType {
case constants.AuditResourceIotCard:
return query.Where("resource_key = ? OR identity_snapshot ->> 'iccid' = ? OR identity_snapshot ->> 'iccid_19' = ? OR identity_snapshot ->> 'iccid_20' = ? OR identity_snapshot ->> 'virtual_no' = ?", filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword)

View 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]
}

View File

@@ -9,6 +9,7 @@ import (
"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"
@@ -18,17 +19,20 @@ import (
type SubjectActivityFilter struct {
ResourceType string
Identifier string
CreatedFrom *time.Time
CreatedTo *time.Time
Page int
PageSize int
}
// SubjectActivityPage 是不包含平台调查字段的代理资源活动分页结果。
type SubjectActivityPage struct {
Resource SubjectResourceSummary `json:"resource"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []SubjectActivity `json:"items"`
Resource SubjectResourceSummary `json:"resource"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []SubjectActivity `json:"items"`
Retention retentionquery.Info `json:"retention"`
}
// SubjectActivity 是写入时已生成的主体安全活动投影。
@@ -78,11 +82,19 @@ func (q *Query) AgentResourceActivities(ctx context.Context, filter SubjectActiv
return nil, errors.New(errors.CodeInvalidParam)
}
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
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)
if err != nil {
return nil, err
}
target, err := q.resolveAgentTarget(ctx, filter.ResourceType, filter.Identifier, shopIDs)
if err != nil {
return nil, err
}
return q.subjectActivitiesForTarget(ctx, filter, target, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) {
return q.subjectActivitiesForTarget(ctx, filter, target, retention, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) {
return q.agentAllowedResourceIDs(ctx, resources, shopIDs)
})
}
@@ -100,21 +112,30 @@ func (q *Query) EnterpriseResourceActivities(ctx context.Context, filter Subject
return nil, errors.New(errors.CodeInvalidParam)
}
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
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)
if err != nil {
return nil, err
}
target, err := q.resolveEnterpriseTarget(ctx, filter.ResourceType, filter.Identifier, enterpriseID)
if err != nil {
return nil, err
}
return q.subjectActivitiesForTarget(ctx, filter, target, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) {
return q.subjectActivitiesForTarget(ctx, filter, target, retention, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) {
return q.enterpriseAllowedResourceIDs(ctx, resources, enterpriseID)
})
}
func (q *Query) subjectActivitiesForTarget(ctx context.Context, filter SubjectActivityFilter, target subjectTarget, authorize subjectResourceAuthorizer) (*SubjectActivityPage, error) {
func (q *Query) subjectActivitiesForTarget(ctx context.Context, filter SubjectActivityFilter, target subjectTarget, retention retentionquery.Info, authorize subjectResourceAuthorizer) (*SubjectActivityPage, error) {
resourceMatch := q.db.Table("tb_audit_event_resource AS target").Select("1").
Where("target.audit_event_id = tb_audit_event.id AND target.resource_type = ? AND target.resource_id = ?", filter.ResourceType, target.id).
Where("target.subject_visibility IN ?", []string{constants.AuditSubjectResult, constants.AuditSubjectDetail})
base := q.db.WithContext(ctx).Model(&model.AuditEvent{}).Where("EXISTS (?)", resourceMatch)
base := q.db.WithContext(ctx).Model(&model.AuditEvent{}).
Where("occurred_at >= ? AND occurred_at < ?", filter.CreatedFrom.UTC(), filter.CreatedTo.UTC()).Where("EXISTS (?)", resourceMatch)
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计代理资源活动失败")
@@ -131,7 +152,7 @@ func (q *Query) subjectActivitiesForTarget(ctx context.Context, filter SubjectAc
if err != nil {
return nil, err
}
return &SubjectActivityPage{Resource: target.summary, Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
return &SubjectActivityPage{Resource: target.summary, Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items, Retention: retention}, nil
}
func (q *Query) resolveEnterpriseTarget(ctx context.Context, resourceType, identifier string, enterpriseID uint) (subjectTarget, error) {

View File

@@ -0,0 +1,317 @@
package audit
import (
"context"
"fmt"
"sort"
"strconv"
"time"
"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"
)
// LinkTimeline 是 request 或 correlation 的跨事实只读时间线。
type LinkTimeline struct {
RequestID *string `json:"request_id"`
CorrelationID *string `json:"correlation_id"`
AccessLogLookupRequestID *string `json:"access_log_lookup_request_id"`
Nodes []LinkTimelineNode `json:"nodes"`
Retention retentionquery.Info `json:"retention"`
}
// LinkTimelineNode 是保留各事实源权威边界的时间线节点。
type LinkTimelineNode struct {
RecordSource string `json:"record_source"`
NodeID string `json:"node_id"`
OccurredAt time.Time `json:"occurred_at"`
Code string `json:"code"`
Title string `json:"title"`
Result string `json:"result"`
ResultName string `json:"result_name"`
Summary string `json:"summary"`
ReferenceOnly bool `json:"reference_only"`
RequestID *string `json:"request_id"`
CorrelationID *string `json:"correlation_id"`
ParentEventID *string `json:"parent_event_id"`
Resources []InvestigationResourceRef `json:"resources"`
InvestigationRefs InvestigationRefs `json:"investigation_refs"`
Fidelity LinkageFidelity `json:"fidelity"`
}
// LinkageFidelity 明确节点已有的稳定关联能力,不补猜历史缺失字段。
type LinkageFidelity struct {
RequestAvailable bool `json:"request_available"`
CorrelationAvailable bool `json:"correlation_available"`
ParentEventAvailable bool `json:"parent_event_available"`
DirectAuditLinkAvailable bool `json:"direct_audit_link_available"`
StableResourceAvailable bool `json:"stable_resource_available"`
}
// RequestTimeline 按精确 request ID 组合已持久化事实,不扫描 Access Log。
func (q *Query) RequestTimeline(ctx context.Context, requestID string) (*LinkTimeline, error) {
return q.linkTimeline(ctx, "request_id", requestID)
}
// CorrelationTimeline 按精确 correlation ID 组合跨请求业务链路。
func (q *Query) CorrelationTimeline(ctx context.Context, correlationID string) (*LinkTimeline, error) {
return q.linkTimeline(ctx, "correlation_id", correlationID)
}
func (q *Query) linkTimeline(ctx context.Context, column, value string) (*LinkTimeline, error) {
if err := q.authorize(ctx); err != nil {
return nil, err
}
if value == "" || (column != "request_id" && column != "correlation_id") {
return nil, errors.New(errors.CodeInvalidParam)
}
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit, retentionquery.SourceIntegration)
if err != nil {
return nil, err
}
auditRows, integrationRows, outboxRows, err := q.loadLinkRows(ctx, column, value, retention.OnlineFrom)
if err != nil {
return nil, err
}
events, err := q.project(ctx, auditRows)
if err != nil {
return nil, err
}
nodes := make([]LinkTimelineNode, 0, len(events)+len(integrationRows)+len(outboxRows))
integrationByAudit := integrationRefsByAuditID(integrationRows)
for index, event := range events {
refs := event.InvestigationRefs
refs.IntegrationRefs = append(refs.IntegrationRefs, integrationByAudit[auditRows[index].ID]...)
refs.IntegrationRefs = append(refs.IntegrationRefs, integrationResourceRefs(event.Resources)...)
refs.IntegrationRefs = uniqueIntegrationRefs(refs.IntegrationRefs)
nodes = append(nodes, auditTimelineNode(event, refs))
nodes = append(nodes, resourceReferenceNodes(event, refs)...)
}
for _, row := range integrationRows {
nodes = append(nodes, integrationTimelineNode(row))
}
for _, row := range outboxRows {
nodes = append(nodes, outboxTimelineNode(row))
}
sort.Slice(nodes, func(i, j int) bool {
if nodes[i].OccurredAt.Equal(nodes[j].OccurredAt) {
if nodes[i].RecordSource == nodes[j].RecordSource {
return nodes[i].NodeID < nodes[j].NodeID
}
return nodes[i].RecordSource < nodes[j].RecordSource
}
return nodes[i].OccurredAt.Before(nodes[j].OccurredAt)
})
timeline := &LinkTimeline{Nodes: nodes, Retention: retention}
if timeline.Nodes == nil {
timeline.Nodes = []LinkTimelineNode{}
}
if column == "request_id" {
timeline.RequestID = stringPointer(value)
timeline.AccessLogLookupRequestID = stringPointer(value)
} else {
timeline.CorrelationID = stringPointer(value)
}
return timeline, nil
}
func (q *Query) loadLinkRows(ctx context.Context, column, value string, onlineFrom time.Time) ([]model.AuditEvent, []model.IntegrationLog, []model.OutboxEvent, error) {
auditRows := []model.AuditEvent{}
if err := q.db.WithContext(ctx).Where(column+" = ? AND occurred_at >= ?", value, onlineFrom.UTC()).Find(&auditRows).Error; err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询链路审计事件失败")
}
integrationRows := []model.IntegrationLog{}
if err := q.db.WithContext(ctx).Where(column+" = ? AND created_at >= ?", value, onlineFrom.UTC()).Find(&integrationRows).Error; err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询链路外部交互失败")
}
outboxRows := []model.OutboxEvent{}
if err := q.db.WithContext(ctx).Where(column+" = ? AND created_at >= ?", value, onlineFrom.UTC()).Find(&outboxRows).Error; err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询链路可靠事件失败")
}
return auditRows, integrationRows, outboxRows, nil
}
func auditTimelineNode(event EventView, refs InvestigationRefs) LinkTimelineNode {
return LinkTimelineNode{
RecordSource: constants.AuditRecordSourceAuditEvent, NodeID: event.EventID,
OccurredAt: event.OccurredAt, Code: event.ActionCode, Title: event.ActionName,
Result: event.Result, Summary: event.Summary,
RequestID: stringPointer(event.RequestID), CorrelationID: stringPointer(event.CorrelationID),
ParentEventID: stringPointer(event.ParentEventID), Resources: refs.ResourceRefs, InvestigationRefs: refs,
Fidelity: linkageFidelity(event.RequestID, event.CorrelationID, event.ParentEventID, true, len(refs.ResourceRefs) > 0),
}
}
func integrationTimelineNode(row model.IntegrationLog) LinkTimelineNode {
resource := integrationResourceRef(row)
resources := make([]InvestigationResourceRef, 0, 1)
if resource != nil {
resources = append(resources, *resource)
}
refs := InvestigationRefs{
ResourceRefs: resources, RequestID: row.RequestID, CorrelationID: row.CorrelationID,
IntegrationRefs: []IntegrationRef{{IntegrationID: row.IntegrationID}},
}
return LinkTimelineNode{
RecordSource: constants.AuditRecordSourceIntegrationLog, NodeID: row.IntegrationID,
OccurredAt: row.CreatedAt, Code: row.Operation,
Title: constants.IntegrationProviderName(row.Provider) + " · " + constants.IntegrationOperationName(row.Operation),
Result: row.Result, ResultName: constants.IntegrationResultName(row.Result), Summary: "外部交互事实",
RequestID: row.RequestID, CorrelationID: row.CorrelationID, Resources: resources, InvestigationRefs: refs,
Fidelity: linkageFidelity(pointerValue(row.RequestID), pointerValue(row.CorrelationID), "", row.AuditEventID != nil, resource != nil),
}
}
func outboxTimelineNode(row model.OutboxEvent) LinkTimelineNode {
resourceType := row.ResourceType
if resourceType == "" {
resourceType = row.AggregateType
}
resourceID := row.ResourceID
if resourceID == "" {
resourceID = row.AggregateID
}
resourceKey := row.BusinessKey
if resourceKey == "" {
resourceKey = row.AggregateID
}
resource := InvestigationResourceRef{ResourceType: resourceType, ResourceID: stringPointer(resourceID), ResourceKey: resourceKey, DisplayName: resourceKey}
refs := InvestigationRefs{
ResourceRefs: []InvestigationResourceRef{resource}, RequestID: stringPointer(row.RequestID),
CorrelationID: stringPointer(row.CorrelationID), IntegrationRefs: []IntegrationRef{},
}
return LinkTimelineNode{
RecordSource: constants.AuditRecordSourceOutboxEvent, NodeID: row.EventID,
OccurredAt: row.CreatedAt, Code: row.EventType, Title: "可靠事件:" + row.EventType,
Result: strconv.Itoa(row.Status), ResultName: constants.GetOutboxStatusName(row.Status),
Summary: fmt.Sprintf("%s/%s重试 %d 次", row.AggregateType, row.AggregateID, row.RetryCount),
RequestID: stringPointer(row.RequestID), CorrelationID: stringPointer(row.CorrelationID),
ParentEventID: stringPointer(row.ParentEventID), Resources: refs.ResourceRefs, InvestigationRefs: refs,
Fidelity: linkageFidelity(row.RequestID, row.CorrelationID, row.ParentEventID, row.ParentEventID != "", resourceType != "" && resourceID != ""),
}
}
func resourceReferenceNodes(event EventView, refs InvestigationRefs) []LinkTimelineNode {
nodes := make([]LinkTimelineNode, 0, len(event.Resources))
for _, resource := range event.Resources {
recordSource := ""
titlePrefix := ""
summary := ""
switch {
case isAsynqTaskResource(resource.ResourceType):
recordSource = constants.AuditRecordSourceAsynqTask
titlePrefix = "异步任务:"
summary = "持久化任务资源摘要;不读取或推断 Redis 队列历史"
case isDomainLedgerResource(resource.ResourceType):
recordSource = constants.AuditRecordSourceDomainLedgerRef
titlePrefix = "业务账本引用:"
summary = "状态、金额及业务结论以对应业务表为准"
default:
continue
}
resourceRef := InvestigationResourceRef{ResourceType: resource.ResourceType, ResourceID: resource.ResourceID, ResourceKey: resource.ResourceKey, DisplayName: resource.DisplayName}
nodeRefs := refs
nodeRefs.ResourceRefs = []InvestigationResourceRef{resourceRef}
nodes = append(nodes, LinkTimelineNode{
RecordSource: recordSource,
NodeID: fmt.Sprintf("%s:%s:%s:%s:%s", event.EventID, resource.ResourceType, pointerValue(resource.ResourceID), resource.ResourceKey, resource.Role),
OccurredAt: event.OccurredAt, Code: resource.ResourceType, Title: titlePrefix + resource.DisplayName,
Result: event.Result, Summary: summary, ReferenceOnly: true,
RequestID: stringPointer(event.RequestID), CorrelationID: stringPointer(event.CorrelationID),
ParentEventID: stringPointer(event.ParentEventID), Resources: nodeRefs.ResourceRefs, InvestigationRefs: nodeRefs,
Fidelity: linkageFidelity(event.RequestID, event.CorrelationID, event.ParentEventID, true, resource.ResourceID != nil || resource.ResourceKey != ""),
})
}
return nodes
}
func integrationRefsByAuditID(rows []model.IntegrationLog) map[uint][]IntegrationRef {
refs := make(map[uint][]IntegrationRef)
for _, row := range rows {
if row.AuditEventID != nil {
refs[*row.AuditEventID] = append(refs[*row.AuditEventID], IntegrationRef{IntegrationID: row.IntegrationID})
}
}
return refs
}
func integrationResourceRefs(resources []ResourceView) []IntegrationRef {
refs := make([]IntegrationRef, 0)
for _, resource := range resources {
if resource.ResourceType == constants.AuditResourceIntegrationLog && resource.ResourceKey != "" {
refs = append(refs, IntegrationRef{IntegrationID: resource.ResourceKey})
}
}
return refs
}
func uniqueIntegrationRefs(refs []IntegrationRef) []IntegrationRef {
unique := make([]IntegrationRef, 0, len(refs))
seen := make(map[string]bool, len(refs))
for _, ref := range refs {
if ref.IntegrationID == "" || seen[ref.IntegrationID] {
continue
}
seen[ref.IntegrationID] = true
unique = append(unique, ref)
}
return unique
}
func integrationResourceRef(row model.IntegrationLog) *InvestigationResourceRef {
if row.ResourceType == nil || *row.ResourceType == "" {
return nil
}
ref := InvestigationResourceRef{ResourceType: *row.ResourceType, ResourceID: row.ResourceID}
if row.ResourceKey != nil {
ref.ResourceKey = *row.ResourceKey
ref.DisplayName = *row.ResourceKey
}
return &ref
}
func linkageFidelity(requestID, correlationID, parentEventID string, directAuditLink, stableResource bool) LinkageFidelity {
return LinkageFidelity{
RequestAvailable: requestID != "", CorrelationAvailable: correlationID != "",
ParentEventAvailable: parentEventID != "", DirectAuditLinkAvailable: directAuditLink,
StableResourceAvailable: stableResource,
}
}
func pointerValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func isAsynqTaskResource(resourceType string) bool {
switch resourceType {
case constants.AuditResourceDeviceBatchTask, constants.AuditResourceIotCardImportTask,
constants.AuditResourceDeviceImportTask, constants.AuditResourceAssetPackageBatchOrderTask,
constants.AuditResourceOrderPackageInvalidateTask, constants.AuditResourceExportTask:
return true
default:
return false
}
}
func isDomainLedgerResource(resourceType string) bool {
switch resourceType {
case constants.AuditResourceOrder, constants.AuditResourcePayment, constants.AuditResourceRefund,
constants.AuditResourceAgentRecharge, constants.AuditResourceRechargeOrder,
constants.AuditResourceAssetWallet, constants.AuditResourceAssetWalletTransaction,
constants.AuditResourceAgentWallet, constants.AuditResourceAgentWalletTransaction,
constants.AuditResourceAgentWalletReservation, constants.AuditResourcePackageUsage,
constants.AuditResourceApprovalInstance, constants.AuditResourceCommissionRecord,
constants.AuditResourceCommissionWithdrawal:
return true
default:
return false
}
}