From cb75b5668bf01a494deae0514f314aa739a099b6 Mon Sep 17 00:00:00 2001 From: huang Date: Mon, 27 Apr 2026 12:16:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bootstrap/handlers.go | 11 +- internal/bootstrap/services.go | 49 +- internal/bootstrap/stores.go | 2 + internal/bootstrap/worker_services.go | 12 +- internal/bootstrap/worker_stores.go | 3 + internal/handler/admin/asset.go | 47 ++ internal/model/asset_operation_log.go | 43 + internal/model/dto/asset_operation_log_dto.go | 54 ++ internal/routes/asset.go | 9 + internal/service/asset/lifecycle_service.go | 214 ++++- internal/service/asset_audit/builder.go | 269 ++++++ internal/service/asset_audit/service.go | 149 ++++ internal/service/device/audit.go | 88 ++ internal/service/device/binding.go | 318 ++++++- internal/service/device/gateway_service.go | 335 +++++++- internal/service/device/service.go | 795 +++++++++++++++++- internal/service/device_import/audit.go | 56 ++ internal/service/device_import/service.go | 23 +- internal/service/iot_card/audit.go | 58 ++ internal/service/iot_card/service.go | 641 +++++++++++++- .../service/iot_card/stop_resume_service.go | 309 ++++++- internal/service/iot_card_import/audit.go | 58 ++ internal/service/iot_card_import/service.go | 27 +- .../service/polling/asset_polling_service.go | 120 ++- .../postgres/asset_operation_log_store.go | 106 +++ ...000136_create_asset_operation_log.down.sql | 8 + .../000136_create_asset_operation_log.up.sql | 69 ++ pkg/constants/asset_audit.go | 58 ++ pkg/constants/constants.go | 2 + pkg/logger/middleware.go | 14 + pkg/middleware/auth.go | 22 + pkg/openapi/handlers.go | 2 +- pkg/queue/types.go | 1 + 33 files changed, 3860 insertions(+), 112 deletions(-) create mode 100644 internal/model/asset_operation_log.go create mode 100644 internal/model/dto/asset_operation_log_dto.go create mode 100644 internal/service/asset_audit/builder.go create mode 100644 internal/service/asset_audit/service.go create mode 100644 internal/service/device/audit.go create mode 100644 internal/service/device_import/audit.go create mode 100644 internal/service/iot_card/audit.go create mode 100644 internal/service/iot_card_import/audit.go create mode 100644 internal/store/postgres/asset_operation_log_store.go create mode 100644 migrations/000136_create_asset_operation_log.down.sql create mode 100644 migrations/000136_create_asset_operation_log.up.sql create mode 100644 pkg/constants/asset_audit.go diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 1071201..5ad14f8 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -119,8 +119,15 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger), Asset: func() *admin.AssetHandler { pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger) - assetPollingSvc := pollingSvcPkg.NewAssetPollingService(deviceStore, deviceSimBindingStore, svc.IotCard, pollingQueueMgr, deps.Logger) - h := admin.NewAssetHandler(svc.Asset, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc) + assetPollingSvc := pollingSvcPkg.NewAssetPollingService( + deviceStore, + deviceSimBindingStore, + svc.IotCard, + pollingQueueMgr, + deps.Logger, + svc.AssetAudit, + ) + h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc) h.SetLifecycleService(svc.AssetLifecycle) return h }(), diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 4a750ce..3824134 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -8,6 +8,7 @@ import ( "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" + assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record" authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth" carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier" @@ -56,6 +57,7 @@ import ( type services struct { Account *accountSvc.Service AccountAudit *accountAuditSvc.Service + AssetAudit *assetAuditSvc.Service Role *roleSvc.Service Permission *permissionSvc.Service PersonalCustomer *personalCustomerSvc.Service @@ -110,10 +112,22 @@ type services struct { func initServices(s *stores, deps *Dependencies) *services { purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopPackageAllocation) accountAudit := accountAuditSvc.NewService(s.AccountOperationLog) + assetAudit := assetAuditSvc.NewService(s.AssetOperationLog) account := accountSvc.New(s.Account, s.Role, s.AccountRole, s.ShopRole, s.Shop, s.Enterprise, accountAudit) // 创建 IotCard service 并设置回调 - iotCard := iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, deps.Logger) + iotCard := iotCardSvc.New( + deps.DB, + s.IotCard, + s.Shop, + s.AssetAllocationRecord, + s.ShopPackageAllocation, + s.ShopSeriesAllocation, + s.PackageSeries, + deps.GatewayClient, + deps.Logger, + assetAudit, + ) // 使用 PollingLifecycleService 替代 APICallback:通过分片队列准确操作(修复 api_callback.go 遗漏 protect 队列的 Bug3) pollingConfigStore := postgres.NewPollingConfigStore(deps.DB) pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, deps.Redis, deps.Logger) @@ -142,17 +156,40 @@ func initServices(s *stores, deps *Dependencies) *services { deps.Logger, ) - stopResumeService := iotCardSvc.NewStopResumeService(deps.Redis, s.IotCard, s.PackageUsage, s.DeviceSimBinding, deps.GatewayClient, deps.Logger) + stopResumeService := iotCardSvc.NewStopResumeService( + deps.Redis, + s.IotCard, + s.PackageUsage, + s.DeviceSimBinding, + deps.GatewayClient, + deps.Logger, + assetAudit, + ) iotCard.SetRealnameActivator(packageActivation) iotCard.SetStopResumeService(stopResumeService) iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding) iotCard.SetRedisClient(deps.Redis) - device := deviceSvc.New(deps.DB, deps.Redis, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, s.AssetIdentifier) + device := deviceSvc.New( + deps.DB, + deps.Redis, + s.Device, + s.DeviceSimBinding, + s.IotCard, + s.Shop, + s.AssetAllocationRecord, + s.ShopPackageAllocation, + s.ShopSeriesAllocation, + s.PackageSeries, + deps.GatewayClient, + s.AssetIdentifier, + assetAudit, + ) operationPassword := operationPasswordSvc.New(deps.Redis) return &services{ Account: account, AccountAudit: accountAudit, + AssetAudit: assetAudit, Role: roleSvc.New(s.Role, s.Permission, s.RolePermission, s.AccountRole, s.ShopRole), Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis), PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger), @@ -198,10 +235,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), Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger), IotCard: iotCard, - IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient), + IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, assetAudit), ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService), Device: device, - DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient), + DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, assetAudit), AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account), Carrier: carrierSvc.New(s.Carrier), PackageSeries: packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package), @@ -223,7 +260,7 @@ func initServices(s *stores, deps *Dependencies) *services { PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger), PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger), Asset: 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), - AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device), + AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, assetAudit), AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction), StopResumeService: stopResumeService, WechatConfig: wechatConfig, diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index 07eec73..e7eaaab 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -7,6 +7,7 @@ import ( type stores struct { Account *postgres.AccountStore AccountOperationLog *postgres.AccountOperationLogStore + AssetOperationLog *postgres.AssetOperationLogStore Shop *postgres.ShopStore Role *postgres.RoleStore Permission *postgres.PermissionStore @@ -75,6 +76,7 @@ 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), Permission: postgres.NewPermissionStore(deps.DB), diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index 8c87235..db7276e 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -1,6 +1,7 @@ package bootstrap import ( + 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" iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" @@ -21,6 +22,7 @@ type workerServices struct { } func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *queue.WorkerServices { + assetAudit := assetAuditSvc.NewService(stores.AssetOperationLog) commissionStatsService := commission_stats.New(stores.ShopSeriesCommissionStats) commissionCalculationService := commission_calculation.New( @@ -108,7 +110,15 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q ) // 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机 - stopResumeService := iotCardSvc.NewStopResumeService(deps.Redis, stores.IotCard, stores.PackageUsage, stores.DeviceSimBinding, deps.GatewayClient, deps.Logger) + stopResumeService := iotCardSvc.NewStopResumeService( + deps.Redis, + stores.IotCard, + stores.PackageUsage, + stores.DeviceSimBinding, + deps.GatewayClient, + deps.Logger, + assetAudit, + ) usageService.SetStopResumeCallback(stopResumeService) activationService.SetResumeCallback(stopResumeService) orderService.SetResumeCallback(stopResumeService) diff --git a/internal/bootstrap/worker_stores.go b/internal/bootstrap/worker_stores.go index c1a8d50..b9aea91 100644 --- a/internal/bootstrap/worker_stores.go +++ b/internal/bootstrap/worker_stores.go @@ -6,6 +6,7 @@ import ( ) type workerStores struct { + AssetOperationLog *postgres.AssetOperationLogStore IotCardImportTask *postgres.IotCardImportTaskStore IotCard *postgres.IotCardStore DeviceImportTask *postgres.DeviceImportTaskStore @@ -38,6 +39,7 @@ type workerStores struct { func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { stores := &workerStores{ + AssetOperationLog: postgres.NewAssetOperationLogStore(deps.DB), IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis), IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis), DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis), @@ -69,6 +71,7 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { } return &queue.WorkerStores{ + AssetOperationLog: stores.AssetOperationLog, IotCardImportTask: stores.IotCardImportTask, IotCard: stores.IotCard, DeviceImportTask: stores.DeviceImportTask, diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index 7a9c722..a211048 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -2,11 +2,13 @@ package admin import ( "strconv" + "strings" "github.com/gofiber/fiber/v2" dto "github.com/break/junhong_cmp_fiber/internal/model/dto" assetService "github.com/break/junhong_cmp_fiber/internal/service/asset" + assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" deviceService "github.com/break/junhong_cmp_fiber/internal/service/device" iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card" pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" @@ -20,6 +22,7 @@ import ( // 提供统一的资产解析、实时状态、套餐查询、停复机、停用、轮询管控等接口 type AssetHandler struct { assetService *assetService.Service + assetAuditService *assetAuditSvc.Service deviceService *deviceService.Service iotCardService *iotCardService.Service iotCardStopResume *iotCardService.StopResumeService @@ -30,6 +33,7 @@ type AssetHandler struct { // NewAssetHandler 创建资产管理处理器 func NewAssetHandler( assetSvc *assetService.Service, + assetAuditService *assetAuditSvc.Service, deviceSvc *deviceService.Service, iotCardSvc *iotCardService.Service, iotCardStopResume *iotCardService.StopResumeService, @@ -37,6 +41,7 @@ func NewAssetHandler( ) *AssetHandler { return &AssetHandler{ assetService: assetSvc, + assetAuditService: assetAuditService, deviceService: deviceSvc, iotCardService: iotCardSvc, iotCardStopResume: iotCardStopResume, @@ -258,6 +263,48 @@ func (h *AssetHandler) Orders(c *fiber.Ctx) error { return response.Success(c, result) } +// OperationLogs 查询资产操作审计日志 +// GET /api/admin/assets/:identifier/operation-logs +func (h *AssetHandler) OperationLogs(c *fiber.Ctx) error { + if h.assetAuditService == nil { + return errors.New(errors.CodeInternalError, "资产审计服务未配置") + } + + asset, err := h.resolveByIdentifier(c) + if err != nil { + return err + } + + page := c.QueryInt("page", 1) + pageSize := c.QueryInt("page_size", 20) + if page <= 0 || pageSize <= 0 || pageSize > 100 { + return errors.New(errors.CodeInvalidParam, "分页参数无效") + } + + operationType := strings.TrimSpace(c.Query("operation_type")) + resultStatus := strings.TrimSpace(c.Query("result_status")) + if resultStatus != "" && + resultStatus != constants.AssetAuditResultSuccess && + resultStatus != constants.AssetAuditResultFailed && + resultStatus != constants.AssetAuditResultDenied { + return errors.New(errors.CodeInvalidParam, "无效的结果状态") + } + + result, err := h.assetAuditService.ListByAsset(c.UserContext(), assetAuditSvc.ListByAssetParams{ + AssetType: asset.AssetType, + AssetID: asset.AssetID, + Page: page, + PageSize: pageSize, + OperationType: operationType, + ResultStatus: resultStatus, + }) + if err != nil { + return err + } + + return response.Success(c, result) +} + // UpdatePollingStatus 更新资产轮询状态 // PATCH /api/admin/assets/:identifier/polling-status func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error { diff --git a/internal/model/asset_operation_log.go b/internal/model/asset_operation_log.go new file mode 100644 index 0000000..c91a2d6 --- /dev/null +++ b/internal/model/asset_operation_log.go @@ -0,0 +1,43 @@ +package model + +import "time" + +// AssetOperationLog 资产操作审计日志模型。 +// 记录资产域(卡/设备)敏感写操作,不建立外键约束,不使用 GORM 关联标签。 +type AssetOperationLog struct { + ID uint `gorm:"column:id;primaryKey;comment:主键ID" json:"id"` + CreatedAt time.Time `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"` + + OperatorID *uint `gorm:"column:operator_id;type:bigint;index:idx_asset_log_operator_created,priority:1;comment:操作人ID(系统触发为空)" json:"operator_id,omitempty"` + OperatorType string `gorm:"column:operator_type;type:varchar(32);not null;comment:操作人类型(system/admin_user/agent_user/enterprise_user/personal_customer)" json:"operator_type"` + OperatorName string `gorm:"column:operator_name;type:varchar(255);not null;default:'';comment:操作人名称快照" json:"operator_name"` + + AssetType string `gorm:"column:asset_type;type:varchar(32);not null;index:idx_asset_log_asset_created,priority:1;comment:资产类型(iot_card/device)" json:"asset_type"` + AssetID uint `gorm:"column:asset_id;type:bigint;not null;index:idx_asset_log_asset_created,priority:2;comment:资产ID" json:"asset_id"` + AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(128);not null;default:'';index:idx_asset_log_identifier_created,priority:1;comment:资产标识(ICCID/虚拟号)" json:"asset_identifier"` + + OperationType string `gorm:"column:operation_type;type:varchar(64);not null;index:idx_asset_log_operation_created,priority:1;comment:操作类型" json:"operation_type"` + OperationDesc string `gorm:"column:operation_desc;type:text;not null;comment:操作描述(中文)" json:"operation_desc"` + + BeforeData JSONB `gorm:"column:before_data;type:jsonb;comment:变更前数据" json:"before_data,omitempty"` + AfterData JSONB `gorm:"column:after_data;type:jsonb;comment:变更后数据" json:"after_data,omitempty"` + + RequestID *string `gorm:"column:request_id;type:varchar(255);comment:请求ID" json:"request_id,omitempty"` + IPAddress *string `gorm:"column:ip_address;type:varchar(64);comment:请求IP" json:"ip_address,omitempty"` + UserAgent *string `gorm:"column:user_agent;type:text;comment:用户代理" json:"user_agent,omitempty"` + RequestPath *string `gorm:"column:request_path;type:varchar(255);comment:请求路径" json:"request_path,omitempty"` + RequestMethod *string `gorm:"column:request_method;type:varchar(16);comment:请求方法" json:"request_method,omitempty"` + + ResultStatus string `gorm:"column:result_status;type:varchar(16);not null;index:idx_asset_log_result_created,priority:1;comment:执行结果(success/failed/denied)" json:"result_status"` + ErrorCode *string `gorm:"column:error_code;type:varchar(64);comment:错误码" json:"error_code,omitempty"` + ErrorMsg *string `gorm:"column:error_msg;type:text;comment:错误摘要" json:"error_msg,omitempty"` + + BatchTotal int `gorm:"column:batch_total;type:int;not null;default:0;comment:批量总数" json:"batch_total"` + SuccessCount int `gorm:"column:success_count;type:int;not null;default:0;comment:批量成功数" json:"success_count"` + FailCount int `gorm:"column:fail_count;type:int;not null;default:0;comment:批量失败数" json:"fail_count"` +} + +// TableName 指定资产操作日志表名。 +func (AssetOperationLog) TableName() string { + return "tb_asset_operation_log" +} diff --git a/internal/model/dto/asset_operation_log_dto.go b/internal/model/dto/asset_operation_log_dto.go new file mode 100644 index 0000000..1ec6619 --- /dev/null +++ b/internal/model/dto/asset_operation_log_dto.go @@ -0,0 +1,54 @@ +package dto + +import "time" + +// AssetOperationLogListRequest 资产操作日志查询请求 +type AssetOperationLogListRequest struct { + Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"` + Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"` + PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数(默认20,最大100)"` + OperationType string `query:"operation_type" description:"操作类型(如 device_stop、card_allocate)"` + ResultStatus string `query:"result_status" description:"执行结果 (success:成功, failed:失败, denied:拒绝)"` +} + +// AssetOperationLogItem 资产操作日志条目 +type AssetOperationLogItem struct { + ID uint `json:"id" description:"日志ID"` + CreatedAt time.Time `json:"created_at" description:"操作时间"` + + OperatorID *uint `json:"operator_id,omitempty" description:"操作人ID(系统触发为空)"` + OperatorType string `json:"operator_type" description:"操作人类型(system/admin_user/agent_user/enterprise_user/personal_customer)"` + OperatorName string `json:"operator_name" description:"操作人名称快照"` + + AssetType string `json:"asset_type" description:"资产类型(iot_card/device)"` + AssetID uint `json:"asset_id" description:"资产ID"` + AssetIdentifier string `json:"asset_identifier" description:"资产标识(ICCID/虚拟号)"` + + OperationType string `json:"operation_type" description:"操作类型"` + OperationDesc string `json:"operation_desc" description:"操作描述(中文)"` + + BeforeData map[string]any `json:"before_data,omitempty" description:"变更前数据"` + AfterData map[string]any `json:"after_data,omitempty" description:"变更后数据"` + + RequestID *string `json:"request_id,omitempty" description:"请求ID"` + IPAddress *string `json:"ip_address,omitempty" description:"请求IP"` + UserAgent *string `json:"user_agent,omitempty" description:"用户代理"` + RequestPath *string `json:"request_path,omitempty" description:"请求路径"` + RequestMethod *string `json:"request_method,omitempty" description:"请求方法"` + + ResultStatus string `json:"result_status" description:"执行结果 (success:成功, failed:失败, denied:拒绝)"` + ErrorCode *string `json:"error_code,omitempty" description:"错误码"` + ErrorMsg *string `json:"error_msg,omitempty" description:"错误摘要"` + + BatchTotal int `json:"batch_total" description:"批量总数"` + SuccessCount int `json:"success_count" description:"批量成功数"` + FailCount int `json:"fail_count" description:"批量失败数"` +} + +// AssetOperationLogListResponse 资产操作日志分页响应 +type AssetOperationLogListResponse struct { + Total int64 `json:"total" description:"总条数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"page_size" description:"每页条数"` + Items []*AssetOperationLogItem `json:"items" description:"日志列表"` +} diff --git a/internal/routes/asset.go b/internal/routes/asset.go index 753e8f1..edff827 100644 --- a/internal/routes/asset.go +++ b/internal/routes/asset.go @@ -110,6 +110,15 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle Auth: true, }) + Register(assets, doc, groupPath, "GET", "/:identifier/operation-logs", handler.OperationLogs, RouteSpec{ + Summary: "资产操作审计日志", + Description: "通过资产标识符查询审计日志,支持分页和操作类型/结果状态筛选。", + Tags: []string{"资产管理"}, + Input: new(dto.AssetOperationLogListRequest), + Output: new(dto.AssetOperationLogListResponse), + Auth: true, + }) + Register(assets, doc, groupPath, "PATCH", "/:identifier/polling-status", handler.UpdatePollingStatus, RouteSpec{ Summary: "更新资产轮询状态", Description: "启用或禁用指定资产(IoT卡或设备)的定期状态轮询。", diff --git a/internal/service/asset/lifecycle_service.go b/internal/service/asset/lifecycle_service.go index 4c8f642..9361abc 100644 --- a/internal/service/asset/lifecycle_service.go +++ b/internal/service/asset/lifecycle_service.go @@ -5,6 +5,7 @@ import ( stderrors "errors" "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/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -15,44 +16,145 @@ var deactivatableAssetStatuses = []int{constants.AssetStatusInStock, constants.A // LifecycleService 资产生命周期服务 type LifecycleService struct { - db *gorm.DB - iotCardStore *postgres.IotCardStore - deviceStore *postgres.DeviceStore + db *gorm.DB + iotCardStore *postgres.IotCardStore + deviceStore *postgres.DeviceStore + assetAuditService assetAuditSvc.OperationLogger } // NewLifecycleService 创建资产生命周期服务 -func NewLifecycleService(db *gorm.DB, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore) *LifecycleService { +func NewLifecycleService( + db *gorm.DB, + iotCardStore *postgres.IotCardStore, + deviceStore *postgres.DeviceStore, + assetAuditService assetAuditSvc.OperationLogger, +) *LifecycleService { return &LifecycleService{ - db: db, - iotCardStore: iotCardStore, - deviceStore: deviceStore, + db: db, + iotCardStore: iotCardStore, + deviceStore: deviceStore, + assetAuditService: assetAuditService, } } +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 卡 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) { - return errors.New(errors.CodeIotCardNotFound) + 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 } - return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") + 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, + }) + return appErr + } + + beforeData := map[string]any{ + "asset_status": card.AssetStatus, + "iccid": card.ICCID, + "virtual_no": card.VirtualNo, } if !canDeactivateAsset(card.AssetStatus) { - return errors.New(errors.CodeForbidden, "当前状态不允许停用") + 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, + }) + 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 { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "停用IoT卡失败") + 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 { - return errors.New(errors.CodeConflict, "状态已变更,请刷新后重试") + 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, + }, + }) + return nil } @@ -61,25 +163,103 @@ 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) { - return errors.New(errors.CodeNotFound, "设备不存在") + 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 } - return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") + 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, + }) + return appErr + } + + beforeData := map[string]any{ + "asset_status": device.AssetStatus, + "virtual_no": device.VirtualNo, } if !canDeactivateAsset(device.AssetStatus) { - return errors.New(errors.CodeForbidden, "当前状态不允许停用") + 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, + }) + 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 { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "停用设备失败") + 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 { - return errors.New(errors.CodeConflict, "状态已变更,请刷新后重试") + 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, + }, + }) + return nil } diff --git a/internal/service/asset_audit/builder.go b/internal/service/asset_audit/builder.go new file mode 100644 index 0000000..09a47fe --- /dev/null +++ b/internal/service/asset_audit/builder.go @@ -0,0 +1,269 @@ +package asset_audit + +import ( + "context" + stderrors "errors" + "fmt" + "strconv" + "strings" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + apperrors "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/bytedance/sonic" +) + +// OperatorInfo 审计操作者信息。 +type OperatorInfo struct { + ID *uint + Type string + Name string +} + +// BuildLogParams 构建资产审计日志参数。 +type BuildLogParams struct { + Operator OperatorInfo + + AssetType string + AssetID uint + AssetIdentifier string + + OperationType string + OperationDesc string + + BeforeData map[string]any + AfterData map[string]any + + ResultStatus string + ErrorCode string + ErrorMsg string + + BatchTotal int + SuccessCount int + FailCount int +} + +// BuildLog 统一构建资产审计日志。 +func BuildLog(ctx context.Context, p BuildLogParams) *model.AssetOperationLog { + resultStatus := strings.TrimSpace(p.ResultStatus) + if resultStatus == "" { + resultStatus = constants.AssetAuditResultSuccess + } + + operationDesc := strings.TrimSpace(p.OperationDesc) + if operationDesc == "" { + operationDesc = p.OperationType + } + + log := &model.AssetOperationLog{ + OperatorID: p.Operator.ID, + OperatorType: strings.TrimSpace(p.Operator.Type), + OperatorName: strings.TrimSpace(p.Operator.Name), + AssetType: NormalizeAssetType(p.AssetType), + AssetID: p.AssetID, + AssetIdentifier: strings.TrimSpace(p.AssetIdentifier), + OperationType: strings.TrimSpace(p.OperationType), + OperationDesc: operationDesc, + BeforeData: sanitizeAndTrimJSONB(p.BeforeData), + AfterData: sanitizeAndTrimJSONB(p.AfterData), + RequestID: middleware.GetRequestIDFromContext(ctx), + IPAddress: middleware.GetIPFromContext(ctx), + UserAgent: middleware.GetUserAgentFromContext(ctx), + RequestPath: middleware.GetRequestPathFromContext(ctx), + RequestMethod: middleware.GetRequestMethodFromContext(ctx), + ResultStatus: resultStatus, + BatchTotal: nonNegative(p.BatchTotal), + SuccessCount: nonNegative(p.SuccessCount), + FailCount: nonNegative(p.FailCount), + } + + if log.OperatorType == "" { + log.OperatorType = constants.AssetAuditOperatorTypeSystem + } + if log.OperatorName == "" { + log.OperatorName = "系统任务" + } + + if code := strings.TrimSpace(p.ErrorCode); code != "" { + log.ErrorCode = strPtr(code) + } + if msg := strings.TrimSpace(p.ErrorMsg); msg != "" { + msg = limitString(msg, 1000) + log.ErrorMsg = strPtr(msg) + } + + return log +} + +// OperatorFromContext 从 context 解析操作者信息。 +func OperatorFromContext(ctx context.Context) OperatorInfo { + uid := middleware.GetUserIDFromContext(ctx) + ut := middleware.GetUserTypeFromContext(ctx) + + if uid == 0 { + return SystemOperator("系统任务") + } + + op := OperatorInfo{ID: uintPtr(uid)} + switch ut { + case constants.UserTypeSuperAdmin, constants.UserTypePlatform: + op.Type = constants.AssetAuditOperatorTypeAdmin + op.Name = fmt.Sprintf("后台用户#%d", uid) + case constants.UserTypeAgent: + op.Type = constants.AssetAuditOperatorTypeAgent + op.Name = fmt.Sprintf("代理用户#%d", uid) + case constants.UserTypeEnterprise: + op.Type = constants.AssetAuditOperatorTypeEnterprise + op.Name = fmt.Sprintf("企业用户#%d", uid) + case constants.UserTypePersonalCustomer: + op.Type = constants.AssetAuditOperatorTypePersonal + op.Name = fmt.Sprintf("个人客户#%d", uid) + default: + op.Type = constants.AssetAuditOperatorTypeAdmin + op.Name = fmt.Sprintf("用户#%d", uid) + } + return op +} + +// SystemOperator 构建系统操作者信息。 +func SystemOperator(name string) OperatorInfo { + n := strings.TrimSpace(name) + if n == "" { + n = "系统任务" + } + return OperatorInfo{ + ID: nil, + Type: constants.AssetAuditOperatorTypeSystem, + Name: n, + } +} + +// BuildErrorInfo 从统一错误提取审计错误码与错误摘要。 +func BuildErrorInfo(err error) (string, string) { + if err == nil { + return "", "" + } + + var appErr *apperrors.AppError + if strings.TrimSpace(err.Error()) == "" { + return "", "未知错误" + } + + if stderrors.As(err, &appErr) && appErr != nil { + return strconv.Itoa(appErr.Code), appErr.Message + } + + return "", err.Error() +} + +// NormalizeAssetType 统一资产类型值。 +func NormalizeAssetType(assetType string) string { + switch strings.TrimSpace(assetType) { + case "card": + return constants.AssetTypeIotCard + case "device": + return constants.AssetTypeDevice + default: + return strings.TrimSpace(assetType) + } +} + +func sanitizeAndTrimJSONB(data map[string]any) model.JSONB { + if len(data) == 0 { + return nil + } + + sanitized := sanitizeMap(data) + raw, err := sonic.Marshal(sanitized) + if err != nil { + return model.JSONB{"_marshal_error": "序列化失败"} + } + if len(raw) <= constants.AssetAuditDataMaxBytes { + return model.JSONB(sanitized) + } + + preview := string(raw) + if len(preview) > constants.AssetAuditPreviewMaxBytes { + preview = preview[:constants.AssetAuditPreviewMaxBytes] + } + + return model.JSONB{ + "_truncated": true, + "_original_bytes": len(raw), + "_preview": preview, + "_message": "数据超过大小限制,已裁剪", + } +} + +func sanitizeMap(source map[string]any) map[string]any { + out := make(map[string]any, len(source)) + for k, v := range source { + if isSensitiveKey(k) { + out[k] = "******" + continue + } + out[k] = sanitizeValue(v) + } + return out +} + +func sanitizeValue(v any) any { + switch vv := v.(type) { + case map[string]any: + return sanitizeMap(vv) + case model.JSONB: + return sanitizeMap(map[string]any(vv)) + case []map[string]any: + arr := make([]any, 0, len(vv)) + for _, item := range vv { + arr = append(arr, sanitizeMap(item)) + } + return arr + case []any: + arr := make([]any, 0, len(vv)) + for _, item := range vv { + arr = append(arr, sanitizeValue(item)) + } + return arr + case string: + return limitString(vv, 2000) + default: + return v + } +} + +func isSensitiveKey(key string) bool { + lower := strings.ToLower(strings.TrimSpace(key)) + for _, kw := range []string{"password", "passwd", "pwd", "secret", "token", "wifi_password", "wifipwd"} { + if strings.Contains(lower, kw) { + return true + } + } + return false +} + +func nonNegative(v int) int { + if v < 0 { + return 0 + } + return v +} + +func limitString(value string, max int) string { + if max <= 0 || len(value) <= max { + return value + } + return value[:max] +} + +func strPtr(v string) *string { + if strings.TrimSpace(v) == "" { + return nil + } + return &v +} + +func uintPtr(v uint) *uint { + return &v +} diff --git a/internal/service/asset_audit/service.go b/internal/service/asset_audit/service.go new file mode 100644 index 0000000..67605fd --- /dev/null +++ b/internal/service/asset_audit/service.go @@ -0,0 +1,149 @@ +// Package asset_audit 提供资产操作审计日志服务。 +package asset_audit + +import ( + "context" + + "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" +) + +// AssetOperationLogStore 资产操作日志存储接口。 +type AssetOperationLogStore interface { + Create(ctx context.Context, log *model.AssetOperationLog) error + ListByAssetPaged( + ctx context.Context, + assetType string, + assetID uint, + page int, + pageSize int, + operationType string, + resultStatus string, + ) ([]*model.AssetOperationLog, int64, error) +} + +// OperationLogger 资产审计记录接口。 +type OperationLogger interface { + LogOperation(ctx context.Context, log *model.AssetOperationLog) +} + +// Service 资产审计服务。 +type Service struct { + store AssetOperationLogStore +} + +// ListByAssetParams 按资产查询日志参数。 +type ListByAssetParams struct { + AssetType string + AssetID uint + Page int + PageSize int + OperationType string + ResultStatus string +} + +// NewService 创建资产审计服务实例。 +func NewService(store AssetOperationLogStore) *Service { + return &Service{store: store} +} + +// 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 { + return &dto.AssetOperationLogListResponse{ + Total: 0, + Page: 1, + PageSize: constants.DefaultPageSize, + Items: []*dto.AssetOperationLogItem{}, + }, nil + } + + page := params.Page + pageSize := params.PageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = constants.DefaultPageSize + } + if pageSize > 100 { + pageSize = 100 + } + + logs, total, err := s.store.ListByAssetPaged( + ctx, + NormalizeAssetType(params.AssetType), + params.AssetID, + page, + pageSize, + params.OperationType, + params.ResultStatus, + ) + if err != nil { + return nil, err + } + + items := make([]*dto.AssetOperationLogItem, 0, len(logs)) + for _, item := range logs { + items = append(items, toAssetOperationLogItem(item)) + } + + return &dto.AssetOperationLogListResponse{ + Total: total, + Page: page, + PageSize: pageSize, + Items: items, + }, nil +} + +func toAssetOperationLogItem(log *model.AssetOperationLog) *dto.AssetOperationLogItem { + if log == nil { + return nil + } + return &dto.AssetOperationLogItem{ + ID: log.ID, + CreatedAt: log.CreatedAt, + OperatorID: log.OperatorID, + OperatorType: log.OperatorType, + OperatorName: log.OperatorName, + AssetType: log.AssetType, + AssetID: log.AssetID, + AssetIdentifier: log.AssetIdentifier, + OperationType: log.OperationType, + OperationDesc: log.OperationDesc, + BeforeData: map[string]any(log.BeforeData), + AfterData: map[string]any(log.AfterData), + RequestID: log.RequestID, + IPAddress: log.IPAddress, + UserAgent: log.UserAgent, + RequestPath: log.RequestPath, + RequestMethod: log.RequestMethod, + ResultStatus: log.ResultStatus, + ErrorCode: log.ErrorCode, + ErrorMsg: log.ErrorMsg, + BatchTotal: log.BatchTotal, + SuccessCount: log.SuccessCount, + FailCount: log.FailCount, + } +} diff --git a/internal/service/device/audit.go b/internal/service/device/audit.go new file mode 100644 index 0000000..c947eb1 --- /dev/null +++ b/internal/service/device/audit.go @@ -0,0 +1,88 @@ +package device + +import ( + "context" + "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, + BeforeData: beforeData, + AfterData: afterData, + BatchTotal: batchTotal, + SuccessCount: successCount, + FailCount: failCount, + } + 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 deviceSnapshot(device *model.Device) map[string]any { + if device == nil { + return nil + } + return map[string]any{ + "id": device.ID, + "virtual_no": device.VirtualNo, + "shop_id": device.ShopID, + "status": device.Status, + "series_id": device.SeriesID, + "enable_polling": device.EnablePolling, + "realname_policy": device.RealnamePolicy, + } +} diff --git a/internal/service/device/binding.go b/internal/service/device/binding.go index 0f7bcd7..be53136 100644 --- a/internal/service/device/binding.go +++ b/internal/service/device/binding.go @@ -77,37 +77,195 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard device, err := s.deviceStore.GetByID(ctx, deviceID) if err != nil { if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeNotFound, "设备不存在") + appErr := errors.New(errors.CodeNotFound, "设备不存在") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceBindCard, + "设备绑卡失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "device_id": deviceID, + "iot_card_id": req.IotCardID, + "slot_position": req.SlotPosition, + }, + 0, + 0, + 0, + appErr, + ) + return nil, appErr } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceBindCard, + "设备绑卡失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "device_id": deviceID, + "iot_card_id": req.IotCardID, + "slot_position": req.SlotPosition, + }, + 0, + 0, + 0, + err, + ) return nil, err } if req.SlotPosition > device.MaxSimSlots { - return nil, errors.New(errors.CodeInvalidParam, "插槽位置超出设备最大插槽数") + appErr := errors.New(errors.CodeInvalidParam, "插槽位置超出设备最大插槽数") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceBindCard, + "设备绑卡被拒绝", + constants.AssetAuditResultDenied, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{ + "iot_card_id": req.IotCardID, + "slot_position": req.SlotPosition, + }, + 0, + 0, + 0, + appErr, + ) + return nil, appErr } existingBinding, err := s.deviceSimBindingStore.GetByDeviceAndSlot(ctx, device.ID, req.SlotPosition) if err != nil && err != gorm.ErrRecordNotFound { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceBindCard, + "设备绑卡失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{ + "iot_card_id": req.IotCardID, + "slot_position": req.SlotPosition, + }, + 0, + 0, + 0, + err, + ) return nil, err } if existingBinding != nil { - return nil, errors.New(errors.CodeConflict, "该插槽已有绑定的卡") + appErr := errors.New(errors.CodeConflict, "该插槽已有绑定的卡") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceBindCard, + "设备绑卡被拒绝", + constants.AssetAuditResultDenied, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{ + "iot_card_id": req.IotCardID, + "slot_position": req.SlotPosition, + }, + 0, + 0, + 0, + appErr, + ) + return nil, appErr } card, err := s.iotCardStore.GetByID(ctx, req.IotCardID) if err != nil { if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeIotCardNotFound) + appErr := errors.New(errors.CodeIotCardNotFound) + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceBindCard, + "设备绑卡失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{ + "iot_card_id": req.IotCardID, + "slot_position": req.SlotPosition, + }, + 0, + 0, + 0, + appErr, + ) + return nil, appErr } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceBindCard, + "设备绑卡失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{ + "iot_card_id": req.IotCardID, + "slot_position": req.SlotPosition, + }, + 0, + 0, + 0, + err, + ) return nil, err } activeBinding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID) if err != nil && err != gorm.ErrRecordNotFound { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceBindCard, + "设备绑卡失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{ + "iot_card_id": req.IotCardID, + "slot_position": req.SlotPosition, + }, + 0, + 0, + 0, + err, + ) return nil, err } if activeBinding != nil { - return nil, errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备") + appErr := errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceBindCard, + "设备绑卡被拒绝", + constants.AssetAuditResultDenied, + device, + map[string]any{ + "device": deviceSnapshot(device), + "card": map[string]any{ + "id": card.ID, + "iccid": card.ICCID, + "status": card.Status, + }, + }, + map[string]any{ + "iot_card_id": req.IotCardID, + "slot_position": req.SlotPosition, + }, + 0, + 0, + 0, + appErr, + ) + return nil, appErr } binding := &model.DeviceSimBinding{ @@ -118,6 +276,28 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard } if err := s.deviceSimBindingStore.Create(ctx, binding); err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceBindCard, + "设备绑卡失败", + constants.AssetAuditResultFailed, + device, + map[string]any{ + "device": deviceSnapshot(device), + "card": map[string]any{ + "id": card.ID, + "iccid": card.ICCID, + "status": card.Status, + }, + }, + map[string]any{ + "slot_position": req.SlotPosition, + }, + 0, + 0, + 0, + err, + ) return nil, err } @@ -130,6 +310,32 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard ) } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceBindCard, + "设备绑卡", + constants.AssetAuditResultSuccess, + device, + map[string]any{ + "device": deviceSnapshot(device), + "card": map[string]any{ + "id": card.ID, + "iccid": card.ICCID, + "status": card.Status, + }, + }, + map[string]any{ + "binding_id": binding.ID, + "slot_position": req.SlotPosition, + "iot_card_id": card.ID, + "iccid": card.ICCID, + }, + 1, + 1, + 0, + nil, + ) + return &dto.BindCardToDeviceResponse{ BindingID: binding.ID, Message: "绑定成功", @@ -140,20 +346,97 @@ func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (* device, err := s.deviceStore.GetByID(ctx, deviceID) if err != nil { if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeNotFound, "设备不存在") + appErr := errors.New(errors.CodeNotFound, "设备不存在") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceUnbindCard, + "设备解绑卡失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "device_id": deviceID, + "iot_card_id": cardID, + }, + 0, + 0, + 0, + appErr, + ) + return nil, appErr } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceUnbindCard, + "设备解绑卡失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "device_id": deviceID, + "iot_card_id": cardID, + }, + 0, + 0, + 0, + err, + ) return nil, err } binding, err := s.deviceSimBindingStore.GetByDeviceAndCard(ctx, device.ID, cardID) if err != nil { if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeNotFound, "该卡未绑定到此设备") + appErr := errors.New(errors.CodeNotFound, "该卡未绑定到此设备") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceUnbindCard, + "设备解绑卡被拒绝", + constants.AssetAuditResultDenied, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{"iot_card_id": cardID}, + 0, + 0, + 0, + appErr, + ) + return nil, appErr } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceUnbindCard, + "设备解绑卡失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{"iot_card_id": cardID}, + 0, + 0, + 0, + err, + ) return nil, err } if err := s.deviceSimBindingStore.Unbind(ctx, binding.ID); err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceUnbindCard, + "设备解绑卡失败", + constants.AssetAuditResultFailed, + device, + map[string]any{ + "device": deviceSnapshot(device), + "binding_id": binding.ID, + "iot_card_id": binding.IotCardID, + }, + nil, + 0, + 0, + 0, + err, + ) return nil, err } @@ -166,6 +449,27 @@ func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (* ) } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceUnbindCard, + "设备解绑卡", + constants.AssetAuditResultSuccess, + device, + map[string]any{ + "device": deviceSnapshot(device), + "binding_id": binding.ID, + "iot_card_id": binding.IotCardID, + }, + map[string]any{ + "iot_card_id": cardID, + "unbind": true, + }, + 1, + 1, + 0, + nil, + ) + return &dto.UnbindCardFromDeviceResponse{ Message: "解绑成功", }, nil diff --git a/internal/service/device/gateway_service.go b/internal/service/device/gateway_service.go index 9dd43b4..2d6340e 100644 --- a/internal/service/device/gateway_service.go +++ b/internal/service/device/gateway_service.go @@ -4,26 +4,28 @@ import ( "context" "github.com/break/junhong_cmp_fiber/internal/gateway" + "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/errors" ) -// getDeviceIMEI 通过标识符获取设备并验证 IMEI 存在 +// getGatewayDevice 通过标识符获取设备并验证 IMEI 存在 // 提供统一的设备查找 + IMEI 校验逻辑,供所有 Gateway 代理方法复用 -func (s *Service) getDeviceIMEI(ctx context.Context, identifier string) (string, error) { +func (s *Service) getGatewayDevice(ctx context.Context, identifier string) (*model.Device, string, error) { device, err := s.deviceStore.GetByIdentifier(ctx, identifier) if err != nil { - return "", errors.New(errors.CodeNotFound, "设备不存在或无权限访问") + return nil, "", errors.New(errors.CodeNotFound, "设备不存在或无权限访问") } if device.IMEI == "" { - return "", errors.New(errors.CodeInvalidParam, "该设备未配置 IMEI,无法调用网关接口") + return nil, "", errors.New(errors.CodeInvalidParam, "该设备未配置 IMEI,无法调用网关接口") } - return device.IMEI, nil + return device, device.IMEI, nil } // GatewayGetDeviceInfo 通过标识符查询设备网关信息 func (s *Service) GatewayGetDeviceInfo(ctx context.Context, identifier string) (*gateway.DeviceInfoResp, error) { - imei, err := s.getDeviceIMEI(ctx, identifier) + _, imei, err := s.getGatewayDevice(ctx, identifier) if err != nil { return nil, err } @@ -34,7 +36,7 @@ func (s *Service) GatewayGetDeviceInfo(ctx context.Context, identifier string) ( // GatewayGetSlotInfo 通过标识符查询设备卡槽信息 func (s *Service) GatewayGetSlotInfo(ctx context.Context, identifier string) (*gateway.SlotInfoResp, error) { - imei, err := s.getDeviceIMEI(ctx, identifier) + _, imei, err := s.getGatewayDevice(ctx, identifier) if err != nil { return nil, err } @@ -45,73 +47,356 @@ func (s *Service) GatewayGetSlotInfo(ctx context.Context, identifier string) (*g // GatewaySetSpeedLimit 通过标识符设置设备限速 func (s *Service) GatewaySetSpeedLimit(ctx context.Context, identifier string, req *dto.SetSpeedLimitRequest) error { - imei, err := s.getDeviceIMEI(ctx, identifier) + device, imei, err := s.getGatewayDevice(ctx, identifier) if err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSpeedLimit, + "设备限速失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "identifier": identifier, + "speed_limit": req.SpeedLimit, + }, + 0, + 0, + 0, + err, + ) return err } - return s.gatewayClient.SetSpeedLimit(ctx, &gateway.SpeedLimitReq{ + if err = s.gatewayClient.SetSpeedLimit(ctx, &gateway.SpeedLimitReq{ DeviceID: imei, SpeedLimit: req.SpeedLimit, - }) + }); err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSpeedLimit, + "设备限速失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{"speed_limit": req.SpeedLimit}, + 0, + 0, + 0, + err, + ) + return err + } + + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSpeedLimit, + "设备限速", + constants.AssetAuditResultSuccess, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{"speed_limit": req.SpeedLimit}, + 0, + 0, + 0, + nil, + ) + return nil } // GatewaySetWiFi 通过标识符设置设备 WiFi func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dto.SetWiFiRequest) error { - imei, err := s.getDeviceIMEI(ctx, identifier) + device, imei, err := s.getGatewayDevice(ctx, identifier) if err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSetWiFi, + "设备设置WiFi失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "identifier": identifier, + "card_no": req.CardNo, + "ssid": req.SSID, + "enabled": req.Enabled, + "password": req.Password, + }, + 0, + 0, + 0, + err, + ) return err } - return s.gatewayClient.SetWiFi(ctx, &gateway.WiFiReq{ + if err = s.gatewayClient.SetWiFi(ctx, &gateway.WiFiReq{ CardNo: req.CardNo, DeviceID: imei, SSID: req.SSID, Password: req.Password, Enabled: req.Enabled, - }) + }); err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSetWiFi, + "设备设置WiFi失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{ + "card_no": req.CardNo, + "ssid": req.SSID, + "enabled": req.Enabled, + "password": req.Password, + }, + 0, + 0, + 0, + err, + ) + return err + } + + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSetWiFi, + "设备设置WiFi", + constants.AssetAuditResultSuccess, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{ + "card_no": req.CardNo, + "ssid": req.SSID, + "enabled": req.Enabled, + "password": req.Password, + }, + 0, + 0, + 0, + nil, + ) + return nil } // GatewaySwitchCard 通过标识符切换设备使用的卡 func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req *dto.SwitchCardRequest) error { - imei, err := s.getDeviceIMEI(ctx, identifier) + device, imei, err := s.getGatewayDevice(ctx, identifier) if err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSwitchCard, + "设备切卡失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "identifier": identifier, + "target_iccid": req.TargetICCID, + }, + 0, + 0, + 0, + err, + ) return err } - return s.gatewayClient.SwitchCard(ctx, &gateway.SwitchCardReq{ + if err = s.gatewayClient.SwitchCard(ctx, &gateway.SwitchCardReq{ CardNo: imei, ICCID: req.TargetICCID, - }) + }); err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSwitchCard, + "设备切卡失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{"target_iccid": req.TargetICCID}, + 0, + 0, + 0, + err, + ) + return err + } + + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSwitchCard, + "设备切卡", + constants.AssetAuditResultSuccess, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{"target_iccid": req.TargetICCID}, + 0, + 0, + 0, + nil, + ) + return nil } // GatewayRebootDevice 通过标识符重启设备 func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) error { - imei, err := s.getDeviceIMEI(ctx, identifier) + device, imei, err := s.getGatewayDevice(ctx, identifier) if err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceReboot, + "设备重启失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{"identifier": identifier}, + 0, + 0, + 0, + err, + ) return err } - return s.gatewayClient.RebootDevice(ctx, &gateway.DeviceOperationReq{ + if err = s.gatewayClient.RebootDevice(ctx, &gateway.DeviceOperationReq{ DeviceID: imei, - }) + }); err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceReboot, + "设备重启失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + nil, + 0, + 0, + 0, + err, + ) + return err + } + + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceReboot, + "设备重启", + constants.AssetAuditResultSuccess, + device, + map[string]any{"device": deviceSnapshot(device)}, + nil, + 0, + 0, + 0, + nil, + ) + return nil } // GatewayResetDevice 通过标识符恢复设备出厂设置 func (s *Service) GatewayResetDevice(ctx context.Context, identifier string) error { - imei, err := s.getDeviceIMEI(ctx, identifier) + device, imei, err := s.getGatewayDevice(ctx, identifier) if err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceReset, + "设备恢复出厂失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{"identifier": identifier}, + 0, + 0, + 0, + err, + ) return err } - return s.gatewayClient.ResetDevice(ctx, &gateway.DeviceOperationReq{ + if err = s.gatewayClient.ResetDevice(ctx, &gateway.DeviceOperationReq{ DeviceID: imei, - }) + }); err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceReset, + "设备恢复出厂失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + nil, + 0, + 0, + 0, + err, + ) + return err + } + + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceReset, + "设备恢复出厂", + constants.AssetAuditResultSuccess, + device, + map[string]any{"device": deviceSnapshot(device)}, + nil, + 0, + 0, + 0, + nil, + ) + return nil } // GatewaySwitchMode 通过标识符设置设备切卡模式 func (s *Service) GatewaySwitchMode(ctx context.Context, identifier string, req *dto.SetSwitchModeRequest) error { - imei, err := s.getDeviceIMEI(ctx, identifier) + device, imei, err := s.getGatewayDevice(ctx, identifier) if err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSwitchMode, + "设备切卡模式切换失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "identifier": identifier, + "switch_mode": req.SwitchMode, + }, + 0, + 0, + 0, + err, + ) return err } - return s.gatewayClient.SwitchMode(ctx, &gateway.SwitchModeReq{ + if err = s.gatewayClient.SwitchMode(ctx, &gateway.SwitchModeReq{ CardNo: imei, SwitchMode: req.SwitchMode, - }) + }); err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSwitchMode, + "设备切卡模式切换失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{"switch_mode": req.SwitchMode}, + 0, + 0, + 0, + err, + ) + return err + } + + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSwitchMode, + "设备切卡模式切换", + constants.AssetAuditResultSuccess, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{"switch_mode": req.SwitchMode}, + 0, + 0, + 0, + nil, + ) + return nil } diff --git a/internal/service/device/service.go b/internal/service/device/service.go index e034e98..121218e 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -32,6 +32,7 @@ type Service struct { packageSeriesStore *postgres.PackageSeriesStore gatewayClient *gateway.Client assetIdentifierStore *postgres.AssetIdentifierStore + assetAuditService AssetAuditService } func New( @@ -47,6 +48,7 @@ func New( packageSeriesStore *postgres.PackageSeriesStore, gatewayClient *gateway.Client, assetIdentifierStore *postgres.AssetIdentifierStore, + assetAuditService AssetAuditService, ) *Service { return &Service{ db: db, @@ -61,6 +63,7 @@ func New( packageSeriesStore: packageSeriesStore, gatewayClient: gatewayClient, assetIdentifierStore: assetIdentifierStore, + assetAuditService: assetAuditService, } } @@ -213,19 +216,75 @@ func (s *Service) GetDeviceByIdentifier(ctx context.Context, identifier string) } func (s *Service) Delete(ctx context.Context, id uint) error { + auditDevice := &model.Device{} + auditDevice.ID = id device, err := s.deviceStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { - return errors.New(errors.CodeNotFound, "设备不存在") + appErr := errors.New(errors.CodeNotFound, "设备不存在") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceDelete, + "删除设备失败", + constants.AssetAuditResultFailed, + auditDevice, + nil, + nil, + 0, + 0, + 0, + appErr, + ) + return appErr } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceDelete, + "删除设备失败", + constants.AssetAuditResultFailed, + auditDevice, + nil, + nil, + 0, + 0, + 0, + err, + ) return err } + beforeData := deviceSnapshot(device) if err := s.deviceSimBindingStore.UnbindByDeviceID(ctx, device.ID); err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceDelete, + "删除设备失败", + constants.AssetAuditResultFailed, + device, + beforeData, + nil, + 0, + 0, + 0, + err, + ) return err } if err := s.deviceStore.Delete(ctx, id); err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceDelete, + "删除设备失败", + constants.AssetAuditResultFailed, + device, + beforeData, + nil, + 0, + 0, + 0, + err, + ) return err } @@ -233,6 +292,22 @@ func (s *Service) Delete(ctx context.Context, id uint) error { _ = s.assetIdentifierStore.DeleteByAsset(ctx, model.AssetTypeDevice, id) } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceDelete, + "删除设备", + constants.AssetAuditResultSuccess, + device, + beforeData, + map[string]any{ + "deleted": true, + }, + 0, + 0, + 0, + nil, + ) + return nil } @@ -257,15 +332,64 @@ func (s *Service) GetCardByICCID(ctx context.Context, iccid string) (*model.IotC func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateDevicesResponse, error) { // 代理仅可分配给直属下级;平台/超级管理员可跨级分配 if err := s.validateDirectSubordinate(ctx, operatorShopID, req.TargetShopID); err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceAllocate, + "设备分配被拒绝", + constants.AssetAuditResultDenied, + nil, + nil, + map[string]any{ + "target_shop_id": req.TargetShopID, + "device_ids": req.DeviceIDs, + }, + len(req.DeviceIDs), + 0, + len(req.DeviceIDs), + err, + ) return nil, err } devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs) if err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceAllocate, + "设备分配失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "target_shop_id": req.TargetShopID, + "device_ids": req.DeviceIDs, + }, + len(req.DeviceIDs), + 0, + len(req.DeviceIDs), + err, + ) return nil, err } if len(devices) == 0 { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceAllocate, + "设备分配被拒绝", + constants.AssetAuditResultDenied, + nil, + nil, + map[string]any{ + "target_shop_id": req.TargetShopID, + "device_ids": req.DeviceIDs, + "reason": "未找到可分配设备", + }, + len(req.DeviceIDs), + 0, + len(req.DeviceIDs), + errors.New(errors.CodeNotFound, "未找到可分配设备"), + ) return &dto.AllocateDevicesResponse{ SuccessCount: 0, FailCount: 0, @@ -303,6 +427,22 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR } if len(deviceIDs) == 0 { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceAllocate, + "设备分配被拒绝", + constants.AssetAuditResultDenied, + nil, + nil, + map[string]any{ + "target_shop_id": req.TargetShopID, + "failed_items": failedItems, + }, + len(devices), + 0, + len(failedItems), + errors.New(errors.CodeForbidden, "无可分配设备"), + ) return &dto.AllocateDevicesResponse{ SuccessCount: 0, FailCount: len(failedItems), @@ -310,6 +450,11 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR }, nil } + beforeData := make([]map[string]any, 0, len(devices)) + for _, device := range devices { + beforeData = append(beforeData, deviceSnapshot(device)) + } + newStatus := constants.DeviceStatusDistributed targetShopID := req.TargetShopID @@ -339,9 +484,43 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR }) if err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceAllocate, + "设备分配失败", + constants.AssetAuditResultFailed, + nil, + map[string]any{"devices": beforeData}, + map[string]any{ + "target_shop_id": req.TargetShopID, + "failed_items": failedItems, + }, + len(devices), + len(deviceIDs), + len(failedItems), + err, + ) return nil, err } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceAllocate, + "设备分配", + constants.AssetAuditResultSuccess, + nil, + map[string]any{"devices": beforeData}, + map[string]any{ + "target_shop_id": req.TargetShopID, + "device_ids": deviceIDs, + "failed_items": failedItems, + }, + len(devices), + len(deviceIDs), + len(failedItems), + nil, + ) + return &dto.AllocateDevicesResponse{ SuccessCount: len(deviceIDs), FailCount: len(failedItems), @@ -353,10 +532,41 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.RecallDevicesResponse, error) { devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs) if err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceRecall, + "设备回收失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "device_ids": req.DeviceIDs, + }, + len(req.DeviceIDs), + 0, + len(req.DeviceIDs), + err, + ) return nil, err } if len(devices) == 0 { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceRecall, + "设备回收被拒绝", + constants.AssetAuditResultDenied, + nil, + nil, + map[string]any{ + "device_ids": req.DeviceIDs, + "reason": "未找到可回收设备", + }, + len(req.DeviceIDs), + 0, + len(req.DeviceIDs), + errors.New(errors.CodeNotFound, "未找到可回收设备"), + ) return &dto.RecallDevicesResponse{ SuccessCount: 0, FailCount: 0, @@ -396,6 +606,22 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque } if len(deviceIDs) == 0 { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceRecall, + "设备回收被拒绝", + constants.AssetAuditResultDenied, + nil, + nil, + map[string]any{ + "device_ids": req.DeviceIDs, + "failed_items": failedItems, + }, + len(devices), + 0, + len(failedItems), + errors.New(errors.CodeForbidden, "无可回收设备"), + ) return &dto.RecallDevicesResponse{ SuccessCount: 0, FailCount: len(failedItems), @@ -403,6 +629,11 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque }, nil } + beforeData := make([]map[string]any, 0, len(devices)) + for _, device := range devices { + beforeData = append(beforeData, deviceSnapshot(device)) + } + var newShopID *uint var newStatus int if isPlatform { @@ -445,9 +676,43 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque }) if err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceRecall, + "设备回收失败", + constants.AssetAuditResultFailed, + nil, + map[string]any{"devices": beforeData}, + map[string]any{ + "device_ids": deviceIDs, + "failed_items": failedItems, + }, + len(devices), + len(deviceIDs), + len(failedItems), + err, + ) return nil, err } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceRecall, + "设备回收", + constants.AssetAuditResultSuccess, + nil, + map[string]any{"devices": beforeData}, + map[string]any{ + "device_ids": deviceIDs, + "failed_items": failedItems, + "target_shop": newShopID, + }, + len(devices), + len(deviceIDs), + len(failedItems), + nil, + ) + return &dto.RecallDevicesResponse{ SuccessCount: len(deviceIDs), FailCount: len(failedItems), @@ -695,10 +960,44 @@ func (s *Service) buildRecallRecords(devices []*model.Device, successDeviceIDs [ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDeviceSeriesBindngRequest, operatorShopID *uint) (*dto.BatchSetDeviceSeriesBindngResponse, error) { devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs) if err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSeriesBinding, + "设备系列绑定失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "series_id": req.SeriesID, + "device_ids": req.DeviceIDs, + "operator_shop_id": operatorShopID, + }, + len(req.DeviceIDs), + 0, + len(req.DeviceIDs), + err, + ) return nil, err } if len(devices) == 0 { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSeriesBinding, + "设备系列绑定被拒绝", + constants.AssetAuditResultDenied, + nil, + nil, + map[string]any{ + "series_id": req.SeriesID, + "device_ids": req.DeviceIDs, + "reason": "设备不存在", + }, + len(req.DeviceIDs), + 0, + len(req.DeviceIDs), + errors.New(errors.CodeNotFound, "设备不存在"), + ) return &dto.BatchSetDeviceSeriesBindngResponse{ SuccessCount: 0, FailCount: len(req.DeviceIDs), @@ -717,12 +1016,62 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe packageSeries, err = s.packageSeriesStore.GetByID(ctx, req.SeriesID) if err != nil { if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeNotFound, "套餐系列不存在或已禁用") + appErr := errors.New(errors.CodeNotFound, "套餐系列不存在或已禁用") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSeriesBinding, + "设备系列绑定被拒绝", + constants.AssetAuditResultDenied, + nil, + nil, + map[string]any{ + "series_id": req.SeriesID, + "device_ids": req.DeviceIDs, + }, + len(req.DeviceIDs), + 0, + len(req.DeviceIDs), + appErr, + ) + return nil, appErr } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSeriesBinding, + "设备系列绑定失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "series_id": req.SeriesID, + "device_ids": req.DeviceIDs, + }, + len(req.DeviceIDs), + 0, + len(req.DeviceIDs), + err, + ) return nil, err } if packageSeries.Status != 1 { - return nil, errors.New(errors.CodeInvalidParam, "套餐系列不存在或已禁用") + appErr := errors.New(errors.CodeInvalidParam, "套餐系列不存在或已禁用") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSeriesBinding, + "设备系列绑定被拒绝", + constants.AssetAuditResultDenied, + nil, + nil, + map[string]any{ + "series_id": req.SeriesID, + "device_ids": req.DeviceIDs, + }, + len(req.DeviceIDs), + 0, + len(req.DeviceIDs), + appErr, + ) + return nil, appErr } } @@ -744,6 +1093,22 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe if operatorShopID != nil && req.SeriesID > 0 { seriesAllocations, err := s.shopSeriesAllocationStore.GetByShopID(ctx, *operatorShopID) if err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSeriesBinding, + "设备系列绑定失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "series_id": req.SeriesID, + "device_ids": req.DeviceIDs, + }, + len(req.DeviceIDs), + 0, + len(req.DeviceIDs), + err, + ) return nil, err } hasSeriesAllocation := false @@ -784,10 +1149,57 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe seriesIDPtr = &req.SeriesID } if err := s.deviceStore.BatchUpdateSeriesID(ctx, successDeviceIDs, seriesIDPtr); err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSeriesBinding, + "设备系列绑定失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "series_id": req.SeriesID, + "success_device_ids": successDeviceIDs, + "failed_items": failedItems, + }, + len(req.DeviceIDs), + len(successDeviceIDs), + len(failedItems), + err, + ) return nil, err } } + beforeData := make([]map[string]any, 0, len(devices)) + for _, device := range devices { + beforeData = append(beforeData, deviceSnapshot(device)) + } + resultStatus := constants.AssetAuditResultSuccess + var resultErr error + if len(successDeviceIDs) == 0 && len(failedItems) > 0 { + resultStatus = constants.AssetAuditResultDenied + resultErr = errors.New(errors.CodeForbidden, "无可绑定设备") + } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSeriesBinding, + "设备系列绑定", + resultStatus, + nil, + map[string]any{ + "devices": beforeData, + }, + map[string]any{ + "series_id": req.SeriesID, + "success_device_ids": successDeviceIDs, + "failed_items": failedItems, + }, + len(req.DeviceIDs), + len(successDeviceIDs), + len(failedItems), + resultErr, + ) + return &dto.BatchSetDeviceSeriesBindngResponse{ SuccessCount: len(successDeviceIDs), FailCount: len(failedItems), @@ -815,29 +1227,101 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { - return nil, errors.New(errors.CodeUnauthorized, "未授权访问") + appErr := errors.New(errors.CodeUnauthorized, "未授权访问") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStop, + "设备停机被拒绝", + constants.AssetAuditResultDenied, + nil, + nil, + map[string]any{"device_id": deviceID}, + 0, + 0, + 0, + appErr, + ) + return nil, appErr } device, err := s.deviceStore.GetByID(ctx, deviceID) if err != nil { - return nil, errors.New(errors.CodeNotFound, "设备不存在") + appErr := errors.New(errors.CodeNotFound, "设备不存在") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStop, + "设备停机失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{"device_id": deviceID}, + 0, + 0, + 0, + appErr, + ) + return nil, appErr } - _ = device // 复机保护期内禁止停机 if s.redis != nil { exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Result() if exists > 0 { - return nil, errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机") + appErr := errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStop, + "设备停机被拒绝", + constants.AssetAuditResultDenied, + device, + map[string]any{"device": deviceSnapshot(device)}, + nil, + 0, + 0, + 0, + appErr, + ) + return nil, appErr } } bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID) if err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败") + appErr := errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStop, + "设备停机失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + nil, + 0, + 0, + 0, + appErr, + ) + return nil, appErr } if len(bindings) == 0 { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStop, + "设备停机", + constants.AssetAuditResultSuccess, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{ + "success_count": 0, + "fail_count": 0, + "skip_count": 0, + }, + 0, + 0, + 0, + nil, + ) return &dto.DeviceSuspendResponse{}, nil } @@ -848,7 +1332,32 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) if err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败") + appErr := errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStop, + "设备停机失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + nil, + len(cardIDs), + 0, + len(cardIDs), + appErr, + ) + return nil, appErr + } + + beforeCards := make([]map[string]any, 0, len(cards)) + for _, card := range cards { + beforeCards = append(beforeCards, map[string]any{ + "id": card.ID, + "iccid": card.ICCID, + "network_status": card.NetworkStatus, + "real_name_status": card.RealNameStatus, + "stop_reason": card.StopReason, + }) } var successCount, skipCount int @@ -906,6 +1415,34 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "start")) } + resultStatus := constants.AssetAuditResultSuccess + var resultErr error + if successCount == 0 && len(failedItems) > 0 { + resultStatus = constants.AssetAuditResultFailed + resultErr = errors.New(errors.CodeGatewayError, "设备停机失败,未成功处理任何卡") + } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStop, + "设备停机", + resultStatus, + device, + map[string]any{ + "device": deviceSnapshot(device), + "cards": beforeCards, + }, + map[string]any{ + "success_count": successCount, + "fail_count": len(failedItems), + "skip_count": skipCount, + "failed_items": failedItems, + }, + len(cards), + successCount, + len(failedItems), + resultErr, + ) + return &dto.DeviceSuspendResponse{ SuccessCount: successCount, FailCount: len(failedItems), @@ -922,29 +1459,100 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { - return errors.New(errors.CodeUnauthorized, "未授权访问") + appErr := errors.New(errors.CodeUnauthorized, "未授权访问") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStart, + "设备复机被拒绝", + constants.AssetAuditResultDenied, + nil, + nil, + map[string]any{"device_id": deviceID}, + 0, + 0, + 0, + appErr, + ) + return appErr } device, err := s.deviceStore.GetByID(ctx, deviceID) if err != nil { - return errors.New(errors.CodeNotFound, "设备不存在") + appErr := errors.New(errors.CodeNotFound, "设备不存在") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStart, + "设备复机失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{"device_id": deviceID}, + 0, + 0, + 0, + appErr, + ) + return appErr } - _ = device // 停机保护期内禁止复机 if s.redis != nil { exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Result() if exists > 0 { - return errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机") + appErr := errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStart, + "设备复机被拒绝", + constants.AssetAuditResultDenied, + device, + map[string]any{"device": deviceSnapshot(device)}, + nil, + 0, + 0, + 0, + appErr, + ) + return appErr } } bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID) if err != nil { - return errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败") + appErr := errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStart, + "设备复机失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + nil, + 0, + 0, + 0, + appErr, + ) + return appErr } if len(bindings) == 0 { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStart, + "设备复机", + constants.AssetAuditResultSuccess, + device, + map[string]any{"device": deviceSnapshot(device)}, + map[string]any{ + "success_count": 0, + "fail_count": 0, + }, + 0, + 0, + 0, + nil, + ) return nil } @@ -955,10 +1563,36 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) if err != nil { - return errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败") + appErr := errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStart, + "设备复机失败", + constants.AssetAuditResultFailed, + device, + map[string]any{"device": deviceSnapshot(device)}, + nil, + len(cardIDs), + 0, + len(cardIDs), + appErr, + ) + return appErr + } + + beforeCards := make([]map[string]any, 0, len(cards)) + for _, card := range cards { + beforeCards = append(beforeCards, map[string]any{ + "id": card.ID, + "iccid": card.ICCID, + "network_status": card.NetworkStatus, + "real_name_status": card.RealNameStatus, + "stop_reason": card.StopReason, + }) } var successCount int + var failCount int var lastErr error for _, card := range cards { @@ -976,6 +1610,7 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { zap.String("iccid", card.ICCID), zap.Error(gwErr)) lastErr = gwErr + failCount++ continue } log.Info("网关复机成功(设备)", @@ -993,6 +1628,7 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { zap.Uint("card_id", card.ID), zap.Error(dbErr)) lastErr = dbErr + failCount++ continue } @@ -1008,9 +1644,49 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { // 全部失败时返回 error if successCount == 0 && lastErr != nil { - return errors.Wrap(errors.CodeGatewayError, lastErr, "设备复机失败,所有卡均复机失败") + appErr := errors.Wrap(errors.CodeGatewayError, lastErr, "设备复机失败,所有卡均复机失败") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStart, + "设备复机失败", + constants.AssetAuditResultFailed, + device, + map[string]any{ + "device": deviceSnapshot(device), + "cards": beforeCards, + }, + map[string]any{ + "success_count": successCount, + "fail_count": failCount, + }, + len(cards), + successCount, + failCount, + appErr, + ) + return appErr } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceStart, + "设备复机", + constants.AssetAuditResultSuccess, + device, + map[string]any{ + "device": deviceSnapshot(device), + "cards": beforeCards, + }, + map[string]any{ + "success_count": successCount, + "fail_count": failCount, + }, + len(cards), + successCount, + failCount, + nil, + ) + return nil } @@ -1020,21 +1696,102 @@ func (s *Service) UpdateRealnamePolicy(ctx context.Context, deviceID uint, realn device, err := s.deviceStore.GetByID(ctx, deviceID) if err != nil { if err == gorm.ErrRecordNotFound { - return errors.New(errors.CodeNotFound, "设备不存在") + appErr := errors.New(errors.CodeNotFound, "设备不存在") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpAssetRealnamePolicy, + "更新设备实名策略失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "device_id": deviceID, + "realname_policy": realnamePolicy, + }, + 0, + 0, + 0, + appErr, + ) + return appErr } - return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") + appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpAssetRealnamePolicy, + "更新设备实名策略失败", + constants.AssetAuditResultFailed, + nil, + nil, + map[string]any{ + "device_id": deviceID, + "realname_policy": realnamePolicy, + }, + 0, + 0, + 0, + appErr, + ) + return appErr + } + beforeData := map[string]any{ + "realname_policy": device.RealnamePolicy, + "device": deviceSnapshot(device), } // 幂等检查 if device.RealnamePolicy == realnamePolicy { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpAssetRealnamePolicy, + "更新设备实名策略被拒绝", + constants.AssetAuditResultDenied, + device, + beforeData, + map[string]any{"realname_policy": realnamePolicy}, + 0, + 0, + 0, + errors.New(errors.CodeConflict, "实名认证策略未变化"), + ) return nil } // 更新数据库 if err := s.deviceStore.UpdateRealnamePolicy(ctx, deviceID, realnamePolicy); err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败") + appErr := errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败") + s.logDeviceOperation( + ctx, + constants.AssetAuditOpAssetRealnamePolicy, + "更新设备实名策略失败", + constants.AssetAuditResultFailed, + device, + beforeData, + map[string]any{"realname_policy": realnamePolicy}, + 0, + 0, + 0, + appErr, + ) + return appErr } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpAssetRealnamePolicy, + "更新设备实名策略", + constants.AssetAuditResultSuccess, + device, + beforeData, + map[string]any{ + "realname_policy": realnamePolicy, + }, + 0, + 0, + 0, + nil, + ) + return nil } diff --git a/internal/service/device_import/audit.go b/internal/service/device_import/audit.go new file mode 100644 index 0000000..54e3f9f --- /dev/null +++ b/internal/service/device_import/audit.go @@ -0,0 +1,56 @@ +package device_import + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + 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) logDeviceImportAudit(ctx context.Context, p assetAuditSvc.BuildLogParams) { + if s == nil || s.assetAudit == nil { + return + } + if p.Operator.Type == "" { + p.Operator = assetAuditSvc.OperatorFromContext(ctx) + } + if p.OperationType == "" { + p.OperationType = constants.AssetAuditOpDeviceImportTaskCreate + } + if p.AssetType == "" { + p.AssetType = constants.AssetTypeDevice + } + s.assetAudit.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p)) +} + +func newDeviceImportAuditParams( + taskID uint, + taskNo string, + req *dto.ImportDeviceRequest, + resultStatus string, + err error, +) assetAuditSvc.BuildLogParams { + afterData := map[string]any{} + if req != nil { + afterData["batch_no"] = req.BatchNo + afterData["file_key"] = req.FileKey + afterData["realname_policy"] = req.RealnamePolicy + } + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + return assetAuditSvc.BuildLogParams{ + AssetID: taskID, + AssetIdentifier: taskNo, + OperationDesc: "创建设备导入任务", + ResultStatus: resultStatus, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AfterData: afterData, + } +} diff --git a/internal/service/device_import/service.go b/internal/service/device_import/service.go index 64ee08f..a0a7080 100644 --- a/internal/service/device_import/service.go +++ b/internal/service/device_import/service.go @@ -20,24 +20,33 @@ type Service struct { db *gorm.DB importTaskStore *postgres.DeviceImportTaskStore queueClient *queue.Client + assetAudit AssetAuditService } type DeviceImportPayload struct { TaskID uint `json:"task_id"` } -func New(db *gorm.DB, importTaskStore *postgres.DeviceImportTaskStore, queueClient *queue.Client) *Service { +func New( + db *gorm.DB, + importTaskStore *postgres.DeviceImportTaskStore, + queueClient *queue.Client, + assetAudit AssetAuditService, +) *Service { return &Service{ db: db, importTaskStore: importTaskStore, queueClient: queueClient, + assetAudit: assetAudit, } } func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceRequest) (*dto.ImportDeviceResponse, error) { userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { - return nil, errors.New(errors.CodeUnauthorized, "未授权访问") + appErr := errors.New(errors.CodeUnauthorized, "未授权访问") + s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr)) + return nil, appErr } taskNo := s.importTaskStore.GenerateTaskNo(ctx) @@ -55,16 +64,22 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq task.Updater = userID if err := s.importTaskStore.Create(ctx, task); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "创建导入任务失败") + appErr := errors.Wrap(errors.CodeInternalError, err, "创建导入任务失败") + s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(0, taskNo, req, constants.AssetAuditResultFailed, appErr)) + return nil, appErr } payload := DeviceImportPayload{TaskID: task.ID} err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeDeviceImport, payload) if err != nil { s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error()) - return nil, errors.Wrap(errors.CodeInternalError, err, "任务入队失败") + appErr := errors.Wrap(errors.CodeInternalError, err, "任务入队失败") + s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultFailed, appErr)) + return nil, appErr } + s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultSuccess, nil)) + return &dto.ImportDeviceResponse{ TaskID: task.ID, TaskNo: taskNo, diff --git a/internal/service/iot_card/audit.go b/internal/service/iot_card/audit.go new file mode 100644 index 0000000..a7f4db7 --- /dev/null +++ b/internal/service/iot_card/audit.go @@ -0,0 +1,58 @@ +package iot_card + +import ( + "context" + + assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" + "github.com/break/junhong_cmp_fiber/internal/model" + "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 + } + 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 + } + s.assetAuditService.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p)) +} + +func cardSnapshot(card *model.IotCard) map[string]any { + if card == nil { + return nil + } + return map[string]any{ + "id": card.ID, + "iccid": card.ICCID, + "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, + } +} diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 10a3e59..bb2a7e3 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -8,6 +8,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" + 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/constants" @@ -64,6 +65,7 @@ type Service struct { deviceSimBindingStore *postgres.DeviceSimBindingStore redis *redis.Client assetIdentifierStore *postgres.AssetIdentifierStore + assetAuditService AssetAuditService } func New( @@ -76,6 +78,7 @@ func New( packageSeriesStore *postgres.PackageSeriesStore, gatewayClient *gateway.Client, logger *zap.Logger, + assetAuditService AssetAuditService, ) *Service { return &Service{ db: db, @@ -87,6 +90,7 @@ func New( packageSeriesStore: packageSeriesStore, gatewayClient: gatewayClient, logger: logger, + assetAuditService: assetAuditService, } } @@ -341,11 +345,37 @@ func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]str func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateStandaloneCardsResponse, error) { if err := s.validateDirectSubordinate(ctx, operatorShopID, req.ToShopID); err != nil { + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardAllocate, + OperationDesc: "单卡分配被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AfterData: map[string]any{ + "to_shop_id": req.ToShopID, + "selection_type": req.SelectionType, + "requested_count": len(req.ICCIDs), + }, + }) return nil, err } cards, err := s.getCardsForAllocation(ctx, req, operatorShopID) if err != nil { + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardAllocate, + OperationDesc: "单卡分配执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AfterData: map[string]any{ + "to_shop_id": req.ToShopID, + "selection_type": req.SelectionType, + "requested_count": len(req.ICCIDs), + }, + }) return nil, err } @@ -401,6 +431,19 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone } if len(cardIDs) == 0 { + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardAllocate, + OperationDesc: "单卡分配被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorMsg: "无可分配卡", + BatchTotal: len(cards), + FailCount: len(failedItems), + AfterData: map[string]any{ + "to_shop_id": req.ToShopID, + "failed_items": failedItems, + "selection_type": req.SelectionType, + }, + }) return &dto.AllocateStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: 0, @@ -426,6 +469,21 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone }) if err != nil { + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardAllocate, + OperationDesc: "单卡分配执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + BatchTotal: len(cards), + SuccessCount: len(cardIDs), + FailCount: len(failedItems), + AfterData: map[string]any{ + "to_shop_id": req.ToShopID, + "failed_items": failedItems, + }, + }) return nil, err } @@ -436,6 +494,32 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone } } + beforeData := make([]map[string]any, 0, len(cards)) + for _, card := range cards { + beforeData = append(beforeData, map[string]any{ + "id": card.ID, + "iccid": card.ICCID, + "shop_id": card.ShopID, + "status": card.Status, + }) + } + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardAllocate, + OperationDesc: "单卡分配", + ResultStatus: constants.AssetAuditResultSuccess, + BatchTotal: len(cards), + SuccessCount: len(cardIDs), + FailCount: len(failedItems), + BeforeData: map[string]any{ + "cards": beforeData, + }, + AfterData: map[string]any{ + "to_shop_id": req.ToShopID, + "new_status": newStatus, + "failed_items": failedItems, + }, + }) + return &dto.AllocateStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: len(cardIDs), @@ -449,6 +533,18 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard // 1. 查询卡列表 cards, err := s.getCardsForRecall(ctx, req) if err != nil { + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRecall, + OperationDesc: "单卡回收执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AfterData: map[string]any{ + "selection_type": req.SelectionType, + "requested_count": len(req.ICCIDs), + }, + }) return nil, err } @@ -535,6 +631,18 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard } if len(cardIDs) == 0 { + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRecall, + OperationDesc: "单卡回收被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorMsg: "无可回收卡", + BatchTotal: len(cards), + FailCount: len(failedItems), + AfterData: map[string]any{ + "failed_items": failedItems, + "selection_type": req.SelectionType, + }, + }) return &dto.RecallStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: 0, @@ -570,6 +678,20 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard }) if err != nil { + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRecall, + OperationDesc: "单卡回收执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + BatchTotal: len(cards), + SuccessCount: len(cardIDs), + FailCount: len(failedItems), + AfterData: map[string]any{ + "failed_items": failedItems, + }, + }) return nil, err } @@ -580,6 +702,32 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard } } + beforeData := make([]map[string]any, 0, len(successCards)) + for _, card := range successCards { + beforeData = append(beforeData, map[string]any{ + "id": card.ID, + "iccid": card.ICCID, + "shop_id": card.ShopID, + "status": card.Status, + }) + } + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRecall, + OperationDesc: "单卡回收", + ResultStatus: constants.AssetAuditResultSuccess, + BatchTotal: len(cards), + SuccessCount: len(cardIDs), + FailCount: len(failedItems), + BeforeData: map[string]any{ + "cards": beforeData, + }, + AfterData: map[string]any{ + "to_shop_id": newShopID, + "new_status": newStatus, + "failed_items": failedItems, + }, + }) + return &dto.RecallStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: len(cardIDs), @@ -750,10 +898,35 @@ func (s *Service) buildRecallRecords(successCards []*model.IotCard, toShopID *ui func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCardSeriesBindngRequest, operatorShopID *uint) (*dto.BatchSetCardSeriesBindngResponse, error) { cards, err := s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs) if err != nil { + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardSeriesBinding, + OperationDesc: "卡系列绑定执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + BatchTotal: len(req.ICCIDs), + AfterData: map[string]any{ + "series_id": req.SeriesID, + "iccids": req.ICCIDs, + }, + }) return nil, err } if len(cards) == 0 { + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardSeriesBinding, + OperationDesc: "卡系列绑定被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorMsg: "卡不存在", + BatchTotal: len(req.ICCIDs), + FailCount: len(req.ICCIDs), + AfterData: map[string]any{ + "series_id": req.SeriesID, + "iccids": req.ICCIDs, + }, + }) return &dto.BatchSetCardSeriesBindngResponse{ SuccessCount: 0, FailCount: len(req.ICCIDs), @@ -772,12 +945,53 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa packageSeries, err = s.packageSeriesStore.GetByID(ctx, req.SeriesID) if err != nil { if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeNotFound, "套餐系列不存在或已禁用") + denyErr := errors.New(errors.CodeNotFound, "套餐系列不存在或已禁用") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardSeriesBinding, + OperationDesc: "卡系列绑定被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + BatchTotal: len(req.ICCIDs), + AfterData: map[string]any{ + "series_id": req.SeriesID, + "iccids": req.ICCIDs, + }, + }) + return nil, denyErr } + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardSeriesBinding, + OperationDesc: "卡系列绑定执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + BatchTotal: len(req.ICCIDs), + AfterData: map[string]any{ + "series_id": req.SeriesID, + "iccids": req.ICCIDs, + }, + }) return nil, err } if packageSeries.Status != 1 { - return nil, errors.New(errors.CodeInvalidParam, "套餐系列不存在或已禁用") + denyErr := errors.New(errors.CodeInvalidParam, "套餐系列不存在或已禁用") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardSeriesBinding, + OperationDesc: "卡系列绑定被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + BatchTotal: len(req.ICCIDs), + AfterData: map[string]any{ + "series_id": req.SeriesID, + "iccids": req.ICCIDs, + }, + }) + return nil, denyErr } } @@ -836,10 +1050,67 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa seriesIDPtr = &req.SeriesID } if err := s.iotCardStore.BatchUpdateSeriesID(ctx, successCardIDs, seriesIDPtr); err != nil { + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardSeriesBinding, + OperationDesc: "卡系列绑定执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + BatchTotal: len(req.ICCIDs), + SuccessCount: len(successCardIDs), + FailCount: len(failedItems), + AfterData: map[string]any{ + "series_id": req.SeriesID, + "success_ids": successCardIDs, + "failed_items": failedItems, + }, + }) return nil, err } } + resultStatus := constants.AssetAuditResultSuccess + operationDesc := "批量设置卡系列绑定" + errorMsg := "" + if len(successCardIDs) == 0 && len(failedItems) > 0 { + resultStatus = constants.AssetAuditResultDenied + operationDesc = "卡系列绑定被拒绝" + errorMsg = "无可操作卡" + } + beforeCards := make([]map[string]any, 0, len(successCardIDs)) + successSet := make(map[uint]struct{}, len(successCardIDs)) + for _, id := range successCardIDs { + successSet[id] = struct{}{} + } + for _, card := range cards { + if _, ok := successSet[card.ID]; !ok { + continue + } + beforeCards = append(beforeCards, map[string]any{ + "id": card.ID, + "iccid": card.ICCID, + "series_id": card.SeriesID, + }) + } + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardSeriesBinding, + OperationDesc: operationDesc, + ResultStatus: resultStatus, + ErrorMsg: errorMsg, + BatchTotal: len(req.ICCIDs), + SuccessCount: len(successCardIDs), + FailCount: len(failedItems), + BeforeData: map[string]any{ + "cards": beforeCards, + }, + AfterData: map[string]any{ + "series_id": req.SeriesID, + "success_ids": successCardIDs, + "failed_items": failedItems, + }, + }) + return &dto.BatchSetCardSeriesBindngResponse{ SuccessCount: len(successCardIDs), FailCount: len(failedItems), @@ -1052,19 +1323,74 @@ func (s *Service) UpdatePollingStatus(ctx context.Context, cardID uint, enablePo card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { if err == gorm.ErrRecordNotFound { - return errors.New(errors.CodeNotFound, "IoT卡不存在") + 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 } + 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, + }, + }) 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 } @@ -1082,6 +1408,20 @@ 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 } @@ -1093,6 +1433,21 @@ func (s *Service) BatchUpdatePollingStatus(ctx context.Context, cardIDs []uint, // 批量更新数据库 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", + }, + }) return err } @@ -1112,6 +1467,19 @@ 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 } @@ -1120,12 +1488,42 @@ func (s *Service) DeleteCard(ctx context.Context, cardID uint) error { card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { if err == gorm.ErrRecordNotFound { - return errors.New(errors.CodeNotFound, "IoT卡不存在") + denyErr := errors.New(errors.CodeNotFound, "IoT卡不存在") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardDelete, + OperationDesc: "删除卡被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: cardID, + }) + return denyErr } + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardDelete, + OperationDesc: "删除卡执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: cardID, + }) return err } if err := s.iotCardStore.Delete(ctx, cardID); err != nil { + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardDelete, + OperationDesc: "删除卡执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: cardSnapshot(card), + }) return err } @@ -1139,6 +1537,18 @@ func (s *Service) DeleteCard(ctx context.Context, cardID uint) error { s.pollingCallback.OnCardDeleted(ctx, cardID) } + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardDelete, + OperationDesc: "删除卡", + ResultStatus: constants.AssetAuditResultSuccess, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: cardSnapshot(card), + AfterData: map[string]any{ + "deleted": true, + }, + }) + return nil } @@ -1147,9 +1557,39 @@ func (s *Service) BatchDeleteCards(ctx context.Context, cardIDs []uint) error { if len(cardIDs) == 0 { return nil } + cards, queryErr := s.iotCardStore.GetByIDs(ctx, cardIDs) + if queryErr != nil { + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(queryErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardBatchDelete, + OperationDesc: "批量删除卡执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + BatchTotal: len(cardIDs), + FailCount: len(cardIDs), + AfterData: map[string]any{ + "card_ids": cardIDs, + }, + }) + return queryErr + } // 批量软删除 if err := s.iotCardStore.BatchDelete(ctx, cardIDs); err != nil { + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardBatchDelete, + OperationDesc: "批量删除卡执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + BatchTotal: len(cardIDs), + FailCount: len(cardIDs), + AfterData: map[string]any{ + "card_ids": cardIDs, + }, + }) return err } @@ -1161,6 +1601,24 @@ func (s *Service) BatchDeleteCards(ctx context.Context, cardIDs []uint) error { s.pollingCallback.OnCardDeleted(ctx, cardID) } } + beforeCards := make([]map[string]any, 0, len(cards)) + for _, card := range cards { + beforeCards = append(beforeCards, cardSnapshot(card)) + } + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardBatchDelete, + OperationDesc: "批量删除卡", + ResultStatus: constants.AssetAuditResultSuccess, + BatchTotal: len(cardIDs), + SuccessCount: len(cardIDs), + BeforeData: map[string]any{ + "cards": beforeCards, + }, + AfterData: map[string]any{ + "card_ids": cardIDs, + "deleted": true, + }, + }) return nil } @@ -1171,19 +1629,75 @@ func (s *Service) UpdateRealnamePolicy(ctx context.Context, cardID uint, realnam card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { if err == gorm.ErrRecordNotFound { - return errors.New(errors.CodeNotFound, "IoT卡不存在") + denyErr := errors.New(errors.CodeNotFound, "IoT卡不存在") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRealnamePolicy, + OperationDesc: "更新卡实名认证策略被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: cardID, + AfterData: map[string]any{ + "realname_policy": realnamePolicy, + }, + }) + return denyErr } - return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") + wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRealnamePolicy, + OperationDesc: "更新卡实名认证策略执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: cardID, + AfterData: map[string]any{ + "realname_policy": realnamePolicy, + }, + }) + return wrapErr } // 幂等检查 if card.RealnamePolicy == realnamePolicy { + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRealnamePolicy, + OperationDesc: "更新卡实名认证策略", + ResultStatus: constants.AssetAuditResultSuccess, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: map[string]any{ + "realname_policy": card.RealnamePolicy, + }, + AfterData: map[string]any{ + "realname_policy": realnamePolicy, + }, + }) return nil } // 更新数据库 if err := s.iotCardStore.UpdateRealnamePolicy(ctx, cardID, realnamePolicy); err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败") + wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRealnamePolicy, + OperationDesc: "更新卡实名认证策略执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: map[string]any{ + "realname_policy": card.RealnamePolicy, + }, + AfterData: map[string]any{ + "realname_policy": realnamePolicy, + }, + }) + return wrapErr } s.logger.Info("更新卡实名认证策略", @@ -1191,6 +1705,20 @@ func (s *Service) UpdateRealnamePolicy(ctx context.Context, cardID uint, realnam zap.String("realname_policy", realnamePolicy), ) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRealnamePolicy, + OperationDesc: "更新卡实名认证策略", + ResultStatus: constants.AssetAuditResultSuccess, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: map[string]any{ + "realname_policy": card.RealnamePolicy, + }, + AfterData: map[string]any{ + "realname_policy": realnamePolicy, + }, + }) + return nil } @@ -1199,15 +1727,54 @@ func (s *Service) UpdateRealnamePolicy(ctx context.Context, cardID uint, realnam func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, realNameStatus int) (*model.IotCard, error) { if realNameStatus != constants.RealNameStatusNotVerified && realNameStatus != constants.RealNameStatusVerified { - return nil, errors.New(errors.CodeInvalidParam, "无效的实名状态") + denyErr := errors.New(errors.CodeInvalidParam, "无效的实名状态") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRealnameStatus, + OperationDesc: "手动更新卡实名状态被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: cardID, + AfterData: map[string]any{ + "real_name_status": realNameStatus, + }, + }) + return nil, denyErr } card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeNotFound, "IoT卡不存在") + denyErr := errors.New(errors.CodeNotFound, "IoT卡不存在") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRealnameStatus, + OperationDesc: "手动更新卡实名状态被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: cardID, + AfterData: map[string]any{ + "real_name_status": realNameStatus, + }, + }) + return nil, denyErr } - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") + wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRealnameStatus, + OperationDesc: "手动更新卡实名状态执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: cardID, + AfterData: map[string]any{ + "real_name_status": realNameStatus, + }, + }) + return nil, wrapErr } oldStatus := card.RealNameStatus @@ -1227,12 +1794,46 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r } if err := s.iotCardStore.UpdateFields(ctx, cardID, fields); err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新卡实名状态失败") + wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡实名状态失败") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRealnameStatus, + OperationDesc: "手动更新卡实名状态执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: map[string]any{ + "real_name_status": oldStatus, + }, + AfterData: map[string]any{ + "real_name_status": realNameStatus, + }, + }) + return nil, wrapErr } freshCard, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询更新后的IoT卡失败") + wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "查询更新后的IoT卡失败") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRealnameStatus, + OperationDesc: "手动更新卡实名状态执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: map[string]any{ + "real_name_status": oldStatus, + }, + AfterData: map[string]any{ + "real_name_status": realNameStatus, + }, + }) + return nil, wrapErr } if statusChanged { @@ -1249,6 +1850,22 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r zap.Int("new_status", realNameStatus), zap.Uint("operator_id", middleware.GetUserIDFromContext(ctx))) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRealnameStatus, + OperationDesc: "手动更新卡实名状态", + ResultStatus: constants.AssetAuditResultSuccess, + AssetID: freshCard.ID, + AssetIdentifier: freshCard.ICCID, + BeforeData: map[string]any{ + "real_name_status": oldStatus, + "first_realname_at": card.FirstRealnameAt, + }, + AfterData: map[string]any{ + "real_name_status": freshCard.RealNameStatus, + "first_realname_at": freshCard.FirstRealnameAt, + }, + }) + return freshCard, nil } diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index b791380..85d43c6 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -12,6 +12,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/gateway" "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/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -36,6 +37,7 @@ type StopResumeService struct { deviceSimBindingStore *postgres.DeviceSimBindingStore gatewayClient *gateway.Client logger *zap.Logger + assetAuditService AssetAuditService maxRetries int retryInterval time.Duration @@ -49,6 +51,7 @@ func NewStopResumeService( deviceSimBindingStore *postgres.DeviceSimBindingStore, gatewayClient *gateway.Client, logger *zap.Logger, + assetAuditService AssetAuditService, ) *StopResumeService { return &StopResumeService{ redis: redis, @@ -57,6 +60,7 @@ func NewStopResumeService( deviceSimBindingStore: deviceSimBindingStore, gatewayClient: gatewayClient, logger: logger, + assetAuditService: assetAuditService, maxRetries: 3, retryInterval: 2 * time.Second, } @@ -363,6 +367,16 @@ func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) error { card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + Operator: assetAuditSvc.SystemOperator("系统任务"), + OperationType: constants.AssetAuditOpCardAutoStart, + OperationDesc: "自动复机执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: cardID, + }) return err } @@ -407,6 +421,21 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(err)) + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + Operator: assetAuditSvc.SystemOperator("系统任务"), + OperationType: constants.AssetAuditOpCardAutoStart, + OperationDesc: "自动复机执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: map[string]any{ + "network_status": card.NetworkStatus, + "stop_reason": card.StopReason, + }, + }) return err } @@ -420,6 +449,25 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(err)) + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + Operator: assetAuditSvc.SystemOperator("系统任务"), + OperationType: constants.AssetAuditOpCardAutoStart, + OperationDesc: "自动复机执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: map[string]any{ + "network_status": constants.NetworkStatusOffline, + "stop_reason": card.StopReason, + }, + AfterData: map[string]any{ + "network_status": constants.NetworkStatusOnline, + "stop_reason": "", + }, + }) return nil } @@ -429,13 +477,52 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID)) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + Operator: assetAuditSvc.SystemOperator("系统任务"), + OperationType: constants.AssetAuditOpCardAutoStart, + OperationDesc: "自动复机", + ResultStatus: constants.AssetAuditResultSuccess, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: map[string]any{ + "network_status": constants.NetworkStatusOffline, + "stop_reason": card.StopReason, + }, + AfterData: map[string]any{ + "network_status": constants.NetworkStatusOnline, + "stop_reason": "", + }, + }) + return nil } // stopCardWithRetry 调用运营商停机接口(带重试机制),并更新 DB 停机原因 func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard, stopReason string) error { + operator := assetAuditSvc.SystemOperator("系统任务") + operationType := constants.AssetAuditOpCardAutoStop + operationDesc := "自动停卡" + if stopReason == constants.StopReasonManual { + operator = assetAuditSvc.OperatorFromContext(ctx) + operationType = constants.AssetAuditOpCardManualStop + operationDesc = "手动停卡" + } + if s.gatewayClient == nil { - return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用") + failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(failErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + Operator: operator, + OperationType: operationType, + OperationDesc: operationDesc + "执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: cardSnapshot(card), + }) + return failErr } s.logger.Info("调用网关停机", @@ -470,9 +557,44 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID), zap.Error(updateErr)) + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(updateErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + Operator: operator, + OperationType: operationType, + OperationDesc: operationDesc + "执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: map[string]any{ + "network_status": card.NetworkStatus, + "stop_reason": card.StopReason, + }, + AfterData: map[string]any{ + "network_status": constants.NetworkStatusOffline, + "stop_reason": stopReason, + }, + }) } s.invalidatePollingCache(ctx, card.ID) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + Operator: operator, + OperationType: operationType, + OperationDesc: operationDesc, + ResultStatus: constants.AssetAuditResultSuccess, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: map[string]any{ + "network_status": card.NetworkStatus, + "stop_reason": card.StopReason, + }, + AfterData: map[string]any{ + "network_status": constants.NetworkStatusOffline, + "stop_reason": stopReason, + }, + }) return nil } @@ -483,6 +605,26 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I zap.Error(err)) } + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(lastErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + Operator: operator, + OperationType: operationType, + OperationDesc: operationDesc + "执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: map[string]any{ + "network_status": card.NetworkStatus, + "stop_reason": card.StopReason, + }, + AfterData: map[string]any{ + "network_status": constants.NetworkStatusOffline, + "stop_reason": stopReason, + }, + }) + return lastErr } @@ -529,11 +671,33 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) error { card, err := s.iotCardStore.GetByICCID(ctx, iccid) if err != nil { - return errors.New(errors.CodeNotFound, "卡不存在") + denyErr := errors.New(errors.CodeNotFound, "卡不存在") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardManualStop, + OperationDesc: "手动停卡被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetIdentifier: iccid, + }) + return denyErr } if card.RealNameStatus != constants.RealNameStatusVerified { - return errors.New(errors.CodeForbidden, "卡未实名,无法操作") + denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardManualStop, + OperationDesc: "手动停卡被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: cardSnapshot(card), + }) + return denyErr } // 检查绑定设备是否在复机保护期 @@ -542,10 +706,37 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er if bindErr == nil && binding != nil { exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "start")).Result() if exists > 0 { - return errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机") + denyErr := errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardManualStop, + OperationDesc: "手动停卡被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: cardSnapshot(card), + AfterData: map[string]any{ + "device_id": binding.DeviceID, + }, + }) + return denyErr } } else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) { - return errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败") + wrapErr := errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardManualStop, + OperationDesc: "手动停卡执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: cardSnapshot(card), + }) + return wrapErr } } @@ -560,11 +751,33 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) error { card, err := s.iotCardStore.GetByICCID(ctx, iccid) if err != nil { - return errors.New(errors.CodeNotFound, "卡不存在") + denyErr := errors.New(errors.CodeNotFound, "卡不存在") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardManualStart, + OperationDesc: "手动复机被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetIdentifier: iccid, + }) + return denyErr } if card.RealNameStatus != constants.RealNameStatusVerified { - return errors.New(errors.CodeForbidden, "卡未实名,无法操作") + denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardManualStart, + OperationDesc: "手动复机被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: cardSnapshot(card), + }) + return denyErr } // 检查绑定设备是否在停机保护期 @@ -573,15 +786,54 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e if bindErr == nil && binding != nil { exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "stop")).Result() if exists > 0 { - return errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机") + denyErr := errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardManualStart, + OperationDesc: "手动复机被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: cardSnapshot(card), + AfterData: map[string]any{ + "device_id": binding.DeviceID, + }, + }) + return denyErr } } else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) { - return errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败") + wrapErr := errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardManualStart, + OperationDesc: "手动复机执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: cardSnapshot(card), + }) + return wrapErr } } if err := s.resumeCardWithRetry(ctx, card); err != nil { - return errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试") + wrapErr := errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardManualStart, + OperationDesc: "手动复机执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: cardSnapshot(card), + }) + return wrapErr } now := time.Now() @@ -590,11 +842,46 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e "resumed_at": now, "stop_reason": "", }); err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败") + wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败") + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardManualStart, + OperationDesc: "手动复机执行失败", + ResultStatus: constants.AssetAuditResultFailed, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: map[string]any{ + "network_status": card.NetworkStatus, + "stop_reason": card.StopReason, + }, + AfterData: map[string]any{ + "network_status": constants.NetworkStatusOnline, + "stop_reason": "", + }, + }) + return wrapErr } s.invalidatePollingCache(ctx, card.ID) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardManualStart, + OperationDesc: "手动复机", + ResultStatus: constants.AssetAuditResultSuccess, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: map[string]any{ + "network_status": card.NetworkStatus, + "stop_reason": card.StopReason, + }, + AfterData: map[string]any{ + "network_status": constants.NetworkStatusOnline, + "stop_reason": "", + }, + }) + return nil } diff --git a/internal/service/iot_card_import/audit.go b/internal/service/iot_card_import/audit.go new file mode 100644 index 0000000..eded342 --- /dev/null +++ b/internal/service/iot_card_import/audit.go @@ -0,0 +1,58 @@ +package iot_card_import + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + 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) logIotCardImportAudit(ctx context.Context, p assetAuditSvc.BuildLogParams) { + if s == nil || s.assetAudit == nil { + return + } + if p.Operator.Type == "" { + p.Operator = assetAuditSvc.OperatorFromContext(ctx) + } + if p.OperationType == "" { + p.OperationType = constants.AssetAuditOpIotCardImportTaskCreate + } + if p.AssetType == "" { + p.AssetType = constants.AssetTypeIotCard + } + s.assetAudit.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p)) +} + +func newIotCardImportAuditParams( + taskID uint, + taskNo string, + req *dto.ImportIotCardRequest, + resultStatus string, + err error, +) assetAuditSvc.BuildLogParams { + afterData := map[string]any{} + if req != nil { + afterData["carrier_id"] = req.CarrierID + afterData["batch_no"] = req.BatchNo + afterData["file_key"] = req.FileKey + afterData["card_category"] = req.CardCategory + afterData["realname_policy"] = req.RealnamePolicy + } + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + return assetAuditSvc.BuildLogParams{ + AssetID: taskID, + AssetIdentifier: taskNo, + OperationDesc: "创建IoT卡导入任务", + ResultStatus: resultStatus, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AfterData: afterData, + } +} diff --git a/internal/service/iot_card_import/service.go b/internal/service/iot_card_import/service.go index 2307890..49ca026 100644 --- a/internal/service/iot_card_import/service.go +++ b/internal/service/iot_card_import/service.go @@ -21,6 +21,7 @@ type Service struct { importTaskStore *postgres.IotCardImportTaskStore carrierStore carrierGetter queueClient *queue.Client + assetAudit AssetAuditService } type carrierGetter interface { @@ -43,12 +44,18 @@ func (s *CarrierStore) GetByID(ctx context.Context, id uint) (*model.Carrier, er return &carrier, nil } -func New(db *gorm.DB, importTaskStore *postgres.IotCardImportTaskStore, queueClient *queue.Client) *Service { +func New( + db *gorm.DB, + importTaskStore *postgres.IotCardImportTaskStore, + queueClient *queue.Client, + assetAudit AssetAuditService, +) *Service { return &Service{ db: db, importTaskStore: importTaskStore, carrierStore: NewCarrierStore(db), queueClient: queueClient, + assetAudit: assetAudit, } } @@ -59,12 +66,16 @@ type IotCardImportPayload struct { func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRequest) (*dto.ImportIotCardResponse, error) { userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { - return nil, errors.New(errors.CodeUnauthorized, "未授权访问") + appErr := errors.New(errors.CodeUnauthorized, "未授权访问") + s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr)) + return nil, appErr } carrier, err := s.carrierStore.GetByID(ctx, req.CarrierID) if err != nil { - return nil, errors.New(errors.CodeInvalidParam, "运营商不存在") + appErr := errors.New(errors.CodeInvalidParam, "运营商不存在") + s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr)) + return nil, appErr } taskNo := s.importTaskStore.GenerateTaskNo(ctx) @@ -91,16 +102,22 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe task.Updater = userID if err := s.importTaskStore.Create(ctx, task); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "创建导入任务失败") + appErr := errors.Wrap(errors.CodeInternalError, err, "创建导入任务失败") + s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(0, taskNo, req, constants.AssetAuditResultFailed, appErr)) + return nil, appErr } payload := IotCardImportPayload{TaskID: task.ID} err = s.queueClient.EnqueueTask(ctx, constants.TaskTypeIotCardImport, payload) if err != nil { s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error()) - return nil, errors.Wrap(errors.CodeInternalError, err, "任务入队失败") + appErr := errors.Wrap(errors.CodeInternalError, err, "任务入队失败") + s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultFailed, appErr)) + return nil, appErr } + s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultSuccess, nil)) + return &dto.ImportIotCardResponse{ TaskID: task.ID, TaskNo: taskNo, diff --git a/internal/service/polling/asset_polling_service.go b/internal/service/polling/asset_polling_service.go index 306f8dc..31a9b39 100644 --- a/internal/service/polling/asset_polling_service.go +++ b/internal/service/polling/asset_polling_service.go @@ -6,6 +6,7 @@ import ( "go.uber.org/zap" "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/constants" @@ -21,6 +22,7 @@ type AssetPollingService struct { iotCardService *iotCardSvc.Service queueMgr *polling.PollingQueueManager logger *zap.Logger + assetAuditService assetAuditSvc.OperationLogger } // NewAssetPollingService 创建资产轮询管控服务 @@ -30,6 +32,7 @@ func NewAssetPollingService( iotCardService *iotCardSvc.Service, queueMgr *polling.PollingQueueManager, logger *zap.Logger, + assetAuditService assetAuditSvc.OperationLogger, ) *AssetPollingService { return &AssetPollingService{ deviceStore: deviceStore, @@ -37,9 +40,23 @@ func NewAssetPollingService( iotCardService: iotCardService, queueMgr: queueMgr, logger: logger, + assetAuditService: assetAuditService, } } +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 @@ -47,12 +64,87 @@ func NewAssetPollingService( 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 回调一并触发 - return s.iotCardService.UpdatePollingStatus(ctx, assetID, enablePolling) + 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 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 + } + 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, + }) return err } bindings, err := s.deviceBindingStore.ListByDeviceID(ctx, assetID) @@ -81,9 +173,33 @@ 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: - return errors.New(errors.CodeInvalidParam, "资产类型无效,支持 card 或 device") + 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 } } diff --git a/internal/store/postgres/asset_operation_log_store.go b/internal/store/postgres/asset_operation_log_store.go new file mode 100644 index 0000000..9b3cd73 --- /dev/null +++ b/internal/store/postgres/asset_operation_log_store.go @@ -0,0 +1,106 @@ +package postgres + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "gorm.io/gorm" +) + +// AssetOperationLogStore 资产操作日志存储层。 +type AssetOperationLogStore struct { + db *gorm.DB +} + +// NewAssetOperationLogStore 创建资产操作日志存储实例。 +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, + assetType string, + assetID uint, + page int, + pageSize int, + operationType string, + resultStatus string, +) ([]*model.AssetOperationLog, int64, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + + query := s.db.WithContext(ctx).Model(&model.AssetOperationLog{}). + Where("asset_type = ? AND asset_id = ?", assetType, assetID) + + if operationType != "" { + query = query.Where("operation_type = ?", operationType) + } + if resultStatus != "" { + query = query.Where("result_status = ?", resultStatus) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + var logs []*model.AssetOperationLog + if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil { + return nil, 0, err + } + + return logs, total, nil +} + +// ListByAsset 按资产查询日志。 +func (s *AssetOperationLogStore) ListByAsset(ctx context.Context, assetType string, assetID uint, limit int) ([]*model.AssetOperationLog, error) { + if limit <= 0 { + limit = 20 + } + var logs []*model.AssetOperationLog + err := s.db.WithContext(ctx). + Where("asset_type = ? AND asset_id = ?", assetType, assetID). + Order("created_at DESC"). + Limit(limit). + Find(&logs).Error + return logs, err +} + +// ListByOperator 按操作人查询日志。 +func (s *AssetOperationLogStore) ListByOperator(ctx context.Context, operatorID uint, limit int) ([]*model.AssetOperationLog, error) { + if limit <= 0 { + limit = 20 + } + var logs []*model.AssetOperationLog + err := s.db.WithContext(ctx). + Where("operator_id = ?", operatorID). + Order("created_at DESC"). + Limit(limit). + Find(&logs).Error + return logs, err +} + +// ListByOperationType 按操作类型查询日志。 +func (s *AssetOperationLogStore) ListByOperationType(ctx context.Context, operationType string, limit int) ([]*model.AssetOperationLog, error) { + if limit <= 0 { + limit = 20 + } + var logs []*model.AssetOperationLog + err := s.db.WithContext(ctx). + Where("operation_type = ?", operationType). + Order("created_at DESC"). + Limit(limit). + Find(&logs).Error + return logs, err +} diff --git a/migrations/000136_create_asset_operation_log.down.sql b/migrations/000136_create_asset_operation_log.down.sql new file mode 100644 index 0000000..5ef73d4 --- /dev/null +++ b/migrations/000136_create_asset_operation_log.down.sql @@ -0,0 +1,8 @@ +-- 回滚资产操作审计日志表 +DROP INDEX IF EXISTS idx_asset_log_asset_created; +DROP INDEX IF EXISTS idx_asset_log_identifier_created; +DROP INDEX IF EXISTS idx_asset_log_operator_created; +DROP INDEX IF EXISTS idx_asset_log_operation_created; +DROP INDEX IF EXISTS idx_asset_log_result_created; + +DROP TABLE IF EXISTS tb_asset_operation_log; diff --git a/migrations/000136_create_asset_operation_log.up.sql b/migrations/000136_create_asset_operation_log.up.sql new file mode 100644 index 0000000..9ba85c5 --- /dev/null +++ b/migrations/000136_create_asset_operation_log.up.sql @@ -0,0 +1,69 @@ +-- 创建资产操作审计日志表 +CREATE TABLE IF NOT EXISTS tb_asset_operation_log ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + operator_id BIGINT, + operator_type VARCHAR(32) NOT NULL, + operator_name VARCHAR(255) NOT NULL DEFAULT '', + + asset_type VARCHAR(32) NOT NULL, + asset_id BIGINT NOT NULL, + asset_identifier VARCHAR(128) NOT NULL DEFAULT '', + + operation_type VARCHAR(64) NOT NULL, + operation_desc TEXT NOT NULL, + + before_data JSONB, + after_data JSONB, + + request_id VARCHAR(255), + ip_address VARCHAR(64), + user_agent TEXT, + request_path VARCHAR(255), + request_method VARCHAR(16), + + result_status VARCHAR(16) NOT NULL, + error_code VARCHAR(64), + error_msg TEXT, + + batch_total INT NOT NULL DEFAULT 0, + success_count INT NOT NULL DEFAULT 0, + fail_count INT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_asset_log_asset_created + ON tb_asset_operation_log(asset_type, asset_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_asset_log_identifier_created + ON tb_asset_operation_log(asset_identifier, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_asset_log_operator_created + ON tb_asset_operation_log(operator_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_asset_log_operation_created + ON tb_asset_operation_log(operation_type, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_asset_log_result_created + ON tb_asset_operation_log(result_status, created_at DESC); + +COMMENT ON TABLE tb_asset_operation_log IS '资产操作审计日志表'; +COMMENT ON COLUMN tb_asset_operation_log.id IS '主键ID'; +COMMENT ON COLUMN tb_asset_operation_log.created_at IS '创建时间'; +COMMENT ON COLUMN tb_asset_operation_log.operator_id IS '操作人ID(系统触发为空)'; +COMMENT ON COLUMN tb_asset_operation_log.operator_type IS '操作人类型(system/admin_user/agent_user/enterprise_user/personal_customer)'; +COMMENT ON COLUMN tb_asset_operation_log.operator_name IS '操作人名称快照'; +COMMENT ON COLUMN tb_asset_operation_log.asset_type IS '资产类型(iot_card/device)'; +COMMENT ON COLUMN tb_asset_operation_log.asset_id IS '资产ID'; +COMMENT ON COLUMN tb_asset_operation_log.asset_identifier IS '资产标识(ICCID/虚拟号)'; +COMMENT ON COLUMN tb_asset_operation_log.operation_type IS '操作类型'; +COMMENT ON COLUMN tb_asset_operation_log.operation_desc IS '操作描述(中文)'; +COMMENT ON COLUMN tb_asset_operation_log.before_data IS '变更前数据(JSONB)'; +COMMENT ON COLUMN tb_asset_operation_log.after_data IS '变更后数据(JSONB)'; +COMMENT ON COLUMN tb_asset_operation_log.request_id IS '请求ID'; +COMMENT ON COLUMN tb_asset_operation_log.ip_address IS '请求IP'; +COMMENT ON COLUMN tb_asset_operation_log.user_agent IS '用户代理'; +COMMENT ON COLUMN tb_asset_operation_log.request_path IS '请求路径'; +COMMENT ON COLUMN tb_asset_operation_log.request_method IS '请求方法'; +COMMENT ON COLUMN tb_asset_operation_log.result_status IS '执行结果(success/failed/denied)'; +COMMENT ON COLUMN tb_asset_operation_log.error_code IS '错误码'; +COMMENT ON COLUMN tb_asset_operation_log.error_msg IS '错误摘要'; +COMMENT ON COLUMN tb_asset_operation_log.batch_total IS '批量总数'; +COMMENT ON COLUMN tb_asset_operation_log.success_count IS '批量成功数'; +COMMENT ON COLUMN tb_asset_operation_log.fail_count IS '批量失败数'; diff --git a/pkg/constants/asset_audit.go b/pkg/constants/asset_audit.go new file mode 100644 index 0000000..1bc9cb2 --- /dev/null +++ b/pkg/constants/asset_audit.go @@ -0,0 +1,58 @@ +package constants + +// 资产审计日志结果状态常量 +const ( + AssetAuditResultSuccess = "success" // 成功 + AssetAuditResultFailed = "failed" // 失败 + AssetAuditResultDenied = "denied" // 拒绝 +) + +// 资产审计日志操作者类型常量 +const ( + AssetAuditOperatorTypeSystem = "system" // 系统任务 + AssetAuditOperatorTypeAdmin = "admin_user" // 平台/超管用户 + AssetAuditOperatorTypeAgent = "agent_user" // 代理用户 + AssetAuditOperatorTypeEnterprise = "enterprise_user" // 企业用户 + AssetAuditOperatorTypePersonal = "personal_customer" // 个人客户 +) + +// 资产审计日志操作类型常量 +const ( + AssetAuditOpCardAllocate = "card_allocate" // 单卡分配 + AssetAuditOpCardRecall = "card_recall" // 单卡回收 + AssetAuditOpCardSeriesBinding = "card_series_binding" // 卡系列绑定 + AssetAuditOpCardPollingStatus = "card_polling_status" // 卡轮询状态更新 + AssetAuditOpCardRealnamePolicy = "card_realname_policy" // 卡实名策略更新 + AssetAuditOpCardRealnameStatus = "card_realname_status" // 卡实名状态更新 + AssetAuditOpCardDelete = "card_delete" // 删除卡 + AssetAuditOpCardBatchDelete = "card_batch_delete" // 批量删除卡 + AssetAuditOpCardManualStop = "card_manual_stop" // 手动停卡 + AssetAuditOpCardManualStart = "card_manual_start" // 手动复机 + AssetAuditOpCardAutoStop = "card_auto_stop" // 自动停卡 + AssetAuditOpCardAutoStart = "card_auto_start" // 自动复机 + AssetAuditOpDeviceDelete = "device_delete" // 删除设备 + AssetAuditOpDeviceAllocate = "device_allocate" // 设备分配 + AssetAuditOpDeviceRecall = "device_recall" // 设备回收 + AssetAuditOpDeviceSeriesBinding = "device_series_binding" // 设备系列绑定 + AssetAuditOpDeviceBindCard = "device_bind_card" // 设备绑卡 + AssetAuditOpDeviceUnbindCard = "device_unbind_card" // 设备解绑卡 + AssetAuditOpDeviceStop = "device_stop" // 设备停机 + AssetAuditOpDeviceStart = "device_start" // 设备复机 + AssetAuditOpDeviceSpeedLimit = "device_speed_limit" // 设备限速 + AssetAuditOpDeviceSetWiFi = "device_set_wifi" // 设备 WiFi 设置 + AssetAuditOpDeviceSwitchCard = "device_switch_card" // 设备切卡 + AssetAuditOpDeviceSwitchMode = "device_switch_mode" // 设备切卡模式 + AssetAuditOpDeviceReboot = "device_reboot" // 设备重启 + AssetAuditOpDeviceReset = "device_reset" // 设备恢复出厂 + AssetAuditOpAssetDeactivate = "asset_deactivate" // 资产停用 + AssetAuditOpAssetPollingStatus = "asset_polling_status" // 统一入口轮询状态更新 + AssetAuditOpAssetRealnamePolicy = "asset_realname_policy" // 统一入口实名策略更新 + AssetAuditOpDeviceImportTaskCreate = "device_import_task_create" // 设备导入任务创建 + AssetAuditOpIotCardImportTaskCreate = "iot_card_import_task_create" // 卡导入任务创建 +) + +// 资产审计日志数据裁剪常量 +const ( + AssetAuditDataMaxBytes = 16 * 1024 // before/after 数据最大字节数 + AssetAuditPreviewMaxBytes = 2048 // 裁剪预览最大字节数 +) diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index a808d4c..530cb80 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -14,6 +14,8 @@ const ( ContextKeyUserInfo = "user_info" // 完整的用户信息 ContextKeyIP = "ip_address" // IP地址 ContextKeyUserAgent = "user_agent" // User-Agent + ContextKeyRequestPath = "request_path" // 请求路径 + ContextKeyRequestMethod = "request_method" // 请求方法 ContextKeySubordinateShopIDs = "subordinate_shop_ids" // 下级店铺ID列表(代理用户预计算) ) diff --git a/pkg/logger/middleware.go b/pkg/logger/middleware.go index 79440b7..442a24d 100644 --- a/pkg/logger/middleware.go +++ b/pkg/logger/middleware.go @@ -1,6 +1,7 @@ package logger import ( + "context" "time" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -35,6 +36,19 @@ func Middleware() fiber.Handler { startTime := time.Now() c.Locals(constants.ContextKeyStartTime, startTime) + // 注入请求上下文,供 Service 层审计日志复用 + ctx := c.UserContext() + if rid := c.Locals(constants.ContextKeyRequestID); rid != nil { + if requestID, ok := rid.(string); ok && requestID != "" { + ctx = context.WithValue(ctx, constants.ContextKeyRequestID, requestID) + } + } + ctx = context.WithValue(ctx, constants.ContextKeyIP, c.IP()) + ctx = context.WithValue(ctx, constants.ContextKeyUserAgent, c.Get("User-Agent")) + ctx = context.WithValue(ctx, constants.ContextKeyRequestPath, c.Path()) + ctx = context.WithValue(ctx, constants.ContextKeyRequestMethod, c.Method()) + c.SetUserContext(ctx) + // 获取请求 body(在 c.Next() 之前读取) requestBody := truncateBody(c.Body(), MaxBodyLogSize) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 8becf75..7fa9740 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -132,6 +132,28 @@ func GetUserAgentFromContext(ctx context.Context) *string { return nil } +// GetRequestPathFromContext 从 context 中提取请求路径。 +func GetRequestPathFromContext(ctx context.Context) *string { + if ctx == nil { + return nil + } + if requestPath, ok := ctx.Value(constants.ContextKeyRequestPath).(string); ok { + return &requestPath + } + return nil +} + +// GetRequestMethodFromContext 从 context 中提取请求方法。 +func GetRequestMethodFromContext(ctx context.Context) *string { + if ctx == nil { + return nil + } + if requestMethod, ok := ctx.Value(constants.ContextKeyRequestMethod).(string); ok { + return &requestMethod + } + return nil +} + // SetUserToFiberContext 将用户信息设置到 Fiber context 的 Locals 中 // 同时也设置到标准 context 中,便于 GORM 查询使用 func SetUserToFiberContext(c *fiber.Ctx, info *UserContextInfo) { diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 74c8c14..0a3245f 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -57,7 +57,7 @@ func BuildDocHandlers() *bootstrap.Handlers { PollingAlert: admin.NewPollingAlertHandler(nil), PollingCleanup: admin.NewPollingCleanupHandler(nil), PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil), - Asset: admin.NewAssetHandler(nil, nil, nil, nil, nil), + Asset: admin.NewAssetHandler(nil, nil, nil, nil, nil, nil), AssetLifecycle: admin.NewAssetLifecycleHandler(nil), AssetWallet: admin.NewAssetWalletHandler(nil), WechatConfig: admin.NewWechatConfigHandler(nil), diff --git a/pkg/queue/types.go b/pkg/queue/types.go index 9a03faf..c4758ba 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -19,6 +19,7 @@ type OrderExpirer interface { // WorkerStores Worker 侧所有 Store 的集合 type WorkerStores struct { + AssetOperationLog *postgres.AssetOperationLogStore IotCardImportTask *postgres.IotCardImportTaskStore IotCard *postgres.IotCardStore DeviceImportTask *postgres.DeviceImportTaskStore