Files
junhong_cmp_fiber/internal/query/audit/timeline.go
break 88cc5e96ec
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m32s
暂存
2026-08-06 09:35:00 +08:00

318 lines
14 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"
"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
}
}