This commit is contained in:
@@ -41,6 +41,7 @@ type RetentionResult struct {
|
||||
EventCount int64
|
||||
ResourceCount int64
|
||||
IntegrationCount int64
|
||||
EstimatedBatches int64
|
||||
ManifestKeys []string
|
||||
Duration time.Duration
|
||||
}
|
||||
@@ -56,6 +57,36 @@ func (s *Service) CleanupPreviousMonth(ctx context.Context) (RetentionResult, er
|
||||
return s.CleanupMonth(ctx, now.AddDate(0, -1, 0))
|
||||
}
|
||||
|
||||
// ValidatePreviousMonth 只读校验上一个完整自然月的归档与清理门禁。
|
||||
func (s *Service) ValidatePreviousMonth(ctx context.Context) (RetentionResult, error) {
|
||||
now := time.Now().In(s.location)
|
||||
return s.ValidateMonth(ctx, now.AddDate(0, -1, 0))
|
||||
}
|
||||
|
||||
// ValidateMonth 只读校验指定完整自然月,不写清理断点且不删除在线数据。
|
||||
func (s *Service) ValidateMonth(ctx context.Context, month time.Time) (result RetentionResult, err error) {
|
||||
if s.db == nil || s.store == nil {
|
||||
return result, fmt.Errorf("日志留存演练数据库或对象存储未配置")
|
||||
}
|
||||
start, end, err := s.retentionMonthRange(month)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
startedAt := time.Now()
|
||||
result.Month = start.Format("2006-01")
|
||||
runs, err := s.loadRetentionRuns(ctx, start, end)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
|
||||
return result, err
|
||||
}
|
||||
summarizeRetentionRuns(runs, &result)
|
||||
result.EstimatedBatches = estimatedRetentionBatches(result)
|
||||
result.Duration = time.Since(startedAt)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CleanupMonth 校验归档硬门禁后按固定顺序物理清理指定完整自然月。
|
||||
func (s *Service) CleanupMonth(ctx context.Context, month time.Time) (result RetentionResult, cleanupErr error) {
|
||||
if s.db == nil || s.store == nil || s.audit == nil {
|
||||
@@ -402,6 +433,13 @@ func summarizeRetentionRuns(runs retentionRuns, result *RetentionResult) {
|
||||
}
|
||||
}
|
||||
|
||||
func estimatedRetentionBatches(result RetentionResult) int64 {
|
||||
batchSize := int64(constants.AuditRetentionDeleteBatchSize)
|
||||
return (result.EventCount+batchSize-1)/batchSize +
|
||||
(result.ResourceCount+batchSize-1)/batchSize +
|
||||
(result.IntegrationCount+batchSize-1)/batchSize
|
||||
}
|
||||
|
||||
func (s *Service) cleanupAuditMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
|
||||
if allRunsCleaned(runs) {
|
||||
return nil
|
||||
|
||||
@@ -263,12 +263,13 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
Asset: func() *admin.AssetHandler {
|
||||
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
|
||||
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(
|
||||
deps.DB,
|
||||
deviceStore,
|
||||
deviceSimBindingStore,
|
||||
svc.IotCard,
|
||||
pollingQueueMgr,
|
||||
deps.Logger,
|
||||
svc.AssetAudit,
|
||||
svc.AccessAudit,
|
||||
)
|
||||
h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc, assetQuery.NewExchangeTraceQuery(deps.DB, deps.Logger))
|
||||
h.SetLifecycleService(svc.AssetLifecycle)
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
|
||||
accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit"
|
||||
agentOpenAPISvc "github.com/break/junhong_cmp_fiber/internal/service/agent_open_api"
|
||||
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
@@ -80,7 +79,6 @@ type services struct {
|
||||
AccessAudit *auditInfra.Writer
|
||||
Approval *approvalApp.CreationService
|
||||
Account *accountSvc.Service
|
||||
AccountAudit *accountAuditSvc.Service
|
||||
AssetAudit *assetAuditSvc.Service
|
||||
Role *roleSvc.Service
|
||||
Permission *permissionSvc.Service
|
||||
@@ -147,11 +145,10 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
customerBinding := customerBindingSvc.New(deps.DB, s.IotCard, s.Device)
|
||||
|
||||
purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopPackageAllocation)
|
||||
accountAudit := accountAuditSvc.NewService(s.AccountOperationLog)
|
||||
assetAudit := assetAuditSvc.NewService(s.AssetOperationLog, deps.DB)
|
||||
auditWriter := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
|
||||
customerBinding.SetAccessAudit(auditWriter)
|
||||
account := accountSvc.New(s.Account, s.Role, s.AccountRole, s.ShopRole, s.Shop, s.Enterprise, accountAudit)
|
||||
account := accountSvc.New(s.Account, s.Role, s.AccountRole, s.ShopRole, s.Shop, s.Enterprise)
|
||||
account.SetLifecycleAudit(deps.DB, auditWriter)
|
||||
account.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
|
||||
account.SetTokenManager(deps.TokenManager)
|
||||
@@ -169,7 +166,6 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
s.PackageSeries,
|
||||
deps.GatewayClient,
|
||||
deps.Logger,
|
||||
assetAudit,
|
||||
)
|
||||
iotCard.SetAccessAudit(auditWriter)
|
||||
cardObservationOutbox := outbox.NewRepository()
|
||||
@@ -230,7 +226,6 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
s.DeviceSimBinding,
|
||||
deps.GatewayClient,
|
||||
deps.Logger,
|
||||
assetAudit,
|
||||
)
|
||||
stopResumeService.SetPollingCallback(pollingLifecycleSvc)
|
||||
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
|
||||
@@ -254,7 +249,6 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
s.PackageSeries,
|
||||
deps.GatewayClient,
|
||||
s.AssetIdentifier,
|
||||
assetAudit,
|
||||
s.EnterpriseDeviceAuthorization,
|
||||
s.Enterprise,
|
||||
)
|
||||
@@ -283,7 +277,6 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
s.AgentWallet,
|
||||
s.Shop,
|
||||
wechatConfig,
|
||||
accountAudit,
|
||||
operationPassword,
|
||||
deps.Redis,
|
||||
deps.Logger,
|
||||
@@ -323,7 +316,8 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger)
|
||||
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))
|
||||
exchangeService.SetAccessAudit(auditWriter)
|
||||
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit)
|
||||
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder)
|
||||
assetService.SetAccessAudit(auditWriter)
|
||||
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
|
||||
agentOpenAPI.SetObservationSeriesDispatcher(observationSeries)
|
||||
wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB)
|
||||
@@ -397,7 +391,6 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
AccessAudit: auditWriter,
|
||||
Approval: approvalCreationService,
|
||||
Account: account,
|
||||
AccountAudit: accountAudit,
|
||||
AssetAudit: assetAudit,
|
||||
Role: roleService,
|
||||
Permission: permissionService,
|
||||
@@ -428,10 +421,10 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger, auditWriter),
|
||||
Authorization: enterpriseCardSvc.NewAuthorizationService(deps.DB, s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger, auditWriter),
|
||||
IotCard: iotCard,
|
||||
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, assetAudit, auditWriter),
|
||||
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, auditWriter),
|
||||
ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService, auditWriter),
|
||||
Device: device,
|
||||
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, assetAudit, auditWriter),
|
||||
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, auditWriter),
|
||||
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
|
||||
Carrier: carrierSvc.New(s.Carrier, auditWriter),
|
||||
PackageSeries: packageSeriesService,
|
||||
@@ -453,7 +446,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
|
||||
PollingManualTrigger: pollingManualTriggerService,
|
||||
Asset: assetService,
|
||||
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, assetAudit),
|
||||
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, auditWriter),
|
||||
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
|
||||
StopResumeService: stopResumeService,
|
||||
WechatConfig: wechatConfig,
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
type stores struct {
|
||||
Account *postgres.AccountStore
|
||||
AccountOperationLog *postgres.AccountOperationLogStore
|
||||
AssetOperationLog *postgres.AssetOperationLogStore
|
||||
Shop *postgres.ShopStore
|
||||
Role *postgres.RoleStore
|
||||
@@ -79,7 +78,6 @@ type stores struct {
|
||||
func initStores(deps *Dependencies) *stores {
|
||||
return &stores{
|
||||
Account: postgres.NewAccountStore(deps.DB, deps.Redis),
|
||||
AccountOperationLog: postgres.NewAccountOperationLogStore(deps.DB),
|
||||
AssetOperationLog: postgres.NewAssetOperationLogStore(deps.DB),
|
||||
Shop: postgres.NewShopStore(deps.DB, deps.Redis),
|
||||
Role: postgres.NewRoleStore(deps.DB),
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||
@@ -31,7 +30,6 @@ type workerServices struct {
|
||||
}
|
||||
|
||||
func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *queue.WorkerServices {
|
||||
assetAudit := assetAuditSvc.NewService(stores.AssetOperationLog, deps.DB)
|
||||
auditWriter := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
|
||||
commissionStatsService := commission_stats.New(stores.ShopSeriesCommissionStats)
|
||||
|
||||
@@ -105,7 +103,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
iotCardAuditService := iotCardSvc.New(
|
||||
deps.DB, stores.IotCard, stores.Shop, stores.AssetAllocationRecord,
|
||||
stores.ShopPackageAllocation, stores.ShopSeriesAllocation, stores.PackageSeries,
|
||||
deps.GatewayClient, deps.Logger, assetAudit,
|
||||
deps.GatewayClient, deps.Logger,
|
||||
)
|
||||
iotCardAuditService.SetAccessAudit(auditWriter)
|
||||
cardObservationService.SetStateAuditWriter(iotCardAuditService)
|
||||
@@ -160,7 +158,6 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
stores.DeviceSimBinding,
|
||||
deps.GatewayClient,
|
||||
deps.Logger,
|
||||
assetAudit,
|
||||
)
|
||||
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
|
||||
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
|
||||
@@ -172,7 +169,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
deviceBatchAllocator := deviceSvc.New(
|
||||
deps.DB, deps.Redis, stores.Device, stores.DeviceSimBinding, stores.IotCard, stores.Shop,
|
||||
stores.AssetAllocationRecord, stores.ShopPackageAllocation, stores.ShopSeriesAllocation,
|
||||
stores.PackageSeries, deps.GatewayClient, stores.AssetIdentifier, assetAudit, nil, nil,
|
||||
stores.PackageSeries, deps.GatewayClient, stores.AssetIdentifier, nil, nil,
|
||||
)
|
||||
|
||||
return &queue.WorkerServices{
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
)
|
||||
|
||||
type workerStores struct {
|
||||
AssetOperationLog *postgres.AssetOperationLogStore
|
||||
AssetAllocationRecord *postgres.AssetAllocationRecordStore
|
||||
IotCardImportTask *postgres.IotCardImportTaskStore
|
||||
IotCard *postgres.IotCardStore
|
||||
@@ -42,7 +41,6 @@ type workerStores struct {
|
||||
|
||||
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
stores := &workerStores{
|
||||
AssetOperationLog: postgres.NewAssetOperationLogStore(deps.DB),
|
||||
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
|
||||
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
|
||||
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||
@@ -77,7 +75,6 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
}
|
||||
|
||||
return &queue.WorkerStores{
|
||||
AssetOperationLog: stores.AssetOperationLog,
|
||||
AssetAllocationRecord: stores.AssetAllocationRecord,
|
||||
IotCardImportTask: stores.IotCardImportTask,
|
||||
IotCard: stores.IotCard,
|
||||
|
||||
@@ -134,12 +134,14 @@ func scanFile(root, path string) ([]Entry, error) {
|
||||
if strings.HasPrefix(relative, "cmd/worker/") {
|
||||
if taskType, schedule, ok := scheduledTask(call); ok {
|
||||
entries = append(entries, classifySchedule(relative, position.Line, taskType, schedule))
|
||||
} else if strings.Contains(expression(selector.X), "outboxConsumers") && len(call.Args) >= 2 {
|
||||
entries = append(entries, classifyOutboxConsumer(relative, position.Line, expression(call.Args[0]), expression(call.Args[1])))
|
||||
}
|
||||
}
|
||||
case "LogOperation":
|
||||
entries = append(entries, classifyLegacyWriter(relative, position.Line, expression(call.Fun)))
|
||||
case "Start", "Complete", "RecordInbound":
|
||||
if isIntegrationLogCall(relative, expression(selector.X)) {
|
||||
case "Start", "Complete", "RecordInbound", "ClaimExpiredInboundPending":
|
||||
if selector.Sel.Name == "ClaimExpiredInboundPending" || isIntegrationLogCall(relative, expression(selector.X)) {
|
||||
entries = append(entries, classifyIntegrationLog(relative, position.Line, expression(call.Fun)))
|
||||
}
|
||||
}
|
||||
@@ -210,7 +212,7 @@ func classifyWorker(file string, line int, taskType, handler string) Entry {
|
||||
FailureStrategy: "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计",
|
||||
SensitivePolicy: "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL",
|
||||
BeforeAfterPolicy: "状态变化保存直接前后值;无业务变化时仅保留 Integration Log",
|
||||
TestSeam: "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口",
|
||||
TestSeam: "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费幂等数据核对;覆盖门禁静态比对本入口",
|
||||
}
|
||||
return entry
|
||||
}
|
||||
@@ -230,6 +232,22 @@ func classifySchedule(file string, line int, taskType, schedule string) Entry {
|
||||
}
|
||||
}
|
||||
|
||||
func classifyOutboxConsumer(file string, line int, eventType, consumer string) Entry {
|
||||
return Entry{
|
||||
Key: fmt.Sprintf("outbox_consumer:%s:%d:%s", file, line, eventType), Kind: "outbox_consumer",
|
||||
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, consumer), Owner: workerOwner(eventType),
|
||||
Summary: "注册 Outbox 消费者 " + eventType,
|
||||
AuditEvent: "N/A", DomainLedger: "N/A", IntegrationLog: "N/A", Outbox: "必须:消费已提交的可靠事件",
|
||||
ActorSource: "system_task/outbox_consumer", Transaction: "N/A",
|
||||
Visibility: "内部系统装配入口,不直接对用户展示",
|
||||
FailureStrategy: "注册失败阻止 Worker 启动;实际消费失败由 Outbox 重试,业务审计由消费者用例负责",
|
||||
SensitivePolicy: "注册入口不读取或记录事件载荷与安全凭据",
|
||||
BeforeAfterPolicy: "N/A:注册入口不修改业务事实",
|
||||
TestSeam: "静态扫描注册点、消费者实现和对应业务动作",
|
||||
NAReason: "本入口只注册事件类型与消费者;实际业务事实和 Audit Event 由对应 Consumer/Application 完整用例负责",
|
||||
}
|
||||
}
|
||||
|
||||
func classifyBusinessMethod(file string, line int, method string) Entry {
|
||||
parts := strings.Split(file, "/")
|
||||
layer := parts[1]
|
||||
@@ -249,7 +267,7 @@ func classifyBusinessMethod(file string, line int, method string) Entry {
|
||||
Visibility: "由完整用例决定平台完整视图、主体安全投影或 internal_only",
|
||||
SensitivePolicy: "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品",
|
||||
BeforeAfterPolicy: "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影",
|
||||
TestSeam: "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口",
|
||||
TestSeam: "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用静态检查与数据核对;覆盖门禁静态比对本入口",
|
||||
}
|
||||
if layer == "domain" {
|
||||
entry.AuditEvent = "N/A"
|
||||
|
||||
@@ -19,6 +19,11 @@ func (w *Writer) AppendBatch(ctx context.Context, tx *gorm.DB, input BatchInput)
|
||||
if input.Root.EventID == "" {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "批次根事件缺少稳定事件ID")
|
||||
}
|
||||
if input.Root.BatchTotal < 0 || input.Root.SuccessCount < 0 || input.Root.FailCount < 0 ||
|
||||
input.Root.SuccessCount+input.Root.FailCount > input.Root.BatchTotal ||
|
||||
len(input.Children) < input.Root.SuccessCount || len(input.Children) > input.Root.SuccessCount+input.Root.FailCount {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "批次根子事件计数不一致")
|
||||
}
|
||||
if err := w.Append(ctx, tx, input.Root); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ func NewRegistry() *Registry {
|
||||
iotCardCreated := iotCardAction(constants.AuditActionIotCardCreated, "创建 IoT 卡", constants.AuditActorSystemTask, constants.AuditSourceWorker)
|
||||
iotCardDeleted := iotCardAction(constants.AuditActionIotCardDeleted, "删除 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
iotCardDeleted.Risk = constants.AuditRiskHigh
|
||||
iotCardDeactivated := iotCardAction(constants.AuditActionIotCardDeactivated, "停用 IoT 卡资产", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
iotCardDeactivated.Risk = constants.AuditRiskHigh
|
||||
iotCardPollingStatusUpdated := iotCardAction(constants.AuditActionIotCardPollingStatusUpdated, "更新 IoT 卡轮询开关", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
iotCardPollingStatusBatchUpdated := iotCardBatchAction(constants.AuditActionIotCardPollingStatusBatchUpdated, "批量更新 IoT 卡轮询开关")
|
||||
iotCardBatchDeleted := ActionDefinition{
|
||||
Code: constants.AuditActionIotCardBatchDeleted, Name: "批量删除 IoT 卡",
|
||||
Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskHigh,
|
||||
@@ -117,6 +121,9 @@ func NewRegistry() *Registry {
|
||||
deviceCreated := deviceAction(constants.AuditActionDeviceCreated, "导入创建设备", constants.AuditActorSystemTask, constants.AuditSourceWorker)
|
||||
deviceDeleted := deviceAction(constants.AuditActionDeviceDeleted, "删除设备", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
deviceDeleted.Risk = constants.AuditRiskHigh
|
||||
deviceDeactivated := deviceAction(constants.AuditActionDeviceDeactivated, "停用设备资产", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
deviceDeactivated.Risk = constants.AuditRiskHigh
|
||||
devicePollingStatusUpdated := deviceAction(constants.AuditActionDevicePollingStatusUpdated, "更新设备轮询开关", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
deviceAllocationBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceAllocationBatch, "批量分配设备")
|
||||
deviceAllocated := deviceMultiOriginAction(constants.AuditActionDeviceAllocated, "分配设备")
|
||||
deviceRecallBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceRecallBatch, "批量回收设备")
|
||||
@@ -180,17 +187,6 @@ func NewRegistry() *Registry {
|
||||
constants.AuditActionOutboxExpiredLeaseReleased,
|
||||
"人工释放 Outbox 过期租约",
|
||||
)
|
||||
deviceBatchCompleted := deviceBatchAction(
|
||||
constants.AuditActionDeviceBatchAllocationCompleted,
|
||||
"完成设备批量分配",
|
||||
constants.AuditResourceDeviceBatchTask,
|
||||
)
|
||||
deviceBatchItem := deviceBatchAction(
|
||||
constants.AuditActionDeviceBatchAllocationItem,
|
||||
"处理设备批量分配项",
|
||||
constants.AuditResourceDevice,
|
||||
)
|
||||
deviceBatchItem.AllowedVisibility = []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult}
|
||||
iotCardImportTaskCreated := taskAction(constants.AuditActionIotCardImportTaskCreated, "创建 IoT 卡导入任务", constants.AuditResourceIotCardImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
iotCardImportTaskCompleted := taskAction(constants.AuditActionIotCardImportTaskCompleted, "完成 IoT 卡导入任务", constants.AuditResourceIotCardImportTask, constants.AuditActorSystemTask, constants.AuditSourceWorker)
|
||||
deviceImportTaskCreated := taskAction(constants.AuditActionDeviceImportTaskCreated, "创建设备导入任务", constants.AuditResourceDeviceImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
@@ -274,6 +270,8 @@ func NewRegistry() *Registry {
|
||||
packageUsageTrafficReset := packageUsageAction(constants.AuditActionPackageUsageTrafficReset, "重置套餐权益流量")
|
||||
packageUsageRefundInvalidated := packageUsageAction(constants.AuditActionPackageUsageRefundInvalidated, "退款失效套餐权益")
|
||||
packageUsageAssetInvalidated := packageUsageAction(constants.AuditActionPackageUsageAssetInvalidated, "资产失效套餐权益")
|
||||
packageUsageExpiresAtUpdated := packageUsageAction(constants.AuditActionPackageUsageExpiresAtUpdated, "调整套餐权益过期时间")
|
||||
packageUsageTrafficAdjusted := packageUsageAction(constants.AuditActionPackageUsageTrafficAdjusted, "调整套餐权益已用量")
|
||||
orderCreated := orderAction(constants.AuditActionOrderCreated, "创建订单")
|
||||
orderCancelled := orderAction(constants.AuditActionOrderCancelled, "取消订单")
|
||||
orderWalletPaid := orderAction(constants.AuditActionOrderWalletPaid, "钱包支付订单")
|
||||
@@ -405,6 +403,9 @@ func NewRegistry() *Registry {
|
||||
constants.AuditActionPersonalCustomerAssetBindingMigrated: personalAssetBindingMigrated,
|
||||
constants.AuditActionIotCardCreated: iotCardCreated,
|
||||
constants.AuditActionIotCardDeleted: iotCardDeleted,
|
||||
constants.AuditActionIotCardDeactivated: iotCardDeactivated,
|
||||
constants.AuditActionIotCardPollingStatusUpdated: iotCardPollingStatusUpdated,
|
||||
constants.AuditActionIotCardPollingStatusBatchUpdated: iotCardPollingStatusBatchUpdated,
|
||||
constants.AuditActionIotCardBatchDeleted: iotCardBatchDeleted,
|
||||
constants.AuditActionIotCardAllocationBatch: iotCardAllocationBatch,
|
||||
constants.AuditActionIotCardAllocated: iotCardAllocated,
|
||||
@@ -430,6 +431,8 @@ func NewRegistry() *Registry {
|
||||
constants.AuditActionIotCardAutoStopReasonUpdated: iotCardAutoStopReasonUpdated,
|
||||
constants.AuditActionDeviceCreated: deviceCreated,
|
||||
constants.AuditActionDeviceDeleted: deviceDeleted,
|
||||
constants.AuditActionDeviceDeactivated: deviceDeactivated,
|
||||
constants.AuditActionDevicePollingStatusUpdated: devicePollingStatusUpdated,
|
||||
constants.AuditActionDeviceAllocationBatch: deviceAllocationBatch,
|
||||
constants.AuditActionDeviceAllocated: deviceAllocated,
|
||||
constants.AuditActionDeviceRecallBatch: deviceRecallBatch,
|
||||
@@ -476,8 +479,6 @@ func NewRegistry() *Registry {
|
||||
constants.AuditActionWeComApprovalSceneSaved: wecomApprovalSceneSaved,
|
||||
constants.AuditActionOutboxReplayed: outboxReplayed,
|
||||
constants.AuditActionOutboxExpiredLeaseReleased: outboxExpiredLeaseReleased,
|
||||
constants.AuditActionDeviceBatchAllocationCompleted: deviceBatchCompleted,
|
||||
constants.AuditActionDeviceBatchAllocationItem: deviceBatchItem,
|
||||
constants.AuditActionIotCardImportTaskCreated: iotCardImportTaskCreated,
|
||||
constants.AuditActionIotCardImportTaskCompleted: iotCardImportTaskCompleted,
|
||||
constants.AuditActionDeviceImportTaskCreated: deviceImportTaskCreated,
|
||||
@@ -546,6 +547,8 @@ func NewRegistry() *Registry {
|
||||
constants.AuditActionPackageUsageTrafficReset: packageUsageTrafficReset,
|
||||
constants.AuditActionPackageUsageRefundInvalidated: packageUsageRefundInvalidated,
|
||||
constants.AuditActionPackageUsageAssetInvalidated: packageUsageAssetInvalidated,
|
||||
constants.AuditActionPackageUsageExpiresAtUpdated: packageUsageExpiresAtUpdated,
|
||||
constants.AuditActionPackageUsageTrafficAdjusted: packageUsageTrafficAdjusted,
|
||||
constants.AuditActionOrderCreated: orderCreated,
|
||||
constants.AuditActionOrderCancelled: orderCancelled,
|
||||
constants.AuditActionOrderWalletPaid: orderWalletPaid,
|
||||
|
||||
@@ -900,14 +900,14 @@ func (w *Writer) Append(ctx context.Context, tx *gorm.DB, input AppendInput) err
|
||||
}
|
||||
event := model.AuditEvent{
|
||||
OccurredAt: occurredAt, Category: action.Category, ActionCode: action.Code, ActionName: action.Name,
|
||||
Summary: input.Summary, ActorKind: input.Actor.Kind, ActorID: input.Actor.ID, ActorName: input.Actor.Name,
|
||||
ActorShopID: input.Actor.ShopID, ActorShopName: input.Actor.ShopName,
|
||||
ActorEnterpriseID: input.Actor.EnterpriseID, ActorEnterpriseName: input.Actor.EnterpriseName,
|
||||
Source: input.Source, RequestPath: input.RequestPath, RequestMethod: input.RequestMethod,
|
||||
IPAddress: input.IPAddress, UserAgent: input.UserAgent,
|
||||
ScopeType: input.ScopeType, ScopeID: input.ScopeID, ScopeName: input.ScopeName,
|
||||
Summary: sanitizer.SanitizeText(input.Summary), ActorKind: input.Actor.Kind, ActorID: input.Actor.ID, ActorName: sanitizer.SanitizeText(input.Actor.Name),
|
||||
ActorShopID: input.Actor.ShopID, ActorShopName: sanitizer.SanitizeText(input.Actor.ShopName),
|
||||
ActorEnterpriseID: input.Actor.EnterpriseID, ActorEnterpriseName: sanitizer.SanitizeText(input.Actor.EnterpriseName),
|
||||
Source: input.Source, RequestPath: sanitizer.SanitizeText(input.RequestPath), RequestMethod: input.RequestMethod,
|
||||
IPAddress: input.IPAddress, UserAgent: sanitizer.SanitizeText(input.UserAgent),
|
||||
ScopeType: input.ScopeType, ScopeID: input.ScopeID, ScopeName: sanitizer.SanitizeText(input.ScopeName),
|
||||
Result: input.Result, RiskLevel: action.Risk, RequestID: input.RequestID,
|
||||
ErrorCode: input.ErrorCode, ErrorSummary: input.ErrorSummary,
|
||||
ErrorCode: input.ErrorCode, ErrorSummary: sanitizer.SanitizeText(input.ErrorSummary),
|
||||
CorrelationID: input.CorrelationID, ParentEventID: input.ParentEventID, Metadata: metadata,
|
||||
BatchTotal: input.BatchTotal, SuccessCount: input.SuccessCount, FailCount: input.FailCount,
|
||||
}
|
||||
@@ -1036,10 +1036,10 @@ func (w *Writer) buildResources(inputs []ResourceInput, action ActionDefinition)
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, model.AuditEventResource{
|
||||
ResourceType: input.Type, ResourceID: input.ID, ResourceKey: input.Key, DisplayName: input.DisplayName,
|
||||
ResourceType: input.Type, ResourceID: input.ID, ResourceKey: sanitizer.SanitizeText(input.Key), DisplayName: sanitizer.SanitizeText(input.DisplayName),
|
||||
Relation: input.Relation, Role: input.Role, IdentitySnapshot: identity,
|
||||
BeforeData: before, AfterData: after, SubjectVisibility: visibility,
|
||||
SubjectSummary: input.SubjectSummary, SubjectData: subjectData, SortOrder: input.SortOrder,
|
||||
SubjectSummary: sanitizer.SanitizeText(input.SubjectSummary), SubjectData: subjectData, SortOrder: input.SortOrder,
|
||||
})
|
||||
}
|
||||
if primaryCount != 1 {
|
||||
|
||||
@@ -37,7 +37,7 @@ func (l *SeriesAttemptLogger) Record(ctx context.Context, payload cardapp.Series
|
||||
return apperrors.New(apperrors.CodeInternalError, "卡观测 Integration Log 未配置")
|
||||
}
|
||||
resourceID := payload.ResourceID
|
||||
source, scene, seriesID := payload.Source, payload.Scene, payload.SeriesID
|
||||
source, scene, seriesID := payload.Source, payload.Scene, payload.SeriesID+":"+payload.SyncType
|
||||
requestID, correlationID := optionalText(payload.RequestID), optionalText(payload.CorrelationID)
|
||||
attempt, err := l.repository.Start(ctx, integrationlog.Attempt{
|
||||
IntegrationID: unsentIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
|
||||
@@ -59,12 +59,12 @@ func (l *SeriesAttemptLogger) Record(ctx context.Context, payload cardapp.Series
|
||||
// RecordMerged 记录同场景重复触发被合并,不创建第二组三任务。
|
||||
func (l *SeriesAttemptLogger) RecordMerged(ctx context.Context, request cardapp.SeriesRequest, seriesID string) error {
|
||||
resourceID := request.ResourceID
|
||||
source, scene := request.Source, request.Scene
|
||||
source, scene, technicalSeriesID := request.Source, request.Scene, seriesID+":"+request.SyncType
|
||||
now := time.Now().UTC()
|
||||
_, err := l.repository.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: operationForSyncType(request.SyncType), ResourceType: request.ResourceType,
|
||||
ResourceID: &resourceID, TriggerSource: &source, TriggerScene: &scene, TriggerSeries: &seriesID,
|
||||
ResourceID: &resourceID, TriggerSource: &source, TriggerScene: &scene, TriggerSeries: &technicalSeriesID,
|
||||
ScheduledAt: &now, Attempt: 1, InitialResult: constants.IntegrationResultMerged,
|
||||
RequestID: optionalText(request.RequestID), CorrelationID: optionalText(request.CorrelationID),
|
||||
Metadata: map[string]any{"reason": "同场景未结束序列已存在", "sync_type": request.SyncType},
|
||||
@@ -215,7 +215,7 @@ func (r *SeriesRunner) runDeviceInfo(ctx context.Context, payload cardapp.Series
|
||||
}
|
||||
|
||||
func (r *SeriesRunner) startDeviceAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload, device *model.Device) (*model.IntegrationLog, error) {
|
||||
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID
|
||||
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID+":"+payload.SyncType
|
||||
resourceKey := "device:" + strconv.FormatUint(uint64(device.ID), 10)
|
||||
return r.integration.Start(ctx, integrationlog.Attempt{
|
||||
IntegrationID: gatewayIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
|
||||
@@ -525,7 +525,7 @@ func parseGatewayTime(raw gateway.FlexString) *time.Time {
|
||||
}
|
||||
|
||||
func (r *SeriesRunner) startAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload, card *model.IotCard) (*model.IntegrationLog, error) {
|
||||
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID
|
||||
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID+":"+payload.SyncType
|
||||
resourceKey := "card:" + formatCardID(card.ID)
|
||||
return r.integration.Start(ctx, integrationlog.Attempt{
|
||||
IntegrationID: gatewayIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
|
||||
|
||||
@@ -107,15 +107,9 @@ func (r *Repository) Start(ctx context.Context, input Attempt) (*model.Integrati
|
||||
if input.IntegrationID == "" {
|
||||
input.IntegrationID = uuid.NewString()
|
||||
}
|
||||
autoAttempt := input.Attempt <= 0 && input.TriggerSeries != nil
|
||||
if input.Attempt <= 0 {
|
||||
input.Attempt = 1
|
||||
if input.TriggerSeries != nil {
|
||||
if err := r.db.WithContext(ctx).Model(&model.IntegrationLog{}).
|
||||
Select("COALESCE(MAX(attempt), 0) + 1").
|
||||
Where("trigger_series = ?", *input.TriggerSeries).Scan(&input.Attempt).Error; err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "计算 Integration Log 尝试序号失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
if input.StartedAt == nil {
|
||||
startedAt := r.now().UTC()
|
||||
@@ -128,16 +122,35 @@ func (r *Repository) Start(ctx context.Context, input Attempt) (*model.Integrati
|
||||
resourceType := optionalString(input.ResourceType)
|
||||
log := &model.IntegrationLog{
|
||||
IntegrationID: input.IntegrationID, Provider: input.Provider, Direction: input.Direction,
|
||||
Operation: input.Operation, ExternalID: input.ExternalID, ResourceType: resourceType,
|
||||
ResourceID: input.ResourceID, ResourceKey: input.ResourceKey, TriggerSource: input.TriggerSource,
|
||||
TriggerScene: input.TriggerScene, TriggerSeries: input.TriggerSeries, ScheduledAt: input.ScheduledAt,
|
||||
Operation: input.Operation, ExternalID: sanitizedOptionalText(input.ExternalID), ResourceType: resourceType,
|
||||
ResourceID: input.ResourceID, ResourceKey: sanitizedOptionalText(input.ResourceKey), TriggerSource: input.TriggerSource,
|
||||
TriggerScene: sanitizedOptionalText(input.TriggerScene), TriggerSeries: input.TriggerSeries, ScheduledAt: input.ScheduledAt,
|
||||
StartedAt: input.StartedAt, Attempt: input.Attempt, Result: result,
|
||||
RequestSummary: requestSummary, Metadata: metadata, RequestID: input.RequestID,
|
||||
CorrelationID: input.CorrelationID, AuditEventID: input.AuditEventID,
|
||||
RecoveryStrategy: input.RecoveryStrategy,
|
||||
RecoveryStrategy: sanitizedOptionalText(input.RecoveryStrategy),
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(log).Error; err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "写入 Integration Log 失败")
|
||||
createAttempt := func(tx *gorm.DB) error {
|
||||
if !autoAttempt {
|
||||
return tx.Create(log).Error
|
||||
}
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", *input.TriggerSeries).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.IntegrationLog{}).Select("COALESCE(MAX(attempt), 0) + 1").
|
||||
Where("trigger_series = ?", *input.TriggerSeries).Scan(&log.Attempt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(log).Error
|
||||
}
|
||||
var createErr error
|
||||
if autoAttempt {
|
||||
createErr = r.db.WithContext(ctx).Transaction(createAttempt)
|
||||
} else {
|
||||
createErr = createAttempt(r.db.WithContext(ctx))
|
||||
}
|
||||
if createErr != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, createErr, "写入 Integration Log 失败")
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
@@ -156,7 +169,7 @@ func (r *Repository) Complete(ctx context.Context, integrationID string, complet
|
||||
if completion.Result == constants.IntegrationResultUnknown && strings.TrimSpace(completion.RecoveryStrategy) == "" {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "结果未知必须记录明确恢复策略")
|
||||
}
|
||||
safeProviderMessage := strings.TrimSpace(completion.SafeProviderMessage)
|
||||
safeProviderMessage := sanitizer.SanitizeText(strings.TrimSpace(completion.SafeProviderMessage))
|
||||
if safeProviderMessage != "" && utf8.RuneCountInString(constants.IntegrationSafeMessagePrefix+safeProviderMessage) > constants.IntegrationProviderMessageMaxLength {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 安全结果摘要过长")
|
||||
}
|
||||
@@ -187,10 +200,10 @@ func (r *Repository) Complete(ctx context.Context, integrationID string, complet
|
||||
updates["resource_id"] = completion.ResourceID
|
||||
}
|
||||
if completion.ResourceKey != nil {
|
||||
updates["resource_key"] = completion.ResourceKey
|
||||
updates["resource_key"] = sanitizedOptionalText(completion.ResourceKey)
|
||||
}
|
||||
if completion.RecoveryStrategy != "" {
|
||||
updates["recovery_strategy"] = completion.RecoveryStrategy
|
||||
updates["recovery_strategy"] = sanitizer.SanitizeText(completion.RecoveryStrategy)
|
||||
}
|
||||
result := r.db.WithContext(ctx).Model(&model.IntegrationLog{}).
|
||||
Where("integration_id = ? AND result = ?", integrationID, constants.IntegrationResultPending).
|
||||
@@ -237,8 +250,8 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*
|
||||
log := &model.IntegrationLog{
|
||||
IntegrationID: input.IntegrationID, IdempotencyKey: &input.IdempotencyKey,
|
||||
Provider: input.Provider, Direction: constants.IntegrationDirectionInbound, Operation: input.Operation,
|
||||
ExternalID: optionalString(input.ExternalID), ResourceType: optionalString(input.ResourceType),
|
||||
ResourceID: input.ResourceID, ResourceKey: input.ResourceKey, StartedAt: &now, Attempt: 1,
|
||||
ExternalID: sanitizedOptionalText(optionalString(input.ExternalID)), ResourceType: optionalString(input.ResourceType),
|
||||
ResourceID: input.ResourceID, ResourceKey: sanitizedOptionalText(input.ResourceKey), StartedAt: &now, Attempt: 1,
|
||||
TriggerSeries: &triggerSeries,
|
||||
Result: constants.IntegrationResultPending, RequestSummary: summary,
|
||||
ContentHash: hex.EncodeToString(hash[:]), RequestID: input.RequestID, CorrelationID: input.CorrelationID,
|
||||
@@ -380,3 +393,11 @@ func optionalString(value string) *string {
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func sanitizedOptionalText(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
sanitized := sanitizer.SanitizeText(*value)
|
||||
return &sanitized
|
||||
}
|
||||
|
||||
@@ -2,24 +2,24 @@ package dto
|
||||
|
||||
// AuditEventListRequest 是平台审计事件列表的组合筛选参数。
|
||||
type AuditEventListRequest struct {
|
||||
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间(RFC3339,含时区)"`
|
||||
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间(RFC3339,含时区,不包含该时刻)"`
|
||||
Action string `json:"action" query:"action" description:"稳定动作编码"`
|
||||
Category string `json:"category" query:"category" description:"动作类别"`
|
||||
ActorKind string `json:"actor_kind" query:"actor_kind" description:"操作者类型"`
|
||||
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间,RFC3339且含时区" example:"2026-08-01T00:00:00+08:00"`
|
||||
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间,RFC3339且含时区,不包含该时刻" example:"2026-08-08T00:00:00+08:00"`
|
||||
Action string `json:"action" query:"action" description:"稳定动作编码;直接使用事件响应的action_code,不按中文名称猜测"`
|
||||
Category string `json:"category" query:"category" enum:"configuration,reliability,asset,security,identity,business" description:"动作类别 (configuration:配置, reliability:可靠性, asset:资产, security:安全, identity:身份, business:业务)"`
|
||||
ActorKind string `json:"actor_kind" query:"actor_kind" enum:"account,personal_customer,openapi,system_task,scheduled_job,external_system" description:"操作者类型 (account:人工账号, personal_customer:个人客户, openapi:开放接口账号, system_task:系统任务, scheduled_job:计划任务, external_system:外部系统)"`
|
||||
ActorID string `json:"actor_id" query:"actor_id" description:"操作者稳定ID"`
|
||||
Source string `json:"source" query:"source" description:"操作入口来源"`
|
||||
Result string `json:"result" query:"result" description:"结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)"`
|
||||
Risk string `json:"risk" query:"risk" description:"风险等级 (low:低, normal:普通, high:高, critical:严重)"`
|
||||
ScopeType string `json:"scope_type" query:"scope_type" description:"业务范围类型"`
|
||||
Source string `json:"source" query:"source" enum:"admin_api,personal_api,openapi,worker,scheduler,callback" description:"操作入口来源 (admin_api:后台管理API, personal_api:个人客户API, openapi:代理OpenAPI, worker:异步Worker, scheduler:计划任务, callback:外部系统回调)"`
|
||||
Result string `json:"result" query:"result" enum:"success,failed,denied,partial,unknown" description:"结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)"`
|
||||
Risk string `json:"risk" query:"risk" enum:"low,normal,high,critical" description:"风险等级 (low:低, normal:普通, high:高, critical:严重)"`
|
||||
ScopeType string `json:"scope_type" query:"scope_type" enum:"platform,shop,personal_customer" description:"业务范围类型 (platform:平台, shop:店铺, personal_customer:个人客户)"`
|
||||
ScopeID string `json:"scope_id" query:"scope_id" description:"业务范围稳定ID"`
|
||||
ResourceType string `json:"resource_type" query:"resource_type" description:"Resource Registry 注册类型"`
|
||||
ResourceID string `json:"resource_id" query:"resource_id" description:"资源内部稳定ID"`
|
||||
ResourceKey string `json:"resource_key" query:"resource_key" description:"资源业务稳定Key"`
|
||||
RequestID string `json:"request_id" query:"request_id" description:"HTTP请求关联ID"`
|
||||
CorrelationID string `json:"correlation_id" query:"correlation_id" description:"跨步骤业务链路ID"`
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
Page int `json:"page" query:"page" minimum:"1" default:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" default:"20" description:"每页数量,最大100"`
|
||||
}
|
||||
|
||||
// AuditEventIDParams 是审计事件详情路径参数。
|
||||
@@ -29,25 +29,25 @@ type AuditEventIDParams struct {
|
||||
|
||||
// AuditActorEventsRequest 是操作者行为时间线参数。
|
||||
type AuditActorEventsRequest struct {
|
||||
Kind string `json:"kind" path:"kind" required:"true" description:"操作者类型 (account:人工账号, openapi:开放接口账号, system_task:系统任务, scheduled_job:计划任务, external_system:外部系统)"`
|
||||
Kind string `json:"kind" path:"kind" required:"true" enum:"account,personal_customer,openapi,system_task,scheduled_job,external_system" description:"操作者类型 (account:人工账号, personal_customer:个人客户, openapi:开放接口账号, system_task:系统任务, scheduled_job:计划任务, external_system:外部系统)"`
|
||||
ID string `json:"id" path:"id" required:"true" description:"操作者稳定ID"`
|
||||
Action string `json:"action" query:"action" description:"稳定动作编码"`
|
||||
Result string `json:"result" query:"result" description:"事件结果"`
|
||||
Risk string `json:"risk" query:"risk" description:"风险等级"`
|
||||
ResourceType string `json:"resource_type" query:"resource_type" description:"资源类型"`
|
||||
Action string `json:"action" query:"action" description:"稳定动作编码;直接使用事件响应的action_code"`
|
||||
Result string `json:"result" query:"result" enum:"success,failed,denied,partial,unknown" description:"事件结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)"`
|
||||
Risk string `json:"risk" query:"risk" enum:"low,normal,high,critical" description:"风险等级 (low:低, normal:普通, high:高, critical:严重)"`
|
||||
ResourceType string `json:"resource_type" query:"resource_type" description:"Resource Registry注册类型;直接使用事件resources或investigation_refs返回的resource_type"`
|
||||
ResourceID string `json:"resource_id" query:"resource_id" description:"资源内部稳定ID"`
|
||||
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间(RFC3339,含时区)"`
|
||||
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间(RFC3339,含时区,不包含该时刻)"`
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
Page int `json:"page" query:"page" minimum:"1" default:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" default:"20" description:"每页数量,最大100"`
|
||||
}
|
||||
|
||||
// AuditResourceSearchRequest 是首批注册资源的精确搜索参数。
|
||||
type AuditResourceSearchRequest struct {
|
||||
ResourceType string `json:"resource_type" query:"resource_type" required:"true" description:"资源类型 (iot_card:IoT卡, device:设备, shop:店铺, order:订单, refund:退款单)"`
|
||||
ResourceType string `json:"resource_type" query:"resource_type" required:"true" enum:"iot_card,device,shop,order,refund" description:"资源类型 (iot_card:IoT卡, device:设备, shop:店铺, order:订单, refund:退款单)"`
|
||||
Keyword string `json:"keyword" query:"keyword" required:"true" description:"精确业务标识;卡支持ICCID/VirtualNo,设备支持VirtualNo/IMEI/SN"`
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
Page int `json:"page" query:"page" minimum:"1" default:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" default:"20" description:"每页数量,最大100"`
|
||||
}
|
||||
|
||||
// AuditResourceTimelineRequest 是通用资源时间线参数。
|
||||
@@ -56,10 +56,10 @@ type AuditResourceTimelineRequest struct {
|
||||
ResourceID string `json:"resource_id" path:"resource_id" required:"true" description:"资源内部稳定ID"`
|
||||
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间(RFC3339,含时区)"`
|
||||
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间(RFC3339,含时区,不包含该时刻)"`
|
||||
Action string `json:"action" query:"action" description:"稳定动作编码"`
|
||||
Result string `json:"result" query:"result" description:"事件结果"`
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
Action string `json:"action" query:"action" description:"稳定动作编码;直接使用事件响应的action_code"`
|
||||
Result string `json:"result" query:"result" enum:"success,failed,denied,partial,unknown" description:"事件结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)"`
|
||||
Page int `json:"page" query:"page" minimum:"1" default:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" default:"20" description:"每页数量,最大100"`
|
||||
}
|
||||
|
||||
// AuditRequestTimelineParams 是请求链路时间线的路径参数。
|
||||
@@ -86,23 +86,23 @@ type AuditFinanceTimelineRequest struct {
|
||||
RechargeNo string `json:"recharge_no" query:"recharge_no" description:"充值单号"`
|
||||
ApprovalInstanceID uint `json:"approval_instance_id" query:"approval_instance_id" description:"审批实例ID"`
|
||||
ThirdPartyTradeNo string `json:"third_party_trade_no" query:"third_party_trade_no" description:"第三方交易号"`
|
||||
ActorKind string `json:"actor_kind" query:"actor_kind" description:"操作者类型;与actor_id同时提供"`
|
||||
ActorKind string `json:"actor_kind" query:"actor_kind" enum:"account,personal_customer,openapi,system_task,scheduled_job,external_system" description:"操作者类型;与actor_id同时提供 (account:人工账号, personal_customer:个人客户, openapi:开放接口账号, system_task:系统任务, scheduled_job:计划任务, external_system:外部系统)"`
|
||||
ActorID string `json:"actor_id" query:"actor_id" description:"操作者稳定ID;与actor_kind同时提供"`
|
||||
CorrelationID string `json:"correlation_id" query:"correlation_id" description:"跨步骤业务链路ID"`
|
||||
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间(RFC3339,含时区)"`
|
||||
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间(RFC3339,含时区,不包含该时刻)"`
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
Page int `json:"page" query:"page" minimum:"1" default:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" default:"20" description:"每页数量,最大100"`
|
||||
}
|
||||
|
||||
// AuditRiskFilterRequest 是风险总览和明细共用的受控筛选参数。
|
||||
type AuditRiskFilterRequest struct {
|
||||
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间(RFC3339,含时区);默认从在线窗口开始"`
|
||||
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间(RFC3339,含时区,不包含该时刻,最长31天);默认当前时间"`
|
||||
Risk string `json:"risk" query:"risk" description:"风险等级 (low:低, normal:普通, high:高, critical:严重)"`
|
||||
Result string `json:"result" query:"result" description:"结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)"`
|
||||
Action string `json:"action" query:"action" description:"稳定动作编码"`
|
||||
Source string `json:"source" query:"source" description:"来源 (admin_api:后台管理API, personal_api:个人客户API, openapi:代理OpenAPI, worker:异步Worker, scheduler:计划任务, callback:外部系统回调)"`
|
||||
Risk string `json:"risk" query:"risk" enum:"low,normal,high,critical" description:"风险等级 (low:低, normal:普通, high:高, critical:严重)"`
|
||||
Result string `json:"result" query:"result" enum:"success,failed,denied,partial,unknown" description:"结果 (success:成功, failed:失败, denied:拒绝, partial:部分成功, unknown:未知)"`
|
||||
Action string `json:"action" query:"action" description:"稳定动作编码;直接使用事件响应的action_code"`
|
||||
Source string `json:"source" query:"source" enum:"admin_api,personal_api,openapi,worker,scheduler,callback" description:"来源 (admin_api:后台管理API, personal_api:个人客户API, openapi:代理OpenAPI, worker:异步Worker, scheduler:计划任务, callback:外部系统回调)"`
|
||||
}
|
||||
|
||||
// AuditRiskOverviewRequest 是风险总览请求参数。
|
||||
@@ -113,18 +113,28 @@ type AuditRiskOverviewRequest struct {
|
||||
// AuditRiskEventsRequest 是风险事件明细请求参数。
|
||||
type AuditRiskEventsRequest struct {
|
||||
AuditRiskFilterRequest
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
Page int `json:"page" query:"page" minimum:"1" default:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" default:"20" description:"每页数量,最大100"`
|
||||
}
|
||||
|
||||
// SubjectResourceActivityRequest 是代理和企业安全资源活动的路径及分页参数。
|
||||
// SubjectResourceActivityRequest 是代理安全资源活动的路径及分页参数。
|
||||
type SubjectResourceActivityRequest struct {
|
||||
ResourceType string `json:"resource_type" path:"resource_type" required:"true" description:"资源类型 (iot_card:IoT卡, device:设备, asset_allocation_record:资产分配记录, exchange_order:换货单, shop:店铺, enterprise:企业)"`
|
||||
ResourceType string `json:"resource_type" path:"resource_type" required:"true" enum:"iot_card,device,asset_allocation_record,exchange_order,shop,enterprise" description:"代理资源类型 (iot_card:IoT卡, device:设备, asset_allocation_record:资产分配记录, exchange_order:换货单, shop:店铺, enterprise:企业)"`
|
||||
Identifier string `json:"identifier" path:"identifier" required:"true" description:"业务稳定标识;卡使用ICCID,设备使用VirtualNo,其他资源使用对应业务编号"`
|
||||
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间(RFC3339,含时区);默认从在线窗口开始"`
|
||||
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间(RFC3339,含时区,不包含该时刻);默认当前时间"`
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
Page int `json:"page" query:"page" minimum:"1" default:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" default:"20" description:"每页数量,最大100"`
|
||||
}
|
||||
|
||||
// EnterpriseResourceActivityRequest 是企业安全资源活动的路径及分页参数。
|
||||
type EnterpriseResourceActivityRequest struct {
|
||||
ResourceType string `json:"resource_type" path:"resource_type" required:"true" enum:"iot_card,device" description:"企业资源类型 (iot_card:IoT卡, device:设备)"`
|
||||
Identifier string `json:"identifier" path:"identifier" required:"true" description:"业务稳定标识;卡使用ICCID,设备使用VirtualNo"`
|
||||
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间(RFC3339,含时区);默认从在线窗口开始"`
|
||||
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间(RFC3339,含时区,不包含该时刻);默认当前时间"`
|
||||
Page int `json:"page" query:"page" minimum:"1" default:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" default:"20" description:"每页数量,最大100"`
|
||||
}
|
||||
|
||||
// IntegrationFilterRequest 是外部集成调查的公共受控筛选参数。
|
||||
@@ -132,13 +142,13 @@ type IntegrationFilterRequest struct {
|
||||
CreatedFrom string `json:"created_from" query:"created_from" description:"开始时间(RFC3339,含时区);默认从在线窗口开始"`
|
||||
CreatedTo string `json:"created_to" query:"created_to" description:"结束时间(RFC3339,含时区,不包含该时刻);默认当前时间"`
|
||||
IntegrationID string `json:"integration_id" query:"integration_id" description:"稳定外部集成记录ID"`
|
||||
Provider string `json:"provider" query:"provider" description:"外部服务提供方稳定编码"`
|
||||
Direction string `json:"direction" query:"direction" description:"交互方向 (inbound:入站, outbound:出站)"`
|
||||
Operation string `json:"operation" query:"operation" description:"外部操作稳定编码"`
|
||||
Result string `json:"result" query:"result" description:"原始结果 (pending:待处理, success:成功, failed:失败, unknown:结果未知, not_found:未找到, invalid_payload:无效载荷, conflict:冲突, ignored:已忽略, merged:已合并, rate_limited:已限频, completed:已提前完成, cancelled:已取消)"`
|
||||
ResultCategory string `json:"result_category" query:"result_category" description:"派生结果类别 (processing:处理中, succeeded:成功, indeterminate:结果不确定, failed:失败, not_sent:未发送)"`
|
||||
Provider string `json:"provider" query:"provider" enum:"ctcc,cmcc,cucc,wechat_pay,alipay,fuiou,wecom,gateway" description:"外部服务提供方 (ctcc:中国电信, cmcc:中国移动, cucc:中国联通, wechat_pay:微信支付, alipay:支付宝, fuiou:富友, wecom:企业微信, gateway:设备网关)"`
|
||||
Direction string `json:"direction" query:"direction" enum:"inbound,outbound" description:"交互方向 (inbound:入站, outbound:出站)"`
|
||||
Operation string `json:"operation" query:"operation" enum:"realname_callback,realname_removal_callback,payment_precreate,payment_query,payment_callback,get_access_token,list_visible_members,list_visible_departments,get_template_detail,upload_approval_attachment,submit_approval,approval_callback,get_approval_detail,get_approval_info,query_realname_status,query_flow,query_card_status,query_device_info,set_speed_tier,stop_card,start_card,set_device_wifi,set_device_switch_mode,switch_device_card,reboot_device,reset_device" description:"外部操作稳定编码;直接使用列表或详情响应的operation,不按中文名称猜测"`
|
||||
Result string `json:"result" query:"result" enum:"pending,success,failed,unknown,not_found,invalid_payload,conflict,ignored,merged,rate_limited,completed,cancelled" description:"原始结果 (pending:待处理, success:成功, failed:失败, unknown:结果未知, not_found:未找到, invalid_payload:无效载荷, conflict:冲突, ignored:已忽略, merged:已合并, rate_limited:已限频, completed:已提前完成, cancelled:已取消)"`
|
||||
ResultCategory string `json:"result_category" query:"result_category" enum:"processing,succeeded,indeterminate,failed,not_sent" description:"派生结果类别 (processing:处理中, succeeded:成功, indeterminate:结果不确定, failed:失败, not_sent:未发送)"`
|
||||
ExternalID string `json:"external_id" query:"external_id" description:"外部系统业务或请求标识"`
|
||||
ResourceType string `json:"resource_type" query:"resource_type" description:"本地主要资源类型"`
|
||||
ResourceType string `json:"resource_type" query:"resource_type" description:"本地主要资源类型;直接使用列表resource.type或调查引用的resource_type"`
|
||||
ResourceID string `json:"resource_id" query:"resource_id" description:"本地主要资源稳定ID"`
|
||||
ResourceKey string `json:"resource_key" query:"resource_key" description:"本地主要资源稳定Key"`
|
||||
TriggerSource string `json:"trigger_source" query:"trigger_source" description:"触发来源稳定编码"`
|
||||
@@ -154,14 +164,14 @@ type IntegrationFilterRequest struct {
|
||||
// IntegrationOverviewRequest 是外部集成交互总览参数。
|
||||
type IntegrationOverviewRequest struct {
|
||||
IntegrationFilterRequest
|
||||
Bucket string `json:"bucket" query:"bucket" description:"趋势时间粒度 (hour:小时, day:自然日),默认hour"`
|
||||
Bucket string `json:"bucket" query:"bucket" enum:"hour,day" default:"hour" description:"趋势时间粒度 (hour:小时, day:自然日)"`
|
||||
}
|
||||
|
||||
// IntegrationListRequest 是外部集成交互列表参数。
|
||||
type IntegrationListRequest struct {
|
||||
IntegrationFilterRequest
|
||||
Page int `json:"page" query:"page" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" description:"每页数量,默认20,最大100"`
|
||||
Page int `json:"page" query:"page" minimum:"1" default:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" minimum:"1" maximum:"100" default:"20" description:"每页数量,最大100"`
|
||||
}
|
||||
|
||||
// IntegrationIDParams 是外部集成详情路径参数。
|
||||
|
||||
@@ -40,104 +40,104 @@ type EventFilter struct {
|
||||
|
||||
// EventPage 是平台全局事件稳定分页结果。
|
||||
type EventPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []EventView `json:"items"`
|
||||
Retention retentionquery.Info `json:"retention"`
|
||||
Total int64 `json:"total" description:"符合条件的事件总数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
Items []EventView `json:"items" description:"审计事件列表,按发生时间和主键稳定倒序"`
|
||||
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
|
||||
}
|
||||
|
||||
// EventDetail 是单个审计事件及在线留存边界。
|
||||
type EventDetail struct {
|
||||
EventView
|
||||
Retention retentionquery.Info `json:"retention"`
|
||||
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
EventID string `json:"event_id" description:"稳定审计事件ID,可传给事件详情接口"`
|
||||
OccurredAt time.Time `json:"occurred_at" description:"业务事实发生时间"`
|
||||
Category string `json:"category" enum:"configuration,reliability,asset,security,identity,business" description:"动作类别稳定编码"`
|
||||
ActionCode string `json:"action_code" description:"稳定动作编码;筛选和跳转必须使用该值"`
|
||||
ActionName string `json:"action_name" description:"action_code对应的中文展示名称"`
|
||||
Summary string `json:"summary" description:"事件中文摘要"`
|
||||
ActorKind string `json:"actor_kind" enum:"account,personal_customer,openapi,system_task,scheduled_job,external_system" description:"操作者类型稳定编码"`
|
||||
ActorID string `json:"actor_id" description:"操作者稳定ID,与actor_kind共同定位操作者时间线"`
|
||||
ActorName string `json:"actor_name" description:"事件发生时的操作者名称快照"`
|
||||
ActorShopID *uint `json:"actor_shop_id,omitempty" description:"操作者所属店铺ID快照"`
|
||||
ActorShopName string `json:"actor_shop_name" description:"操作者所属店铺名称快照"`
|
||||
ActorEnterpriseID *uint `json:"actor_enterprise_id,omitempty" description:"操作者所属企业ID快照"`
|
||||
ActorEnterpriseName string `json:"actor_enterprise_name" description:"操作者所属企业名称快照"`
|
||||
Source string `json:"source" enum:"admin_api,personal_api,openapi,worker,scheduler,callback" description:"操作入口来源稳定编码"`
|
||||
RequestPath string `json:"request_path" description:"触发操作的HTTP路径;非HTTP入口可为空"`
|
||||
RequestMethod string `json:"request_method" description:"触发操作的HTTP方法;非HTTP入口可为空"`
|
||||
IPAddress string `json:"ip_address" description:"触发请求的IP地址;非HTTP入口可为空"`
|
||||
UserAgent string `json:"user_agent" description:"触发请求的User-Agent;非HTTP入口可为空"`
|
||||
ScopeType string `json:"scope_type" enum:"platform,shop,personal_customer" description:"业务范围类型稳定编码"`
|
||||
ScopeID string `json:"scope_id" description:"业务范围稳定ID,与scope_type共同使用"`
|
||||
ScopeName string `json:"scope_name" description:"业务范围名称快照"`
|
||||
Result string `json:"result" enum:"success,failed,denied,partial,unknown" description:"事件结果稳定编码"`
|
||||
RiskLevel string `json:"risk_level" enum:"low,normal,high,critical" description:"风险等级稳定编码"`
|
||||
ErrorCode string `json:"error_code" description:"失败或拒绝时的稳定错误码"`
|
||||
ErrorSummary string `json:"error_summary" description:"已脱敏的失败原因摘要"`
|
||||
RequestID string `json:"request_id" description:"HTTP请求关联ID,可传给请求时间线接口"`
|
||||
CorrelationID string `json:"correlation_id" description:"跨请求业务链路ID,可传给关联时间线接口"`
|
||||
ParentEventID string `json:"parent_event_id" description:"批量或异步链路的父审计事件ID"`
|
||||
BatchTotal int `json:"batch_total" description:"批次声明处理总数,非批次为0"`
|
||||
SuccessCount int `json:"success_count" description:"批次成功数,非批次为0"`
|
||||
FailCount int `json:"fail_count" description:"批次失败数,非批次为0"`
|
||||
Metadata map[string]any `json:"metadata" description:"已脱敏的动作扩展元数据,字段由action_code定义"`
|
||||
ContentHash string `json:"content_hash" description:"事件不可变内容摘要"`
|
||||
CreatedAt time.Time `json:"created_at" description:"审计记录写入时间"`
|
||||
Resources []ResourceView `json:"resources" description:"事件涉及的全部资源及各自前后快照"`
|
||||
InvestigationRefs InvestigationRefs `json:"investigation_refs" description:"跨审计视角的稳定跳转参数集合"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
EventID *string `json:"event_id" description:"传给GET /audit/events/{event_id}"`
|
||||
ActorRef *ActorRef `json:"actor_ref" description:"kind/id传给GET /audit/actors/{kind}/{id}/events"`
|
||||
ResourceRefs []InvestigationResourceRef `json:"resource_refs" description:"resource_type/resource_id传给GET /audit/resources/{resource_type}/{resource_id}/timeline;resource_id为空时不可跳转"`
|
||||
RequestID *string `json:"request_id" description:"传给GET /audit/requests/{request_id}/timeline"`
|
||||
CorrelationID *string `json:"correlation_id" description:"传给GET /audit/correlations/{correlation_id}/timeline"`
|
||||
IntegrationRefs []IntegrationRef `json:"integration_refs" description:"integration_id传给GET /audit/integrations/{integration_id}"`
|
||||
}
|
||||
|
||||
// ActorRef 是操作者时间线的稳定引用。
|
||||
type ActorRef struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind" enum:"account,personal_customer,openapi,system_task,scheduled_job,external_system" description:"操作者类型"`
|
||||
ID string `json:"id" description:"操作者稳定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"`
|
||||
ResourceType string `json:"resource_type" description:"Resource Registry注册类型"`
|
||||
ResourceID *string `json:"resource_id" description:"资源内部稳定ID;为空时不展示平台资源时间线入口"`
|
||||
ResourceKey string `json:"resource_key" description:"资源业务稳定Key,用于展示或精确搜索"`
|
||||
DisplayName string `json:"display_name" description:"事件发生时的资源展示名称"`
|
||||
}
|
||||
|
||||
// IntegrationRef 是 Integration 详情的稳定引用。
|
||||
type IntegrationRef struct {
|
||||
IntegrationID string `json:"integration_id"`
|
||||
IntegrationID string `json:"integration_id" description:"稳定外部集成记录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"`
|
||||
ResourceType string `json:"resource_type" description:"Resource Registry注册类型"`
|
||||
ResourceID *string `json:"resource_id,omitempty" description:"资源内部稳定ID"`
|
||||
ResourceKey string `json:"resource_key" description:"资源业务稳定Key"`
|
||||
DisplayName string `json:"display_name" description:"事件发生时的资源展示名称"`
|
||||
Relation string `json:"relation" enum:"primary,affected,reference" description:"资源关系 (primary:主要资源, affected:受影响资源, reference:引用资源)"`
|
||||
Role string `json:"role" description:"Resource Registry定义的资源业务角色编码"`
|
||||
IdentitySnapshot map[string]any `json:"identity_snapshot" description:"事件发生时的资源身份快照"`
|
||||
BeforeData map[string]any `json:"before_data" description:"该资源变更前的完整平台审计数据"`
|
||||
AfterData map[string]any `json:"after_data" description:"该资源变更后的完整平台审计数据"`
|
||||
SubjectVisibility string `json:"subject_visibility" enum:"internal_only,subject_result,subject_detail" description:"主体可见性 (internal_only:仅平台, subject_result:主体可见结论, subject_detail:主体可见安全详情)"`
|
||||
SubjectSummary string `json:"subject_summary" description:"允许代理或企业查看的安全摘要"`
|
||||
SubjectData map[string]any `json:"subject_data" description:"写入时生成的主体安全字段,不等同于before_data或after_data"`
|
||||
SortOrder int `json:"sort_order" description:"资源在事件内的稳定展示顺序"`
|
||||
CreatedAt time.Time `json:"created_at" description:"资源关联记录写入时间"`
|
||||
}
|
||||
|
||||
// Query 提供平台统一审计事件列表与详情读取。
|
||||
|
||||
@@ -40,45 +40,45 @@ type FinanceFilter struct {
|
||||
|
||||
// FinanceTimelinePage 是资金多源投影的稳定分页结果。
|
||||
type FinanceTimelinePage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []FinanceTimelineNode `json:"items"`
|
||||
Retention retentionquery.Info `json:"retention"`
|
||||
Total int64 `json:"total" description:"关联资金事实总数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
Items []FinanceTimelineNode `json:"items" description:"按发生时间稳定倒序的资金事实节点"`
|
||||
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
|
||||
}
|
||||
|
||||
// FinanceTimelineNode 是明确事实来源和金额权威的资金时间线节点。
|
||||
type FinanceTimelineNode struct {
|
||||
RecordSource string `json:"record_source"`
|
||||
NodeID string `json:"node_id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Result string `json:"result"`
|
||||
ResultName string `json:"result_name"`
|
||||
Amount *int64 `json:"amount"`
|
||||
BalanceBefore *int64 `json:"balance_before"`
|
||||
BalanceAfter *int64 `json:"balance_after"`
|
||||
Currency string `json:"currency"`
|
||||
ShopID *uint `json:"shop_id"`
|
||||
Wallet *FinanceWalletRef `json:"wallet"`
|
||||
AmountAuthority FinanceAmountAuthority `json:"amount_authority"`
|
||||
Facts map[string]any `json:"facts"`
|
||||
InvestigationRefs InvestigationRefs `json:"investigation_refs"`
|
||||
RecordSource string `json:"record_source" enum:"audit_event,domain_ledger_ref,agent_wallet_transaction,asset_wallet_transaction,agent_wallet_reservation,order,payment,refund,agent_recharge,recharge_order,commission_record,commission_withdrawal,approval_instance" description:"资金事实来源稳定编码"`
|
||||
NodeID string `json:"node_id" description:"该事实来源内的稳定节点ID"`
|
||||
OccurredAt time.Time `json:"occurred_at" description:"资金事实发生时间"`
|
||||
Code string `json:"code" description:"来源内稳定业务动作或状态编码"`
|
||||
Title string `json:"title" description:"code对应的中文展示名称"`
|
||||
Result string `json:"result" description:"来源内原始结果或状态编码"`
|
||||
ResultName string `json:"result_name" description:"result对应的中文展示名称"`
|
||||
Amount *int64 `json:"amount" description:"本节点金额,单位分;为空表示该节点不承载金额"`
|
||||
BalanceBefore *int64 `json:"balance_before" description:"变更前余额,单位分"`
|
||||
BalanceAfter *int64 `json:"balance_after" description:"变更后余额,单位分"`
|
||||
Currency string `json:"currency" description:"币种编码,人民币为CNY"`
|
||||
ShopID *uint `json:"shop_id" description:"关联店铺ID"`
|
||||
Wallet *FinanceWalletRef `json:"wallet" description:"关联钱包稳定引用"`
|
||||
AmountAuthority FinanceAmountAuthority `json:"amount_authority" description:"金额是否权威及权威字段来源"`
|
||||
Facts map[string]any `json:"facts" description:"该事实来源的安全结构化业务字段"`
|
||||
InvestigationRefs InvestigationRefs `json:"investigation_refs" description:"可继续跳转的稳定调查引用"`
|
||||
}
|
||||
|
||||
// FinanceWalletRef 是资金节点关联的钱包稳定引用。
|
||||
type FinanceWalletRef struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
WalletID uint `json:"wallet_id"`
|
||||
ResourceType string `json:"resource_type" enum:"agent_wallet,asset_wallet" description:"钱包资源类型"`
|
||||
WalletID uint `json:"wallet_id" description:"钱包内部稳定ID,可作为finance/timeline的wallet_id"`
|
||||
}
|
||||
|
||||
// FinanceAmountAuthority 说明当前金额是否为业务权威及其字段来源。
|
||||
type FinanceAmountAuthority struct {
|
||||
Authoritative bool `json:"authoritative"`
|
||||
Table string `json:"table"`
|
||||
Field string `json:"field"`
|
||||
ConflictRule string `json:"conflict_rule"`
|
||||
Authoritative bool `json:"authoritative" description:"当前amount或余额是否来自业务权威表"`
|
||||
Table string `json:"table" description:"权威金额所在业务表;非权威节点可为空"`
|
||||
Field string `json:"field" description:"权威金额所在字段;非权威节点可为空"`
|
||||
ConflictRule string `json:"conflict_rule" description:"多来源冲突时的取值规则说明"`
|
||||
}
|
||||
|
||||
type financeRefs struct {
|
||||
|
||||
@@ -8,12 +8,15 @@ import (
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"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"
|
||||
)
|
||||
|
||||
var timelineRegistry = auditinfra.NewRegistry()
|
||||
|
||||
// ResourceSearchFilter 定义注册资源的精确标识搜索。
|
||||
type ResourceSearchFilter struct {
|
||||
ResourceType string
|
||||
@@ -25,21 +28,21 @@ type ResourceSearchFilter struct {
|
||||
|
||||
// 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"`
|
||||
Total int64 `json:"total" description:"符合精确标识的资源总数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
Items []ResourceCandidate `json:"items" description:"当前资源或历史快照解析出的候选资源"`
|
||||
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
ResourceType string `json:"resource_type" enum:"iot_card,device,shop,order,refund" description:"资源类型稳定编码"`
|
||||
ResourceID string `json:"resource_id" description:"资源内部稳定ID,可传给通用资源时间线接口"`
|
||||
ResourceKey string `json:"resource_key" description:"资源业务稳定Key"`
|
||||
DisplayName string `json:"display_name" description:"资源展示名称"`
|
||||
IdentitySnapshot map[string]any `json:"identity_snapshot" description:"当前业务表或历史事件保存的资源身份快照"`
|
||||
Historical bool `json:"historical" description:"是否仅由历史事件快照解析,true不代表资源当前仍存在"`
|
||||
}
|
||||
|
||||
// ResourceTimelineFilter 定义通用资源时间线筛选。
|
||||
@@ -95,16 +98,8 @@ func (q *Query) ResourceTimeline(ctx context.Context, filter ResourceTimelineFil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
_, ok := timelineRegistry.Resource(resourceType)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (q *Query) searchCurrent(ctx context.Context, filter ResourceSearchFilter) ([]ResourceCandidate, int64, error) {
|
||||
|
||||
@@ -26,44 +26,44 @@ type RiskFilter struct {
|
||||
|
||||
// RiskOverview 是风险信号、固定维度与时间趋势的只读聚合。
|
||||
type RiskOverview struct {
|
||||
Total int64 `json:"total"`
|
||||
Bucket string `json:"bucket"`
|
||||
Signals []RiskNamedCount `json:"signals"`
|
||||
Risks []RiskNamedCount `json:"risks"`
|
||||
Results []RiskNamedCount `json:"results"`
|
||||
Actions []RiskNamedCount `json:"actions"`
|
||||
Sources []RiskNamedCount `json:"sources"`
|
||||
Trend []RiskTrendPoint `json:"trend"`
|
||||
Retention retentionquery.Info `json:"retention"`
|
||||
Total int64 `json:"total" description:"固定风险集合内的事件总数"`
|
||||
Bucket string `json:"bucket" enum:"hour,day" description:"服务端选择的趋势时间粒度"`
|
||||
Signals []RiskNamedCount `json:"signals" description:"固定信号分布,code为high_risk、finance、security、failed、denied、partial或unknown,name为中文展示名"`
|
||||
Risks []RiskNamedCount `json:"risks" description:"风险等级分布,code为low、normal、high或critical,name为中文展示名"`
|
||||
Results []RiskNamedCount `json:"results" description:"结果分布,code为success、failed、denied、partial或unknown,name为中文展示名"`
|
||||
Actions []RiskNamedCount `json:"actions" description:"动作分布,code为稳定action_code,name为中文action_name"`
|
||||
Sources []RiskNamedCount `json:"sources" description:"来源分布,code为admin_api、personal_api、openapi、worker、scheduler或callback,name为中文展示名"`
|
||||
Trend []RiskTrendPoint `json:"trend" description:"固定风险信号的时间趋势"`
|
||||
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
|
||||
}
|
||||
|
||||
// RiskNamedCount 是风险聚合维度的稳定编码、中文名称和数量。
|
||||
type RiskNamedCount struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
Code string `json:"code" description:"当前聚合维度的稳定编码,具体枚举域由所属数组字段说明"`
|
||||
Name string `json:"name" description:"code对应的中文展示名称"`
|
||||
Count int64 `json:"count" description:"该编码的事件数量"`
|
||||
}
|
||||
|
||||
// RiskTrendPoint 是固定时间桶内的风险信号趋势。
|
||||
type RiskTrendPoint struct {
|
||||
BucketAt time.Time `json:"bucket_at"`
|
||||
Total int64 `json:"total"`
|
||||
HighRisk int64 `json:"high_risk"`
|
||||
Finance int64 `json:"finance"`
|
||||
Security int64 `json:"security"`
|
||||
Failed int64 `json:"failed"`
|
||||
Denied int64 `json:"denied"`
|
||||
Partial int64 `json:"partial"`
|
||||
Unknown int64 `json:"unknown"`
|
||||
BucketAt time.Time `json:"bucket_at" description:"时间桶起点"`
|
||||
Total int64 `json:"total" description:"桶内固定风险集合事件数"`
|
||||
HighRisk int64 `json:"high_risk" description:"桶内high或critical风险事件数"`
|
||||
Finance int64 `json:"finance" description:"桶内资金类风险事件数"`
|
||||
Security int64 `json:"security" description:"桶内安全类风险事件数"`
|
||||
Failed int64 `json:"failed" description:"桶内failed事件数"`
|
||||
Denied int64 `json:"denied" description:"桶内denied事件数"`
|
||||
Partial int64 `json:"partial" description:"桶内partial事件数"`
|
||||
Unknown int64 `json:"unknown" description:"桶内unknown事件数"`
|
||||
}
|
||||
|
||||
// RiskEventPage 是风险事件的稳定分页结果。
|
||||
type RiskEventPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []EventView `json:"items"`
|
||||
Retention retentionquery.Info `json:"retention"`
|
||||
Total int64 `json:"total" description:"符合条件的风险事件总数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
Items []EventView `json:"items" description:"风险事件及稳定调查引用"`
|
||||
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
|
||||
}
|
||||
|
||||
// RiskOverview 查询指定时间范围内的固定风险调查总览。
|
||||
|
||||
@@ -27,31 +27,31 @@ type SubjectActivityFilter struct {
|
||||
|
||||
// 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"`
|
||||
Retention retentionquery.Info `json:"retention"`
|
||||
Resource SubjectResourceSummary `json:"resource" description:"已完成授权校验的目标资源"`
|
||||
Total int64 `json:"total" description:"主体可见活动总数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
Items []SubjectActivity `json:"items" description:"不包含平台内部调查字段的安全活动列表"`
|
||||
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
ActionCode string `json:"action_code" description:"稳定动作编码"`
|
||||
ActionName string `json:"action_name" description:"action_code对应的中文展示名称"`
|
||||
SubjectSummary string `json:"subject_summary" description:"写入时生成的主体安全摘要"`
|
||||
SubjectData map[string]any `json:"subject_data" description:"写入时生成的主体安全业务字段,不包含平台before/after或内部原因"`
|
||||
Result string `json:"result" enum:"success,failed,denied,partial,unknown" description:"活动结果稳定编码"`
|
||||
OccurredAt time.Time `json:"occurred_at" description:"业务事实发生时间"`
|
||||
RelatedResources []SubjectResourceSummary `json:"related_resources" description:"当前主体授权范围内的相关资源摘要"`
|
||||
}
|
||||
|
||||
// SubjectResourceSummary 是主体活动允许公开的资源摘要。
|
||||
type SubjectResourceSummary struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ResourceType string `json:"resource_type" description:"资源类型稳定编码"`
|
||||
ResourceID string `json:"resource_id" description:"资源内部稳定ID;主体前端不据此调用平台审计接口"`
|
||||
ResourceKey string `json:"resource_key" description:"资源业务稳定Key"`
|
||||
DisplayName string `json:"display_name" description:"资源安全展示名称"`
|
||||
}
|
||||
|
||||
type subjectTarget struct {
|
||||
@@ -60,14 +60,15 @@ type subjectTarget struct {
|
||||
}
|
||||
|
||||
type subjectActivityRow struct {
|
||||
ID uint
|
||||
ActionCode string
|
||||
ActionName string
|
||||
Result string
|
||||
OccurredAt time.Time
|
||||
SubjectSummary string
|
||||
SubjectData datatypes.JSON
|
||||
TargetResourceID uint
|
||||
ID uint
|
||||
ActionCode string
|
||||
ActionName string
|
||||
Result string
|
||||
OccurredAt time.Time
|
||||
SubjectVisibility string
|
||||
SubjectSummary string
|
||||
SubjectData datatypes.JSON
|
||||
TargetResourceID uint
|
||||
}
|
||||
|
||||
type subjectResourceAuthorizer func(context.Context, []model.AuditEventResource) (map[string]bool, error)
|
||||
@@ -78,9 +79,12 @@ func (q *Query) AgentResourceActivities(ctx context.Context, filter SubjectActiv
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter.Identifier == "" || !agentActivityResourceType(filter.ResourceType) || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
|
||||
if filter.Identifier == "" || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if !agentActivityResourceType(filter.ResourceType) {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||||
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
|
||||
if err != nil {
|
||||
@@ -108,9 +112,12 @@ func (q *Query) EnterpriseResourceActivities(ctx context.Context, filter Subject
|
||||
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 {
|
||||
if filter.Identifier == "" || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if !enterpriseActivityResourceType(filter.ResourceType) {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||||
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
|
||||
if err != nil {
|
||||
@@ -142,13 +149,13 @@ func (q *Query) subjectActivitiesForTarget(ctx context.Context, filter SubjectAc
|
||||
}
|
||||
|
||||
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").
|
||||
if err := base.Select("tb_audit_event.id, action_code, action_name, result, occurred_at, target.subject_visibility, 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)
|
||||
items, err := q.projectSubjectActivities(ctx, rows, target, authorize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -265,24 +272,28 @@ func newSubjectTarget(resourceType string, id uint, key, name string) subjectTar
|
||||
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) {
|
||||
func (q *Query) projectSubjectActivities(ctx context.Context, rows []subjectActivityRow, target subjectTarget, 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, "批量查询主体可见关联资源失败")
|
||||
resources := make([]model.AuditEventResource, 0)
|
||||
if len(eventIDs) > 0 {
|
||||
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)
|
||||
targetResourceID := target.id
|
||||
authorizationResources := append(resources, model.AuditEventResource{ResourceType: target.summary.ResourceType, ResourceID: &targetResourceID})
|
||||
allowed, err := authorize(ctx, authorizationResources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !allowed[resourceAccessKey(target.summary.ResourceType, target.id)] {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
related := make(map[uint][]SubjectResourceSummary, len(rows))
|
||||
for _, resource := range resources {
|
||||
if resource.ResourceID == nil || !allowed[resourceAccessKey(resource.ResourceType, *resource.ResourceID)] {
|
||||
@@ -294,9 +305,12 @@ func (q *Query) projectSubjectActivities(ctx context.Context, rows []subjectActi
|
||||
})
|
||||
}
|
||||
for _, row := range rows {
|
||||
data, err := decodeObject(row.SubjectData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
data := map[string]any{}
|
||||
if row.SubjectVisibility == constants.AuditSubjectDetail {
|
||||
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,
|
||||
|
||||
@@ -15,39 +15,39 @@ import (
|
||||
|
||||
// LinkTimeline 是 request 或 correlation 的跨事实只读时间线。
|
||||
type LinkTimeline struct {
|
||||
RequestID *string `json:"request_id"`
|
||||
CorrelationID *string `json:"correlation_id"`
|
||||
AccessLogLookupRequestID *string `json:"access_log_lookup_request_id"`
|
||||
Nodes []LinkTimelineNode `json:"nodes"`
|
||||
Retention retentionquery.Info `json:"retention"`
|
||||
RequestID *string `json:"request_id" description:"本次按请求查询的稳定ID"`
|
||||
CorrelationID *string `json:"correlation_id" description:"本次按业务关联查询的稳定ID"`
|
||||
AccessLogLookupRequestID *string `json:"access_log_lookup_request_id" description:"可复制到Access Log检索的request_id;本接口自身不扫描Access Log"`
|
||||
Nodes []LinkTimelineNode `json:"nodes" description:"跨事实来源按发生时间稳定排序的节点"`
|
||||
Retention retentionquery.Info `json:"retention" description:"Audit与Integration共同在线留存边界"`
|
||||
}
|
||||
|
||||
// LinkTimelineNode 是保留各事实源权威边界的时间线节点。
|
||||
type LinkTimelineNode struct {
|
||||
RecordSource string `json:"record_source"`
|
||||
NodeID string `json:"node_id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Result string `json:"result"`
|
||||
ResultName string `json:"result_name"`
|
||||
Summary string `json:"summary"`
|
||||
ReferenceOnly bool `json:"reference_only"`
|
||||
RequestID *string `json:"request_id"`
|
||||
CorrelationID *string `json:"correlation_id"`
|
||||
ParentEventID *string `json:"parent_event_id"`
|
||||
Resources []InvestigationResourceRef `json:"resources"`
|
||||
InvestigationRefs InvestigationRefs `json:"investigation_refs"`
|
||||
Fidelity LinkageFidelity `json:"fidelity"`
|
||||
RecordSource string `json:"record_source" enum:"audit_event,integration_log,outbox_event,asynq_task,domain_ledger_ref" description:"事实来源 (audit_event:审计事件, integration_log:外部交互, outbox_event:可靠事件引用, asynq_task:异步任务引用, domain_ledger_ref:业务账本引用)"`
|
||||
NodeID string `json:"node_id" description:"该事实来源内的稳定节点ID"`
|
||||
OccurredAt time.Time `json:"occurred_at" description:"节点发生时间"`
|
||||
Code string `json:"code" description:"来源内稳定动作、操作或事件编码"`
|
||||
Title string `json:"title" description:"code对应的中文展示名称"`
|
||||
Result string `json:"result" description:"来源内原始结果稳定编码"`
|
||||
ResultName string `json:"result_name" description:"result对应的中文展示名称"`
|
||||
Summary string `json:"summary" description:"已脱敏节点摘要"`
|
||||
ReferenceOnly bool `json:"reference_only" description:"true表示仅保存其他事实的引用,不代表该来源独立完成业务状态变更"`
|
||||
RequestID *string `json:"request_id" description:"HTTP请求关联ID"`
|
||||
CorrelationID *string `json:"correlation_id" description:"跨请求业务链路ID"`
|
||||
ParentEventID *string `json:"parent_event_id" description:"父审计事件ID"`
|
||||
Resources []InvestigationResourceRef `json:"resources" description:"节点可稳定定位的资源引用"`
|
||||
InvestigationRefs InvestigationRefs `json:"investigation_refs" description:"可继续跳转的稳定调查引用"`
|
||||
Fidelity LinkageFidelity `json:"fidelity" description:"历史字段完整度和可关联能力"`
|
||||
}
|
||||
|
||||
// LinkageFidelity 明确节点已有的稳定关联能力,不补猜历史缺失字段。
|
||||
type LinkageFidelity struct {
|
||||
RequestAvailable bool `json:"request_available"`
|
||||
CorrelationAvailable bool `json:"correlation_available"`
|
||||
ParentEventAvailable bool `json:"parent_event_available"`
|
||||
DirectAuditLinkAvailable bool `json:"direct_audit_link_available"`
|
||||
StableResourceAvailable bool `json:"stable_resource_available"`
|
||||
RequestAvailable bool `json:"request_available" description:"是否有稳定request_id"`
|
||||
CorrelationAvailable bool `json:"correlation_available" description:"是否有稳定correlation_id"`
|
||||
ParentEventAvailable bool `json:"parent_event_available" description:"是否有稳定parent_event_id"`
|
||||
DirectAuditLinkAvailable bool `json:"direct_audit_link_available" description:"是否可直接跳转审计事件详情"`
|
||||
StableResourceAvailable bool `json:"stable_resource_available" description:"是否至少有一个含resource_id的稳定资源引用"`
|
||||
}
|
||||
|
||||
// RequestTimeline 按精确 request ID 组合已持久化事实,不扫描 Access Log。
|
||||
|
||||
@@ -36,134 +36,137 @@ type ListFilter struct {
|
||||
|
||||
// ListPage 是按创建时间和主键稳定倒序的分页结果。
|
||||
type ListPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []ListItem `json:"items"`
|
||||
Retention retentionquery.Info `json:"retention"`
|
||||
Total int64 `json:"total" description:"符合条件的外部交互总数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
Items []ListItem `json:"items" description:"按创建时间和主键稳定倒序的外部交互"`
|
||||
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
IntegrationID string `json:"integration_id" description:"稳定外部集成记录ID,可传给详情接口"`
|
||||
Provider string `json:"provider" enum:"ctcc,cmcc,cucc,wechat_pay,alipay,fuiou,wecom,gateway" description:"外部服务提供方稳定编码"`
|
||||
ProviderName string `json:"provider_name" description:"provider对应的中文展示名称"`
|
||||
Direction string `json:"direction" enum:"inbound,outbound" description:"交互方向稳定编码"`
|
||||
DirectionName string `json:"direction_name" description:"direction对应的中文展示名称"`
|
||||
Operation string `json:"operation" enum:"realname_callback,realname_removal_callback,payment_precreate,payment_query,payment_callback,get_access_token,list_visible_members,list_visible_departments,get_template_detail,upload_approval_attachment,submit_approval,approval_callback,get_approval_detail,get_approval_info,query_realname_status,query_flow,query_card_status,query_device_info,set_speed_tier,stop_card,start_card,set_device_wifi,set_device_switch_mode,switch_device_card,reboot_device,reset_device" description:"外部操作稳定编码,可直接用于列表筛选"`
|
||||
OperationName string `json:"operation_name" description:"operation对应的中文展示名称"`
|
||||
Resource ResourceView `json:"resource" description:"外部交互直接关联的本地主要资源"`
|
||||
Result string `json:"result" enum:"pending,success,failed,unknown,not_found,invalid_payload,conflict,ignored,merged,rate_limited,completed,cancelled" description:"外部交互原始结果稳定编码"`
|
||||
ResultName string `json:"result_name" description:"result对应的中文展示名称"`
|
||||
ResultCategory string `json:"result_category" enum:"processing,succeeded,indeterminate,failed,not_sent" description:"由result派生的固定结果类别"`
|
||||
DurationMS int64 `json:"duration_ms" description:"交互耗时,单位毫秒"`
|
||||
StateChanged bool `json:"state_changed" description:"本次交互是否改变本地业务状态"`
|
||||
RequestID *string `json:"request_id" description:"来源HTTP请求ID,可跳转请求时间线"`
|
||||
CorrelationID *string `json:"correlation_id" description:"跨步骤业务链路ID,可跳转关联时间线"`
|
||||
CreatedAt time.Time `json:"created_at" description:"外部交互记录创建时间"`
|
||||
}
|
||||
|
||||
// ResourceView 是外部交互直接主资源投影。
|
||||
type ResourceView struct {
|
||||
Type *string `json:"type"`
|
||||
ID *string `json:"id"`
|
||||
Key *string `json:"key"`
|
||||
Type *string `json:"type" description:"Resource Registry注册类型"`
|
||||
ID *string `json:"id" description:"资源内部稳定ID;type和id均有值时可跳转资源时间线"`
|
||||
Key *string `json:"key" description:"资源业务稳定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"`
|
||||
Identity IdentityView `json:"identity" description:"提供方、方向、操作和外部标识"`
|
||||
Resource ResourceView `json:"resource" description:"本地主要资源引用"`
|
||||
Trigger TriggerView `json:"trigger" description:"触发来源、场景和显式尝试序列"`
|
||||
Result ResultView `json:"result" description:"原始结果、派生类别和本地状态变化"`
|
||||
Content ContentView `json:"content" description:"已脱敏的结构化请求、响应和元数据摘要"`
|
||||
Linkage LinkageView `json:"linkage" description:"可跳转请求、关联和审计视角的稳定字段"`
|
||||
Timestamps TimestampView `json:"timestamps" description:"调度、开始、创建和更新时间"`
|
||||
Attempts []AttemptView `json:"attempts" description:"同一trigger_series下按attempt排序的技术尝试"`
|
||||
Fidelity FidelityView `json:"fidelity" description:"历史记录字段完整度,false时禁止前端猜测关联"`
|
||||
}
|
||||
|
||||
// DetailResponse 是外部交互详情及在线留存边界。
|
||||
type DetailResponse struct {
|
||||
Detail
|
||||
Retention retentionquery.Info `json:"retention"`
|
||||
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
IntegrationID string `json:"integration_id" description:"本次尝试的稳定外部集成记录ID"`
|
||||
Attempt int `json:"attempt" description:"同一显式序列内从1开始的尝试序号"`
|
||||
Operation string `json:"operation" enum:"realname_callback,realname_removal_callback,payment_precreate,payment_query,payment_callback,get_access_token,list_visible_members,list_visible_departments,get_template_detail,upload_approval_attachment,submit_approval,approval_callback,get_approval_detail,get_approval_info,query_realname_status,query_flow,query_card_status,query_device_info,set_speed_tier,stop_card,start_card,set_device_wifi,set_device_switch_mode,switch_device_card,reboot_device,reset_device" description:"外部操作稳定编码"`
|
||||
OperationName string `json:"operation_name" description:"operation对应的中文展示名称"`
|
||||
Sent bool `json:"sent" description:"是否实际向外部系统发送请求"`
|
||||
Result string `json:"result" enum:"pending,success,failed,unknown,not_found,invalid_payload,conflict,ignored,merged,rate_limited,completed,cancelled" description:"本次尝试原始结果"`
|
||||
ResultName string `json:"result_name" description:"result对应的中文展示名称"`
|
||||
ResultCategory string `json:"result_category" enum:"processing,succeeded,indeterminate,failed,not_sent" description:"本次尝试的派生结果类别"`
|
||||
DurationMS int64 `json:"duration_ms" description:"本次尝试耗时,单位毫秒"`
|
||||
StateChanged bool `json:"state_changed" description:"本次尝试是否改变本地业务状态"`
|
||||
CreatedAt time.Time `json:"created_at" description:"本次尝试记录创建时间"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
TriggerSeriesAvailable bool `json:"trigger_series_available" description:"是否存在显式trigger_series"`
|
||||
AttemptSequenceReliable bool `json:"attempt_sequence_reliable" description:"attempt是否连续且operation一致"`
|
||||
CorrelationAvailable bool `json:"correlation_available" description:"是否存在稳定correlation_id"`
|
||||
ResourceIDAvailable bool `json:"resource_id_available" description:"是否存在稳定本地resource.id"`
|
||||
ProviderMessageFidelity string `json:"provider_message_fidelity" description:"外部消息保真等级;受限时只展示已脱敏摘要"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
IntegrationID string `json:"integration_id" description:"稳定外部集成记录ID"`
|
||||
Provider string `json:"provider" enum:"ctcc,cmcc,cucc,wechat_pay,alipay,fuiou,wecom,gateway" description:"外部服务提供方稳定编码"`
|
||||
ProviderName string `json:"provider_name" description:"provider对应的中文展示名称"`
|
||||
Direction string `json:"direction" enum:"inbound,outbound" description:"交互方向稳定编码"`
|
||||
DirectionName string `json:"direction_name" description:"direction对应的中文展示名称"`
|
||||
Operation string `json:"operation" enum:"realname_callback,realname_removal_callback,payment_precreate,payment_query,payment_callback,get_access_token,list_visible_members,list_visible_departments,get_template_detail,upload_approval_attachment,submit_approval,approval_callback,get_approval_detail,get_approval_info,query_realname_status,query_flow,query_card_status,query_device_info,set_speed_tier,stop_card,start_card,set_device_wifi,set_device_switch_mode,switch_device_card,reboot_device,reset_device" description:"外部操作稳定编码"`
|
||||
OperationName string `json:"operation_name" description:"operation对应的中文展示名称"`
|
||||
ExternalID *string `json:"external_id" description:"外部系统业务或请求标识"`
|
||||
}
|
||||
|
||||
// TriggerView 是外部交互触发分组。
|
||||
type TriggerView struct {
|
||||
Source *string `json:"source"`
|
||||
Scene *string `json:"scene"`
|
||||
Series *string `json:"series"`
|
||||
Attempt int `json:"attempt"`
|
||||
Source *string `json:"source" description:"触发来源稳定编码"`
|
||||
Scene *string `json:"scene" description:"触发业务场景"`
|
||||
Series *string `json:"series" description:"显式技术尝试序列ID;为空时禁止按时间或资源猜测重试关系"`
|
||||
Attempt int `json:"attempt" description:"显式序列内的尝试序号"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Code string `json:"code" enum:"pending,success,failed,unknown,not_found,invalid_payload,conflict,ignored,merged,rate_limited,completed,cancelled" description:"原始结果稳定编码"`
|
||||
Name string `json:"name" description:"code对应的中文展示名称"`
|
||||
Category string `json:"category" enum:"processing,succeeded,indeterminate,failed,not_sent" description:"由code派生的固定结果类别"`
|
||||
HTTPStatus *int `json:"http_status" description:"外部HTTP响应状态码"`
|
||||
ProviderCode *string `json:"provider_code" description:"外部服务稳定结果码"`
|
||||
ProviderMessage *string `json:"provider_message" description:"已脱敏的外部结果摘要"`
|
||||
DurationMS int64 `json:"duration_ms" description:"交互耗时,单位毫秒"`
|
||||
StateChanged bool `json:"state_changed" description:"是否改变本地业务状态"`
|
||||
RecoveryStrategy *string `json:"recovery_strategy" description:"已脱敏的既有恢复策略说明;本接口不执行恢复"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
RequestSummary map[string]any `json:"request_summary" description:"按白名单重新清理的请求摘要"`
|
||||
ResponseSummary map[string]any `json:"response_summary" description:"按白名单重新清理的响应摘要"`
|
||||
Metadata map[string]any `json:"metadata" description:"按白名单重新清理的扩展元数据"`
|
||||
ContentHash string `json:"content_hash" description:"持久化内容摘要"`
|
||||
}
|
||||
|
||||
// LinkageView 是外部交互关联分组。
|
||||
type LinkageView struct {
|
||||
RequestID *string `json:"request_id"`
|
||||
CorrelationID *string `json:"correlation_id"`
|
||||
AuditEventID *uint `json:"audit_event_id"`
|
||||
RequestID *string `json:"request_id" description:"传给GET /audit/requests/{request_id}/timeline"`
|
||||
CorrelationID *string `json:"correlation_id" description:"传给GET /audit/correlations/{correlation_id}/timeline"`
|
||||
AuditEventID *uint `json:"audit_event_id" description:"内部审计事件数据库引用;前端优先使用调查接口返回的稳定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"`
|
||||
ScheduledAt *time.Time `json:"scheduled_at" description:"计划发送时间"`
|
||||
StartedAt *time.Time `json:"started_at" description:"实际开始时间"`
|
||||
CreatedAt time.Time `json:"created_at" description:"记录创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"记录最后更新时间"`
|
||||
}
|
||||
|
||||
// Query 提供平台 Integration Log 列表和详情读取。
|
||||
@@ -253,7 +256,7 @@ func (q *Query) Get(ctx context.Context, integrationID string) (*DetailResponse,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attempts, err := q.loadAttempts(ctx, row, retention.OnlineFrom)
|
||||
attempts, attemptSequenceReliable, err := q.loadAttempts(ctx, row, retention.OnlineFrom)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -261,13 +264,14 @@ func (q *Query) Get(ctx context.Context, integrationID string) (*DetailResponse,
|
||||
return &DetailResponse{Detail: 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},
|
||||
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: sanitizedTextPointer(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 != "",
|
||||
AttemptSequenceReliable: attemptSequenceReliable,
|
||||
CorrelationAvailable: row.CorrelationID != nil && *row.CorrelationID != "",
|
||||
ResourceIDAvailable: row.ResourceID != nil && *row.ResourceID != "",
|
||||
ProviderMessageFidelity: providerMessageFidelity,
|
||||
@@ -275,25 +279,31 @@ func (q *Query) Get(ctx context.Context, integrationID string) (*DetailResponse,
|
||||
}, Retention: retention}, nil
|
||||
}
|
||||
|
||||
func (q *Query) loadAttempts(ctx context.Context, current model.IntegrationLog, onlineFrom time.Time) ([]AttemptView, error) {
|
||||
func (q *Query) loadAttempts(ctx context.Context, current model.IntegrationLog, onlineFrom time.Time) ([]AttemptView, bool, error) {
|
||||
rows := []model.IntegrationLog{current}
|
||||
if current.TriggerSeries != nil && *current.TriggerSeries != "" {
|
||||
if err := q.db.WithContext(ctx).Where("trigger_series = ? AND created_at >= ?", *current.TriggerSeries, onlineFrom.UTC()).
|
||||
Order("attempt ASC, created_at ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互尝试序列失败")
|
||||
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "查询外部交互尝试序列失败")
|
||||
}
|
||||
}
|
||||
items := make([]AttemptView, len(rows))
|
||||
reliable := current.TriggerSeries != nil && *current.TriggerSeries != ""
|
||||
expectedAttempt := 1
|
||||
for index, row := range rows {
|
||||
category := constants.IntegrationResultCategory(row.Result)
|
||||
items[index] = AttemptView{
|
||||
IntegrationID: row.IntegrationID, Attempt: row.Attempt,
|
||||
IntegrationID: row.IntegrationID, Attempt: row.Attempt, Operation: row.Operation, OperationName: constants.IntegrationOperationName(row.Operation),
|
||||
Sent: category != constants.IntegrationResultCategoryNotSent,
|
||||
Result: row.Result, ResultName: constants.IntegrationResultName(row.Result), ResultCategory: category,
|
||||
DurationMS: row.DurationMS, StateChanged: row.StateChanged, CreatedAt: row.CreatedAt,
|
||||
}
|
||||
if row.Operation != current.Operation || row.Attempt != expectedAttempt {
|
||||
reliable = false
|
||||
}
|
||||
expectedAttempt = row.Attempt + 1
|
||||
}
|
||||
return items, nil
|
||||
return items, reliable, nil
|
||||
}
|
||||
|
||||
func (q *Query) authorize(ctx context.Context) error {
|
||||
@@ -418,12 +428,23 @@ func safeProviderMessage(value *string) (*string, string) {
|
||||
}
|
||||
if len(*value) >= len(constants.IntegrationSafeMessagePrefix) && (*value)[:len(constants.IntegrationSafeMessagePrefix)] == constants.IntegrationSafeMessagePrefix {
|
||||
message := (*value)[len(constants.IntegrationSafeMessagePrefix):]
|
||||
if sanitized := sanitizer.SanitizeText(message); sanitized != message {
|
||||
return &sanitized, "redacted"
|
||||
}
|
||||
return &message, "readable"
|
||||
}
|
||||
summary := sanitizer.TextSummary(*value)
|
||||
return &summary, "historical_redacted"
|
||||
}
|
||||
|
||||
func sanitizedTextPointer(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
sanitized := sanitizer.SanitizeText(*value)
|
||||
return &sanitized
|
||||
}
|
||||
|
||||
func normalizePage(page, pageSize int) (int, int) {
|
||||
if page < 1 {
|
||||
page = constants.DefaultPage
|
||||
|
||||
@@ -20,44 +20,44 @@ type OverviewFilter struct {
|
||||
|
||||
// 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"`
|
||||
Retention retentionquery.Info `json:"retention"`
|
||||
Total int64 `json:"total" description:"外部交互总数"`
|
||||
AnomalyCount int64 `json:"anomaly_count" description:"failed或indeterminate类别的异常交互数"`
|
||||
UnknownCount int64 `json:"unknown_count" description:"原始结果为unknown的交互数"`
|
||||
StalePendingCount int64 `json:"stale_pending_count" description:"超过既定阈值仍为pending的交互数"`
|
||||
StateChangedCount int64 `json:"state_changed_count" description:"改变本地业务状态的交互数"`
|
||||
AverageDurationMS float64 `json:"average_duration_ms" description:"平均交互耗时,单位毫秒"`
|
||||
P95DurationMS float64 `json:"p95_duration_ms" description:"P95交互耗时,单位毫秒"`
|
||||
Results []ResultCount `json:"results" description:"原始结果分布,code/name/category分别为稳定编码、中文名和派生类别"`
|
||||
Providers []NamedCount `json:"providers" description:"提供方分布,code为provider枚举,name为中文展示名"`
|
||||
Directions []NamedCount `json:"directions" description:"方向分布,code为inbound或outbound,name为中文展示名"`
|
||||
Trend []TrendPoint `json:"trend" description:"五类派生结果的时间趋势"`
|
||||
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
|
||||
}
|
||||
|
||||
// ResultCount 是原始结果及其派生类别计数。
|
||||
type ResultCount struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Count int64 `json:"count"`
|
||||
Code string `json:"code" enum:"pending,success,failed,unknown,not_found,invalid_payload,conflict,ignored,merged,rate_limited,completed,cancelled" description:"外部交互原始结果稳定编码"`
|
||||
Name string `json:"name" description:"code对应的中文展示名称"`
|
||||
Category string `json:"category" enum:"processing,succeeded,indeterminate,failed,not_sent" description:"由code派生的固定结果类别"`
|
||||
Count int64 `json:"count" description:"该原始结果的交互数量"`
|
||||
}
|
||||
|
||||
// NamedCount 是稳定编码、中文名称和数量。
|
||||
type NamedCount struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
Code string `json:"code" description:"当前聚合维度的稳定编码,枚举域由所属数组字段说明"`
|
||||
Name string `json:"name" description:"code对应的中文展示名称"`
|
||||
Count int64 `json:"count" description:"该编码的交互数量"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
BucketAt time.Time `json:"bucket_at" description:"时间桶起点"`
|
||||
Total int64 `json:"total" description:"桶内外部交互总数"`
|
||||
Succeeded int64 `json:"succeeded" description:"桶内succeeded类别数量"`
|
||||
Processing int64 `json:"processing" description:"桶内processing类别数量"`
|
||||
Indeterminate int64 `json:"indeterminate" description:"桶内indeterminate类别数量"`
|
||||
Failed int64 `json:"failed" description:"桶内failed类别数量"`
|
||||
NotSent int64 `json:"not_sent" description:"桶内not_sent类别数量"`
|
||||
}
|
||||
|
||||
// Overview 查询指定时间范围的固定维度外部交互总览。
|
||||
|
||||
@@ -15,14 +15,14 @@ func registerAuditRoutes(router fiber.Router, handler *admin.AuditHandler, doc *
|
||||
agent := router.Group("/agent/resource-activities")
|
||||
Register(agent, doc, basePath+"/agent/resource-activities", "GET", "/:resource_type/:identifier", handler.AgentResourceActivities, RouteSpec{
|
||||
Summary: "查询代理资源活动",
|
||||
Description: "resource_type/identifier 来自代理当前业务页面稳定字段;店铺范围只读取认证上下文。仅查询 retention 标明的在线窗口,归档范围不从对象存储读取。",
|
||||
Description: "代理业务页映射:卡详情使用 `resource_type=iot_card`、`identifier=response.data.iccid`;设备详情使用 `device`、`response.data.virtual_no`;分配详情使用 `asset_allocation_record` 和分配单号;换货详情使用 `exchange_order` 和换货单号;店铺详情使用 `shop` 和店铺编号;企业详情使用 `enterprise` 和企业编号。身份与店铺范围只读取认证上下文,前端不得传入或推断。缺少稳定 identifier 时隐藏入口。仅查询 `retention` 标明的在线窗口。",
|
||||
Tags: []string{"资源活动"}, Input: new(dto.SubjectResourceActivityRequest), Output: new(auditquery.SubjectActivityPage), Auth: true,
|
||||
})
|
||||
enterprise := router.Group("/enterprise/resource-activities")
|
||||
Register(enterprise, doc, basePath+"/enterprise/resource-activities", "GET", "/:resource_type/:identifier", handler.EnterpriseResourceActivities, RouteSpec{
|
||||
Summary: "查询企业资源活动",
|
||||
Description: "仅支持企业当前有效授权的卡和设备;企业身份只读取认证上下文。仅查询 retention 标明的在线窗口,响应不包含平台内部调查字段。",
|
||||
Tags: []string{"资源活动"}, Input: new(dto.SubjectResourceActivityRequest), Output: new(auditquery.SubjectActivityPage), Auth: true,
|
||||
Description: "企业仅支持当前有效授权资产:卡列表/详情使用 `resource_type=iot_card`、`identifier=response.data.iccid`;设备列表/详情使用 `resource_type=device`、`identifier=response.data.virtual_no`。企业身份与授权范围只读取认证上下文,前端不得传入或推断。缺少稳定 identifier 时隐藏入口。响应只含主体安全投影,不含平台操作者、风险、内部原因或 before/after。",
|
||||
Tags: []string{"资源活动"}, Input: new(dto.EnterpriseResourceActivityRequest), Output: new(auditquery.SubjectActivityPage), Auth: true,
|
||||
})
|
||||
|
||||
audit := router.Group("/audit")
|
||||
@@ -30,69 +30,69 @@ func registerAuditRoutes(router fiber.Router, handler *admin.AuditHandler, doc *
|
||||
|
||||
Register(audit, doc, groupPath, "GET", "/events", handler.ListEvents, RouteSpec{
|
||||
Summary: "查询全局审计事件",
|
||||
Description: "筛选值来自调查人员输入或其他调查节点的稳定引用;缺省只查 retention 标明的在线窗口,归档范围返回稳定错误。固定倒序分页,不提供导出、修改或删除。",
|
||||
Description: "平台业务页按内部 ID 进入:卡 `resource_type=iot_card&resource_id=response.data.id`,设备 `device/id`,账号 `account/id`,店铺 `shop/id`,企业 `enterprise/id`,订单 `order/id`,退款 `refund/id`,充值 `agent_recharge/id`。筛选 action 必须使用响应 `action_code`,不可用中文名称反推。缺少稳定 ID 时隐藏入口。固定倒序分页,只查 `retention` 在线窗口,不提供导出、修改或删除。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditEventListRequest), Output: new(auditquery.EventPage), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/events/:event_id", handler.GetEvent, RouteSpec{
|
||||
Summary: "查询审计事件详情",
|
||||
Description: "event_id 来自 investigation_refs;只查询在线 PostgreSQL,未命中仍返回资源不存在,不扫描对象存储。返回全部资源快照和各资源 before/after。",
|
||||
Description: "`event_id` 来自列表的 `event_id` 或 `investigation_refs.event_id`。响应 `investigation_refs` 映射:`actor_ref.kind/id` → 操作者时间线;`resource_refs[].resource_type/resource_id` → 资源时间线;`request_id` → 请求时间线;`correlation_id` → 关联时间线;`integration_refs[].integration_id` → 外部集成详情。引用字段为空时隐藏对应入口,不按名称、时间或摘要猜测。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditEventIDParams), Output: new(auditquery.EventDetail), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/actors/:kind/:id/events", handler.ListActorEvents, RouteSpec{
|
||||
Summary: "查询操作者行为时间线",
|
||||
Description: "kind/id 来自事件 actor_ref 或平台账号选择器;历史名称直接使用事件快照,不查询当前账号名称覆盖历史。",
|
||||
Description: "`kind/id` 来自事件 `investigation_refs.actor_ref`;人工账号页也可使用 `kind=account`、`id=response.data.id`。历史名称使用响应 `actor_name` 快照,不以当前账号名称覆盖。action 使用事件 `action_code`,resource_type/resource_id 使用事件资源引用。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditActorEventsRequest), Output: new(auditquery.EventPage), Auth: true,
|
||||
})
|
||||
// 资源搜索静态路径必须先于资源动态时间线路径,避免被动态参数吞掉。
|
||||
Register(audit, doc, groupPath, "GET", "/resources/search", handler.SearchResources, RouteSpec{
|
||||
Summary: "精确搜索注册资源",
|
||||
Description: "卡支持 ICCID/VirtualNo,设备支持 VirtualNo/IMEI/SN,店铺、订单、退款使用各自稳定编号。当前资源不存在时仅按 Registry 白名单快照字段精确查找历史,不做任意 JSON 模糊搜索。",
|
||||
Description: "用于平台调查选择器:卡 keyword 使用 ICCID/VirtualNo,设备使用 VirtualNo/IMEI/SN,店铺使用店铺编号,订单使用订单号,退款使用退款单号。选择结果后将 `items[].resource_type/resource_id` 原样传给资源时间线;`historical=true` 表示仅由历史快照命中。仅精确搜索,不做任意 JSON 模糊搜索。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditResourceSearchRequest), Output: new(auditquery.ResourceSearchPage), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/resources/:resource_type/:resource_id/timeline", handler.ResourceTimeline, RouteSpec{
|
||||
Summary: "查询通用资源时间线",
|
||||
Description: "resource_type/resource_id 必须来自业务页面稳定字段、资源搜索结果或 investigation_refs。事件在资源作为 primary、affected 或 reference 时均会返回。",
|
||||
Description: "`resource_type/resource_id` 必须来自平台业务页的内部 `response.data.id`、资源搜索 `items[]` 或 `investigation_refs.resource_refs[]`。设备卡槽可使用资源引用返回的卡槽类型和 ID,不自行拼接。事件在资源作为 `primary/affected/reference` 时均返回;缺少 resource_id 时隐藏入口。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditResourceTimelineRequest), Output: new(auditquery.EventPage), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/requests/:request_id/timeline", handler.RequestTimeline, RouteSpec{
|
||||
Summary: "查询请求关联时间线",
|
||||
Description: "request_id 来自审计或外部集成节点,也可从 Access Log 粘贴。只组合 retention 在线窗口内的持久化事实,不扫描 Access Log 或对象存储。",
|
||||
Description: "`request_id` 来自事件 `investigation_refs.request_id`、Integration `request_id/linkage.request_id`,也可从 Access Log 粘贴。响应节点的 `investigation_refs` 可继续跳转事件、资源、操作者或 Integration 详情。只组合在线持久化事实,不扫描 Access Log 或对象存储。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditRequestTimelineParams), Output: new(auditquery.LinkTimeline), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/correlations/:correlation_id/timeline", handler.CorrelationTimeline, RouteSpec{
|
||||
Summary: "查询业务关联时间线",
|
||||
Description: "correlation_id 来自稳定调查引用。只组合 retention 在线窗口内的持久化事实;相同 correlation 不用于猜测技术重试。",
|
||||
Description: "`correlation_id` 来自事件 `investigation_refs.correlation_id` 或 Integration `correlation_id/linkage.correlation_id`。响应节点的 `investigation_refs` 可继续跳转其他视角。只组合在线持久化事实;相同 correlation 不等于技术重试,重试序列只认 Integration 的 `trigger.series`。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditCorrelationTimelineParams), Output: new(auditquery.LinkTimeline), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/finance/timeline", handler.FinanceTimeline, RouteSpec{
|
||||
Summary: "查询资金调查时间线",
|
||||
Description: "可使用任一稳定资金条件进入;缺省只查 retention 在线窗口,归档范围不返回部分结果。关联事实由服务端解析,金额以业务账本为权威。",
|
||||
Description: "任一稳定条件即可进入,关联事实由服务端补全:订单页 `order_id=response.data.id`,退款页 `refund_id=response.data.id`,充值页 `recharge_id=response.data.id`,钱包页 `wallet_id=response.data.id`,店铺页 `shop_id=response.data.id`;也支持各业务编号、第三方交易号、actor 或 correlation。金额单位为分,以 `amount_authority.authoritative=true` 指向的业务表字段为权威。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditFinanceTimelineRequest), Output: new(auditquery.FinanceTimelinePage), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/risks/overview", handler.RiskOverview, RouteSpec{
|
||||
Summary: "查询风险调查总览",
|
||||
Description: "时间范围最长31天,缺省时使用当前在线窗口;只聚合高风险、资金、安全、失败、拒绝、部分成功和结果未知事件。",
|
||||
Description: "时间范围最长31天,缺省使用当前在线窗口。`signals[].code` 固定为 high_risk、finance、security、failed、denied、partial、unknown;`risks/results/sources[].code` 可原样回填同名筛选参数,`actions[].code` 回填 action。name 字段只用于中文展示。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditRiskOverviewRequest), Output: new(auditquery.RiskOverview), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/risks/events", handler.RiskEvents, RouteSpec{
|
||||
Summary: "查询风险事件明细",
|
||||
Description: "筛选条件来自风险总览分桶或调查人员输入,缺省只查 retention 在线窗口;明细返回 investigation_refs,不提供处置或封禁能力。",
|
||||
Description: "筛选值来自风险总览:`risks[].code→risk`、`results[].code→result`、`actions[].code→action`、`sources[].code→source`。明细 `investigation_refs` 按事件详情相同规则跳转。缺省只查在线窗口,不提供处置或封禁能力。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.AuditRiskEventsRequest), Output: new(auditquery.RiskEventPage), Auth: true,
|
||||
})
|
||||
// Integration 总览静态路径必须先于动态详情路径,避免 overview 被当作 integration_id。
|
||||
Register(audit, doc, groupPath, "GET", "/integrations/overview", handler.IntegrationOverview, RouteSpec{
|
||||
Summary: "查询外部集成交互总览",
|
||||
Description: "筛选和时间范围来自调查输入或关联视角跳转;缺省只查 retention 在线窗口,归档范围返回稳定错误。总览区分五类结果。",
|
||||
Description: "筛选来自调查输入或其他视角稳定字段。`results[].code→result`,`results[].category→result_category`,`providers[].code→provider`,`directions[].code→direction`;所有 name 仅用于中文展示。趋势严格分为 processing、succeeded、indeterminate、failed、not_sent 五类,bucket 为 hour 或 day。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.IntegrationOverviewRequest), Output: new(integrationquery.Overview), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/integrations", handler.ListIntegrations, RouteSpec{
|
||||
Summary: "查询外部集成交互列表",
|
||||
Description: "组合筛选来自调查输入或稳定引用;缺省只查 retention 在线窗口,归档范围不返回空页或部分结果。固定倒序分页,不提供任意摘要搜索。",
|
||||
Description: "组合筛选来自总览 code、事件调查引用或业务页稳定资源字段;operation 必须使用列表/详情返回的稳定编码。列表 `integration_id` 原样传给详情;`request_id/correlation_id` 可跳转链路时间线;`resource.type/resource.id` 均存在时可跳转资源时间线。固定倒序分页,不提供任意摘要搜索。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.IntegrationListRequest), Output: new(integrationquery.ListPage), Auth: true,
|
||||
})
|
||||
Register(audit, doc, groupPath, "GET", "/integrations/:integration_id", handler.GetIntegration, RouteSpec{
|
||||
Summary: "查询外部集成交互详情",
|
||||
Description: "integration_id 来自稳定引用;只查询在线 PostgreSQL,未命中仍返回资源不存在。展示结构化详情和在线尝试序列,不提供归档读取、恢复、修改、删除或导出。",
|
||||
Description: "`integration_id` 来自列表、事件 `investigation_refs.integration_refs[]`,或通知 `GET /notifications/{id}/target`:仅当 `available=true` 且 `target_type=integration_log` 时,将 `target_key` 原样作为 integration_id;否则隐藏入口。`linkage.request_id/correlation_id` 可跳转链路时间线;`fidelity` 为 false 时禁止按时间、资源或摘要猜测缺失关系。只读,不提供恢复、修改、删除或导出。",
|
||||
Tags: []string{"审计调查"}, Input: new(dto.IntegrationIDParams), Output: new(integrationquery.DetailResponse), Auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ type Service struct {
|
||||
shopRoleStore *postgres.ShopRoleStore
|
||||
shopStore ShopStoreInterface
|
||||
enterpriseStore middleware.EnterpriseStoreInterface
|
||||
auditService AuditServiceInterface
|
||||
wecomMembers WeComMemberFinder
|
||||
tokenManager *pkgAuth.TokenManager
|
||||
}
|
||||
@@ -70,10 +69,6 @@ func (s *Service) SetTokenManager(tokenManager *pkgAuth.TokenManager) {
|
||||
s.tokenManager = tokenManager
|
||||
}
|
||||
|
||||
type AuditServiceInterface interface {
|
||||
LogOperation(ctx context.Context, log *model.AccountOperationLog)
|
||||
}
|
||||
|
||||
// WeComMemberFinder 定义账号绑定时校验应用可见成员的边界。
|
||||
type WeComMemberFinder interface {
|
||||
GetVisible(ctx context.Context, applicationID uint, userID string) (*model.WeComMember, error)
|
||||
@@ -87,7 +82,6 @@ func New(
|
||||
shopRoleStore *postgres.ShopRoleStore,
|
||||
shopStore ShopStoreInterface,
|
||||
enterpriseStore middleware.EnterpriseStoreInterface,
|
||||
auditService AuditServiceInterface,
|
||||
) *Service {
|
||||
return &Service{
|
||||
accountStore: accountStore,
|
||||
@@ -96,7 +90,6 @@ func New(
|
||||
shopRoleStore: shopRoleStore,
|
||||
shopStore: shopStore,
|
||||
enterpriseStore: enterpriseStore,
|
||||
auditService: auditService,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// Package account_audit 提供账号操作审计日志服务
|
||||
// 负责记录所有账号管理操作,用于审计追踪和合规要求
|
||||
package account_audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AccountOperationLogStore 账号操作日志存储接口
|
||||
type AccountOperationLogStore interface {
|
||||
Create(ctx context.Context, log *model.AccountOperationLog) error
|
||||
}
|
||||
|
||||
// Service 账号审计服务
|
||||
type Service struct {
|
||||
store AccountOperationLogStore
|
||||
}
|
||||
|
||||
// NewService 创建账号审计服务实例
|
||||
func NewService(store AccountOperationLogStore) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// LogOperation 记录账号操作日志(异步写入,不阻塞主流程)
|
||||
func (s *Service) LogOperation(ctx context.Context, log *model.AccountOperationLog) {
|
||||
// 异步写入审计日志,不阻塞业务操作
|
||||
go func() {
|
||||
if err := s.store.Create(context.Background(), log); err != nil {
|
||||
// 写入失败只记录错误日志,不影响业务
|
||||
logger.GetAppLogger().Error("写入账号操作日志失败",
|
||||
zap.Uint("operator_id", log.OperatorID),
|
||||
zap.String("operation_type", log.OperationType),
|
||||
zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -24,11 +24,6 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// AuditServiceInterface 审计日志服务接口
|
||||
type AuditServiceInterface interface {
|
||||
LogOperation(ctx context.Context, log *model.AccountOperationLog)
|
||||
}
|
||||
|
||||
// OperationPasswordServiceInterface 全局操作密码服务接口
|
||||
type OperationPasswordServiceInterface interface {
|
||||
Verify(ctx context.Context, inputPassword string) error
|
||||
@@ -49,7 +44,6 @@ type Service struct {
|
||||
offlineCreation *agentrechargeapp.OfflineCreationService
|
||||
shopStore *postgres.ShopStore
|
||||
wechatConfigService WechatConfigServiceInterface
|
||||
auditService AuditServiceInterface
|
||||
operationPasswordService OperationPasswordServiceInterface
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
@@ -63,7 +57,6 @@ func New(
|
||||
agentWalletStore *postgres.AgentWalletStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
wechatConfigService WechatConfigServiceInterface,
|
||||
auditService AuditServiceInterface,
|
||||
operationPasswordService OperationPasswordServiceInterface,
|
||||
rdb *redis.Client,
|
||||
logger *zap.Logger,
|
||||
@@ -74,7 +67,6 @@ func New(
|
||||
agentWalletStore: agentWalletStore,
|
||||
shopStore: shopStore,
|
||||
wechatConfigService: wechatConfigService,
|
||||
auditService: auditService,
|
||||
operationPasswordService: operationPasswordService,
|
||||
redis: rdb,
|
||||
logger: logger,
|
||||
|
||||
@@ -3,10 +3,14 @@ package asset
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
|
||||
infraAudit "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
@@ -14,255 +18,154 @@ import (
|
||||
|
||||
var deactivatableAssetStatuses = []int{constants.AssetStatusInStock, constants.AssetStatusSold}
|
||||
|
||||
// LifecycleService 资产生命周期服务
|
||||
// LifecycleService 资产生命周期服务。
|
||||
type LifecycleService struct {
|
||||
db *gorm.DB
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
assetAuditService assetAuditSvc.OperationLogger
|
||||
db *gorm.DB
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
auditWriter *infraAudit.Writer
|
||||
}
|
||||
|
||||
// NewLifecycleService 创建资产生命周期服务
|
||||
func NewLifecycleService(
|
||||
db *gorm.DB,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
assetAuditService assetAuditSvc.OperationLogger,
|
||||
) *LifecycleService {
|
||||
return &LifecycleService{
|
||||
db: db,
|
||||
iotCardStore: iotCardStore,
|
||||
deviceStore: deviceStore,
|
||||
assetAuditService: assetAuditService,
|
||||
}
|
||||
// NewLifecycleService 创建资产生命周期服务。
|
||||
func NewLifecycleService(db *gorm.DB, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore, auditWriter *infraAudit.Writer) *LifecycleService {
|
||||
return &LifecycleService{db: db, iotCardStore: iotCardStore, deviceStore: deviceStore, auditWriter: auditWriter}
|
||||
}
|
||||
|
||||
func (s *LifecycleService) logLifecycleAudit(ctx context.Context, p assetAuditSvc.BuildLogParams) {
|
||||
if s == nil || s.assetAuditService == nil {
|
||||
return
|
||||
}
|
||||
if p.Operator.Type == "" {
|
||||
p.Operator = assetAuditSvc.OperatorFromContext(ctx)
|
||||
}
|
||||
if p.OperationType == "" {
|
||||
p.OperationType = constants.AssetAuditOpAssetDeactivate
|
||||
}
|
||||
s.assetAuditService.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p))
|
||||
}
|
||||
|
||||
// DeactivateIotCard 手动停用 IoT 卡
|
||||
// DeactivateIotCard 手动停用 IoT 卡。
|
||||
func (s *LifecycleService) DeactivateIotCard(ctx context.Context, id uint) error {
|
||||
card, err := s.iotCardStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
appErr := errors.New(errors.CodeIotCardNotFound)
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(appErr)
|
||||
s.logLifecycleAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: id,
|
||||
OperationDesc: "统一入口停用IoT卡失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
})
|
||||
return appErr
|
||||
}
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(appErr)
|
||||
s.logLifecycleAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: id,
|
||||
OperationDesc: "统一入口停用IoT卡失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
})
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
appErr = errors.New(errors.CodeIotCardNotFound)
|
||||
}
|
||||
result := constants.AuditResultFailed
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
result = constants.AuditResultDenied
|
||||
}
|
||||
s.recordLifecycleFailure(ctx, constants.AuditActionIotCardDeactivated, "停用 IoT 卡失败", result, cardResourceStub(id), nil, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
beforeData := map[string]any{
|
||||
"asset_status": card.AssetStatus,
|
||||
"iccid": card.ICCID,
|
||||
"virtual_no": card.VirtualNo,
|
||||
}
|
||||
|
||||
beforeData := map[string]any{"asset_status": card.AssetStatus}
|
||||
if !canDeactivateAsset(card.AssetStatus) {
|
||||
appErr := errors.New(errors.CodeForbidden, "当前状态不允许停用")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(appErr)
|
||||
s.logLifecycleAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
OperationDesc: "统一入口停用IoT卡被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
BeforeData: beforeData,
|
||||
})
|
||||
s.recordLifecycleFailure(ctx, constants.AuditActionIotCardDeactivated, "停用 IoT 卡被拒绝", constants.AuditResultDenied, card, beforeData, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
result := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id = ? AND asset_status IN ?", id, deactivatableAssetStatuses).
|
||||
Update("asset_status", constants.AssetStatusDeactivated)
|
||||
if result.Error != nil {
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, result.Error, "停用IoT卡失败")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(appErr)
|
||||
s.logLifecycleAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
OperationDesc: "统一入口停用IoT卡失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
BeforeData: beforeData,
|
||||
AfterData: map[string]any{
|
||||
"asset_status": constants.AssetStatusDeactivated,
|
||||
"iccid": card.ICCID,
|
||||
"virtual_no": card.VirtualNo,
|
||||
},
|
||||
})
|
||||
return appErr
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
appErr := errors.New(errors.CodeConflict, "状态已变更,请刷新后重试")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(appErr)
|
||||
s.logLifecycleAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
OperationDesc: "统一入口停用IoT卡失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
BeforeData: beforeData,
|
||||
})
|
||||
return appErr
|
||||
}
|
||||
|
||||
s.logLifecycleAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
OperationDesc: "统一入口停用IoT卡",
|
||||
ResultStatus: constants.AssetAuditResultSuccess,
|
||||
BeforeData: beforeData,
|
||||
AfterData: map[string]any{
|
||||
"asset_status": constants.AssetStatusDeactivated,
|
||||
"iccid": card.ICCID,
|
||||
"virtual_no": card.VirtualNo,
|
||||
},
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.IotCard{}).Where("id = ? AND asset_status IN ?", id, deactivatableAssetStatuses).Update("asset_status", constants.AssetStatusDeactivated)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "停用IoT卡失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeConflict, "状态已变更,请刷新后重试")
|
||||
}
|
||||
return s.appendCardDeactivationAudit(ctx, tx, card, beforeData, map[string]any{"asset_status": constants.AssetStatusDeactivated}, constants.AuditResultSuccess, nil)
|
||||
})
|
||||
|
||||
return nil
|
||||
if err != nil {
|
||||
s.recordLifecycleFailure(ctx, constants.AuditActionIotCardDeactivated, "停用 IoT 卡失败", constants.AuditResultFailed, card, beforeData, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeactivateDevice 手动停用设备
|
||||
// DeactivateDevice 手动停用设备。
|
||||
func (s *LifecycleService) DeactivateDevice(ctx context.Context, id uint) error {
|
||||
device, err := s.deviceStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
appErr := errors.New(errors.CodeNotFound, "设备不存在")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(appErr)
|
||||
s.logLifecycleAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: id,
|
||||
OperationDesc: "统一入口停用设备失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
})
|
||||
return appErr
|
||||
}
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(appErr)
|
||||
s.logLifecycleAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: id,
|
||||
OperationDesc: "统一入口停用设备失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
})
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
appErr = errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
result := constants.AuditResultFailed
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
result = constants.AuditResultDenied
|
||||
}
|
||||
s.recordLifecycleFailure(ctx, constants.AuditActionDeviceDeactivated, "停用设备失败", result, deviceResourceStub(id), nil, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
beforeData := map[string]any{
|
||||
"asset_status": device.AssetStatus,
|
||||
"virtual_no": device.VirtualNo,
|
||||
}
|
||||
|
||||
beforeData := map[string]any{"asset_status": device.AssetStatus}
|
||||
if !canDeactivateAsset(device.AssetStatus) {
|
||||
appErr := errors.New(errors.CodeForbidden, "当前状态不允许停用")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(appErr)
|
||||
s.logLifecycleAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: device.ID,
|
||||
AssetIdentifier: device.VirtualNo,
|
||||
OperationDesc: "统一入口停用设备被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
BeforeData: beforeData,
|
||||
})
|
||||
s.recordLifecycleFailure(ctx, constants.AuditActionDeviceDeactivated, "停用设备被拒绝", constants.AuditResultDenied, device, beforeData, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
result := s.db.WithContext(ctx).Model(&model.Device{}).
|
||||
Where("id = ? AND asset_status IN ?", id, deactivatableAssetStatuses).
|
||||
Update("asset_status", constants.AssetStatusDeactivated)
|
||||
if result.Error != nil {
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, result.Error, "停用设备失败")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(appErr)
|
||||
s.logLifecycleAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: device.ID,
|
||||
AssetIdentifier: device.VirtualNo,
|
||||
OperationDesc: "统一入口停用设备失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
BeforeData: beforeData,
|
||||
AfterData: map[string]any{
|
||||
"asset_status": constants.AssetStatusDeactivated,
|
||||
"virtual_no": device.VirtualNo,
|
||||
},
|
||||
})
|
||||
return appErr
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
appErr := errors.New(errors.CodeConflict, "状态已变更,请刷新后重试")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(appErr)
|
||||
s.logLifecycleAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: device.ID,
|
||||
AssetIdentifier: device.VirtualNo,
|
||||
OperationDesc: "统一入口停用设备失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
BeforeData: beforeData,
|
||||
})
|
||||
return appErr
|
||||
}
|
||||
|
||||
s.logLifecycleAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: device.ID,
|
||||
AssetIdentifier: device.VirtualNo,
|
||||
OperationDesc: "统一入口停用设备",
|
||||
ResultStatus: constants.AssetAuditResultSuccess,
|
||||
BeforeData: beforeData,
|
||||
AfterData: map[string]any{
|
||||
"asset_status": constants.AssetStatusDeactivated,
|
||||
"virtual_no": device.VirtualNo,
|
||||
},
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.Device{}).Where("id = ? AND asset_status IN ?", id, deactivatableAssetStatuses).Update("asset_status", constants.AssetStatusDeactivated)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "停用设备失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeConflict, "状态已变更,请刷新后重试")
|
||||
}
|
||||
return s.appendDeviceDeactivationAudit(ctx, tx, device, beforeData, map[string]any{"asset_status": constants.AssetStatusDeactivated}, constants.AuditResultSuccess, nil)
|
||||
})
|
||||
|
||||
return nil
|
||||
if err != nil {
|
||||
s.recordLifecycleFailure(ctx, constants.AuditActionDeviceDeactivated, "停用设备失败", constants.AuditResultFailed, device, beforeData, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *LifecycleService) appendCardDeactivationAudit(ctx context.Context, tx *gorm.DB, card *model.IotCard, beforeData, afterData map[string]any, result string, businessErr error) error {
|
||||
if s.auditWriter == nil || card == nil || card.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "IoT 卡资产统一审计接缝未配置或资源不完整")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(card.ID), 10)
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
return s.auditWriter.Append(ctx, tx, infraAudit.AppendInput{
|
||||
ActionCode: constants.AuditActionIotCardDeactivated, Summary: "停用 IoT 卡资产", Result: result,
|
||||
ErrorCode: errorCode, ErrorSummary: errorSummary, ScopeType: constants.AuditScopePlatform,
|
||||
Resources: []infraAudit.ResourceInput{{
|
||||
Type: constants.AuditResourceIotCard, ID: &id, Key: infraAudit.IotCardResourceKey(card), DisplayName: card.ICCID,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardTarget,
|
||||
IdentitySnapshot: infraAudit.IotCardIdentitySnapshot(card), BeforeData: beforeData, AfterData: afterData,
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "IoT 卡资产已停用",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LifecycleService) appendDeviceDeactivationAudit(ctx context.Context, tx *gorm.DB, device *model.Device, beforeData, afterData map[string]any, result string, businessErr error) error {
|
||||
if s.auditWriter == nil || device == nil || device.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "设备资产统一审计接缝未配置或资源不完整")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(device.ID), 10)
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
return s.auditWriter.Append(ctx, tx, infraAudit.AppendInput{
|
||||
ActionCode: constants.AuditActionDeviceDeactivated, Summary: "停用设备资产", Result: result,
|
||||
ErrorCode: errorCode, ErrorSummary: errorSummary, ScopeType: constants.AuditScopePlatform,
|
||||
Resources: []infraAudit.ResourceInput{{
|
||||
Type: constants.AuditResourceDevice, ID: &id, Key: infraAudit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
|
||||
IdentitySnapshot: infraAudit.DeviceIdentitySnapshot(device), BeforeData: beforeData, AfterData: afterData,
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备资产已停用",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LifecycleService) recordLifecycleFailure(ctx context.Context, actionCode, summary, result string, resource any, beforeData map[string]any, businessErr error) {
|
||||
if s == nil || s.db == nil || s.auditWriter == nil {
|
||||
return
|
||||
}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
switch value := resource.(type) {
|
||||
case *model.IotCard:
|
||||
return s.appendCardDeactivationAudit(ctx, tx, value, beforeData, nil, result, businessErr)
|
||||
case *model.Device:
|
||||
return s.appendDeviceDeactivationAudit(ctx, tx, value, beforeData, nil, result, businessErr)
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidStatus, "资产审计资源类型无效")
|
||||
}
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
linkage := auditcontext.From(ctx)
|
||||
errorCode, _ := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
auditfailure.RecordSecondaryWriteFailure(actionCode, summary, linkage.RequestID, linkage.CorrelationID, errorCode, err)
|
||||
}
|
||||
|
||||
func cardResourceStub(id uint) *model.IotCard { return &model.IotCard{Model: gorm.Model{ID: id}} }
|
||||
func deviceResourceStub(id uint) *model.Device { return &model.Device{Model: gorm.Model{ID: id}} }
|
||||
|
||||
func canDeactivateAsset(assetStatus int) bool {
|
||||
return assetStatus == constants.AssetStatusInStock || assetStatus == constants.AssetStatusSold
|
||||
}
|
||||
|
||||
94
internal/service/asset/manual_adjustment_audit.go
Normal file
94
internal/service/asset/manual_adjustment_audit.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
infraAudit "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *Service) appendPackageAdjustmentAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
actionCode, summary, result string,
|
||||
usage *model.PackageUsage,
|
||||
assetType string,
|
||||
assetID uint,
|
||||
assetIdentifier string,
|
||||
beforeData, afterData map[string]any,
|
||||
businessErr error,
|
||||
) error {
|
||||
if s.auditWriter == nil || usage == nil || usage.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "资产套餐统一审计接缝未配置或资源不完整")
|
||||
}
|
||||
usageID := strconv.FormatUint(uint64(usage.ID), 10)
|
||||
assetResourceID := strconv.FormatUint(uint64(assetID), 10)
|
||||
var resourceType string
|
||||
switch assetType {
|
||||
case constants.AssetTypeIotCard:
|
||||
resourceType = constants.AuditResourceIotCard
|
||||
case constants.AssetTypeDevice:
|
||||
resourceType = constants.AuditResourceDevice
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "资产类型无效")
|
||||
}
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
return s.auditWriter.Append(ctx, tx, infraAudit.AppendInput{
|
||||
ActionCode: actionCode,
|
||||
Summary: summary,
|
||||
Result: result,
|
||||
ErrorCode: errorCode,
|
||||
ErrorSummary: errorSummary,
|
||||
ScopeType: constants.AuditScopePlatform,
|
||||
Resources: []infraAudit.ResourceInput{
|
||||
{
|
||||
Type: resourceType, ID: &assetResourceID, Key: assetIdentifier, DisplayName: assetIdentifier,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePackageUsageAsset,
|
||||
IdentitySnapshot: map[string]any{"id": assetID, "asset_type": assetType, "identifier": assetIdentifier},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
},
|
||||
{
|
||||
Type: constants.AuditResourcePackageUsage, ID: &usageID,
|
||||
Key: "package_usage:" + usageID, DisplayName: "套餐权益#" + usageID,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePackageUsageTarget,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": usage.ID, "order_id": usage.OrderID, "package_id": usage.PackageID,
|
||||
"iot_card_id": usage.IotCardID, "device_id": usage.DeviceID, "status": usage.Status,
|
||||
},
|
||||
BeforeData: beforeData, AfterData: afterData,
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordPackageAdjustmentFailure(
|
||||
ctx context.Context,
|
||||
actionCode, summary string,
|
||||
usage *model.PackageUsage,
|
||||
assetType string,
|
||||
assetID uint,
|
||||
assetIdentifier string,
|
||||
beforeData, afterData map[string]any,
|
||||
businessErr error,
|
||||
) {
|
||||
if s == nil || s.db == nil || usage == nil {
|
||||
return
|
||||
}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendPackageAdjustmentAudit(ctx, tx, actionCode, summary, constants.AuditResultFailed, usage, assetType, assetID, assetIdentifier, beforeData, afterData, businessErr)
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
linkage := auditcontext.From(ctx)
|
||||
errorCode, _ := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
auditfailure.RecordSecondaryWriteFailure(actionCode, strconv.FormatUint(uint64(usage.ID), 10), linkage.RequestID, linkage.CorrelationID, errorCode, err)
|
||||
}
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
infraAudit "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -52,7 +52,7 @@ type Service struct {
|
||||
iotCardService IotCardRefresher
|
||||
gatewayClient *gateway.Client
|
||||
assetIdentifierStore *postgres.AssetIdentifierStore
|
||||
assetAuditService assetAuditSvc.OperationLogger
|
||||
auditWriter *infraAudit.Writer
|
||||
packageExpiryQuery PackageExpiryResolver
|
||||
}
|
||||
|
||||
@@ -78,7 +78,6 @@ func New(
|
||||
orderStore *postgres.OrderStore,
|
||||
orderItemStore *postgres.OrderItemStore,
|
||||
exchangeOrderStore *postgres.ExchangeOrderStore,
|
||||
assetAuditService assetAuditSvc.OperationLogger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
@@ -96,11 +95,15 @@ func New(
|
||||
iotCardService: iotCardService,
|
||||
gatewayClient: gatewayClient,
|
||||
assetIdentifierStore: assetIdentifierStore,
|
||||
assetAuditService: assetAuditService,
|
||||
packageExpiryQuery: packageexpiry.NewQuery(db),
|
||||
}
|
||||
}
|
||||
|
||||
// SetAccessAudit 注入资产人工调整的统一审计 Writer。
|
||||
func (s *Service) SetAccessAudit(writer *infraAudit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// Resolve 通过任意标识符解析资产
|
||||
// 主路径:查注册表(精确匹配 ICCID 或 VirtualNo)
|
||||
// Fallback:原有跨表 OR 查询(处理 IMEI/SN/MSISDN 等非注册标识符)
|
||||
@@ -912,35 +915,26 @@ func (s *Service) UpdatePackageExpiresAt(ctx context.Context, assetType string,
|
||||
}
|
||||
|
||||
beforeData := packageUsageExpiresAtAuditData(before)
|
||||
rows, err := s.packageUsageStore.UpdateExpiresAtForCarrier(ctx, packageUsageID, carrierType, assetID, expiresAt)
|
||||
var updated *model.PackageUsage
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := postgres.NewPackageUsageStore(tx, nil)
|
||||
rows, updateErr := store.UpdateExpiresAtForCarrier(ctx, packageUsageID, carrierType, assetID, expiresAt)
|
||||
if updateErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updateErr, "修改套餐过期时间失败")
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
updated, updateErr = store.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID)
|
||||
if updateErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updateErr, "查询套餐使用记录失败")
|
||||
}
|
||||
return s.appendPackageAdjustmentAudit(ctx, tx, constants.AuditActionPackageUsageExpiresAtUpdated, "修改资产套餐过期时间", constants.AuditResultSuccess, updated, assetType, assetID, assetIdentifier, beforeData, packageUsageExpiresAtAuditData(updated), nil)
|
||||
})
|
||||
if err != nil {
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, err, "修改套餐过期时间失败")
|
||||
s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageExpiresAt, "修改资产套餐过期时间失败", constants.AssetAuditResultFailed, assetType, assetID, assetIdentifier, beforeData, map[string]any{
|
||||
"package_usage_id": packageUsageID,
|
||||
"expires_at": expiresAt,
|
||||
}, appErr)
|
||||
return nil, appErr
|
||||
s.recordPackageAdjustmentFailure(ctx, constants.AuditActionPackageUsageExpiresAtUpdated, "修改资产套餐过期时间失败", before, assetType, assetID, assetIdentifier, beforeData, map[string]any{"package_usage_id": packageUsageID, "expires_at": expiresAt}, err)
|
||||
return nil, err
|
||||
}
|
||||
if rows == 0 {
|
||||
appErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageExpiresAt, "修改资产套餐过期时间失败", constants.AssetAuditResultFailed, assetType, assetID, assetIdentifier, beforeData, map[string]any{
|
||||
"package_usage_id": packageUsageID,
|
||||
"expires_at": expiresAt,
|
||||
}, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
updated, err := s.packageUsageStore.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID)
|
||||
if err != nil {
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||||
s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageExpiresAt, "修改资产套餐过期时间失败", constants.AssetAuditResultFailed, assetType, assetID, assetIdentifier, beforeData, map[string]any{
|
||||
"package_usage_id": packageUsageID,
|
||||
"expires_at": expiresAt,
|
||||
}, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageExpiresAt, "修改资产套餐过期时间", constants.AssetAuditResultSuccess, assetType, assetID, assetIdentifier, beforeData, packageUsageExpiresAtAuditData(updated), nil)
|
||||
return s.buildAssetPackageResponse(ctx, updated, constants.OwnerTypePlatform), nil
|
||||
}
|
||||
|
||||
@@ -962,35 +956,26 @@ func (s *Service) UpdatePackageUsage(ctx context.Context, assetType string, asse
|
||||
|
||||
beforeData := packageUsageTrafficAuditData(before)
|
||||
nextStatus := statusForManualDataUsage(before, dataUsageMB)
|
||||
rows, err := s.packageUsageStore.UpdateDataUsageForCarrier(ctx, packageUsageID, carrierType, assetID, dataUsageMB, nextStatus)
|
||||
var updated *model.PackageUsage
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := postgres.NewPackageUsageStore(tx, nil)
|
||||
rows, updateErr := store.UpdateDataUsageForCarrier(ctx, packageUsageID, carrierType, assetID, dataUsageMB, nextStatus)
|
||||
if updateErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updateErr, "修改套餐已用量失败")
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
updated, updateErr = store.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID)
|
||||
if updateErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updateErr, "查询套餐使用记录失败")
|
||||
}
|
||||
return s.appendPackageAdjustmentAudit(ctx, tx, constants.AuditActionPackageUsageTrafficAdjusted, "修改资产套餐已用量", constants.AuditResultSuccess, updated, assetType, assetID, assetIdentifier, beforeData, packageUsageTrafficAuditData(updated), nil)
|
||||
})
|
||||
if err != nil {
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, err, "修改套餐已用量失败")
|
||||
s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageUsage, "修改资产套餐已用量失败", constants.AssetAuditResultFailed, assetType, assetID, assetIdentifier, beforeData, map[string]any{
|
||||
"package_usage_id": packageUsageID,
|
||||
"data_usage_mb": dataUsageMB,
|
||||
}, appErr)
|
||||
return nil, appErr
|
||||
s.recordPackageAdjustmentFailure(ctx, constants.AuditActionPackageUsageTrafficAdjusted, "修改资产套餐已用量失败", before, assetType, assetID, assetIdentifier, beforeData, map[string]any{"package_usage_id": packageUsageID, "data_usage_mb": dataUsageMB}, err)
|
||||
return nil, err
|
||||
}
|
||||
if rows == 0 {
|
||||
appErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageUsage, "修改资产套餐已用量失败", constants.AssetAuditResultFailed, assetType, assetID, assetIdentifier, beforeData, map[string]any{
|
||||
"package_usage_id": packageUsageID,
|
||||
"data_usage_mb": dataUsageMB,
|
||||
}, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
updated, err := s.packageUsageStore.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID)
|
||||
if err != nil {
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||||
s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageUsage, "修改资产套餐已用量失败", constants.AssetAuditResultFailed, assetType, assetID, assetIdentifier, beforeData, map[string]any{
|
||||
"package_usage_id": packageUsageID,
|
||||
"data_usage_mb": dataUsageMB,
|
||||
}, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageUsage, "修改资产套餐已用量", constants.AssetAuditResultSuccess, assetType, assetID, assetIdentifier, beforeData, packageUsageTrafficAuditData(updated), nil)
|
||||
return s.buildAssetPackageResponse(ctx, updated, constants.OwnerTypePlatform), nil
|
||||
}
|
||||
|
||||
@@ -1210,31 +1195,6 @@ func packageUsageTrafficAuditData(usage *model.PackageUsage) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) logPackageAdjustmentAudit(ctx context.Context, operationType, operationDesc, resultStatus, assetType string, assetID uint, assetIdentifier string, beforeData, afterData map[string]any, err error) {
|
||||
if s == nil || s.assetAuditService == nil {
|
||||
return
|
||||
}
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
wrappedBefore, wrappedAfter := assetAuditSvc.WrapOperationContent(beforeData, afterData, map[string]any{
|
||||
"asset_type": assetAuditSvc.NormalizeAssetType(assetType),
|
||||
"asset_id": assetID,
|
||||
"asset_identifier": assetIdentifier,
|
||||
})
|
||||
s.assetAuditService.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, assetAuditSvc.BuildLogParams{
|
||||
Operator: assetAuditSvc.OperatorFromContext(ctx),
|
||||
AssetType: assetType,
|
||||
AssetID: assetID,
|
||||
AssetIdentifier: assetIdentifier,
|
||||
OperationType: operationType,
|
||||
OperationDesc: operationDesc,
|
||||
BeforeData: wrappedBefore,
|
||||
AfterData: wrappedAfter,
|
||||
ResultStatus: resultStatus,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
}))
|
||||
}
|
||||
|
||||
// tracePreviousGenerations 通过换货链逆向追溯前代资产的订单,最多追溯10代
|
||||
func (s *Service) tracePreviousGenerations(ctx context.Context, assetType string, assetID uint) ([]*dto.PreviousGenerationOrders, bool) {
|
||||
const maxDepth = 10
|
||||
|
||||
@@ -9,14 +9,11 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AssetOperationLogStore 资产操作日志存储接口。
|
||||
type AssetOperationLogStore interface {
|
||||
Create(ctx context.Context, log *model.AssetOperationLog) error
|
||||
ListByAssetPaged(
|
||||
ctx context.Context,
|
||||
assetType string,
|
||||
@@ -28,11 +25,6 @@ type AssetOperationLogStore interface {
|
||||
) ([]*model.AssetOperationLog, int64, error)
|
||||
}
|
||||
|
||||
// OperationLogger 资产审计记录接口。
|
||||
type OperationLogger interface {
|
||||
LogOperation(ctx context.Context, log *model.AssetOperationLog)
|
||||
}
|
||||
|
||||
// Service 资产审计服务。
|
||||
type Service struct {
|
||||
store AssetOperationLogStore
|
||||
@@ -57,24 +49,6 @@ func NewService(store AssetOperationLogStore, db *gorm.DB) *Service {
|
||||
}
|
||||
}
|
||||
|
||||
// LogOperation 记录资产操作日志(异步写入,不阻塞主流程)。
|
||||
func (s *Service) LogOperation(ctx context.Context, log *model.AssetOperationLog) {
|
||||
if s == nil || s.store == nil || log == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := s.store.Create(context.Background(), log); err != nil {
|
||||
logger.GetAppLogger().Error("写入资产操作日志失败",
|
||||
zap.String("asset_type", log.AssetType),
|
||||
zap.Uint("asset_id", log.AssetID),
|
||||
zap.String("operation_type", log.OperationType),
|
||||
zap.String("result_status", log.ResultStatus),
|
||||
zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ListByAsset 按资产分页查询操作日志。
|
||||
func (s *Service) ListByAsset(ctx context.Context, params ListByAssetParams) (*dto.AssetOperationLogListResponse, error) {
|
||||
if s == nil || s.store == nil {
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// AssetAuditService 资产审计服务接口。
|
||||
type AssetAuditService interface {
|
||||
LogOperation(ctx context.Context, log *model.AssetOperationLog)
|
||||
}
|
||||
|
||||
func (s *Service) logDeviceAudit(ctx context.Context, p assetAuditSvc.BuildLogParams) {
|
||||
if s == nil || s.assetAuditService == nil {
|
||||
return
|
||||
}
|
||||
if p.Operator.Type == "" {
|
||||
p.Operator = assetAuditSvc.OperatorFromContext(ctx)
|
||||
}
|
||||
if p.AssetType == "" {
|
||||
p.AssetType = constants.AssetTypeDevice
|
||||
}
|
||||
s.assetAuditService.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p))
|
||||
}
|
||||
|
||||
func (s *Service) logDeviceOperation(
|
||||
ctx context.Context,
|
||||
operationType string,
|
||||
operationDesc string,
|
||||
resultStatus string,
|
||||
device *model.Device,
|
||||
beforeData map[string]any,
|
||||
afterData map[string]any,
|
||||
batchTotal int,
|
||||
successCount int,
|
||||
failCount int,
|
||||
err error,
|
||||
) {
|
||||
if strings.TrimSpace(operationDesc) == "" {
|
||||
operationDesc = operationType
|
||||
}
|
||||
if strings.TrimSpace(resultStatus) == "" {
|
||||
if err != nil {
|
||||
resultStatus = constants.AssetAuditResultFailed
|
||||
} else {
|
||||
resultStatus = constants.AssetAuditResultSuccess
|
||||
}
|
||||
}
|
||||
|
||||
params := assetAuditSvc.BuildLogParams{
|
||||
OperationType: operationType,
|
||||
OperationDesc: operationDesc,
|
||||
ResultStatus: resultStatus,
|
||||
BatchTotal: batchTotal,
|
||||
SuccessCount: successCount,
|
||||
FailCount: failCount,
|
||||
}
|
||||
snapshot := deviceSnapshot(device)
|
||||
beforeContent := stripDeviceOperationMeta(beforeData)
|
||||
afterContent := stripDeviceOperationMeta(afterData)
|
||||
beforeContent = ensureDeviceOperationContentMap(beforeContent, beforeData)
|
||||
afterContent = ensureDeviceOperationContentMap(afterContent, afterData)
|
||||
enrichDeviceOperationContent(beforeContent, beforeData)
|
||||
enrichDeviceOperationContent(afterContent, afterData)
|
||||
params.BeforeData, params.AfterData = assetAuditSvc.WrapOperationContent(beforeContent, afterContent, snapshot)
|
||||
|
||||
if device != nil {
|
||||
params.AssetID = device.ID
|
||||
params.AssetIdentifier = device.VirtualNo
|
||||
}
|
||||
if err != nil {
|
||||
params.ErrorCode, params.ErrorMsg = assetAuditSvc.BuildErrorInfo(err)
|
||||
}
|
||||
|
||||
s.logDeviceAudit(ctx, params)
|
||||
}
|
||||
|
||||
func stripDeviceOperationMeta(data map[string]any) map[string]any {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(data))
|
||||
for k, v := range data {
|
||||
if k == "device" || k == "devices" || k == "card" || k == "cards" || k == "asset_snapshot" || k == "operation_content" {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ensureDeviceOperationContentMap(content map[string]any, raw map[string]any) map[string]any {
|
||||
if content != nil || len(raw) == 0 {
|
||||
return content
|
||||
}
|
||||
if _, ok := raw["device"].(map[string]any); ok {
|
||||
return make(map[string]any)
|
||||
}
|
||||
if _, ok := raw["card"].(map[string]any); ok {
|
||||
return make(map[string]any)
|
||||
}
|
||||
switch raw["devices"].(type) {
|
||||
case []map[string]any, []any:
|
||||
return make(map[string]any)
|
||||
}
|
||||
switch raw["cards"].(type) {
|
||||
case []map[string]any, []any:
|
||||
return make(map[string]any)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func enrichDeviceOperationContent(content map[string]any, raw map[string]any) {
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if deviceRaw, ok := raw["device"].(map[string]any); ok {
|
||||
mergeReadableDeviceFields(content, deviceRaw)
|
||||
}
|
||||
if cardRaw, ok := raw["card"].(map[string]any); ok {
|
||||
mergeReadableCardFields(content, cardRaw)
|
||||
}
|
||||
|
||||
switch devicesRaw := raw["devices"].(type) {
|
||||
case []map[string]any:
|
||||
deviceIDs, virtualNos := collectDeviceReadableLists(devicesRaw)
|
||||
if len(deviceIDs) > 0 && content["device_ids"] == nil {
|
||||
content["device_ids"] = deviceIDs
|
||||
}
|
||||
if len(virtualNos) > 0 && content["device_virtual_nos"] == nil {
|
||||
content["device_virtual_nos"] = virtualNos
|
||||
}
|
||||
case []any:
|
||||
devices := make([]map[string]any, 0, len(devicesRaw))
|
||||
for _, item := range devicesRaw {
|
||||
deviceMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
devices = append(devices, deviceMap)
|
||||
}
|
||||
deviceIDs, virtualNos := collectDeviceReadableLists(devices)
|
||||
if len(deviceIDs) > 0 && content["device_ids"] == nil {
|
||||
content["device_ids"] = deviceIDs
|
||||
}
|
||||
if len(virtualNos) > 0 && content["device_virtual_nos"] == nil {
|
||||
content["device_virtual_nos"] = virtualNos
|
||||
}
|
||||
}
|
||||
|
||||
switch cardsRaw := raw["cards"].(type) {
|
||||
case []map[string]any:
|
||||
cardIDs, iccids := collectDeviceAuditCardReadableLists(cardsRaw)
|
||||
if len(cardIDs) > 0 && content["card_ids"] == nil {
|
||||
content["card_ids"] = cardIDs
|
||||
}
|
||||
if len(iccids) > 0 && content["iccids"] == nil {
|
||||
content["iccids"] = iccids
|
||||
}
|
||||
case []any:
|
||||
cards := make([]map[string]any, 0, len(cardsRaw))
|
||||
for _, item := range cardsRaw {
|
||||
cardMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cards = append(cards, cardMap)
|
||||
}
|
||||
cardIDs, iccids := collectDeviceAuditCardReadableLists(cards)
|
||||
if len(cardIDs) > 0 && content["card_ids"] == nil {
|
||||
content["card_ids"] = cardIDs
|
||||
}
|
||||
if len(iccids) > 0 && content["iccids"] == nil {
|
||||
content["iccids"] = iccids
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergeReadableDeviceFields(content map[string]any, device map[string]any) {
|
||||
if len(device) == 0 {
|
||||
return
|
||||
}
|
||||
if _, ok := content["device_id"]; !ok {
|
||||
if v, exists := device["id"]; exists {
|
||||
content["device_id"] = v
|
||||
}
|
||||
}
|
||||
if _, ok := content["device_virtual_no"]; !ok {
|
||||
if v := stringifyDeviceAuditValue(device["virtual_no"]); v != "" {
|
||||
content["device_virtual_no"] = v
|
||||
}
|
||||
}
|
||||
if _, ok := content["device_imei"]; !ok {
|
||||
if v := stringifyDeviceAuditValue(device["imei"]); v != "" {
|
||||
content["device_imei"] = v
|
||||
}
|
||||
}
|
||||
if _, ok := content["device_sn"]; !ok {
|
||||
if v := stringifyDeviceAuditValue(device["sn"]); v != "" {
|
||||
content["device_sn"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergeReadableCardFields(content map[string]any, card map[string]any) {
|
||||
if len(card) == 0 {
|
||||
return
|
||||
}
|
||||
if _, ok := content["iot_card_id"]; !ok {
|
||||
if v, exists := card["id"]; exists {
|
||||
content["iot_card_id"] = v
|
||||
}
|
||||
}
|
||||
if _, ok := content["iccid"]; !ok {
|
||||
if v := stringifyDeviceAuditValue(card["iccid"]); v != "" {
|
||||
content["iccid"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectDeviceReadableLists(devices []map[string]any) ([]any, []string) {
|
||||
deviceIDs := make([]any, 0, len(devices))
|
||||
virtualNos := make([]string, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if id, ok := device["id"]; ok {
|
||||
deviceIDs = append(deviceIDs, id)
|
||||
}
|
||||
if virtualNo := stringifyDeviceAuditValue(device["virtual_no"]); virtualNo != "" {
|
||||
virtualNos = append(virtualNos, virtualNo)
|
||||
}
|
||||
}
|
||||
return deviceIDs, virtualNos
|
||||
}
|
||||
|
||||
func collectDeviceAuditCardReadableLists(cards []map[string]any) ([]any, []string) {
|
||||
cardIDs := make([]any, 0, len(cards))
|
||||
iccids := make([]string, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if id, ok := card["id"]; ok {
|
||||
cardIDs = append(cardIDs, id)
|
||||
}
|
||||
if iccid := stringifyDeviceAuditValue(card["iccid"]); iccid != "" {
|
||||
iccids = append(iccids, iccid)
|
||||
}
|
||||
}
|
||||
return cardIDs, iccids
|
||||
}
|
||||
|
||||
func stringifyDeviceAuditValue(v any) string {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
return vv
|
||||
case fmt.Stringer:
|
||||
return vv.String()
|
||||
default:
|
||||
if vv == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(vv)
|
||||
}
|
||||
}
|
||||
|
||||
func deviceSnapshot(device *model.Device) map[string]any {
|
||||
if device == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": device.ID,
|
||||
"virtual_no": device.VirtualNo,
|
||||
"imei": device.IMEI,
|
||||
"sn": device.SN,
|
||||
"shop_id": device.ShopID,
|
||||
"status": device.Status,
|
||||
"series_id": device.SeriesID,
|
||||
"enable_polling": device.EnablePolling,
|
||||
"realname_policy": device.RealnamePolicy,
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,6 @@ type Service struct {
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
gatewayClient *gateway.Client
|
||||
assetIdentifierStore *postgres.AssetIdentifierStore
|
||||
assetAuditService AssetAuditService
|
||||
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
|
||||
enterpriseStore *postgres.EnterpriseStore
|
||||
packageExpiryQuery *packageexpiry.Query
|
||||
@@ -152,7 +151,6 @@ func New(
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
gatewayClient *gateway.Client,
|
||||
assetIdentifierStore *postgres.AssetIdentifierStore,
|
||||
assetAuditService AssetAuditService,
|
||||
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore,
|
||||
enterpriseStore *postgres.EnterpriseStore,
|
||||
) *Service {
|
||||
@@ -169,7 +167,6 @@ func New(
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
gatewayClient: gatewayClient,
|
||||
assetIdentifierStore: assetIdentifierStore,
|
||||
assetAuditService: assetAuditService,
|
||||
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
|
||||
enterpriseStore: enterpriseStore,
|
||||
packageExpiryQuery: packageexpiry.NewQuery(db),
|
||||
|
||||
@@ -15,11 +15,6 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// AssetAuditService 资产审计服务接口。
|
||||
type AssetAuditService interface {
|
||||
LogOperation(ctx context.Context, log *model.AssetOperationLog)
|
||||
}
|
||||
|
||||
func (s *Service) writeDeviceImportTaskAudit(ctx context.Context, tx *gorm.DB, task *model.DeviceImportTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error {
|
||||
scopeType, scopeID := constants.AuditScopePlatform, ""
|
||||
if task.OperatorShopID != nil {
|
||||
|
||||
@@ -23,7 +23,6 @@ type Service struct {
|
||||
db *gorm.DB
|
||||
importTaskStore *postgres.DeviceImportTaskStore
|
||||
queueClient *queue.Client
|
||||
assetAudit AssetAuditService
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
@@ -35,14 +34,12 @@ func New(
|
||||
db *gorm.DB,
|
||||
importTaskStore *postgres.DeviceImportTaskStore,
|
||||
queueClient *queue.Client,
|
||||
assetAudit AssetAuditService,
|
||||
auditWriters ...*audit.Writer,
|
||||
) *Service {
|
||||
service := &Service{
|
||||
db: db,
|
||||
importTaskStore: importTaskStore,
|
||||
queueClient: queueClient,
|
||||
assetAudit: assetAudit,
|
||||
}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
package iot_card
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// AssetAuditService 资产审计服务接口。
|
||||
type AssetAuditService interface {
|
||||
LogOperation(ctx context.Context, log *model.AssetOperationLog)
|
||||
}
|
||||
|
||||
func (s *Service) logCardAudit(ctx context.Context, p assetAuditSvc.BuildLogParams) {
|
||||
if s == nil || s.assetAuditService == nil {
|
||||
return
|
||||
}
|
||||
if p.Operator.Type == "" {
|
||||
p.Operator = assetAuditSvc.OperatorFromContext(ctx)
|
||||
}
|
||||
if p.AssetType == "" {
|
||||
p.AssetType = constants.AssetTypeIotCard
|
||||
}
|
||||
p.BeforeData, p.AfterData = normalizeCardAuditPayload(p.BeforeData, p.AfterData)
|
||||
s.assetAuditService.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p))
|
||||
}
|
||||
|
||||
func (s *StopResumeService) logCardAudit(ctx context.Context, p assetAuditSvc.BuildLogParams) {
|
||||
if s == nil || s.assetAuditService == nil {
|
||||
return
|
||||
}
|
||||
if p.Operator.Type == "" {
|
||||
p.Operator = assetAuditSvc.OperatorFromContext(ctx)
|
||||
}
|
||||
if p.AssetType == "" {
|
||||
p.AssetType = constants.AssetTypeIotCard
|
||||
}
|
||||
p.BeforeData, p.AfterData = normalizeCardAuditPayload(p.BeforeData, p.AfterData)
|
||||
s.assetAuditService.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p))
|
||||
}
|
||||
|
||||
func normalizeCardAuditPayload(beforeData, afterData map[string]any) (map[string]any, map[string]any) {
|
||||
snapshot := map[string]any(nil)
|
||||
if raw, ok := beforeData["card"]; ok {
|
||||
if m, ok := raw.(map[string]any); ok {
|
||||
snapshot = m
|
||||
}
|
||||
}
|
||||
if snapshot == nil {
|
||||
if raw, ok := afterData["card"]; ok {
|
||||
if m, ok := raw.(map[string]any); ok {
|
||||
snapshot = m
|
||||
}
|
||||
}
|
||||
}
|
||||
beforeContent := stripCardOperationMeta(beforeData)
|
||||
afterContent := stripCardOperationMeta(afterData)
|
||||
beforeContent = ensureCardOperationContentMap(beforeContent, beforeData)
|
||||
afterContent = ensureCardOperationContentMap(afterContent, afterData)
|
||||
enrichCardOperationContent(beforeContent, beforeData)
|
||||
enrichCardOperationContent(afterContent, afterData)
|
||||
return assetAuditSvc.WrapOperationContent(beforeContent, afterContent, snapshot)
|
||||
}
|
||||
|
||||
func stripCardOperationMeta(data map[string]any) map[string]any {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(data))
|
||||
for k, v := range data {
|
||||
if k == "device" || k == "card" || k == "cards" || k == "asset_snapshot" || k == "operation_content" {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ensureCardOperationContentMap(content map[string]any, raw map[string]any) map[string]any {
|
||||
if content != nil || len(raw) == 0 {
|
||||
return content
|
||||
}
|
||||
if _, ok := raw["card"].(map[string]any); ok {
|
||||
return make(map[string]any)
|
||||
}
|
||||
if _, ok := raw["device"].(map[string]any); ok {
|
||||
return make(map[string]any)
|
||||
}
|
||||
switch raw["cards"].(type) {
|
||||
case []map[string]any, []any:
|
||||
return make(map[string]any)
|
||||
default:
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
func enrichCardOperationContent(content map[string]any, raw map[string]any) {
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if cardRaw, ok := raw["card"].(map[string]any); ok {
|
||||
mergeReadableCardFields(content, cardRaw)
|
||||
}
|
||||
if deviceRaw, ok := raw["device"].(map[string]any); ok {
|
||||
mergeReadableDeviceFields(content, deviceRaw)
|
||||
}
|
||||
|
||||
switch cardsRaw := raw["cards"].(type) {
|
||||
case []map[string]any:
|
||||
cardIDs, iccids := collectCardReadableLists(cardsRaw)
|
||||
if len(cardIDs) > 0 && content["card_ids"] == nil {
|
||||
content["card_ids"] = cardIDs
|
||||
}
|
||||
if len(iccids) > 0 && content["iccids"] == nil {
|
||||
content["iccids"] = iccids
|
||||
}
|
||||
case []any:
|
||||
cards := make([]map[string]any, 0, len(cardsRaw))
|
||||
for _, item := range cardsRaw {
|
||||
cardMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cards = append(cards, cardMap)
|
||||
}
|
||||
cardIDs, iccids := collectCardReadableLists(cards)
|
||||
if len(cardIDs) > 0 && content["card_ids"] == nil {
|
||||
content["card_ids"] = cardIDs
|
||||
}
|
||||
if len(iccids) > 0 && content["iccids"] == nil {
|
||||
content["iccids"] = iccids
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergeReadableCardFields(content map[string]any, card map[string]any) {
|
||||
if len(card) == 0 {
|
||||
return
|
||||
}
|
||||
if _, ok := content["card_id"]; !ok {
|
||||
if v, exists := card["id"]; exists {
|
||||
content["card_id"] = v
|
||||
}
|
||||
}
|
||||
if _, ok := content["iccid"]; !ok {
|
||||
if v := stringifyCardAuditValue(card["iccid"]); v != "" {
|
||||
content["iccid"] = v
|
||||
}
|
||||
}
|
||||
if _, ok := content["device_virtual_no"]; !ok {
|
||||
if v := stringifyCardAuditValue(card["device_virtual_no"]); v != "" {
|
||||
content["device_virtual_no"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergeReadableDeviceFields(content map[string]any, device map[string]any) {
|
||||
if len(device) == 0 {
|
||||
return
|
||||
}
|
||||
if _, ok := content["device_id"]; !ok {
|
||||
if v, exists := device["id"]; exists {
|
||||
content["device_id"] = v
|
||||
}
|
||||
}
|
||||
if _, ok := content["device_virtual_no"]; !ok {
|
||||
if v := stringifyCardAuditValue(device["virtual_no"]); v != "" {
|
||||
content["device_virtual_no"] = v
|
||||
}
|
||||
}
|
||||
if _, ok := content["device_imei"]; !ok {
|
||||
if v := stringifyCardAuditValue(device["imei"]); v != "" {
|
||||
content["device_imei"] = v
|
||||
}
|
||||
}
|
||||
if _, ok := content["device_sn"]; !ok {
|
||||
if v := stringifyCardAuditValue(device["sn"]); v != "" {
|
||||
content["device_sn"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectCardReadableLists(cards []map[string]any) ([]any, []string) {
|
||||
cardIDs := make([]any, 0, len(cards))
|
||||
iccids := make([]string, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if id, ok := card["id"]; ok {
|
||||
cardIDs = append(cardIDs, id)
|
||||
}
|
||||
if iccid := stringifyCardAuditValue(card["iccid"]); iccid != "" {
|
||||
iccids = append(iccids, iccid)
|
||||
}
|
||||
}
|
||||
return cardIDs, iccids
|
||||
}
|
||||
|
||||
func stringifyCardAuditValue(v any) string {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
return vv
|
||||
case fmt.Stringer:
|
||||
return vv.String()
|
||||
default:
|
||||
if vv == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(vv)
|
||||
}
|
||||
}
|
||||
|
||||
func cardSnapshot(card *model.IotCard) map[string]any {
|
||||
if card == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": card.ID,
|
||||
"iccid": card.ICCID,
|
||||
"device_virtual_no": card.DeviceVirtualNo,
|
||||
"shop_id": card.ShopID,
|
||||
"status": card.Status,
|
||||
"series_id": card.SeriesID,
|
||||
"enable_polling": card.EnablePolling,
|
||||
"realname_policy": card.RealnamePolicy,
|
||||
"real_name_status": card.RealNameStatus,
|
||||
"network_status": card.NetworkStatus,
|
||||
"stop_reason": card.StopReason,
|
||||
}
|
||||
}
|
||||
16
internal/service/iot_card/card_snapshot.go
Normal file
16
internal/service/iot_card/card_snapshot.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package iot_card
|
||||
|
||||
import "github.com/break/junhong_cmp_fiber/internal/model"
|
||||
|
||||
func cardSnapshot(card *model.IotCard) map[string]any {
|
||||
if card == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": card.ID, "iccid": card.ICCID, "device_virtual_no": card.DeviceVirtualNo,
|
||||
"shop_id": card.ShopID, "status": card.Status, "series_id": card.SeriesID,
|
||||
"enable_polling": card.EnablePolling, "realname_policy": card.RealnamePolicy,
|
||||
"real_name_status": card.RealNameStatus, "network_status": card.NetworkStatus,
|
||||
"stop_reason": card.StopReason,
|
||||
}
|
||||
}
|
||||
85
internal/service/iot_card/polling_audit.go
Normal file
85
internal/service/iot_card/polling_audit.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package iot_card
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
infraAudit "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
func (s *Service) appendPollingStatusAudit(ctx context.Context, tx *gorm.DB, card *model.IotCard, before, after bool) error {
|
||||
return s.appendCardLifecycleAudit(ctx, tx, constants.AuditActionIotCardPollingStatusUpdated, "更新 IoT 卡轮询开关", constants.AuditResultSuccess, card,
|
||||
map[string]any{"enable_polling": before}, map[string]any{"enable_polling": after}, nil)
|
||||
}
|
||||
|
||||
func (s *Service) appendBatchPollingStatusAudit(ctx context.Context, tx *gorm.DB, cards []*model.IotCard, enablePolling bool) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置")
|
||||
}
|
||||
linkage := auditcontext.From(ctx)
|
||||
batchKey := linkage.RequestID
|
||||
if batchKey == "" {
|
||||
batchKey = uuid.NewString()
|
||||
}
|
||||
rootEventID := "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("iot-card-polling-status:"+batchKey)).String()
|
||||
children := make([]infraAudit.AppendInput, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card == nil || card.ID == 0 {
|
||||
continue
|
||||
}
|
||||
id := strconv.FormatUint(uint64(card.ID), 10)
|
||||
children = append(children, infraAudit.AppendInput{
|
||||
EventID: "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte(rootEventID+":"+id)).String(),
|
||||
ActionCode: constants.AuditActionIotCardPollingStatusUpdated,
|
||||
Summary: "批量更新 IoT 卡轮询开关",
|
||||
Result: constants.AuditResultSuccess,
|
||||
Resources: []infraAudit.ResourceInput{{
|
||||
Type: constants.AuditResourceIotCard, ID: &id, Key: infraAudit.IotCardResourceKey(card), DisplayName: card.ICCID,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardTarget,
|
||||
IdentitySnapshot: infraAudit.IotCardIdentitySnapshot(card),
|
||||
BeforeData: map[string]any{"enable_polling": card.EnablePolling}, AfterData: map[string]any{"enable_polling": enablePolling},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "IoT 卡轮询开关已更新",
|
||||
}},
|
||||
})
|
||||
}
|
||||
return s.auditWriter.AppendBatch(ctx, tx, infraAudit.BatchInput{
|
||||
Root: infraAudit.AppendInput{
|
||||
EventID: rootEventID,
|
||||
ActionCode: constants.AuditActionIotCardPollingStatusBatchUpdated,
|
||||
Summary: "批量更新 IoT 卡轮询开关", Result: constants.AuditResultSuccess,
|
||||
BatchTotal: len(children), SuccessCount: len(children),
|
||||
Resources: []infraAudit.ResourceInput{{
|
||||
Type: constants.AuditResourceIotCardBatch, Key: batchKey, DisplayName: batchKey,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardBatch,
|
||||
IdentitySnapshot: map[string]any{"card_count": len(children), "enable_polling": enablePolling},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
},
|
||||
Children: children,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordPollingStatusFailure(ctx context.Context, actionCode, result string, card *model.IotCard, enablePolling bool, businessErr error) {
|
||||
if card == nil || card.ID == 0 || s.db == nil || s.auditWriter == nil {
|
||||
return
|
||||
}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendCardLifecycleAudit(ctx, tx, actionCode, "更新 IoT 卡轮询开关失败", result, card, nil,
|
||||
map[string]any{"enable_polling": enablePolling}, businessErr)
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
linkage := auditcontext.From(ctx)
|
||||
errorCode, _ := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
auditfailure.RecordSecondaryWriteFailure(actionCode, strconv.FormatUint(uint64(card.ID), 10), linkage.RequestID, linkage.CorrelationID, errorCode, err)
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
@@ -68,7 +67,6 @@ type Service struct {
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
redis *redis.Client
|
||||
assetIdentifierStore *postgres.AssetIdentifierStore
|
||||
assetAuditService AssetAuditService
|
||||
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
|
||||
enterpriseStore *postgres.EnterpriseStore
|
||||
packageExpiryQuery *packageexpiry.Query
|
||||
@@ -109,7 +107,6 @@ func New(
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
gatewayClient *gateway.Client,
|
||||
logger *zap.Logger,
|
||||
assetAuditService AssetAuditService,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
@@ -121,7 +118,6 @@ func New(
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
gatewayClient: gatewayClient,
|
||||
logger: logger,
|
||||
assetAuditService: assetAuditService,
|
||||
packageExpiryQuery: packageexpiry.NewQuery(db),
|
||||
}
|
||||
}
|
||||
@@ -1696,77 +1692,33 @@ func parseGatewayRealnameStatus(realStatus bool) int {
|
||||
func (s *Service) UpdatePollingStatus(ctx context.Context, cardID uint, enablePolling bool) error {
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询 IoT 卡失败")
|
||||
result := constants.AuditResultFailed
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
denyErr := errors.New(errors.CodeNotFound, "IoT卡不存在")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardPollingStatus,
|
||||
OperationDesc: "更新卡轮询状态被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: cardID,
|
||||
AssetIdentifier: "",
|
||||
AfterData: map[string]any{
|
||||
"enable_polling": enablePolling,
|
||||
},
|
||||
})
|
||||
return denyErr
|
||||
appErr = errors.New(errors.CodeNotFound, "IoT卡不存在")
|
||||
result = constants.AuditResultDenied
|
||||
}
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardPollingStatus,
|
||||
OperationDesc: "更新卡轮询状态执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: cardID,
|
||||
AfterData: map[string]any{
|
||||
"enable_polling": enablePolling,
|
||||
},
|
||||
})
|
||||
s.recordPollingStatusFailure(ctx, constants.AuditActionIotCardPollingStatusUpdated, result, &model.IotCard{Model: gorm.Model{ID: cardID}}, enablePolling, appErr)
|
||||
return appErr
|
||||
}
|
||||
beforePolling := card.EnablePolling
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if beforePolling != enablePolling {
|
||||
result := tx.Model(&model.IotCard{}).Where("id = ? AND enable_polling = ?", card.ID, beforePolling).Update("enable_polling", enablePolling)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新 IoT 卡轮询状态失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeConflict, "轮询状态已变更,请刷新后重试")
|
||||
}
|
||||
}
|
||||
return s.appendPollingStatusAudit(ctx, tx, card, beforePolling, enablePolling)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordPollingStatusFailure(ctx, constants.AuditActionIotCardPollingStatusUpdated, constants.AuditResultFailed, card, enablePolling, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查是否需要更新
|
||||
if card.EnablePolling == enablePolling {
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardPollingStatus,
|
||||
OperationDesc: "更新卡轮询状态",
|
||||
ResultStatus: constants.AssetAuditResultSuccess,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: map[string]any{
|
||||
"enable_polling": card.EnablePolling,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"enable_polling": enablePolling,
|
||||
},
|
||||
})
|
||||
return nil // 状态未变化
|
||||
}
|
||||
|
||||
// 更新数据库
|
||||
card.EnablePolling = enablePolling
|
||||
if err := s.iotCardStore.Update(ctx, card); err != nil {
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardPollingStatus,
|
||||
OperationDesc: "更新卡轮询状态执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: map[string]any{
|
||||
"enable_polling": !enablePolling,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"enable_polling": enablePolling,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("更新卡轮询状态",
|
||||
zap.Uint("card_id", cardID),
|
||||
@@ -1782,20 +1734,6 @@ func (s *Service) UpdatePollingStatus(ctx context.Context, cardID uint, enablePo
|
||||
}
|
||||
}
|
||||
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardPollingStatus,
|
||||
OperationDesc: "更新卡轮询状态",
|
||||
ResultStatus: constants.AssetAuditResultSuccess,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: map[string]any{
|
||||
"enable_polling": !enablePolling,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"enable_polling": enablePolling,
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1805,23 +1743,17 @@ func (s *Service) BatchUpdatePollingStatus(ctx context.Context, cardIDs []uint,
|
||||
return nil
|
||||
}
|
||||
|
||||
// 批量更新数据库
|
||||
if err := s.iotCardStore.BatchUpdatePollingStatus(ctx, cardIDs, enablePolling); err != nil {
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardPollingStatus,
|
||||
OperationDesc: "批量更新卡轮询状态执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
BatchTotal: len(cardIDs),
|
||||
FailCount: len(cardIDs),
|
||||
AfterData: map[string]any{
|
||||
"card_ids": cardIDs,
|
||||
"enable_polling": enablePolling,
|
||||
"trigger_source": "batch",
|
||||
},
|
||||
})
|
||||
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if updateErr := tx.Model(&model.IotCard{}).Where("id IN ?", cardIDs).Update("enable_polling", enablePolling).Error; updateErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updateErr, "批量更新 IoT 卡轮询状态失败")
|
||||
}
|
||||
return s.appendBatchPollingStatusAudit(ctx, tx, cards, enablePolling)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1841,19 +1773,6 @@ func (s *Service) BatchUpdatePollingStatus(ctx context.Context, cardIDs []uint,
|
||||
}
|
||||
}
|
||||
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardPollingStatus,
|
||||
OperationDesc: "批量更新卡轮询状态",
|
||||
ResultStatus: constants.AssetAuditResultSuccess,
|
||||
BatchTotal: len(cardIDs),
|
||||
SuccessCount: len(cardIDs),
|
||||
AfterData: map[string]any{
|
||||
"card_ids": cardIDs,
|
||||
"enable_polling": enablePolling,
|
||||
"trigger_source": "batch",
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ type StopResumeService struct {
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
gatewayClient *gateway.Client
|
||||
logger *zap.Logger
|
||||
assetAuditService AssetAuditService
|
||||
pollingCallback PollingCallback
|
||||
observationSeriesEvents cardObservationApp.SeriesEventWriter
|
||||
auditWriter *audit.Writer
|
||||
@@ -75,7 +74,6 @@ func NewStopResumeService(
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||
gatewayClient *gateway.Client,
|
||||
logger *zap.Logger,
|
||||
assetAuditService AssetAuditService,
|
||||
) *StopResumeService {
|
||||
return &StopResumeService{
|
||||
redis: redis,
|
||||
@@ -84,7 +82,6 @@ func NewStopResumeService(
|
||||
deviceSimBindingStore: deviceSimBindingStore,
|
||||
gatewayClient: gatewayClient,
|
||||
logger: logger,
|
||||
assetAuditService: assetAuditService,
|
||||
maxRetries: 3,
|
||||
retryInterval: 2 * time.Second,
|
||||
}
|
||||
|
||||
@@ -13,11 +13,6 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// AssetAuditService 资产审计服务接口。
|
||||
type AssetAuditService interface {
|
||||
LogOperation(ctx context.Context, log *model.AssetOperationLog)
|
||||
}
|
||||
|
||||
func (s *Service) writeImportTaskAudit(ctx context.Context, tx *gorm.DB, task *model.IotCardImportTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error {
|
||||
return s.auditWriter.WriteTask(ctx, tx, infraAudit.TaskInput{
|
||||
EventID: infraAudit.TaskEventID(constants.AuditResourceIotCardImportTask, task.ID, phase),
|
||||
|
||||
@@ -24,7 +24,6 @@ type Service struct {
|
||||
importTaskStore *postgres.IotCardImportTaskStore
|
||||
carrierStore carrierGetter
|
||||
queueClient *queue.Client
|
||||
assetAudit AssetAuditService
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
@@ -52,7 +51,6 @@ func New(
|
||||
db *gorm.DB,
|
||||
importTaskStore *postgres.IotCardImportTaskStore,
|
||||
queueClient *queue.Client,
|
||||
assetAudit AssetAuditService,
|
||||
auditWriters ...*audit.Writer,
|
||||
) *Service {
|
||||
service := &Service{
|
||||
@@ -60,7 +58,6 @@ func New(
|
||||
importTaskStore: importTaskStore,
|
||||
carrierStore: NewCarrierStore(db),
|
||||
queueClient: queueClient,
|
||||
assetAudit: assetAudit,
|
||||
}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
|
||||
@@ -2,13 +2,19 @@ package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
@@ -17,46 +23,36 @@ import (
|
||||
// 管理 IoT 卡和设备的轮询启用状态
|
||||
// S2 修复:card 类型委托给 IotCardService(含 DB 写入 + callback 通知),避免绕过生命周期
|
||||
type AssetPollingService struct {
|
||||
db *gorm.DB
|
||||
deviceStore *postgres.DeviceStore
|
||||
deviceBindingStore *postgres.DeviceSimBindingStore
|
||||
iotCardService *iotCardSvc.Service
|
||||
queueMgr *polling.PollingQueueManager
|
||||
logger *zap.Logger
|
||||
assetAuditService assetAuditSvc.OperationLogger
|
||||
auditWriter *auditinfra.Writer
|
||||
}
|
||||
|
||||
// NewAssetPollingService 创建资产轮询管控服务
|
||||
func NewAssetPollingService(
|
||||
db *gorm.DB,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
deviceBindingStore *postgres.DeviceSimBindingStore,
|
||||
iotCardService *iotCardSvc.Service,
|
||||
queueMgr *polling.PollingQueueManager,
|
||||
logger *zap.Logger,
|
||||
assetAuditService assetAuditSvc.OperationLogger,
|
||||
auditWriter *auditinfra.Writer,
|
||||
) *AssetPollingService {
|
||||
return &AssetPollingService{
|
||||
db: db,
|
||||
deviceStore: deviceStore,
|
||||
deviceBindingStore: deviceBindingStore,
|
||||
iotCardService: iotCardService,
|
||||
queueMgr: queueMgr,
|
||||
logger: logger,
|
||||
assetAuditService: assetAuditService,
|
||||
auditWriter: auditWriter,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AssetPollingService) logAssetPollingAudit(ctx context.Context, p assetAuditSvc.BuildLogParams) {
|
||||
if s == nil || s.assetAuditService == nil {
|
||||
return
|
||||
}
|
||||
if p.OperationType == "" {
|
||||
p.OperationType = constants.AssetAuditOpAssetPollingStatus
|
||||
}
|
||||
if p.Operator.Type == "" {
|
||||
p.Operator = assetAuditSvc.OperatorFromContext(ctx)
|
||||
}
|
||||
s.assetAuditService.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p))
|
||||
}
|
||||
|
||||
// UpdatePollingStatus 更新资产轮询状态
|
||||
// assetType: "card" 或 "device"
|
||||
// assetID: 资产ID
|
||||
@@ -64,87 +60,23 @@ func (s *AssetPollingService) logAssetPollingAudit(ctx context.Context, p assetA
|
||||
func (s *AssetPollingService) UpdatePollingStatus(ctx context.Context, assetType string, assetID uint, enablePolling bool) error {
|
||||
switch assetType {
|
||||
case constants.AssetTypeIotCard:
|
||||
beforeData := map[string]any{
|
||||
"asset_type": constants.AssetTypeIotCard,
|
||||
"asset_id": assetID,
|
||||
"enable_polling": "unknown",
|
||||
"source_service": "asset_polling",
|
||||
}
|
||||
afterData := map[string]any{
|
||||
"asset_type": constants.AssetTypeIotCard,
|
||||
"asset_id": assetID,
|
||||
"enable_polling": enablePolling,
|
||||
}
|
||||
// S2 修复:委托给 IotCardService,确保 DB 写入 + PollingCallback 回调一并触发
|
||||
if err := s.iotCardService.UpdatePollingStatus(ctx, assetID, enablePolling); err != nil {
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
s.logAssetPollingAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: assetID,
|
||||
OperationDesc: "统一入口更新轮询状态失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
BeforeData: beforeData,
|
||||
AfterData: afterData,
|
||||
})
|
||||
return err
|
||||
}
|
||||
s.logAssetPollingAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: assetID,
|
||||
OperationDesc: "统一入口更新轮询状态",
|
||||
ResultStatus: constants.AssetAuditResultSuccess,
|
||||
BeforeData: beforeData,
|
||||
AfterData: afterData,
|
||||
})
|
||||
return nil
|
||||
return s.iotCardService.UpdatePollingStatus(ctx, assetID, enablePolling)
|
||||
|
||||
case constants.AssetTypeDevice:
|
||||
device, getErr := s.deviceStore.GetByID(ctx, assetID)
|
||||
if getErr != nil {
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(getErr)
|
||||
s.logAssetPollingAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: assetID,
|
||||
OperationDesc: "统一入口更新轮询状态失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AfterData: map[string]any{
|
||||
"asset_type": constants.AssetTypeDevice,
|
||||
"asset_id": assetID,
|
||||
"enable_polling": enablePolling,
|
||||
},
|
||||
})
|
||||
return getErr
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, getErr, "查询设备失败")
|
||||
result := constants.AuditResultFailed
|
||||
if stderrors.Is(getErr, gorm.ErrRecordNotFound) {
|
||||
appErr = errors.New(errors.CodeNotFound, "设备不存在")
|
||||
result = constants.AuditResultDenied
|
||||
}
|
||||
s.recordDevicePollingFailure(ctx, &model.Device{Model: gorm.Model{ID: assetID}}, enablePolling, result, appErr)
|
||||
return appErr
|
||||
}
|
||||
beforeData := map[string]any{
|
||||
"asset_type": constants.AssetTypeDevice,
|
||||
"asset_id": device.ID,
|
||||
"asset_identifier": device.VirtualNo,
|
||||
"enable_polling": device.EnablePolling,
|
||||
}
|
||||
afterData := map[string]any{
|
||||
"asset_type": constants.AssetTypeDevice,
|
||||
"asset_id": device.ID,
|
||||
"asset_identifier": device.VirtualNo,
|
||||
"enable_polling": enablePolling,
|
||||
}
|
||||
// 1. 更新设备的 enable_polling 字段
|
||||
if err := s.deviceStore.UpdatePollingStatus(ctx, assetID, enablePolling); err != nil {
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
s.logAssetPollingAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: device.ID,
|
||||
AssetIdentifier: device.VirtualNo,
|
||||
OperationDesc: "统一入口更新轮询状态失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
BeforeData: beforeData,
|
||||
AfterData: afterData,
|
||||
})
|
||||
if err := s.updateDevicePollingStatus(ctx, device, enablePolling); err != nil {
|
||||
s.recordDevicePollingFailure(ctx, device, enablePolling, constants.AuditResultFailed, err)
|
||||
return err
|
||||
}
|
||||
bindings, err := s.deviceBindingStore.ListByDeviceID(ctx, assetID)
|
||||
@@ -173,33 +105,76 @@ func (s *AssetPollingService) UpdatePollingStatus(ctx context.Context, assetType
|
||||
}
|
||||
}
|
||||
}
|
||||
s.logAssetPollingAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: device.ID,
|
||||
AssetIdentifier: device.VirtualNo,
|
||||
OperationDesc: "统一入口更新轮询状态",
|
||||
ResultStatus: constants.AssetAuditResultSuccess,
|
||||
BeforeData: beforeData,
|
||||
AfterData: afterData,
|
||||
})
|
||||
return nil
|
||||
|
||||
default:
|
||||
err := errors.New(errors.CodeInvalidParam, "资产类型无效,支持 card 或 device")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
s.logAssetPollingAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: assetType,
|
||||
AssetID: assetID,
|
||||
OperationDesc: "统一入口更新轮询状态被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AfterData: map[string]any{
|
||||
"asset_type": assetType,
|
||||
"asset_id": assetID,
|
||||
"enable_polling": enablePolling,
|
||||
},
|
||||
})
|
||||
return err
|
||||
return errors.New(errors.CodeInvalidParam, "资产类型无效,支持 card 或 device")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AssetPollingService) updateDevicePollingStatus(ctx context.Context, device *model.Device, enablePolling bool) error {
|
||||
if s.db == nil || s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "设备统一审计接缝未配置")
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if device.EnablePolling != enablePolling {
|
||||
result := tx.Model(&model.Device{}).Where("id = ? AND enable_polling = ?", device.ID, device.EnablePolling).Update("enable_polling", enablePolling)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新设备轮询状态失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeConflict, "轮询状态已变更,请刷新后重试")
|
||||
}
|
||||
}
|
||||
return s.appendDevicePollingAudit(ctx, tx, device, enablePolling, constants.AuditResultSuccess, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AssetPollingService) appendDevicePollingAudit(ctx context.Context, tx *gorm.DB, device *model.Device, enablePolling bool, result string, businessErr error) error {
|
||||
if device == nil || device.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "设备审计资源不完整")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(device.ID), 10)
|
||||
errorCode, errorSummary := auditErrorInfo(businessErr)
|
||||
return s.auditWriter.Append(ctx, tx, auditinfra.AppendInput{
|
||||
ActionCode: constants.AuditActionDevicePollingStatusUpdated,
|
||||
Summary: "更新设备轮询开关",
|
||||
Result: result,
|
||||
ErrorCode: errorCode,
|
||||
ErrorSummary: errorSummary,
|
||||
ScopeType: constants.AuditScopePlatform,
|
||||
Resources: []auditinfra.ResourceInput{{
|
||||
Type: constants.AuditResourceDevice, ID: &id, Key: auditinfra.DeviceResourceKey(device), DisplayName: device.VirtualNo,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
|
||||
IdentitySnapshot: auditinfra.DeviceIdentitySnapshot(device),
|
||||
BeforeData: map[string]any{"enable_polling": device.EnablePolling}, AfterData: map[string]any{"enable_polling": enablePolling},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备轮询开关已更新",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AssetPollingService) recordDevicePollingFailure(ctx context.Context, device *model.Device, enablePolling bool, result string, businessErr error) {
|
||||
if s == nil || s.db == nil || s.auditWriter == nil || device == nil || device.ID == 0 {
|
||||
return
|
||||
}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendDevicePollingAudit(ctx, tx, device, enablePolling, result, businessErr)
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
linkage := auditcontext.From(ctx)
|
||||
errorCode, _ := auditErrorInfo(businessErr)
|
||||
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionDevicePollingStatusUpdated, strconv.FormatUint(uint64(device.ID), 10), linkage.RequestID, linkage.CorrelationID, errorCode, err)
|
||||
}
|
||||
|
||||
func auditErrorInfo(err error) (string, string) {
|
||||
if err == nil {
|
||||
return "", ""
|
||||
}
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return strconv.Itoa(appErr.Code), appErr.Error()
|
||||
}
|
||||
return "", err.Error()
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AccountOperationLogStore 账号操作日志存储层
|
||||
type AccountOperationLogStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAccountOperationLogStore 创建账号操作日志存储实例
|
||||
func NewAccountOperationLogStore(db *gorm.DB) *AccountOperationLogStore {
|
||||
return &AccountOperationLogStore{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建账号操作日志记录
|
||||
func (s *AccountOperationLogStore) Create(ctx context.Context, log *model.AccountOperationLog) error {
|
||||
return s.db.WithContext(ctx).Create(log).Error
|
||||
}
|
||||
@@ -17,11 +17,6 @@ func NewAssetOperationLogStore(db *gorm.DB) *AssetOperationLogStore {
|
||||
return &AssetOperationLogStore{db: db}
|
||||
}
|
||||
|
||||
// Create 创建资产操作日志记录。
|
||||
func (s *AssetOperationLogStore) Create(ctx context.Context, log *model.AssetOperationLog) error {
|
||||
return s.db.WithContext(ctx).Create(log).Error
|
||||
}
|
||||
|
||||
// ListByAssetPaged 按资产分页查询日志。
|
||||
func (s *AssetOperationLogStore) ListByAssetPaged(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -19,17 +19,21 @@ type AuditMonthlyRetentionPayload struct {
|
||||
|
||||
// AuditMonthlyRetentionHandler 处理归档完整性门禁与上月在线日志物理清理。
|
||||
type AuditMonthlyRetentionHandler struct {
|
||||
service *auditarchive.Service
|
||||
logger *zap.Logger
|
||||
service *auditarchive.Service
|
||||
logger *zap.Logger
|
||||
cleanupEnabled bool
|
||||
}
|
||||
|
||||
// NewAuditMonthlyRetentionHandler 创建月度日志留存清理处理器。
|
||||
func NewAuditMonthlyRetentionHandler(service *auditarchive.Service, logger *zap.Logger) *AuditMonthlyRetentionHandler {
|
||||
return &AuditMonthlyRetentionHandler{service: service, logger: logger}
|
||||
func NewAuditMonthlyRetentionHandler(service *auditarchive.Service, logger *zap.Logger, cleanupEnabled bool) *AuditMonthlyRetentionHandler {
|
||||
return &AuditMonthlyRetentionHandler{service: service, logger: logger, cleanupEnabled: cleanupEnabled}
|
||||
}
|
||||
|
||||
// Handle 校验整月归档后按固定顺序分批物理删除 PostgreSQL 在线日志。
|
||||
func (h *AuditMonthlyRetentionHandler) Handle(ctx context.Context, task *asynq.Task) error {
|
||||
if !h.cleanupEnabled {
|
||||
return h.handleDryRun(ctx, task)
|
||||
}
|
||||
if h.service == nil {
|
||||
return fmt.Errorf("月度日志留存清理服务未配置")
|
||||
}
|
||||
@@ -62,3 +66,37 @@ func (h *AuditMonthlyRetentionHandler) Handle(ctx context.Context, task *asynq.T
|
||||
h.logger.Info("月度日志留存清理完成", fields...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuditMonthlyRetentionHandler) handleDryRun(ctx context.Context, task *asynq.Task) error {
|
||||
if h.service == nil {
|
||||
return fmt.Errorf("月度日志留存演练服务未配置")
|
||||
}
|
||||
var result auditarchive.RetentionResult
|
||||
var err error
|
||||
if len(task.Payload()) == 0 {
|
||||
result, err = h.service.ValidatePreviousMonth(ctx)
|
||||
} else {
|
||||
var payload AuditMonthlyRetentionPayload
|
||||
if unmarshalErr := sonic.Unmarshal(task.Payload(), &payload); unmarshalErr != nil {
|
||||
return fmt.Errorf("解析月度日志留存演练任务载荷失败: %w", unmarshalErr)
|
||||
}
|
||||
month, parseErr := parseArchiveMonth(payload.ArchiveMonth)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
result, err = h.service.ValidateMonth(ctx, month)
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.Bool("cleanup_enabled", false), zap.String("archive_month", result.Month),
|
||||
zap.Int64("audit_event_count", result.EventCount), zap.Int64("event_resource_count", result.ResourceCount),
|
||||
zap.Int64("integration_log_count", result.IntegrationCount), zap.Int64("estimated_cleanup_batches", result.EstimatedBatches),
|
||||
zap.Duration("validation_duration", result.Duration), zap.Int("manifest_count", len(result.ManifestKeys)),
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, zap.String("severity", "critical"), zap.Error(err))
|
||||
h.logger.Error("审计日志月度只读演练失败,物理清理保持关闭", fields...)
|
||||
return err
|
||||
}
|
||||
h.logger.Info("审计日志月度只读演练通过,物理清理保持关闭", fields...)
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user