Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1h43m42s
- 迁移 000221:新增 tb_business_user_group、tb_business_user_group_member、tb_shop_business_owner_import_task,成员一账号一行由部分唯一索引保证,店铺所属组按当前负责人实时推导,不回填历史分组。 - 用户组 CRUD、成员改组/清空归属、店铺批量交接(原子失败不部分写入)。 - 店铺负责人 CSV 导入任务:逐行独立事务、逐行明细、任务级与行级失败分离。 - 读侧推导与筛选:未分组、业务线、停用组可筛出并带停用标记。 - 补齐操作审计动作与资源、openapi 清单、发布门禁巡检表清单。 - 归档 add-shop-salesperson-groups 变更并同步 openspec/specs/business-user-group,补齐 AUG26-003 验证证据链。
319 lines
15 KiB
Go
319 lines
15 KiB
Go
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:
|
||
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
|
||
}
|
||
}
|