All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m48s
435 lines
12 KiB
Go
435 lines
12 KiB
Go
// 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
|
|
}
|
|
}
|