Files
junhong_cmp_fiber/internal/query/audit/resources.go
break b3499adfca 固化七月迭代审计治理进展以隔离线上热修
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 参数类型错误未通过。
2026-08-03 09:47:22 +08:00

248 lines
10 KiB
Go

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)
}