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

417 lines
18 KiB
Go

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"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []SubjectActivity `json:"items"`
Retention retentionquery.Info `json:"retention"`
}
// SubjectActivity 是写入时已生成的主体安全活动投影。
type SubjectActivity struct {
ActionCode string `json:"action_code"`
ActionName string `json:"action_name"`
SubjectSummary string `json:"subject_summary"`
SubjectData map[string]any `json:"subject_data"`
Result string `json:"result"`
OccurredAt time.Time `json:"occurred_at"`
RelatedResources []SubjectResourceSummary `json:"related_resources"`
}
// SubjectResourceSummary 是主体活动允许公开的资源摘要。
type SubjectResourceSummary struct {
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
ResourceKey string `json:"resource_key"`
DisplayName string `json:"display_name"`
}
type subjectTarget struct {
summary SubjectResourceSummary
id string
}
type subjectActivityRow struct {
ID uint
ActionCode string
ActionName string
Result string
OccurredAt time.Time
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 == "" || !agentActivityResourceType(filter.ResourceType) || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
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, 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 == "" || !enterpriseActivityResourceType(filter.ResourceType) || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
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, 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_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, 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, authorize subjectResourceAuthorizer) ([]SubjectActivity, error) {
items := make([]SubjectActivity, 0, len(rows))
if len(rows) == 0 {
return items, nil
}
eventIDs := make([]uint, 0, len(rows))
for _, row := range rows {
eventIDs = append(eventIDs, row.ID)
}
var resources []model.AuditEventResource
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, "批量查询主体可见关联资源失败")
}
allowed, err := authorize(ctx, resources)
if err != nil {
return nil, err
}
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, 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
}