Files
junhong_cmp_fiber/internal/query/audit/timeline.go
break 70e680eb0a
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s
feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
- 新增成对迁移 000223(tb_phone_asset_association,含有效关系部分唯一索引与 down 守卫)与 000224(解绑导入任务表),不回填历史
- H5:need_bind_phone 三支判定(开关关闭完全短路);已有主号幂等建联;十项上限按手机号 advisory 串行化(含换绑到全新号的并发场景);换绑原子迁移与冲突整单回滚;不写遗留列
- 后台:关联列表、单项/批量解绑、CSV 导入解绑(B1–B16),超管/平台 gate + 资产数据范围复核,三态统一文案
- 读侧:卡/设备列表与详情按页一次 IN 聚合;两类导出补「关联手机号」列并保留历史表头反解兼容
- 脱敏:关联审计走独立动作/资源只写脱敏手机号;访问日志手机号类字段脱敏
- 同步主 Spec openspec/specs/phone-asset-association 并归档 AUG26-009,补齐 requirement-evidence 与入口矩阵,context-health 通过
2026-09-15 11:54:56 +08:00

320 lines
15 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" description:"本次按请求查询的稳定ID"`
CorrelationID *string `json:"correlation_id" description:"本次按业务关联查询的稳定ID"`
AccessLogLookupRequestID *string `json:"access_log_lookup_request_id" description:"可复制到Access Log检索的request_id本接口自身不扫描Access Log"`
Nodes []LinkTimelineNode `json:"nodes" description:"跨事实来源按发生时间稳定排序的节点"`
Retention retentionquery.Info `json:"retention" description:"Audit与Integration共同在线留存边界"`
}
// LinkTimelineNode 是保留各事实源权威边界的时间线节点。
type LinkTimelineNode struct {
RecordSource string `json:"record_source" enum:"audit_event,integration_log,outbox_event,asynq_task,domain_ledger_ref" description:"事实来源 (audit_event:审计事件, integration_log:外部交互, outbox_event:可靠事件引用, asynq_task:异步任务引用, domain_ledger_ref:业务账本引用)"`
NodeID string `json:"node_id" description:"该事实来源内的稳定节点ID"`
OccurredAt time.Time `json:"occurred_at" description:"节点发生时间"`
Code string `json:"code" description:"来源内稳定动作、操作或事件编码"`
Title string `json:"title" description:"code对应的中文展示名称"`
Result string `json:"result" description:"来源内原始结果稳定编码"`
ResultName string `json:"result_name" description:"result对应的中文展示名称"`
Summary string `json:"summary" description:"已脱敏节点摘要"`
ReferenceOnly bool `json:"reference_only" description:"true表示仅保存其他事实的引用不代表该来源独立完成业务状态变更"`
RequestID *string `json:"request_id" description:"HTTP请求关联ID"`
CorrelationID *string `json:"correlation_id" description:"跨请求业务链路ID"`
ParentEventID *string `json:"parent_event_id" description:"父审计事件ID"`
Resources []InvestigationResourceRef `json:"resources" description:"节点可稳定定位的资源引用"`
InvestigationRefs InvestigationRefs `json:"investigation_refs" description:"可继续跳转的稳定调查引用"`
Fidelity LinkageFidelity `json:"fidelity" description:"历史字段完整度和可关联能力"`
}
// LinkageFidelity 明确节点已有的稳定关联能力,不补猜历史缺失字段。
type LinkageFidelity struct {
RequestAvailable bool `json:"request_available" description:"是否有稳定request_id"`
CorrelationAvailable bool `json:"correlation_available" description:"是否有稳定correlation_id"`
ParentEventAvailable bool `json:"parent_event_available" description:"是否有稳定parent_event_id"`
DirectAuditLinkAvailable bool `json:"direct_audit_link_available" description:"是否可直接跳转审计事件详情"`
StableResourceAvailable bool `json:"stable_resource_available" description:"是否至少有一个含resource_id的稳定资源引用"`
}
// 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.AuditResourceShopBusinessOwnerImportTask,
constants.AuditResourceOrderPackageInvalidateTask, constants.AuditResourceExportTask,
constants.AuditResourcePhoneAssetUnbindImportTask:
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
}
}