固化七月迭代审计治理进展以隔离线上热修
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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user