操作日志
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m10s

This commit is contained in:
2026-04-27 12:16:38 +08:00
parent 95104100c9
commit cb75b5668b
33 changed files with 3860 additions and 112 deletions

View File

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

View File

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