package audit import ( "context" "strconv" "time" "gorm.io/datatypes" "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" ) // SubjectActivityFilter 定义代理资源活动的稳定标识和分页参数。 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" description:"已完成授权校验的目标资源"` Total int64 `json:"total" description:"主体可见活动总数"` Page int `json:"page" description:"当前页码"` PageSize int `json:"page_size" description:"每页数量"` Items []SubjectActivity `json:"items" description:"不包含平台内部调查字段的安全活动列表"` Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"` } // SubjectActivity 是写入时已生成的主体安全活动投影。 type SubjectActivity struct { ActionCode string `json:"action_code" description:"稳定动作编码"` ActionName string `json:"action_name" description:"action_code对应的中文展示名称"` SubjectSummary string `json:"subject_summary" description:"写入时生成的主体安全摘要"` SubjectData map[string]any `json:"subject_data" description:"写入时生成的主体安全业务字段,不包含平台before/after或内部原因"` Result string `json:"result" enum:"success,failed,denied,partial,unknown" description:"活动结果稳定编码"` OccurredAt time.Time `json:"occurred_at" description:"业务事实发生时间"` RelatedResources []SubjectResourceSummary `json:"related_resources" description:"当前主体授权范围内的相关资源摘要"` } // SubjectResourceSummary 是主体活动允许公开的资源摘要。 type SubjectResourceSummary struct { ResourceType string `json:"resource_type" description:"资源类型稳定编码"` ResourceID string `json:"resource_id" description:"资源内部稳定ID;主体前端不据此调用平台审计接口"` ResourceKey string `json:"resource_key" description:"资源业务稳定Key"` DisplayName string `json:"display_name" description:"资源安全展示名称"` } type subjectTarget struct { summary SubjectResourceSummary id string } type subjectActivityRow struct { ID uint ActionCode string ActionName string Result string OccurredAt time.Time SubjectVisibility string SubjectSummary string SubjectData datatypes.JSON TargetResourceID uint } type subjectResourceAuthorizer func(context.Context, []model.AuditEventResource) (map[string]bool, error) // AgentResourceActivities 查询代理自身及下级店铺范围内的安全资源活动。 func (q *Query) AgentResourceActivities(ctx context.Context, filter SubjectActivityFilter) (*SubjectActivityPage, error) { shopIDs, err := q.agentShopScope(ctx) if err != nil { return nil, err } if filter.Identifier == "" || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize { return nil, errors.New(errors.CodeInvalidParam) } if !agentActivityResourceType(filter.ResourceType) { return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } 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, retention, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) { return q.agentAllowedResourceIDs(ctx, resources, shopIDs) }) } // EnterpriseResourceActivities 查询企业当前有效授权卡或设备的安全资源活动。 func (q *Query) EnterpriseResourceActivities(ctx context.Context, filter SubjectActivityFilter) (*SubjectActivityPage, error) { if q == nil || q.db == nil || middleware.GetUserTypeFromContext(ctx) != constants.UserTypeEnterprise { return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } enterpriseID := middleware.GetEnterpriseIDFromContext(ctx) if enterpriseID == 0 { return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } if filter.Identifier == "" || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize { return nil, errors.New(errors.CodeInvalidParam) } if !enterpriseActivityResourceType(filter.ResourceType) { return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } 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, 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, 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("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, "统计代理资源活动失败") } rows := make([]subjectActivityRow, 0, filter.PageSize) if err := base.Select("tb_audit_event.id, action_code, action_name, result, occurred_at, target.subject_visibility, target.subject_summary, target.subject_data, target.id AS target_resource_id"). Joins("JOIN tb_audit_event_resource AS target ON 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}). Order("occurred_at DESC, tb_audit_event.id DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Scan(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理资源活动失败") } items, err := q.projectSubjectActivities(ctx, rows, target, authorize) if err != nil { return nil, err } 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) { var target subjectTarget switch resourceType { case constants.AuditResourceIotCard: var row model.IotCard err := q.db.WithContext(ctx).Table("tb_iot_card AS card"). Joins("JOIN tb_enterprise_card_authorization AS auth ON auth.card_id = card.id AND auth.deleted_at IS NULL AND auth.revoked_at IS NULL"). Where("card.iccid = ? AND auth.enterprise_id = ? AND card.deleted_at IS NULL", identifier, enterpriseID).First(&row).Error if err != nil { return target, q.subjectTargetError(err) } target = newSubjectTarget(resourceType, row.ID, row.ICCID, row.ICCID) case constants.AuditResourceDevice: var row model.Device err := q.db.WithContext(ctx).Table("tb_device AS device"). Joins("JOIN tb_enterprise_device_authorization AS auth ON auth.device_id = device.id AND auth.deleted_at IS NULL AND auth.revoked_at IS NULL"). Where("device.virtual_no = ? AND auth.enterprise_id = ? AND device.deleted_at IS NULL", identifier, enterpriseID).First(&row).Error if err != nil { return target, q.subjectTargetError(err) } target = newSubjectTarget(resourceType, row.ID, row.VirtualNo, row.VirtualNo) } return target, nil } func (q *Query) agentShopScope(ctx context.Context) ([]uint, error) { if q == nil || q.db == nil || middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent { return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } shopIDs := middleware.GetSubordinateShopIDs(ctx) if len(shopIDs) == 0 { if shopID := middleware.GetShopIDFromContext(ctx); shopID > 0 { return []uint{shopID}, nil } return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } return shopIDs, nil } func (q *Query) resolveAgentTarget(ctx context.Context, resourceType, identifier string, shopIDs []uint) (subjectTarget, error) { var target subjectTarget query := q.db.WithContext(ctx) switch resourceType { case constants.AuditResourceIotCard: var row model.IotCard if err := query.Where("iccid = ? AND shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil { target = newSubjectTarget(resourceType, row.ID, row.ICCID, row.ICCID) } else { return target, q.subjectTargetError(err) } case constants.AuditResourceDevice: var row model.Device if err := query.Where("virtual_no = ? AND shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil { target = newSubjectTarget(resourceType, row.ID, row.VirtualNo, row.VirtualNo) } else { return target, q.subjectTargetError(err) } case constants.AuditResourceShop: var row model.Shop if err := query.Where("shop_code = ? AND id IN ?", identifier, shopIDs).First(&row).Error; err == nil { target = newSubjectTarget(resourceType, row.ID, row.ShopCode, row.ShopName) } else { return target, q.subjectTargetError(err) } case constants.AuditResourceEnterprise: var row model.Enterprise if err := query.Where("enterprise_code = ? AND owner_shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil { target = newSubjectTarget(resourceType, row.ID, row.EnterpriseCode, row.EnterpriseName) } else { return target, q.subjectTargetError(err) } case constants.AuditResourceExchangeOrder: var row model.ExchangeOrder if err := query.Where("exchange_no = ? AND shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil { target = newSubjectTarget(resourceType, row.ID, row.ExchangeNo, row.ExchangeNo) } else { return target, q.subjectTargetError(err) } case constants.AuditResourceAssetAllocationRecord: var row model.AssetAllocationRecord err := agentAllocationScope(query.Where("allocation_no = ?", identifier), shopIDs).Order("id DESC").First(&row).Error if err != nil { return target, q.subjectTargetError(err) } target = newSubjectTarget(resourceType, row.ID, row.AllocationNo, row.AllocationNo) } return target, nil } func agentAllocationScope(query *gorm.DB, shopIDs []uint) *gorm.DB { return query.Where(` (from_owner_type = 'shop' AND from_owner_id IN ?) OR (to_owner_type = 'shop' AND to_owner_id IN ?) OR (asset_type = 'iot_card' AND EXISTS (SELECT 1 FROM tb_iot_card c WHERE c.id = asset_id AND c.deleted_at IS NULL AND c.shop_id IN ?)) OR (asset_type = 'device' AND EXISTS (SELECT 1 FROM tb_device d WHERE d.id = asset_id AND d.deleted_at IS NULL AND d.shop_id IN ?))`, shopIDs, shopIDs, shopIDs, shopIDs) } func (q *Query) subjectTargetError(err error) error { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } return errors.Wrap(errors.CodeDatabaseError, err, "校验代理资源范围失败") } func newSubjectTarget(resourceType string, id uint, key, name string) subjectTarget { resourceID := strconv.FormatUint(uint64(id), 10) return subjectTarget{summary: SubjectResourceSummary{ResourceType: resourceType, ResourceID: resourceID, ResourceKey: key, DisplayName: name}, id: resourceID} } func (q *Query) projectSubjectActivities(ctx context.Context, rows []subjectActivityRow, target subjectTarget, authorize subjectResourceAuthorizer) ([]SubjectActivity, error) { items := make([]SubjectActivity, 0, len(rows)) eventIDs := make([]uint, 0, len(rows)) for _, row := range rows { eventIDs = append(eventIDs, row.ID) } resources := make([]model.AuditEventResource, 0) if len(eventIDs) > 0 { if err := q.db.WithContext(ctx).Where("audit_event_id IN ? AND subject_visibility IN ?", eventIDs, []string{constants.AuditSubjectResult, constants.AuditSubjectDetail}). Order("audit_event_id ASC, sort_order ASC, id ASC").Find(&resources).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询主体可见关联资源失败") } } targetResourceID := target.id authorizationResources := append(resources, model.AuditEventResource{ResourceType: target.summary.ResourceType, ResourceID: &targetResourceID}) allowed, err := authorize(ctx, authorizationResources) if err != nil { return nil, err } if !allowed[resourceAccessKey(target.summary.ResourceType, target.id)] { return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } related := make(map[uint][]SubjectResourceSummary, len(rows)) for _, resource := range resources { if resource.ResourceID == nil || !allowed[resourceAccessKey(resource.ResourceType, *resource.ResourceID)] { continue } related[resource.AuditEventID] = append(related[resource.AuditEventID], SubjectResourceSummary{ ResourceType: resource.ResourceType, ResourceID: *resource.ResourceID, ResourceKey: resource.ResourceKey, DisplayName: resource.DisplayName, }) } for _, row := range rows { data := map[string]any{} if row.SubjectVisibility == constants.AuditSubjectDetail { data, err = decodeObject(row.SubjectData) if err != nil { return nil, err } } items = append(items, SubjectActivity{ActionCode: row.ActionCode, ActionName: row.ActionName, SubjectSummary: row.SubjectSummary, SubjectData: data, Result: row.Result, OccurredAt: row.OccurredAt, RelatedResources: related[row.ID]}) if items[len(items)-1].RelatedResources == nil { items[len(items)-1].RelatedResources = []SubjectResourceSummary{} } } return items, nil } func (q *Query) enterpriseAllowedResourceIDs(ctx context.Context, resources []model.AuditEventResource, enterpriseID uint) (map[string]bool, error) { idsByType := collectResourceIDs(resources) allowed := make(map[string]bool) queries := []struct { resourceType string table string resourceID string }{ {constants.AuditResourceIotCard, "tb_enterprise_card_authorization", "card_id"}, {constants.AuditResourceDevice, "tb_enterprise_device_authorization", "device_id"}, } for _, spec := range queries { ids := idsByType[spec.resourceType] if len(ids) == 0 { continue } var visible []uint if err := q.db.WithContext(ctx).Table(spec.table). Where("enterprise_id = ? AND "+spec.resourceID+" IN ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID, ids). Pluck(spec.resourceID, &visible).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验企业关联资源授权失败") } markAllowedIDs(allowed, spec.resourceType, visible) } return allowed, nil } func (q *Query) agentAllowedResourceIDs(ctx context.Context, resources []model.AuditEventResource, shopIDs []uint) (map[string]bool, error) { idsByType := collectResourceIDs(resources) allowed := make(map[string]bool) queries := []struct { resourceType string table string condition string }{ {constants.AuditResourceIotCard, "tb_iot_card", "shop_id IN ? AND deleted_at IS NULL"}, {constants.AuditResourceDevice, "tb_device", "shop_id IN ? AND deleted_at IS NULL"}, {constants.AuditResourceShop, "tb_shop", "id IN ? AND deleted_at IS NULL"}, {constants.AuditResourceEnterprise, "tb_enterprise", "owner_shop_id IN ? AND deleted_at IS NULL"}, {constants.AuditResourceExchangeOrder, "tb_exchange_order", "shop_id IN ? AND deleted_at IS NULL"}, } for _, spec := range queries { if err := q.collectAgentAllowedIDs(ctx, allowed, spec.resourceType, spec.table, spec.condition, idsByType[spec.resourceType], shopIDs); err != nil { return nil, err } } if ids := idsByType[constants.AuditResourceAssetAllocationRecord]; len(ids) > 0 { var visible []uint if err := agentAllocationScope(q.db.WithContext(ctx).Table("tb_asset_allocation_record").Where("id IN ? AND deleted_at IS NULL", ids), shopIDs). Pluck("id", &visible).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验代理分配记录关联资源失败") } markAllowedIDs(allowed, constants.AuditResourceAssetAllocationRecord, visible) } return allowed, nil } func collectResourceIDs(resources []model.AuditEventResource) map[string][]uint { idsByType := make(map[string][]uint) for _, resource := range resources { if resource.ResourceID == nil { continue } id, err := strconv.ParseUint(*resource.ResourceID, 10, 64) if err == nil { idsByType[resource.ResourceType] = append(idsByType[resource.ResourceType], uint(id)) } } return idsByType } func (q *Query) collectAgentAllowedIDs(ctx context.Context, allowed map[string]bool, resourceType, table, condition string, ids, shopIDs []uint) error { if len(ids) == 0 { return nil } var visible []uint if err := q.db.WithContext(ctx).Table(table).Where("id IN ?", ids).Where(condition, shopIDs).Pluck("id", &visible).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "校验代理关联资源范围失败") } markAllowedIDs(allowed, resourceType, visible) return nil } func markAllowedIDs(allowed map[string]bool, resourceType string, ids []uint) { for _, id := range ids { allowed[resourceAccessKey(resourceType, strconv.FormatUint(uint64(id), 10))] = true } } func resourceAccessKey(resourceType, resourceID string) string { return resourceType + ":" + resourceID } func agentActivityResourceType(resourceType string) bool { switch resourceType { case constants.AuditResourceIotCard, constants.AuditResourceDevice, constants.AuditResourceAssetAllocationRecord, constants.AuditResourceExchangeOrder, constants.AuditResourceShop, constants.AuditResourceEnterprise: return true default: return false } } func enterpriseActivityResourceType(resourceType string) bool { return resourceType == constants.AuditResourceIotCard || resourceType == constants.AuditResourceDevice }