更新
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m18s

This commit is contained in:
2026-04-27 14:52:17 +08:00
parent cb75b5668b
commit 66cec7515a
7 changed files with 458 additions and 35 deletions

View File

@@ -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:"变更后数据"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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