From 66cec7515a0ae973272af91188a33c338fa6a6fe Mon Sep 17 00:00:00 2001 From: huang Date: Mon, 27 Apr 2026 14:52:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/model/dto/asset_operation_log_dto.go | 4 + .../service/asset_audit/operation_content.go | 353 ++++++++++++++++++ internal/service/asset_audit/service.go | 54 +-- internal/service/device/audit.go | 24 +- internal/service/device_import/audit.go | 1 + internal/service/iot_card/audit.go | 56 ++- internal/service/iot_card_import/audit.go | 1 + 7 files changed, 458 insertions(+), 35 deletions(-) create mode 100644 internal/service/asset_audit/operation_content.go diff --git a/internal/model/dto/asset_operation_log_dto.go b/internal/model/dto/asset_operation_log_dto.go index 1ec6619..34ba532 100644 --- a/internal/model/dto/asset_operation_log_dto.go +++ b/internal/model/dto/asset_operation_log_dto.go @@ -27,6 +27,10 @@ type AssetOperationLogItem struct { OperationType string `json:"operation_type" description:"操作类型"` OperationDesc string `json:"operation_desc" description:"操作描述(中文)"` + OperationContentBefore map[string]any `json:"operation_content_before,omitempty" description:"操作内容(变更前)"` + OperationContentAfter map[string]any `json:"operation_content_after,omitempty" description:"操作内容(变更后)"` + OperationFieldsDesc map[string]string `json:"operation_fields_desc,omitempty" description:"操作内容字段说明(key 为字段名,value 为中文说明)"` + BeforeData map[string]any `json:"before_data,omitempty" description:"变更前数据"` AfterData map[string]any `json:"after_data,omitempty" description:"变更后数据"` diff --git a/internal/service/asset_audit/operation_content.go b/internal/service/asset_audit/operation_content.go new file mode 100644 index 0000000..ecfb8f0 --- /dev/null +++ b/internal/service/asset_audit/operation_content.go @@ -0,0 +1,353 @@ +package asset_audit + +import ( + "strings" +) + +var snapshotFieldSet = map[string]struct{}{ + "id": {}, + "virtual_no": {}, + "iccid": {}, + "imei": {}, + "sn": {}, + "msisdn": {}, + "shop_id": {}, + "status": {}, + "series_id": {}, + "enable_polling": {}, + "realname_policy": {}, + "real_name_status": {}, + "network_status": {}, + "stop_reason": {}, + "deleted": {}, + "asset_status": {}, +} + +// normalizeOperationContent 将原始 before/after 数据归一化为“操作内容”。 +// 兼容历史数据: +// 1. 优先读取 operation_content; +// 2. 若 before 仅有资产快照而 after 是操作内容,则按字段名回填 before; +// 3. 若 after 为空则按 before 补齐,保证前端有稳定结构。 +func normalizeOperationContent(operationType string, beforeData, afterData map[string]any) (map[string]any, map[string]any) { + beforeRaw := extractOperationContent(beforeData) + afterRaw := extractOperationContent(afterData) + before := trimNonOperationKeys(beforeRaw) + after := trimNonOperationKeys(afterRaw) + snapshot := extractAssetSnapshot(beforeData, afterData) + + // 历史数据可能把扁平资产快照直接写到 before_data。 + // 这种情况下 before 不是“操作内容”,需要按 after 字段从快照回填。 + if isLikelySnapshotInsteadOfOperation(before, after, operationType) { + if len(snapshot) == 0 { + snapshot = cloneMap(beforeRaw) + } + before = deriveBeforeFromSnapshot(after, snapshot) + } + + if len(before) == 0 && len(after) > 0 { + before = deriveBeforeFromSnapshot(after, snapshot) + } + if len(after) == 0 && len(before) > 0 { + after = cloneMap(before) + } + + return before, after +} + +// WrapOperationContent 将操作内容包装为统一结构写入 before_data/after_data。 +// 写入格式: +// +// { +// "operation_content": {...}, +// "asset_snapshot": {...} // 可选 +// } +func WrapOperationContent(beforeOperation, afterOperation, snapshot map[string]any) (map[string]any, map[string]any) { + before := cloneMap(beforeOperation) + after := cloneMap(afterOperation) + + if len(before) == 0 && len(after) > 0 { + before = deriveBeforeFromSnapshot(after, snapshot) + } + if len(after) == 0 && len(before) > 0 { + after = cloneMap(before) + } + + beforeWrapped := map[string]any{ + "operation_content": before, + } + afterWrapped := map[string]any{ + "operation_content": after, + } + if len(snapshot) > 0 { + beforeWrapped["asset_snapshot"] = cloneMap(snapshot) + afterWrapped["asset_snapshot"] = cloneMap(snapshot) + } + return beforeWrapped, afterWrapped +} + +func extractOperationContent(data map[string]any) map[string]any { + if len(data) == 0 { + return nil + } + if v, ok := data["operation_content"]; ok { + if m, ok := toMap(v); ok { + return cloneMap(m) + } + } + return cloneMap(data) +} + +func extractAssetSnapshot(beforeData, afterData map[string]any) map[string]any { + candidates := []map[string]any{beforeData, afterData} + for _, data := range candidates { + if len(data) == 0 { + continue + } + if v, ok := data["asset_snapshot"]; ok { + if m, ok := toMap(v); ok { + return cloneMap(m) + } + } + if v, ok := data["device"]; ok { + if m, ok := toMap(v); ok { + return cloneMap(m) + } + } + if v, ok := data["card"]; ok { + if m, ok := toMap(v); ok { + return cloneMap(m) + } + } + if isLikelyAssetSnapshot(data) { + return cloneMap(data) + } + } + return nil +} + +func trimNonOperationKeys(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := cloneMap(in) + delete(out, "operation_content") + delete(out, "asset_snapshot") + delete(out, "operation_context") + delete(out, "device") + delete(out, "card") + delete(out, "cards") + if len(out) == 0 { + return nil + } + return out +} + +func deriveBeforeFromSnapshot(after map[string]any, snapshot map[string]any) map[string]any { + if len(after) == 0 { + return nil + } + out := make(map[string]any, len(after)) + for key := range after { + if val, ok := snapshot[key]; ok { + out[key] = val + continue + } + if alias := snapshotAlias(key); alias != "" { + if val, ok := snapshot[alias]; ok { + out[key] = val + continue + } + } + out[key] = nil + } + return out +} + +func snapshotAlias(operationField string) string { + switch operationField { + case "target_shop_id", "to_shop_id", "source_shop_id", "from_shop_id": + return "shop_id" + case "new_status": + return "status" + case "new_series_id": + return "series_id" + case "new_realname_policy": + return "realname_policy" + case "new_enable_polling": + return "enable_polling" + case "new_switch_mode": + return "switch_mode" + default: + return "" + } +} + +func cloneMap(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func toMap(v any) (map[string]any, bool) { + switch val := v.(type) { + case map[string]any: + return val, true + default: + return nil, false + } +} + +func isLikelyAssetSnapshot(data map[string]any) bool { + if len(data) == 0 { + return false + } + + snapshotCount := 0 + for key := range data { + if _, ok := snapshotFieldSet[key]; !ok { + return false + } + snapshotCount++ + } + + return snapshotCount >= 2 +} + +func isLikelySnapshotInsteadOfOperation(before, after map[string]any, operationType string) bool { + if len(before) == 0 || len(after) == 0 { + return false + } + if !isLikelyAssetSnapshot(before) { + return false + } + + fieldDesc := getOperationFieldDesc(operationType) + for key := range after { + if _, ok := fieldDesc[key]; ok { + return true + } + if _, ok := snapshotFieldSet[key]; !ok { + return true + } + } + return false +} + +func getOperationFieldDesc(operationType string) map[string]string { + common := map[string]string{ + "batch_total": "批量总数", + "success_count": "成功数量", + "fail_count": "失败数量", + "failed_items": "失败明细", + "reason": "原因说明", + } + + switch strings.TrimSpace(operationType) { + case "card_allocate", "device_allocate": + return mergeFieldDesc(common, map[string]string{ + "target_shop_id": "目标店铺ID", + "to_shop_id": "目标店铺ID", + "device_ids": "设备ID列表", + "card_ids": "卡ID列表", + "selection_type": "选择方式", + "new_status": "变更后状态", + }) + case "card_recall", "device_recall": + return mergeFieldDesc(common, map[string]string{ + "source_shop_id": "来源店铺ID", + "target_shop": "回收后归属店铺(空表示平台库存)", + "device_ids": "设备ID列表", + "card_ids": "卡ID列表", + "selection_type": "选择方式", + "new_status": "变更后状态", + }) + case "device_bind_card": + return map[string]string{ + "binding_id": "绑定记录ID", + "slot_position": "插槽位置", + "iot_card_id": "卡ID", + "iccid": "卡ICCID", + } + case "device_unbind_card": + return map[string]string{ + "binding_id": "绑定记录ID", + "iot_card_id": "卡ID", + "unbind": "是否执行解绑", + } + case "device_stop", "device_start": + return mergeFieldDesc(common, map[string]string{ + "skip_count": "跳过数量", + "failed_items": "失败明细(含ICCID与原因)", + }) + case "device_speed_limit": + return map[string]string{ + "speed_limit": "限速值(KB/s)", + } + case "device_set_wifi": + return map[string]string{ + "card_no": "卡槽/卡序号", + "ssid": "WiFi 名称", + "password": "WiFi 密码(脱敏)", + "enabled": "WiFi 开关状态", + } + case "device_switch_card": + return map[string]string{ + "target_iccid": "目标ICCID", + } + case "device_switch_mode": + return map[string]string{ + "switch_mode": "切卡模式(0自动/1手动)", + } + case "device_reboot": + return map[string]string{ + "action": "设备重启动作", + } + case "device_reset": + return map[string]string{ + "action": "设备恢复出厂动作", + } + case "asset_polling_status", "card_polling_status": + return map[string]string{ + "enable_polling": "轮询开关(true启用/false禁用)", + } + case "asset_realname_policy", "card_realname_policy": + return map[string]string{ + "realname_policy": "实名策略(none/before_order/after_order)", + } + case "card_realname_status": + return map[string]string{ + "real_name_status": "实名状态(0未实名/1已实名)", + } + case "asset_deactivate", "card_delete", "card_batch_delete", "device_delete": + return map[string]string{ + "asset_status": "资产状态", + "deleted": "是否删除", + } + case "device_import_task_create", "iot_card_import_task_create": + return map[string]string{ + "batch_no": "批次号", + "file_key": "文件存储键", + "realname_policy": "实名策略", + "carrier_id": "运营商ID(仅卡导入)", + "card_category": "卡类型(仅卡导入)", + } + default: + return common + } +} + +func mergeFieldDesc(base, extra map[string]string) map[string]string { + out := make(map[string]string, len(base)+len(extra)) + for k, v := range base { + out[k] = v + } + for k, v := range extra { + out[k] = v + } + return out +} diff --git a/internal/service/asset_audit/service.go b/internal/service/asset_audit/service.go index 67605fd..4e00009 100644 --- a/internal/service/asset_audit/service.go +++ b/internal/service/asset_audit/service.go @@ -121,29 +121,37 @@ func toAssetOperationLogItem(log *model.AssetOperationLog) *dto.AssetOperationLo if log == nil { return nil } + operationBefore, operationAfter := normalizeOperationContent( + log.OperationType, + map[string]any(log.BeforeData), + map[string]any(log.AfterData), + ) 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, + 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, + OperationContentBefore: operationBefore, + OperationContentAfter: operationAfter, + OperationFieldsDesc: getOperationFieldDesc(log.OperationType), + 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 index c947eb1..e4b3b0b 100644 --- a/internal/service/device/audit.go +++ b/internal/service/device/audit.go @@ -55,12 +55,15 @@ func (s *Service) logDeviceOperation( OperationType: operationType, OperationDesc: operationDesc, ResultStatus: resultStatus, - BeforeData: beforeData, - AfterData: afterData, BatchTotal: batchTotal, SuccessCount: successCount, FailCount: failCount, } + snapshot := deviceSnapshot(device) + beforeContent := stripDeviceOperationMeta(beforeData) + afterContent := stripDeviceOperationMeta(afterData) + params.BeforeData, params.AfterData = assetAuditSvc.WrapOperationContent(beforeContent, afterContent, snapshot) + if device != nil { params.AssetID = device.ID params.AssetIdentifier = device.VirtualNo @@ -72,6 +75,23 @@ func (s *Service) logDeviceOperation( s.logDeviceAudit(ctx, params) } +func stripDeviceOperationMeta(data map[string]any) map[string]any { + if len(data) == 0 { + return nil + } + out := make(map[string]any, len(data)) + for k, v := range data { + if k == "device" || k == "card" || k == "cards" || k == "asset_snapshot" || k == "operation_content" { + continue + } + out[k] = v + } + if len(out) == 0 { + return nil + } + return out +} + func deviceSnapshot(device *model.Device) map[string]any { if device == nil { return nil diff --git a/internal/service/device_import/audit.go b/internal/service/device_import/audit.go index 54e3f9f..813279a 100644 --- a/internal/service/device_import/audit.go +++ b/internal/service/device_import/audit.go @@ -27,6 +27,7 @@ func (s *Service) logDeviceImportAudit(ctx context.Context, p assetAuditSvc.Buil if p.AssetType == "" { p.AssetType = constants.AssetTypeDevice } + p.BeforeData, p.AfterData = assetAuditSvc.WrapOperationContent(p.BeforeData, p.AfterData, nil) s.assetAudit.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p)) } diff --git a/internal/service/iot_card/audit.go b/internal/service/iot_card/audit.go index a7f4db7..8d9292f 100644 --- a/internal/service/iot_card/audit.go +++ b/internal/service/iot_card/audit.go @@ -3,8 +3,8 @@ package iot_card import ( "context" - assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" "github.com/break/junhong_cmp_fiber/internal/model" + assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" "github.com/break/junhong_cmp_fiber/pkg/constants" ) @@ -23,6 +23,7 @@ func (s *Service) logCardAudit(ctx context.Context, p assetAuditSvc.BuildLogPara if p.AssetType == "" { p.AssetType = constants.AssetTypeIotCard } + p.BeforeData, p.AfterData = normalizeCardAuditPayload(p.BeforeData, p.AfterData) s.assetAuditService.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p)) } @@ -36,23 +37,58 @@ func (s *StopResumeService) logCardAudit(ctx context.Context, p assetAuditSvc.Bu if p.AssetType == "" { p.AssetType = constants.AssetTypeIotCard } + p.BeforeData, p.AfterData = normalizeCardAuditPayload(p.BeforeData, p.AfterData) s.assetAuditService.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p)) } +func normalizeCardAuditPayload(beforeData, afterData map[string]any) (map[string]any, map[string]any) { + snapshot := map[string]any(nil) + if raw, ok := beforeData["card"]; ok { + if m, ok := raw.(map[string]any); ok { + snapshot = m + } + } + if snapshot == nil { + if raw, ok := afterData["card"]; ok { + if m, ok := raw.(map[string]any); ok { + snapshot = m + } + } + } + return assetAuditSvc.WrapOperationContent(stripCardOperationMeta(beforeData), stripCardOperationMeta(afterData), snapshot) +} + +func stripCardOperationMeta(data map[string]any) map[string]any { + if len(data) == 0 { + return nil + } + out := make(map[string]any, len(data)) + for k, v := range data { + if k == "device" || k == "card" || k == "cards" || k == "asset_snapshot" || k == "operation_content" { + continue + } + out[k] = v + } + if len(out) == 0 { + return nil + } + return out +} + func 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, + "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, + "network_status": card.NetworkStatus, + "stop_reason": card.StopReason, } } diff --git a/internal/service/iot_card_import/audit.go b/internal/service/iot_card_import/audit.go index eded342..89c7b6c 100644 --- a/internal/service/iot_card_import/audit.go +++ b/internal/service/iot_card_import/audit.go @@ -27,6 +27,7 @@ func (s *Service) logIotCardImportAudit(ctx context.Context, p assetAuditSvc.Bui if p.AssetType == "" { p.AssetType = constants.AssetTypeIotCard } + p.BeforeData, p.AfterData = assetAuditSvc.WrapOperationContent(p.BeforeData, p.AfterData, nil) s.assetAudit.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p)) }