固化七月迭代审计治理进展以隔离线上热修
Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。 Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。 Confidence: medium Scope-risk: broad Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。 Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。 Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
This commit is contained in:
49
internal/query/audit/actors.go
Normal file
49
internal/query/audit/actors.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ActorEventFilter 定义操作者行为视角的受控筛选。
|
||||
type ActorEventFilter struct {
|
||||
Kind string
|
||||
ID string
|
||||
Action string
|
||||
Result string
|
||||
Risk string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// ListActorEvents 查询指定人工账号、OpenAPI、系统任务或外部系统的历史行为。
|
||||
func (q *Query) ListActorEvents(ctx context.Context, filter ActorEventFilter) (*EventPage, error) {
|
||||
if filter.ID == "" || !validActorKind(filter.Kind) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
return q.List(ctx, EventFilter{
|
||||
ActorKind: filter.Kind, ActorID: filter.ID,
|
||||
Action: filter.Action, Result: filter.Result, Risk: filter.Risk,
|
||||
ResourceType: filter.ResourceType, ResourceID: filter.ResourceID,
|
||||
CreatedFrom: filter.CreatedFrom, CreatedTo: filter.CreatedTo,
|
||||
Page: filter.Page, PageSize: filter.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func validActorKind(kind string) bool {
|
||||
switch kind {
|
||||
case constants.AuditActorAccount, constants.AuditActorPersonalCustomer,
|
||||
constants.AuditActorOpenAPI, constants.AuditActorSystemTask,
|
||||
constants.AuditActorScheduledJob, constants.AuditActorExternalSystem:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
396
internal/query/audit/events.go
Normal file
396
internal/query/audit/events.go
Normal file
@@ -0,0 +1,396 @@
|
||||
// Package audit 提供统一审计事件的只读调查查询。
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// EventFilter 定义平台全局事件列表的受控组合筛选。
|
||||
type EventFilter struct {
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
Action string
|
||||
Category string
|
||||
ActorKind string
|
||||
ActorID string
|
||||
Source string
|
||||
Result string
|
||||
Risk string
|
||||
ScopeType string
|
||||
ScopeID string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
ResourceKey string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// EventPage 是平台全局事件稳定分页结果。
|
||||
type EventPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []EventView `json:"items"`
|
||||
}
|
||||
|
||||
// EventView 是不暴露 GORM Model 的审计事件投影。
|
||||
type EventView struct {
|
||||
EventID string `json:"event_id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Category string `json:"category"`
|
||||
ActionCode string `json:"action_code"`
|
||||
ActionName string `json:"action_name"`
|
||||
Summary string `json:"summary"`
|
||||
ActorKind string `json:"actor_kind"`
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorName string `json:"actor_name"`
|
||||
ActorShopID *uint `json:"actor_shop_id,omitempty"`
|
||||
ActorShopName string `json:"actor_shop_name"`
|
||||
ActorEnterpriseID *uint `json:"actor_enterprise_id,omitempty"`
|
||||
ActorEnterpriseName string `json:"actor_enterprise_name"`
|
||||
Source string `json:"source"`
|
||||
RequestPath string `json:"request_path"`
|
||||
RequestMethod string `json:"request_method"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
ScopeType string `json:"scope_type"`
|
||||
ScopeID string `json:"scope_id"`
|
||||
ScopeName string `json:"scope_name"`
|
||||
Result string `json:"result"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
ErrorSummary string `json:"error_summary"`
|
||||
RequestID string `json:"request_id"`
|
||||
CorrelationID string `json:"correlation_id"`
|
||||
ParentEventID string `json:"parent_event_id"`
|
||||
BatchTotal int `json:"batch_total"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailCount int `json:"fail_count"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Resources []ResourceView `json:"resources"`
|
||||
InvestigationRefs InvestigationRefs `json:"investigation_refs"`
|
||||
}
|
||||
|
||||
// InvestigationRefs 是平台调查视角间唯一允许使用的稳定跳转引用。
|
||||
type InvestigationRefs struct {
|
||||
EventID *string `json:"event_id"`
|
||||
ActorRef *ActorRef `json:"actor_ref"`
|
||||
ResourceRefs []InvestigationResourceRef `json:"resource_refs"`
|
||||
RequestID *string `json:"request_id"`
|
||||
CorrelationID *string `json:"correlation_id"`
|
||||
IntegrationRefs []IntegrationRef `json:"integration_refs"`
|
||||
}
|
||||
|
||||
// ActorRef 是操作者时间线的稳定引用。
|
||||
type ActorRef struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// InvestigationResourceRef 是通用资源时间线的稳定引用。
|
||||
type InvestigationResourceRef struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID *string `json:"resource_id"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// IntegrationRef 是 Integration 详情的稳定引用。
|
||||
type IntegrationRef struct {
|
||||
IntegrationID string `json:"integration_id"`
|
||||
}
|
||||
|
||||
// ResourceView 是事件发生时独立资源身份与变化的只读投影。
|
||||
type ResourceView struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID *string `json:"resource_id,omitempty"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Relation string `json:"relation"`
|
||||
Role string `json:"role"`
|
||||
IdentitySnapshot map[string]any `json:"identity_snapshot"`
|
||||
BeforeData map[string]any `json:"before_data"`
|
||||
AfterData map[string]any `json:"after_data"`
|
||||
SubjectVisibility string `json:"subject_visibility"`
|
||||
SubjectSummary string `json:"subject_summary"`
|
||||
SubjectData map[string]any `json:"subject_data"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Query 提供平台统一审计事件列表与详情读取。
|
||||
type Query struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// New 创建统一审计事件 Query。
|
||||
func New(db *gorm.DB) *Query {
|
||||
return &Query{db: db}
|
||||
}
|
||||
|
||||
// List 查询平台范围的全局审计事件。
|
||||
func (q *Query) List(ctx context.Context, filter EventFilter) (*EventPage, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validEventFilter(filter) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||||
query := q.applyFilters(q.db.WithContext(ctx).Model(&model.AuditEvent{}), filter)
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计审计事件失败")
|
||||
}
|
||||
rows := make([]model.AuditEvent, 0, filter.PageSize)
|
||||
if err := query.Order("occurred_at DESC, id DESC").
|
||||
Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审计事件失败")
|
||||
}
|
||||
items, err := q.project(ctx, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EventPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
|
||||
}
|
||||
|
||||
func validEventFilter(filter EventFilter) bool {
|
||||
return validOptionalValue(filter.Result, constants.AuditResultSuccess, constants.AuditResultFailed,
|
||||
constants.AuditResultDenied, constants.AuditResultPartial, constants.AuditResultUnknown) &&
|
||||
validOptionalValue(filter.Risk, constants.AuditRiskLow, constants.AuditRiskNormal,
|
||||
constants.AuditRiskHigh, constants.AuditRiskCritical) &&
|
||||
validOptionalValue(filter.Source, constants.AuditSourceAdminAPI, constants.AuditSourcePersonalAPI,
|
||||
constants.AuditSourceOpenAPI, constants.AuditSourceWorker, constants.AuditSourceScheduler, constants.AuditSourceCallback) &&
|
||||
(filter.ActorKind == "" || validActorKind(filter.ActorKind)) &&
|
||||
filter.Page >= 0 && filter.PageSize >= 0 && filter.PageSize <= constants.MaxPageSize
|
||||
}
|
||||
|
||||
func validOptionalValue(value string, allowed ...string) bool {
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if value == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Get 查询平台范围的单个稳定审计事件详情。
|
||||
func (q *Query) Get(ctx context.Context, eventID string) (*EventView, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if eventID == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
var row model.AuditEvent
|
||||
if err := q.db.WithContext(ctx).Where("event_id = ?", eventID).First(&row).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "审计事件不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审计事件详情失败")
|
||||
}
|
||||
items, err := q.project(ctx, []model.AuditEvent{row})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &items[0], nil
|
||||
}
|
||||
|
||||
func (q *Query) authorize(ctx context.Context) error {
|
||||
if q == nil || q.db == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "审计查询能力未配置")
|
||||
}
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *Query) applyFilters(query *gorm.DB, filter EventFilter) *gorm.DB {
|
||||
if filter.CreatedFrom != nil {
|
||||
query = query.Where("occurred_at >= ?", filter.CreatedFrom.UTC())
|
||||
}
|
||||
if filter.CreatedTo != nil {
|
||||
query = query.Where("occurred_at < ?", filter.CreatedTo.UTC())
|
||||
}
|
||||
for column, value := range map[string]string{
|
||||
"action_code": filter.Action, "category": filter.Category,
|
||||
"actor_kind": filter.ActorKind, "actor_id": filter.ActorID,
|
||||
"source": filter.Source, "result": filter.Result, "risk_level": filter.Risk,
|
||||
"scope_type": filter.ScopeType, "scope_id": filter.ScopeID,
|
||||
"request_id": filter.RequestID, "correlation_id": filter.CorrelationID,
|
||||
} {
|
||||
if value != "" {
|
||||
query = query.Where(column+" = ?", value)
|
||||
}
|
||||
}
|
||||
if filter.ResourceType != "" || filter.ResourceID != "" || filter.ResourceKey != "" {
|
||||
resource := q.db.Table("tb_audit_event_resource AS aer").Select("1").
|
||||
Where("aer.audit_event_id = tb_audit_event.id")
|
||||
if filter.ResourceType != "" {
|
||||
resource = resource.Where("aer.resource_type = ?", filter.ResourceType)
|
||||
}
|
||||
if filter.ResourceID != "" {
|
||||
resource = resource.Where("aer.resource_id = ?", filter.ResourceID)
|
||||
}
|
||||
if filter.ResourceKey != "" {
|
||||
resource = resource.Where("aer.resource_key = ?", filter.ResourceKey)
|
||||
}
|
||||
query = query.Where("EXISTS (?)", resource)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func (q *Query) project(ctx context.Context, rows []model.AuditEvent) ([]EventView, error) {
|
||||
items := make([]EventView, len(rows))
|
||||
if len(rows) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
ids := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
var resources []model.AuditEventResource
|
||||
if err := q.db.WithContext(ctx).Where("audit_event_id IN ?", ids).
|
||||
Order("audit_event_id ASC, sort_order ASC, id ASC").Find(&resources).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询审计事件资源失败")
|
||||
}
|
||||
resourcesByEvent := make(map[uint][]ResourceView, len(rows))
|
||||
for _, resource := range resources {
|
||||
view, err := projectResource(resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resourcesByEvent[resource.AuditEventID] = append(resourcesByEvent[resource.AuditEventID], view)
|
||||
}
|
||||
for index, row := range rows {
|
||||
metadata, err := decodeObject(row.Metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items[index] = EventView{
|
||||
EventID: row.EventID, OccurredAt: row.OccurredAt, Category: row.Category,
|
||||
ActionCode: row.ActionCode, ActionName: row.ActionName, Summary: row.Summary,
|
||||
ActorKind: row.ActorKind, ActorID: row.ActorID, ActorName: row.ActorName,
|
||||
ActorShopID: row.ActorShopID, ActorShopName: row.ActorShopName,
|
||||
ActorEnterpriseID: row.ActorEnterpriseID, ActorEnterpriseName: row.ActorEnterpriseName,
|
||||
Source: row.Source, RequestPath: row.RequestPath, RequestMethod: row.RequestMethod,
|
||||
IPAddress: row.IPAddress, UserAgent: row.UserAgent,
|
||||
ScopeType: row.ScopeType, ScopeID: row.ScopeID, ScopeName: row.ScopeName,
|
||||
Result: row.Result, RiskLevel: row.RiskLevel, ErrorCode: row.ErrorCode, ErrorSummary: row.ErrorSummary,
|
||||
RequestID: row.RequestID, CorrelationID: row.CorrelationID, ParentEventID: row.ParentEventID,
|
||||
BatchTotal: row.BatchTotal, SuccessCount: row.SuccessCount, FailCount: row.FailCount,
|
||||
Metadata: metadata, ContentHash: row.ContentHash, CreatedAt: row.CreatedAt,
|
||||
Resources: resourcesByEvent[row.ID],
|
||||
}
|
||||
if items[index].Resources == nil {
|
||||
items[index].Resources = []ResourceView{}
|
||||
}
|
||||
items[index].InvestigationRefs = investigationRefs(row, items[index].Resources)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func investigationRefs(event model.AuditEvent, resources []ResourceView) InvestigationRefs {
|
||||
refs := InvestigationRefs{
|
||||
EventID: stringPointer(event.EventID), ActorRef: investigationActorRef(event.ActorKind, event.ActorID),
|
||||
ResourceRefs: make([]InvestigationResourceRef, 0, len(resources)),
|
||||
RequestID: stringPointer(event.RequestID), CorrelationID: stringPointer(event.CorrelationID),
|
||||
IntegrationRefs: []IntegrationRef{},
|
||||
}
|
||||
for _, resource := range resources {
|
||||
refs.ResourceRefs = append(refs.ResourceRefs, InvestigationResourceRef{
|
||||
ResourceType: resource.ResourceType, ResourceID: resource.ResourceID,
|
||||
ResourceKey: resource.ResourceKey, DisplayName: resource.DisplayName,
|
||||
})
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func investigationActorRef(kind, id string) *ActorRef {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
switch kind {
|
||||
case constants.AuditActorAccount, constants.AuditActorOpenAPI, constants.AuditActorSystemTask,
|
||||
constants.AuditActorScheduledJob, constants.AuditActorExternalSystem:
|
||||
return &ActorRef{Kind: kind, ID: id}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func stringPointer(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func projectResource(row model.AuditEventResource) (ResourceView, error) {
|
||||
identity, err := decodeObject(row.IdentitySnapshot)
|
||||
if err != nil {
|
||||
return ResourceView{}, err
|
||||
}
|
||||
before, err := decodeObject(row.BeforeData)
|
||||
if err != nil {
|
||||
return ResourceView{}, err
|
||||
}
|
||||
after, err := decodeObject(row.AfterData)
|
||||
if err != nil {
|
||||
return ResourceView{}, err
|
||||
}
|
||||
subject, err := decodeObject(row.SubjectData)
|
||||
if err != nil {
|
||||
return ResourceView{}, err
|
||||
}
|
||||
return ResourceView{
|
||||
ResourceType: row.ResourceType, ResourceID: row.ResourceID, ResourceKey: row.ResourceKey,
|
||||
DisplayName: row.DisplayName, Relation: row.Relation, Role: row.Role,
|
||||
IdentitySnapshot: identity, BeforeData: before, AfterData: after,
|
||||
SubjectVisibility: row.SubjectVisibility, SubjectSummary: row.SubjectSummary, SubjectData: subject,
|
||||
SortOrder: row.SortOrder, CreatedAt: row.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeObject(value datatypes.JSON) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
if len(value) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if err := sonic.Unmarshal(value, &result); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "解析审计结构化字段失败")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizePage(page, pageSize int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
if pageSize > constants.MaxPageSize {
|
||||
pageSize = constants.MaxPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
247
internal/query/audit/resources.go
Normal file
247
internal/query/audit/resources.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ResourceSearchFilter 定义注册资源的精确标识搜索。
|
||||
type ResourceSearchFilter struct {
|
||||
ResourceType string
|
||||
Keyword string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// ResourceSearchPage 是资源候选稳定分页结果。
|
||||
type ResourceSearchPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []ResourceCandidate `json:"items"`
|
||||
}
|
||||
|
||||
// ResourceCandidate 是当前业务表或历史事件快照解析出的稳定资源候选。
|
||||
type ResourceCandidate struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IdentitySnapshot map[string]any `json:"identity_snapshot"`
|
||||
Historical bool `json:"historical"`
|
||||
}
|
||||
|
||||
// ResourceTimelineFilter 定义通用资源时间线筛选。
|
||||
type ResourceTimelineFilter struct {
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
Action string
|
||||
Result string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// SearchResources 按 Resource Registry 声明的稳定标识精确搜索资源。
|
||||
func (q *Query) SearchResources(ctx context.Context, filter ResourceSearchFilter) (*ResourceSearchPage, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter.Keyword == "" || !searchableResourceType(filter.ResourceType) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||||
items, total, err := q.searchCurrent(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total == 0 {
|
||||
items, total, err = q.searchHistorical(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &ResourceSearchPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
|
||||
}
|
||||
|
||||
// ResourceTimeline 查询注册资源作为任意关系参与的统一事件时间线。
|
||||
func (q *Query) ResourceTimeline(ctx context.Context, filter ResourceTimelineFilter) (*EventPage, error) {
|
||||
if filter.ResourceID == "" || !timelineResourceType(filter.ResourceType) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
return q.List(ctx, EventFilter{
|
||||
ResourceType: filter.ResourceType, ResourceID: filter.ResourceID,
|
||||
CreatedFrom: filter.CreatedFrom, CreatedTo: filter.CreatedTo,
|
||||
Action: filter.Action, Result: filter.Result,
|
||||
Page: filter.Page, PageSize: filter.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func timelineResourceType(resourceType string) bool {
|
||||
switch resourceType {
|
||||
case constants.AuditResourceAccount, constants.AuditResourceShop, constants.AuditResourceEnterprise,
|
||||
constants.AuditResourceIotCard, constants.AuditResourceDevice, constants.AuditResourceDeviceSIMBinding,
|
||||
constants.AuditResourceAssetAllocationRecord, constants.AuditResourceExchangeOrder, constants.AuditResourceOrder,
|
||||
constants.AuditResourceRefund, constants.AuditResourceAgentRecharge, constants.AuditResourceAssetWallet,
|
||||
constants.AuditResourceApprovalInstance:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Query) searchCurrent(ctx context.Context, filter ResourceSearchFilter) ([]ResourceCandidate, int64, error) {
|
||||
switch filter.ResourceType {
|
||||
case constants.AuditResourceIotCard:
|
||||
var rows []model.IotCard
|
||||
query := q.db.WithContext(ctx).Where("iccid = ? OR virtual_no = ? OR iccid_19 = ? OR iccid_20 = ?", filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword)
|
||||
return searchModels(query, filter, &rows, func(row model.IotCard) ResourceCandidate {
|
||||
return candidate(filter.ResourceType, row.ID, row.ICCID, row.ICCID, map[string]any{
|
||||
"id": row.ID, "iccid": row.ICCID, "virtual_no": row.VirtualNo, "msisdn": row.MSISDN,
|
||||
"carrier_type": row.CarrierType, "shop_id": row.ShopID, "series_id": row.SeriesID, "generation": row.Generation,
|
||||
})
|
||||
})
|
||||
case constants.AuditResourceDevice:
|
||||
var rows []model.Device
|
||||
query := q.db.WithContext(ctx).Where("virtual_no = ? OR imei = ? OR sn = ?", filter.Keyword, filter.Keyword, filter.Keyword)
|
||||
return searchModels(query, filter, &rows, func(row model.Device) ResourceCandidate {
|
||||
return candidate(filter.ResourceType, row.ID, deviceCandidateKey(row), deviceCandidateKey(row), map[string]any{
|
||||
"id": row.ID, "virtual_no": row.VirtualNo, "imei": row.IMEI, "sn": row.SN,
|
||||
"device_name": row.DeviceName, "device_model": row.DeviceModel, "shop_id": row.ShopID,
|
||||
"series_id": row.SeriesID, "generation": row.Generation,
|
||||
})
|
||||
})
|
||||
case constants.AuditResourceShop:
|
||||
var rows []model.Shop
|
||||
return searchModels(q.db.WithContext(ctx).Where("shop_code = ?", filter.Keyword), filter, &rows, func(row model.Shop) ResourceCandidate {
|
||||
return candidate(filter.ResourceType, row.ID, row.ShopCode, row.ShopName, map[string]any{
|
||||
"id": row.ID, "shop_code": row.ShopCode, "shop_name": row.ShopName, "parent_id": row.ParentID, "level": row.Level,
|
||||
})
|
||||
})
|
||||
case constants.AuditResourceOrder:
|
||||
var rows []model.Order
|
||||
return searchModels(q.db.WithContext(ctx).Where("order_no = ?", filter.Keyword), filter, &rows, func(row model.Order) ResourceCandidate {
|
||||
return candidate(filter.ResourceType, row.ID, row.OrderNo, row.OrderNo, map[string]any{
|
||||
"id": row.ID, "order_no": row.OrderNo, "buyer_type": row.BuyerType, "buyer_id": row.BuyerID,
|
||||
"asset_identifier": row.AssetIdentifier, "total_amount": row.TotalAmount,
|
||||
"payment_method": row.PaymentMethod, "payment_status": row.PaymentStatus,
|
||||
})
|
||||
})
|
||||
case constants.AuditResourceRefund:
|
||||
var rows []model.RefundRequest
|
||||
return searchModels(q.db.WithContext(ctx).Where("refund_no = ?", filter.Keyword), filter, &rows, func(row model.RefundRequest) ResourceCandidate {
|
||||
return candidate(filter.ResourceType, row.ID, row.RefundNo, row.RefundNo, map[string]any{
|
||||
"id": row.ID, "refund_no": row.RefundNo, "order_id": row.OrderID, "order_no": row.OrderNo,
|
||||
"asset_identifier": row.AssetIdentifier, "shop_id": row.ShopID,
|
||||
"requested_refund_amount": row.RequestedRefundAmount, "status": row.Status,
|
||||
})
|
||||
})
|
||||
default:
|
||||
return nil, 0, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
}
|
||||
|
||||
func searchModels[T any](query *gorm.DB, filter ResourceSearchFilter, rows *[]T, project func(T) ResourceCandidate) ([]ResourceCandidate, int64, error) {
|
||||
var total int64
|
||||
if err := query.Model(new(T)).Count(&total).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计资源候选失败")
|
||||
}
|
||||
if err := query.Order("id ASC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(rows).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资源候选失败")
|
||||
}
|
||||
items := make([]ResourceCandidate, 0, len(*rows))
|
||||
for _, row := range *rows {
|
||||
items = append(items, project(row))
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (q *Query) searchHistorical(ctx context.Context, filter ResourceSearchFilter) ([]ResourceCandidate, int64, error) {
|
||||
base := q.historicalIdentifierQuery(ctx, filter)
|
||||
var total int64
|
||||
if err := base.Distinct("resource_id").Count(&total).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计历史资源候选失败")
|
||||
}
|
||||
latest := q.historicalIdentifierQuery(ctx, filter).
|
||||
Select("DISTINCT ON (resource_id) resource_id, resource_key, display_name, identity_snapshot, created_at, id").
|
||||
Order("resource_id ASC, created_at DESC, id DESC")
|
||||
var rows []historicalResourceRow
|
||||
if err := q.db.WithContext(ctx).Table("(?) AS historical", latest).
|
||||
Order("resource_id ASC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询历史资源候选失败")
|
||||
}
|
||||
items := make([]ResourceCandidate, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
identity, err := decodeObject(row.IdentitySnapshot)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, ResourceCandidate{
|
||||
ResourceType: filter.ResourceType, ResourceID: row.ResourceID,
|
||||
ResourceKey: row.ResourceKey, DisplayName: row.DisplayName,
|
||||
IdentitySnapshot: identity, Historical: true,
|
||||
})
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (q *Query) historicalIdentifierQuery(ctx context.Context, filter ResourceSearchFilter) *gorm.DB {
|
||||
query := q.db.WithContext(ctx).Model(&model.AuditEventResource{}).
|
||||
Where("resource_type = ? AND resource_id IS NOT NULL", filter.ResourceType)
|
||||
switch filter.ResourceType {
|
||||
case constants.AuditResourceIotCard:
|
||||
return query.Where("resource_key = ? OR identity_snapshot ->> 'iccid' = ? OR identity_snapshot ->> 'iccid_19' = ? OR identity_snapshot ->> 'iccid_20' = ? OR identity_snapshot ->> 'virtual_no' = ?", filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword)
|
||||
case constants.AuditResourceDevice:
|
||||
return query.Where("resource_key = ? OR identity_snapshot ->> 'virtual_no' = ? OR identity_snapshot ->> 'imei' = ? OR identity_snapshot ->> 'sn' = ?", filter.Keyword, filter.Keyword, filter.Keyword, filter.Keyword)
|
||||
case constants.AuditResourceShop:
|
||||
return query.Where("resource_key = ? OR identity_snapshot ->> 'shop_code' = ?", filter.Keyword, filter.Keyword)
|
||||
case constants.AuditResourceOrder:
|
||||
return query.Where("resource_key = ? OR identity_snapshot ->> 'order_no' = ?", filter.Keyword, filter.Keyword)
|
||||
case constants.AuditResourceRefund:
|
||||
return query.Where("resource_key = ? OR identity_snapshot ->> 'refund_no' = ?", filter.Keyword, filter.Keyword)
|
||||
default:
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
}
|
||||
|
||||
type historicalResourceRow struct {
|
||||
ResourceID string
|
||||
ResourceKey string
|
||||
DisplayName string
|
||||
IdentitySnapshot datatypes.JSON
|
||||
}
|
||||
|
||||
func candidate(resourceType string, id uint, key, name string, identity map[string]any) ResourceCandidate {
|
||||
return ResourceCandidate{
|
||||
ResourceType: resourceType, ResourceID: strconv.FormatUint(uint64(id), 10),
|
||||
ResourceKey: key, DisplayName: name, IdentitySnapshot: identity,
|
||||
}
|
||||
}
|
||||
|
||||
func searchableResourceType(resourceType string) bool {
|
||||
switch resourceType {
|
||||
case constants.AuditResourceIotCard, constants.AuditResourceDevice, constants.AuditResourceShop,
|
||||
constants.AuditResourceOrder, constants.AuditResourceRefund:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func deviceCandidateKey(row model.Device) string {
|
||||
for _, value := range []string{row.VirtualNo, row.IMEI, row.SN} {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return strconv.FormatUint(uint64(row.ID), 10)
|
||||
}
|
||||
395
internal/query/audit/subject_activities.go
Normal file
395
internal/query/audit/subject_activities.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
target, err := q.resolveAgentTarget(ctx, filter.ResourceType, filter.Identifier, shopIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.subjectActivitiesForTarget(ctx, filter, target, 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)
|
||||
target, err := q.resolveEnterpriseTarget(ctx, filter.ResourceType, filter.Identifier, enterpriseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.subjectActivitiesForTarget(ctx, filter, target, 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, 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("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}, 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
|
||||
}
|
||||
389
internal/query/integration/logs.go
Normal file
389
internal/query/integration/logs.go
Normal file
@@ -0,0 +1,389 @@
|
||||
// Package integration 提供 Integration Log 只读调查投影。
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
|
||||
pkgvalidator "github.com/break/junhong_cmp_fiber/pkg/validator"
|
||||
)
|
||||
|
||||
// ListFilter 定义 Integration Log 组合筛选。
|
||||
type ListFilter struct {
|
||||
CreatedFrom, CreatedTo *time.Time
|
||||
IntegrationID, Provider, Direction string
|
||||
Operation, Result, ResultCategory string
|
||||
ExternalID, ResourceType, ResourceID string
|
||||
ResourceKey, TriggerSource, TriggerScene string
|
||||
TriggerSeries, ProviderCode string
|
||||
RequestID, CorrelationID string
|
||||
StateChanged *bool
|
||||
HTTPStatus *int
|
||||
Page, PageSize int
|
||||
}
|
||||
|
||||
// ListPage 是按创建时间和主键稳定倒序的分页结果。
|
||||
type ListPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []ListItem `json:"items"`
|
||||
}
|
||||
|
||||
// ListItem 是 Integration Log 列表投影。
|
||||
type ListItem struct {
|
||||
IntegrationID string `json:"integration_id"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
Direction string `json:"direction"`
|
||||
DirectionName string `json:"direction_name"`
|
||||
Operation string `json:"operation"`
|
||||
OperationName string `json:"operation_name"`
|
||||
Resource ResourceView `json:"resource"`
|
||||
Result string `json:"result"`
|
||||
ResultName string `json:"result_name"`
|
||||
ResultCategory string `json:"result_category"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
StateChanged bool `json:"state_changed"`
|
||||
RequestID *string `json:"request_id"`
|
||||
CorrelationID *string `json:"correlation_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ResourceView 是外部交互直接主资源投影。
|
||||
type ResourceView struct {
|
||||
Type *string `json:"type"`
|
||||
ID *string `json:"id"`
|
||||
Key *string `json:"key"`
|
||||
}
|
||||
|
||||
// Detail 是按稳定 integration_id 返回的结构化详情。
|
||||
type Detail struct {
|
||||
Identity IdentityView `json:"identity"`
|
||||
Resource ResourceView `json:"resource"`
|
||||
Trigger TriggerView `json:"trigger"`
|
||||
Result ResultView `json:"result"`
|
||||
Content ContentView `json:"content"`
|
||||
Linkage LinkageView `json:"linkage"`
|
||||
Timestamps TimestampView `json:"timestamps"`
|
||||
Attempts []AttemptView `json:"attempts"`
|
||||
Fidelity FidelityView `json:"fidelity"`
|
||||
}
|
||||
|
||||
// AttemptView 是显式 trigger_series 下的单次技术尝试。
|
||||
type AttemptView struct {
|
||||
IntegrationID string `json:"integration_id"`
|
||||
Attempt int `json:"attempt"`
|
||||
Sent bool `json:"sent"`
|
||||
Result string `json:"result"`
|
||||
ResultName string `json:"result_name"`
|
||||
ResultCategory string `json:"result_category"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
StateChanged bool `json:"state_changed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// FidelityView 明确历史记录可关联能力,不推断缺失字段。
|
||||
type FidelityView struct {
|
||||
TriggerSeriesAvailable bool `json:"trigger_series_available"`
|
||||
CorrelationAvailable bool `json:"correlation_available"`
|
||||
ResourceIDAvailable bool `json:"resource_id_available"`
|
||||
ProviderMessageFidelity string `json:"provider_message_fidelity"`
|
||||
}
|
||||
|
||||
// IdentityView 是外部交互身份分组。
|
||||
type IdentityView struct {
|
||||
IntegrationID string `json:"integration_id"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
Direction string `json:"direction"`
|
||||
DirectionName string `json:"direction_name"`
|
||||
Operation string `json:"operation"`
|
||||
OperationName string `json:"operation_name"`
|
||||
ExternalID *string `json:"external_id"`
|
||||
}
|
||||
|
||||
// TriggerView 是外部交互触发分组。
|
||||
type TriggerView struct {
|
||||
Source *string `json:"source"`
|
||||
Scene *string `json:"scene"`
|
||||
Series *string `json:"series"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
// ResultView 是外部交互结果分组。
|
||||
type ResultView struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
HTTPStatus *int `json:"http_status"`
|
||||
ProviderCode *string `json:"provider_code"`
|
||||
ProviderMessage *string `json:"provider_message"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
StateChanged bool `json:"state_changed"`
|
||||
RecoveryStrategy *string `json:"recovery_strategy"`
|
||||
}
|
||||
|
||||
// ContentView 是已持久化安全摘要分组。
|
||||
type ContentView struct {
|
||||
RequestSummary map[string]any `json:"request_summary"`
|
||||
ResponseSummary map[string]any `json:"response_summary"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
}
|
||||
|
||||
// LinkageView 是外部交互关联分组。
|
||||
type LinkageView struct {
|
||||
RequestID *string `json:"request_id"`
|
||||
CorrelationID *string `json:"correlation_id"`
|
||||
AuditEventID *uint `json:"audit_event_id"`
|
||||
}
|
||||
|
||||
// TimestampView 是外部交互时间分组。
|
||||
type TimestampView struct {
|
||||
ScheduledAt *time.Time `json:"scheduled_at"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Query 提供平台 Integration Log 列表和详情读取。
|
||||
type Query struct{ db *gorm.DB }
|
||||
|
||||
// New 创建 Integration Log 调查 Query。
|
||||
func New(db *gorm.DB) *Query { return &Query{db: db} }
|
||||
|
||||
// List 查询受时间范围约束的 Integration Log 列表。
|
||||
func (q *Query) List(ctx context.Context, filter ListFilter) (*ListPage, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validFilter(filter) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||||
query := applyFilters(q.db.WithContext(ctx).Model(&model.IntegrationLog{}), filter)
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计外部交互日志失败")
|
||||
}
|
||||
rows := make([]model.IntegrationLog, 0, filter.PageSize)
|
||||
if err := query.Order("created_at DESC, id DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互日志失败")
|
||||
}
|
||||
items := make([]ListItem, len(rows))
|
||||
for i, row := range rows {
|
||||
items[i] = projectListItem(row)
|
||||
}
|
||||
return &ListPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
|
||||
}
|
||||
|
||||
// Get 使用稳定 integration_id 查询结构化详情。
|
||||
func (q *Query) Get(ctx context.Context, integrationID string) (*Detail, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if integrationID == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
var row model.IntegrationLog
|
||||
if err := q.db.WithContext(ctx).Where("integration_id = ?", integrationID).First(&row).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "外部交互日志不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互日志详情失败")
|
||||
}
|
||||
requestSummary, err := decodeObject(row.RequestSummary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responseSummary, err := decodeObject(row.ResponseSummary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata, err := decodeObject(row.Metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attempts, err := q.loadAttempts(ctx, row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
providerMessage, providerMessageFidelity := safeProviderMessage(row.ProviderMessage)
|
||||
return &Detail{
|
||||
Identity: IdentityView{IntegrationID: row.IntegrationID, Provider: row.Provider, ProviderName: constants.IntegrationProviderName(row.Provider), Direction: row.Direction, DirectionName: constants.IntegrationDirectionName(row.Direction), Operation: row.Operation, OperationName: constants.IntegrationOperationName(row.Operation), ExternalID: row.ExternalID},
|
||||
Resource: resourceView(row), Trigger: TriggerView{Source: row.TriggerSource, Scene: row.TriggerScene, Series: row.TriggerSeries, Attempt: row.Attempt},
|
||||
Result: ResultView{Code: row.Result, Name: constants.IntegrationResultName(row.Result), Category: constants.IntegrationResultCategory(row.Result), HTTPStatus: row.HTTPStatus, ProviderCode: row.ProviderCode, ProviderMessage: providerMessage, DurationMS: row.DurationMS, StateChanged: row.StateChanged, RecoveryStrategy: row.RecoveryStrategy},
|
||||
Content: ContentView{RequestSummary: requestSummary, ResponseSummary: responseSummary, Metadata: metadata, ContentHash: row.ContentHash},
|
||||
Linkage: LinkageView{RequestID: row.RequestID, CorrelationID: row.CorrelationID, AuditEventID: row.AuditEventID},
|
||||
Timestamps: TimestampView{ScheduledAt: row.ScheduledAt, StartedAt: row.StartedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt},
|
||||
Attempts: attempts,
|
||||
Fidelity: FidelityView{
|
||||
TriggerSeriesAvailable: row.TriggerSeries != nil && *row.TriggerSeries != "",
|
||||
CorrelationAvailable: row.CorrelationID != nil && *row.CorrelationID != "",
|
||||
ResourceIDAvailable: row.ResourceID != nil && *row.ResourceID != "",
|
||||
ProviderMessageFidelity: providerMessageFidelity,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *Query) loadAttempts(ctx context.Context, current model.IntegrationLog) ([]AttemptView, error) {
|
||||
rows := []model.IntegrationLog{current}
|
||||
if current.TriggerSeries != nil && *current.TriggerSeries != "" {
|
||||
if err := q.db.WithContext(ctx).Where("trigger_series = ?", *current.TriggerSeries).
|
||||
Order("attempt ASC, created_at ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互尝试序列失败")
|
||||
}
|
||||
}
|
||||
items := make([]AttemptView, len(rows))
|
||||
for index, row := range rows {
|
||||
category := constants.IntegrationResultCategory(row.Result)
|
||||
items[index] = AttemptView{
|
||||
IntegrationID: row.IntegrationID, Attempt: row.Attempt,
|
||||
Sent: category != constants.IntegrationResultCategoryNotSent,
|
||||
Result: row.Result, ResultName: constants.IntegrationResultName(row.Result), ResultCategory: category,
|
||||
DurationMS: row.DurationMS, StateChanged: row.StateChanged, CreatedAt: row.CreatedAt,
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (q *Query) authorize(ctx context.Context) error {
|
||||
if q == nil || q.db == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "外部交互调查能力未配置")
|
||||
}
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validFilter(filter ListFilter) bool {
|
||||
if filter.CreatedFrom == nil || filter.CreatedTo == nil || !filter.CreatedFrom.Before(*filter.CreatedTo) || filter.CreatedTo.Sub(*filter.CreatedFrom) > constants.IntegrationQueryMaxRange {
|
||||
return false
|
||||
}
|
||||
if filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
|
||||
return false
|
||||
}
|
||||
if filter.Direction != "" && filter.Direction != constants.IntegrationDirectionInbound && filter.Direction != constants.IntegrationDirectionOutbound {
|
||||
return false
|
||||
}
|
||||
if filter.Result != "" && constants.IntegrationResultName(filter.Result) == "" {
|
||||
return false
|
||||
}
|
||||
if filter.ResultCategory != "" && len(categoryResults(filter.ResultCategory)) == 0 {
|
||||
return false
|
||||
}
|
||||
return filter.HTTPStatus == nil || (*filter.HTTPStatus >= 100 && *filter.HTTPStatus <= 599)
|
||||
}
|
||||
|
||||
func applyFilters(query *gorm.DB, filter ListFilter) *gorm.DB {
|
||||
query = query.Where("created_at >= ? AND created_at < ?", filter.CreatedFrom.UTC(), filter.CreatedTo.UTC())
|
||||
for column, value := range map[string]string{
|
||||
"integration_id": filter.IntegrationID, "provider": filter.Provider, "direction": filter.Direction,
|
||||
"operation": filter.Operation, "result": filter.Result, "external_id": filter.ExternalID,
|
||||
"resource_type": filter.ResourceType, "resource_id": filter.ResourceID,
|
||||
"trigger_source": filter.TriggerSource, "trigger_scene": filter.TriggerScene, "trigger_series": filter.TriggerSeries,
|
||||
"provider_code": filter.ProviderCode, "request_id": filter.RequestID, "correlation_id": filter.CorrelationID,
|
||||
} {
|
||||
if value != "" {
|
||||
query = query.Where(column+" = ?", value)
|
||||
}
|
||||
}
|
||||
if filter.ResourceKey != "" {
|
||||
query = query.Where("resource_key IN ?", compatibleResourceKeys(filter.ResourceType, filter.ResourceKey))
|
||||
}
|
||||
if filter.ResultCategory != "" {
|
||||
query = query.Where("result IN ?", categoryResults(filter.ResultCategory))
|
||||
}
|
||||
if filter.StateChanged != nil {
|
||||
query = query.Where("state_changed = ?", *filter.StateChanged)
|
||||
}
|
||||
if filter.HTTPStatus != nil {
|
||||
query = query.Where("http_status = ?", *filter.HTTPStatus)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func compatibleResourceKeys(resourceType, resourceKey string) []string {
|
||||
keys := []string{resourceKey}
|
||||
if resourceType != constants.AssetTypeIotCard || !pkgvalidator.ValidateICCIDWithoutCarrier(resourceKey).Valid {
|
||||
return keys
|
||||
}
|
||||
sum := sha256.Sum256([]byte(resourceKey))
|
||||
return append(keys, "iccid-sha256:"+hex.EncodeToString(sum[:])[:32])
|
||||
}
|
||||
|
||||
func categoryResults(category string) []string {
|
||||
switch category {
|
||||
case constants.IntegrationResultCategoryProcessing:
|
||||
return []string{constants.IntegrationResultPending}
|
||||
case constants.IntegrationResultCategorySucceeded:
|
||||
return []string{constants.IntegrationResultSuccess}
|
||||
case constants.IntegrationResultCategoryIndeterminate:
|
||||
return []string{constants.IntegrationResultUnknown}
|
||||
case constants.IntegrationResultCategoryFailed:
|
||||
return []string{constants.IntegrationResultFailed, constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultConflict}
|
||||
case constants.IntegrationResultCategoryNotSent:
|
||||
return []string{constants.IntegrationResultIgnored, constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted, constants.IntegrationResultCancelled}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func projectListItem(row model.IntegrationLog) ListItem {
|
||||
return ListItem{IntegrationID: row.IntegrationID, Provider: row.Provider, ProviderName: constants.IntegrationProviderName(row.Provider), Direction: row.Direction, DirectionName: constants.IntegrationDirectionName(row.Direction), Operation: row.Operation, OperationName: constants.IntegrationOperationName(row.Operation), Resource: resourceView(row), Result: row.Result, ResultName: constants.IntegrationResultName(row.Result), ResultCategory: constants.IntegrationResultCategory(row.Result), DurationMS: row.DurationMS, StateChanged: row.StateChanged, RequestID: row.RequestID, CorrelationID: row.CorrelationID, CreatedAt: row.CreatedAt}
|
||||
}
|
||||
|
||||
func resourceView(row model.IntegrationLog) ResourceView {
|
||||
return ResourceView{Type: row.ResourceType, ID: row.ResourceID, Key: row.ResourceKey}
|
||||
}
|
||||
|
||||
func decodeObject(value datatypes.JSON) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
if len(value) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if err := sonic.Unmarshal(value, &result); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "解析外部交互结构化摘要失败")
|
||||
}
|
||||
sanitizer.RemoveForbiddenFields(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func safeProviderMessage(value *string) (*string, string) {
|
||||
if value == nil || *value == "" {
|
||||
return nil, "missing"
|
||||
}
|
||||
if len(*value) >= len("外部文本摘要") && (*value)[:len("外部文本摘要")] == "外部文本摘要" {
|
||||
return value, "historical_summary"
|
||||
}
|
||||
if len(*value) >= len(constants.IntegrationSafeMessagePrefix) && (*value)[:len(constants.IntegrationSafeMessagePrefix)] == constants.IntegrationSafeMessagePrefix {
|
||||
message := (*value)[len(constants.IntegrationSafeMessagePrefix):]
|
||||
return &message, "readable"
|
||||
}
|
||||
summary := sanitizer.TextSummary(*value)
|
||||
return &summary, "historical_redacted"
|
||||
}
|
||||
|
||||
func normalizePage(page, pageSize int) (int, int) {
|
||||
if page < 1 {
|
||||
page = constants.DefaultPage
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
171
internal/query/integration/overview.go
Normal file
171
internal/query/integration/overview.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// OverviewFilter 定义外部交互总览的受控筛选和时间粒度。
|
||||
type OverviewFilter struct {
|
||||
ListFilter
|
||||
Bucket string
|
||||
}
|
||||
|
||||
// Overview 是外部交互固定维度聚合结果。
|
||||
type Overview struct {
|
||||
Total int64 `json:"total"`
|
||||
AnomalyCount int64 `json:"anomaly_count"`
|
||||
UnknownCount int64 `json:"unknown_count"`
|
||||
StalePendingCount int64 `json:"stale_pending_count"`
|
||||
StateChangedCount int64 `json:"state_changed_count"`
|
||||
AverageDurationMS float64 `json:"average_duration_ms"`
|
||||
P95DurationMS float64 `json:"p95_duration_ms"`
|
||||
Results []ResultCount `json:"results"`
|
||||
Providers []NamedCount `json:"providers"`
|
||||
Directions []NamedCount `json:"directions"`
|
||||
Trend []TrendPoint `json:"trend"`
|
||||
}
|
||||
|
||||
// ResultCount 是原始结果及其派生类别计数。
|
||||
type ResultCount struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// NamedCount 是稳定编码、中文名称和数量。
|
||||
type NamedCount struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// TrendPoint 是固定时间桶内的结果类别趋势。
|
||||
type TrendPoint struct {
|
||||
BucketAt time.Time `json:"bucket_at"`
|
||||
Total int64 `json:"total"`
|
||||
Succeeded int64 `json:"succeeded"`
|
||||
Processing int64 `json:"processing"`
|
||||
Indeterminate int64 `json:"indeterminate"`
|
||||
Failed int64 `json:"failed"`
|
||||
NotSent int64 `json:"not_sent"`
|
||||
}
|
||||
|
||||
// Overview 查询指定时间范围的固定维度外部交互总览。
|
||||
func (q *Query) Overview(ctx context.Context, filter OverviewFilter) (*Overview, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter.Bucket == "" {
|
||||
filter.Bucket = "hour"
|
||||
}
|
||||
if !validFilter(filter.ListFilter) || (filter.Bucket != "hour" && filter.Bucket != "day") {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
base := applyFilters(q.db.WithContext(ctx).Model(&model.IntegrationLog{}), filter.ListFilter)
|
||||
result := &Overview{Results: []ResultCount{}, Providers: []NamedCount{}, Directions: []NamedCount{}, Trend: []TrendPoint{}}
|
||||
if err := loadOverviewMetrics(base, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadResultCounts(base, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadNamedCounts(base, "provider", result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadNamedCounts(base, "direction", result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadTrend(base, filter.Bucket, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadOverviewMetrics(query *gorm.DB, result *Overview) error {
|
||||
failed := categoryResults(constants.IntegrationResultCategoryFailed)
|
||||
var row struct {
|
||||
Total, AnomalyCount, UnknownCount, StalePendingCount, StateChangedCount int64
|
||||
AverageDurationMS, P95DurationMS float64
|
||||
}
|
||||
err := query.Select(`COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE result IN ? OR result = ?) AS anomaly_count,
|
||||
COUNT(*) FILTER (WHERE result = ?) AS unknown_count,
|
||||
COUNT(*) FILTER (WHERE result = ? AND created_at < ?) AS stale_pending_count,
|
||||
COUNT(*) FILTER (WHERE state_changed) AS state_changed_count,
|
||||
COALESCE(AVG(duration_ms), 0)::float8 AS average_duration_ms,
|
||||
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms), 0)::float8 AS p95_duration_ms`,
|
||||
failed, constants.IntegrationResultUnknown, constants.IntegrationResultUnknown,
|
||||
constants.IntegrationResultPending, time.Now().UTC().Add(-constants.IntegrationPendingStaleAfter)).Scan(&row).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互总览失败")
|
||||
}
|
||||
result.Total, result.AnomalyCount, result.UnknownCount = row.Total, row.AnomalyCount, row.UnknownCount
|
||||
result.StalePendingCount, result.StateChangedCount = row.StalePendingCount, row.StateChangedCount
|
||||
result.AverageDurationMS, result.P95DurationMS = row.AverageDurationMS, row.P95DurationMS
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadResultCounts(query *gorm.DB, result *Overview) error {
|
||||
var rows []struct {
|
||||
Code string
|
||||
Count int64
|
||||
}
|
||||
if err := query.Select("result AS code, COUNT(*) AS count").Group("result").Order("result ASC").Scan(&rows).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互结果分布失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
result.Results = append(result.Results, ResultCount{Code: row.Code, Name: constants.IntegrationResultName(row.Code), Category: constants.IntegrationResultCategory(row.Code), Count: row.Count})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadNamedCounts(query *gorm.DB, column string, result *Overview) error {
|
||||
var rows []struct {
|
||||
Code string
|
||||
Count int64
|
||||
}
|
||||
if err := query.Select(column + " AS code, COUNT(*) AS count").Group(column).Order(column + " ASC").Scan(&rows).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互维度分布失败")
|
||||
}
|
||||
items := make([]NamedCount, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
name := constants.IntegrationProviderName(row.Code)
|
||||
if column == "direction" {
|
||||
name = constants.IntegrationDirectionName(row.Code)
|
||||
}
|
||||
items = append(items, NamedCount{Code: row.Code, Name: name, Count: row.Count})
|
||||
}
|
||||
if column == "provider" {
|
||||
result.Providers = items
|
||||
} else {
|
||||
result.Directions = items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadTrend(query *gorm.DB, bucket string, result *Overview) error {
|
||||
failed, notSent := categoryResults(constants.IntegrationResultCategoryFailed), categoryResults(constants.IntegrationResultCategoryNotSent)
|
||||
return wrapTrendError(query.Select(`date_trunc(?, created_at) AS bucket_at, COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE result = ?) AS succeeded,
|
||||
COUNT(*) FILTER (WHERE result = ?) AS processing,
|
||||
COUNT(*) FILTER (WHERE result = ?) AS indeterminate,
|
||||
COUNT(*) FILTER (WHERE result IN ?) AS failed,
|
||||
COUNT(*) FILTER (WHERE result IN ?) AS not_sent`, bucket, constants.IntegrationResultSuccess,
|
||||
constants.IntegrationResultPending, constants.IntegrationResultUnknown, failed, notSent).
|
||||
Group("bucket_at").Order("bucket_at ASC").Scan(&result.Trend).Error)
|
||||
}
|
||||
|
||||
func wrapTrendError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "聚合外部交互趋势失败")
|
||||
}
|
||||
Reference in New Issue
Block a user