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" ) // ResourceSearchFilter 定义注册资源的精确标识搜索。 type ResourceSearchFilter struct { ResourceType string Keyword string OnlineFrom time.Time 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"` Retention retentionquery.Info `json:"retention"` } // 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) } retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit) if err != nil { return nil, err } filter.OnlineFrom = retention.OnlineFrom 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, Retention: retention}, 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 AND created_at >= ?", filter.ResourceType, filter.OnlineFrom.UTC()) 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) }