固化七月迭代审计治理进展以隔离线上热修

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:
2026-08-03 09:47:22 +08:00
parent cf2ff0ac1c
commit b3499adfca
114 changed files with 16961 additions and 2782 deletions

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

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

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

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

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

View 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, "聚合外部交互趋势失败")
}