全局审计完成
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m31s

This commit is contained in:
2026-08-07 11:02:52 +08:00
parent 88cc5e96ec
commit c64f3d8b80
94 changed files with 8641 additions and 6714 deletions

View File

@@ -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

View File

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

View File

@@ -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,

View File

@@ -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),

View File

@@ -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{

View File

@@ -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,

View File

@@ -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"

View File

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

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

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

View File

@@ -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 是外部集成详情路径参数。

View File

@@ -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}/timelineresource_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 提供平台统一审计事件列表与详情读取。

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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或unknownname为中文展示名"`
Risks []RiskNamedCount `json:"risks" description:"风险等级分布code为low、normal、high或criticalname为中文展示名"`
Results []RiskNamedCount `json:"results" description:"结果分布code为success、failed、denied、partial或unknownname为中文展示名"`
Actions []RiskNamedCount `json:"actions" description:"动作分布code为稳定action_codename为中文action_name"`
Sources []RiskNamedCount `json:"sources" description:"来源分布code为admin_api、personal_api、openapi、worker、scheduler或callbackname为中文展示名"`
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 查询指定时间范围内的固定风险调查总览。

View File

@@ -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,

View File

@@ -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。

View File

@@ -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:"资源内部稳定IDtype和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

View File

@@ -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或outboundname为中文展示名"`
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 查询指定时间范围的固定维度外部交互总览。

View File

@@ -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。事件在资源作为 primaryaffectedreference 时均返回。",
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,
})
}

View File

@@ -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,
}
}

View File

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

View File

@@ -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,

View File

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

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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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,
}
}

View File

@@ -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),

View File

@@ -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 {

View File

@@ -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]

View File

@@ -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,
}
}

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

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

View File

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

View File

@@ -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,
}

View File

@@ -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),

View File

@@ -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]

View File

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

View File

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

View File

@@ -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,

View File

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