// Package asset_audit 提供资产操作审计日志服务。 package asset_audit import ( "context" "fmt" "strings" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/logger" "go.uber.org/zap" "gorm.io/gorm" ) // AssetOperationLogStore 资产操作日志存储接口。 type AssetOperationLogStore interface { Create(ctx context.Context, log *model.AssetOperationLog) error ListByAssetPaged( ctx context.Context, assetType string, 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 db *gorm.DB } // ListByAssetParams 按资产查询日志参数。 type ListByAssetParams struct { AssetType string AssetID uint Page int PageSize int OperationType string ResultStatus string } // NewService 创建资产审计服务实例。 func NewService(store AssetOperationLogStore, db *gorm.DB) *Service { return &Service{ store: store, db: db, } } // 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 } operatorViews := s.loadOperatorViews(ctx, logs) items := make([]*dto.AssetOperationLogItem, 0, len(logs)) for _, item := range logs { items = append(items, toAssetOperationLogItem(item, operatorViews[item.ID])) } return &dto.AssetOperationLogListResponse{ Total: total, Page: page, PageSize: pageSize, Items: items, }, nil } func toAssetOperationLogItem(log *model.AssetOperationLog, operatorView operatorView) *dto.AssetOperationLogItem { 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: operatorView.TypeName, OperatorTypeCode: log.OperatorType, OperatorName: operatorView.Name, 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, } } type operatorView struct { TypeName string Name string } func (s *Service) loadOperatorViews(ctx context.Context, logs []*model.AssetOperationLog) map[uint]operatorView { result := make(map[uint]operatorView, len(logs)) if len(logs) == 0 { return result } accountIDs := make([]uint, 0) customerIDs := make([]uint, 0) accountIDSet := make(map[uint]struct{}) customerIDSet := make(map[uint]struct{}) for _, log := range logs { if log == nil { continue } typeName := operatorTypeDisplayName(log.OperatorType) result[log.ID] = operatorView{ TypeName: typeName, Name: fallbackResolvedOperatorName(log.OperatorName, typeName, log.OperatorID), } if log.OperatorID == nil { continue } switch log.OperatorType { case constants.AssetAuditOperatorTypeAdmin, constants.AssetAuditOperatorTypeAgent, constants.AssetAuditOperatorTypeEnterprise: if _, exists := accountIDSet[*log.OperatorID]; !exists { accountIDSet[*log.OperatorID] = struct{}{} accountIDs = append(accountIDs, *log.OperatorID) } case constants.AssetAuditOperatorTypePersonal: if _, exists := customerIDSet[*log.OperatorID]; !exists { customerIDSet[*log.OperatorID] = struct{}{} customerIDs = append(customerIDs, *log.OperatorID) } } } if s.db == nil { return result } accountMap, shopMap, enterpriseMap := s.loadAccountRelatedMaps(ctx, accountIDs) customerMap := s.loadPersonalCustomerMap(ctx, customerIDs) for _, log := range logs { if log == nil { continue } view := result[log.ID] if log.OperatorID == nil { result[log.ID] = finalizeOperatorView(view, log.OperatorName, log.OperatorType, log.OperatorID) continue } switch log.OperatorType { case constants.AssetAuditOperatorTypeAdmin, constants.AssetAuditOperatorTypeAgent, constants.AssetAuditOperatorTypeEnterprise: if account, ok := accountMap[*log.OperatorID]; ok { view.Name = buildAccountOperatorName(account, shopMap, enterpriseMap) } case constants.AssetAuditOperatorTypePersonal: if customer, ok := customerMap[*log.OperatorID]; ok { if nickname := strings.TrimSpace(customer.Nickname); nickname != "" { view.Name = nickname } } } result[log.ID] = finalizeOperatorView(view, log.OperatorName, log.OperatorType, log.OperatorID) } return result } func (s *Service) loadAccountRelatedMaps(ctx context.Context, accountIDs []uint) (map[uint]*model.Account, map[uint]string, map[uint]string) { accountMap := make(map[uint]*model.Account) shopMap := make(map[uint]string) enterpriseMap := make(map[uint]string) if len(accountIDs) == 0 || s.db == nil { return accountMap, shopMap, enterpriseMap } var accounts []*model.Account if err := s.db.WithContext(ctx).Unscoped().Where("id IN ?", accountIDs).Find(&accounts).Error; err != nil { return accountMap, shopMap, enterpriseMap } shopIDs := make([]uint, 0) shopIDSet := make(map[uint]struct{}) enterpriseIDs := make([]uint, 0) enterpriseIDSet := make(map[uint]struct{}) for _, account := range accounts { if account == nil { continue } accountMap[account.ID] = account if account.ShopID != nil { if _, exists := shopIDSet[*account.ShopID]; !exists { shopIDSet[*account.ShopID] = struct{}{} shopIDs = append(shopIDs, *account.ShopID) } } if account.EnterpriseID != nil { if _, exists := enterpriseIDSet[*account.EnterpriseID]; !exists { enterpriseIDSet[*account.EnterpriseID] = struct{}{} enterpriseIDs = append(enterpriseIDs, *account.EnterpriseID) } } } if len(shopIDs) > 0 { var shops []*model.Shop if err := s.db.WithContext(ctx).Unscoped().Where("id IN ?", shopIDs).Find(&shops).Error; err == nil { for _, shop := range shops { if shop == nil { continue } shopMap[shop.ID] = strings.TrimSpace(shop.ShopName) } } } if len(enterpriseIDs) > 0 { var enterprises []*model.Enterprise if err := s.db.WithContext(ctx).Unscoped().Where("id IN ?", enterpriseIDs).Find(&enterprises).Error; err == nil { for _, enterprise := range enterprises { if enterprise == nil { continue } enterpriseMap[enterprise.ID] = strings.TrimSpace(enterprise.EnterpriseName) } } } return accountMap, shopMap, enterpriseMap } func (s *Service) loadPersonalCustomerMap(ctx context.Context, customerIDs []uint) map[uint]*model.PersonalCustomer { result := make(map[uint]*model.PersonalCustomer) if len(customerIDs) == 0 || s.db == nil { return result } var customers []*model.PersonalCustomer if err := s.db.WithContext(ctx).Unscoped().Where("id IN ?", customerIDs).Find(&customers).Error; err != nil { return result } for _, customer := range customers { if customer == nil { continue } result[customer.ID] = customer } return result } func finalizeOperatorView(view operatorView, storedName string, typeCode string, operatorID *uint) operatorView { if strings.TrimSpace(view.TypeName) == "" { view.TypeName = operatorTypeDisplayName(typeCode) } view.Name = resolveOperatorName(view.Name, storedName, view.TypeName, operatorID) return view } func operatorTypeDisplayName(typeCode string) string { switch strings.TrimSpace(typeCode) { case constants.AssetAuditOperatorTypeSystem: return "系统" case constants.AssetAuditOperatorTypeAdmin: return "平台用户" case constants.AssetAuditOperatorTypeAgent: return "代理账号" case constants.AssetAuditOperatorTypeEnterprise: return "企业账号" case constants.AssetAuditOperatorTypePersonal: return "个人客户" default: if strings.TrimSpace(typeCode) == "" { return "未知" } return strings.TrimSpace(typeCode) } } func fallbackResolvedOperatorName(storedName string, typeName string, operatorID *uint) string { storedName = strings.TrimSpace(storedName) if storedName == "" || isPlaceholderOperatorName(storedName) { return typeNameWithOptionalID(typeName, operatorID) } return storedName } func resolveOperatorName(resolvedName string, storedName string, typeName string, operatorID *uint) string { if strings.TrimSpace(resolvedName) != "" && !isPlaceholderOperatorName(resolvedName) { return strings.TrimSpace(resolvedName) } return fallbackResolvedOperatorName(storedName, typeName, operatorID) } func isPlaceholderOperatorName(name string) bool { name = strings.TrimSpace(name) if name == "" { return true } for _, prefix := range []string{"后台用户#", "代理用户#", "企业用户#", "个人客户#", "用户#"} { if strings.HasPrefix(name, prefix) { return true } } return false } func typeNameWithOptionalID(typeName string, operatorID *uint) string { if operatorID == nil || *operatorID == 0 { return typeName } return fmt.Sprintf("%s(ID:%d)", typeName, *operatorID) } func buildAccountOperatorName(account *model.Account, shopMap map[uint]string, enterpriseMap map[uint]string) string { if account == nil { return "" } username := strings.TrimSpace(account.Username) switch account.UserType { case constants.UserTypeAgent: return composeReadableName(lookupNameByPtr(account.ShopID, shopMap), username) case constants.UserTypeEnterprise: return composeReadableName(lookupNameByPtr(account.EnterpriseID, enterpriseMap), username) default: return username } } func lookupNameByPtr(id *uint, nameMap map[uint]string) string { if id == nil { return "" } return strings.TrimSpace(nameMap[*id]) } func composeReadableName(primary string, secondary string) string { primary = strings.TrimSpace(primary) secondary = strings.TrimSpace(secondary) switch { case primary != "" && secondary != "" && primary != secondary: return fmt.Sprintf("%s(%s)", primary, secondary) case primary != "": return primary default: return secondary } }