From 1294974010b52bad7a69401c4d63ba1b778e80ba Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Wed, 12 Aug 2026 05:12:09 +0000 Subject: [PATCH] feat: add user activity statistics collection and query APIs Introduce the user activity report skeleton: request path classification into business modules, the query API contract and controller, the retention system variable, and the cron task wiring. The collection and aggregation behaviour itself is provided by the enterprise build; the community build keeps no-op stubs so the endpoints report the feature as unavailable. --- api/dms/service/v1/user_activity.go | 125 +++++++++ .../apiserver/middleware/user_activity_ce.go | 14 + .../middleware/user_activity_path.go | 255 ++++++++++++++++++ .../middleware/user_activity_path_test.go | 67 +++++ .../service/dms_controller_user_activity.go | 199 ++++++++++++++ internal/apiserver/service/router.go | 9 + internal/dms/biz/cron_task.go | 8 +- internal/dms/biz/cron_task_ce.go | 7 + internal/dms/biz/system_variable.go | 2 + internal/dms/biz/user_activity.go | 192 +++++++++++++ internal/dms/biz/user_activity_ce.go | 38 +++ internal/dms/biz/user_activity_test.go | 48 ++++ internal/dms/service/configuration.go | 13 + internal/dms/service/service.go | 6 +- internal/dms/service/user_activity_ce.go | 36 +++ internal/dms/storage/system_variable.go | 7 + internal/dms/storage/user_activity_ce.go | 55 ++++ pkg/dms-common/api/dms/v1/system_variable.go | 2 + 18 files changed, 1081 insertions(+), 2 deletions(-) create mode 100644 api/dms/service/v1/user_activity.go create mode 100644 internal/apiserver/middleware/user_activity_ce.go create mode 100644 internal/apiserver/middleware/user_activity_path.go create mode 100644 internal/apiserver/middleware/user_activity_path_test.go create mode 100644 internal/apiserver/service/dms_controller_user_activity.go create mode 100644 internal/dms/biz/cron_task_ce.go create mode 100644 internal/dms/biz/user_activity.go create mode 100644 internal/dms/biz/user_activity_ce.go create mode 100644 internal/dms/biz/user_activity_test.go create mode 100644 internal/dms/service/user_activity_ce.go create mode 100644 internal/dms/storage/user_activity_ce.go diff --git a/api/dms/service/v1/user_activity.go b/api/dms/service/v1/user_activity.go new file mode 100644 index 000000000..062699e79 --- /dev/null +++ b/api/dms/service/v1/user_activity.go @@ -0,0 +1,125 @@ +package v1 + +import ( + "time" + + base "github.com/actiontech/dms/pkg/dms-common/api/base/v1" +) + +// swagger:parameters GetUserActivitySummary +type GetUserActivitySummaryReq struct { + // in:query + // Required: true + StatDate string `json:"stat_date" query:"stat_date" validate:"required"` +} + +// swagger:model GetUserActivitySummaryReply +type GetUserActivitySummaryReply struct { + Data *UserActivitySummary `json:"data"` + base.GenericResp +} + +type UserActivitySummary struct { + DAU int `json:"dau"` + RequestCount int `json:"request_count"` + AvgRequestPerUser float64 `json:"avg_request_per_user"` + ErrorCount int `json:"error_count"` + ErrorRate float64 `json:"error_rate"` + PeakHour int `json:"peak_hour"` + PeakHourRequests int `json:"peak_hour_requests"` +} + +// swagger:parameters ListUserActivityDailyTrend +type ListUserActivityDailyTrendReq struct { + // in:query + // Required: true + FilterDateFrom string `json:"filter_date_from" query:"filter_date_from" validate:"required"` + // in:query + // Required: true + FilterDateTo string `json:"filter_date_to" query:"filter_date_to" validate:"required"` +} + +// swagger:model ListUserActivityDailyTrendReply +type ListUserActivityDailyTrendReply struct { + Data []UserActivityDailyTrendItem `json:"data"` + base.GenericResp +} + +type UserActivityDailyTrendItem struct { + StatDate string `json:"stat_date"` + DAU int `json:"dau"` + RequestCount int `json:"request_count"` + ErrorCount int `json:"error_count"` +} + +// swagger:parameters ListUserActivityModuleDistribution +type ListUserActivityModuleDistributionReq struct { + // in:query + // Required: true + StatDate string `json:"stat_date" query:"stat_date" validate:"required"` +} + +// swagger:model ListUserActivityModuleDistributionReply +type ListUserActivityModuleDistributionReply struct { + Data []UserActivityModuleDistributionItem `json:"data"` + base.GenericResp +} + +type UserActivityModuleDistributionItem struct { + ModuleCode string `json:"module_code"` + ModuleName string `json:"module_name"` + RequestCount int `json:"request_count"` + Percent float64 `json:"percent"` +} + +// swagger:parameters ListUserActivityHourlyDistribution +type ListUserActivityHourlyDistributionReq struct { + // in:query + // Required: true + StatDate string `json:"stat_date" query:"stat_date" validate:"required"` +} + +// swagger:model ListUserActivityHourlyDistributionReply +type ListUserActivityHourlyDistributionReply struct { + Data []UserActivityHourlyDistributionItem `json:"data"` + base.GenericResp +} + +type UserActivityHourlyDistributionItem struct { + StatHour int `json:"stat_hour"` + RequestCount int `json:"request_count"` + ActiveUsers int `json:"active_users"` +} + +// swagger:parameters ListUserActivityUsers +type ListUserActivityUsersReq struct { + // in:query + // Required: true + FilterDateFrom string `json:"filter_date_from" query:"filter_date_from" validate:"required"` + // in:query + // Required: true + FilterDateTo string `json:"filter_date_to" query:"filter_date_to" validate:"required"` + // in:query + // Required: true + PageIndex uint32 `json:"page_index" query:"page_index" validate:"required"` + // in:query + // Required: true + PageSize uint32 `json:"page_size" query:"page_size" validate:"required"` +} + +// swagger:model ListUserActivityUsersReply +type ListUserActivityUsersReply struct { + Data []UserActivityUserItem `json:"data"` + TotalNums uint64 `json:"total_nums"` + base.GenericResp +} + +type UserActivityUserItem struct { + UserUID string `json:"user_uid"` + UserName string `json:"user_name"` + ActiveDays int `json:"active_days"` + RequestCount int `json:"request_count"` + TopModuleCode string `json:"top_module_code"` + TopModuleName string `json:"top_module_name"` + LastActiveAt *time.Time `json:"last_active_at"` +} diff --git a/internal/apiserver/middleware/user_activity_ce.go b/internal/apiserver/middleware/user_activity_ce.go new file mode 100644 index 000000000..3584e753f --- /dev/null +++ b/internal/apiserver/middleware/user_activity_ce.go @@ -0,0 +1,14 @@ +//go:build !enterprise + +package middleware + +import ( + "github.com/actiontech/dms/internal/dms/service" + "github.com/labstack/echo/v4" +) + +func UserActivityMiddleware(_ *service.DMSService) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return next + } +} diff --git a/internal/apiserver/middleware/user_activity_path.go b/internal/apiserver/middleware/user_activity_path.go new file mode 100644 index 000000000..e4cd50313 --- /dev/null +++ b/internal/apiserver/middleware/user_activity_path.go @@ -0,0 +1,255 @@ +package middleware + +import ( + "net/http" + "regexp" + "strings" + + "github.com/actiontech/dms/internal/dms/pkg/constant" + "github.com/actiontech/dms/pkg/dms-common/api/jwt" + "github.com/labstack/echo/v4" +) + +const maxUserAgentLen = 255 + +var ( + snowflakeSegmentPattern = regexp.MustCompile(`^[0-9]{15,20}$`) + numericSegmentPattern = regexp.MustCompile(`^[0-9]+$`) +) + +type modulePrefixRule struct { + ModuleCode string + Prefixes []string +} + +var defaultModulePrefixRules = []modulePrefixRule{ + {ModuleCode: "AUTH", Prefixes: []string{"/v1/dms/sessions", "/v1/dms/oauth2/", "/v1/dms/users/verify_user_login", "/v1/dms/configurations/login"}}, + {ModuleCode: "USER_ROLE", Prefixes: []string{"/v1/dms/users", "/v1/dms/user_groups", "/v1/dms/roles", "/v1/dms/op_permissions", "/sqle/v1/user_tips", "/sqle/v2/user_tips", "/sqle/v3/user_tips"}}, + {ModuleCode: "PROJECT", Prefixes: []string{"/v1/dms/projects", "/v2/dms/projects", "/sqle/v1/projects/", "/sqle/v2/projects/", "/sqle/v3/projects/"}}, + {ModuleCode: "DB_SERVICE", Prefixes: []string{"/v1/dms/db_services", "/v2/dms/db_services", "/v1/dms/projects/", "/v2/dms/projects/", "/v1/dms/db_service_sync_tasks", "/sqle/v1/projects/", "/sqle/v2/projects/", "/sqle/v3/projects/"}}, + {ModuleCode: "WORKFLOW", Prefixes: []string{"/sqle/v1/projects/", "/sqle/v2/projects/", "/sqle/v3/projects/", "/sqle/v1/dashboard/workflows", "/sqle/v2/dashboard/workflows", "/sqle/v1/workflows/"}}, + {ModuleCode: "DATA_EXPORT", Prefixes: []string{"/v1/dms/projects/", "/v1/dms/dashboard/data_export_workflows", "/sqle/v1/dashboard/data_export_workflows", "/sqle/v2/dashboard/data_export_workflows"}}, + {ModuleCode: "SQL_AUDIT", Prefixes: []string{"/sqle/v1/sql_audit", "/sqle/v2/sql_audit", "/sqle/v1/audit_files", "/sqle/v2/audit_files", "/sqle/v1/sql_analysis", "/sqle/v1/sql_lineage_analysis", "/sqle/v1/tasks/audits/", "/sqle/v2/tasks/audits/", "/sqle/v1/task_groups/audit"}}, + {ModuleCode: "RULE", Prefixes: []string{"/sqle/v1/rule_templates", "/sqle/v2/rule_templates", "/sqle/v1/custom_rules", "/sqle/v1/rules", "/sqle/v1/rule_knowledge", "/sqle/v1/knowledge_bases", "/sqle/v1/rule_template_tips"}}, + {ModuleCode: "AUDIT_PLAN", Prefixes: []string{"/sqle/v1/audit_plan_metas", "/sqle/v1/audit_plan_types", "/sqle/v1/projects/", "/sqle/v2/projects/", "/sqle/v3/projects/"}}, + {ModuleCode: "SQL_MANAGE", Prefixes: []string{"/sqle/v1/dashboard/sql_manage", "/sqle/v2/dashboard/sql_manage", "/sqle/v1/dashboard/sql_manages", "/sqle/v2/dashboard/sql_manages"}}, + {ModuleCode: "SQL_OPTIMIZE", Prefixes: []string{"/sqle/v1/projects/", "/sqle/v2/projects/", "/sqle/v3/projects/"}}, + {ModuleCode: "SQL_VERSION", Prefixes: []string{"/sqle/v1/projects/", "/sqle/v2/projects/", "/sqle/v3/projects/"}}, + {ModuleCode: "WORKBENCH", Prefixes: []string{"/sql_query/", "/odc_query/", "/v1/dms/configurations/sql_query", "/v1/dms/projects/"}}, + {ModuleCode: "SYS_CONFIG", Prefixes: []string{"/v1/dms/configurations/", "/sqle/v1/configurations/", "/sqle/v2/configurations/", "/v1/dms/company_notice", "/sqle/v1/company_notice", "/v1/dms/notifications", "/v1/dms/webhooks", "/v1/dms/personalization", "/v1/dms/masking/", "/v1/dms/gateways", "/sqle/v1/system/", "/sqle/v2/system/"}}, + {ModuleCode: "DASHBOARD", Prefixes: []string{"/sqle/v1/dashboard", "/sqle/v2/dashboard", "/sqle/v1/statistic/", "/v1/dms/resource_overview/", "/sqle/v1/ai_hub/"}}, + {ModuleCode: "AUDIT_LOG", Prefixes: []string{"/v1/dms/operation_records"}}, + {ModuleCode: "PROVISION", Prefixes: []string{"/provision/v1/auth/", "/provision/v1/plugin/"}}, + {ModuleCode: "INTERNAL", Prefixes: []string{"/v1/dms/proxys", "/v1/dms/plugins", "/sqle/v1/internal/", "/v1/internal/"}}, +} + +var staticAssetExtensions = map[string]struct{}{ + ".js": {}, ".mjs": {}, ".css": {}, ".map": {}, + ".png": {}, ".jpg": {}, ".jpeg": {}, ".gif": {}, ".webp": {}, ".svg": {}, ".ico": {}, + ".woff": {}, ".woff2": {}, ".ttf": {}, ".eot": {}, + ".html": {}, ".htm": {}, ".txt": {}, ".xml": {}, +} + +var userActivityAPITrackPrefixes = []string{ + "/v1/", "/sqle/", "/sql_query/", "/odc_query/", "/provision/", +} + +func shouldSkipUserActivityPath(path string) bool { + if path == "" || path == "/" { + return true + } + skipPrefixes := []string{ + "/logo", + "/static/", + "/assets/", + "/fonts/", + "/favicon", + "/manifest", + "/swagger", + "/v1/dms/sessions/refresh", + "/v1/dms/basic_info", + "/v1/dms/personalization/", + "/v1/dms/statistic/user_activity/", + } + for _, prefix := range skipPrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + if strings.Contains(path, "/swagger") { + return true + } + if hasStaticAssetExtension(path) { + return true + } + return false +} + +func hasStaticAssetExtension(path string) bool { + if idx := strings.LastIndex(path, "."); idx >= 0 { + if _, ok := staticAssetExtensions[strings.ToLower(path[idx:])]; ok { + return true + } + } + return false +} + +func isTrackableUserActivityPath(path string) bool { + if shouldSkipUserActivityPath(path) { + return false + } + for _, prefix := range userActivityAPITrackPrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +func extractTokenFromRequest(c echo.Context) string { + auth := c.Request().Header.Get(echo.HeaderAuthorization) + if auth != "" { + auth = strings.TrimSpace(auth) + if strings.HasPrefix(strings.ToLower(auth), "bearer ") { + return strings.TrimSpace(auth[7:]) + } + return auth + } + if cookie, err := c.Request().Cookie("dms-token"); err == nil && cookie.Value != "" { + return cookie.Value + } + return "" +} + +func parseUserUIDFromRequest(c echo.Context) (string, bool) { + tokenStr := extractTokenFromRequest(c) + if tokenStr == "" { + return "", false + } + uid, err := jwt.ParseUidFromJwtTokenStr(tokenStr) + if err != nil || uid == "" { + return "", false + } + if uid == constant.UIDOfUserSys { + return "", false + } + return uid, true +} + +func normalizeRequestPath(path string) string { + if path == "" { + return path + } + if idx := strings.Index(path, "?"); idx >= 0 { + path = path[:idx] + } + segments := strings.Split(strings.Trim(path, "/"), "/") + for i, segment := range segments { + if segment == "" { + continue + } + switch { + case snowflakeSegmentPattern.MatchString(segment): + segments[i] = ":uid" + case numericSegmentPattern.MatchString(segment): + segments[i] = ":id" + default: + segments[i] = segment + } + } + return "/" + strings.Join(segments, "/") +} + +func resolveModuleCode(normalizedPath string) string { + normalizedPath = strings.ToLower(normalizedPath) + if strings.Contains(normalizedPath, "/workflows") || + strings.Contains(normalizedPath, "/workflow_template") { + return "WORKFLOW" + } + if strings.Contains(normalizedPath, "/sql_audit_records") || + strings.HasSuffix(normalizedPath, "/sql_audit") || + strings.Contains(normalizedPath, "/audit_files") || + strings.Contains(normalizedPath, "/sql_analysis") || + strings.Contains(normalizedPath, "/tasks/audits/") { + return "SQL_AUDIT" + } + if strings.Contains(normalizedPath, "/audit_plans") || + strings.Contains(normalizedPath, "/instance_audit_plans") || + strings.Contains(normalizedPath, "/audit_plan_metas") || + strings.Contains(normalizedPath, "/audit_plan_types") { + return "AUDIT_PLAN" + } + if strings.Contains(normalizedPath, "/sql_manages") { + return "SQL_MANAGE" + } + if strings.Contains(normalizedPath, "/sql_optimization_records") { + return "SQL_OPTIMIZE" + } + if strings.Contains(normalizedPath, "/sql_versions") || + strings.Contains(normalizedPath, "/pipelines") || + strings.Contains(normalizedPath, "/database_comparison") { + return "SQL_VERSION" + } + if strings.Contains(normalizedPath, "/data_export_workflows") || + strings.Contains(normalizedPath, "/data_export_tasks") { + return "DATA_EXPORT" + } + if strings.Contains(normalizedPath, "/cb_operation_logs") || + strings.HasPrefix(normalizedPath, "/sql_query/") || + strings.HasPrefix(normalizedPath, "/odc_query/") { + return "WORKBENCH" + } + if strings.Contains(normalizedPath, "/db_services") || + strings.Contains(normalizedPath, "/instances/") || + strings.Contains(normalizedPath, "/environment_tags") || + strings.Contains(normalizedPath, "/db_service_sync_tasks") { + return "DB_SERVICE" + } + if strings.Contains(normalizedPath, "/members") || + strings.Contains(normalizedPath, "/member_groups") || + strings.Contains(normalizedPath, "/business_tags") || + (strings.Contains(normalizedPath, "/projects/") && strings.Contains(normalizedPath, "/statistic")) { + return "PROJECT" + } + if strings.Contains(normalizedPath, "/rule_templates") || + strings.Contains(normalizedPath, "/custom_rules") || + strings.Contains(normalizedPath, "/rule_knowledge") || + strings.Contains(normalizedPath, "/knowledge_bases") { + return "RULE" + } + + for _, rule := range defaultModulePrefixRules { + for _, prefix := range rule.Prefixes { + if strings.HasPrefix(normalizedPath, strings.ToLower(prefix)) { + return rule.ModuleCode + } + } + } + return "OTHER" +} + +func extractProjectUIDFromPath(path string) string { + segments := strings.Split(strings.Trim(path, "/"), "/") + for i := 0; i < len(segments)-1; i++ { + if segments[i] == "projects" && snowflakeSegmentPattern.MatchString(segments[i+1]) { + return segments[i+1] + } + } + return "" +} + +func truncateUserAgent(ua string) string { + if len(ua) <= maxUserAgentLen { + return ua + } + return ua[:maxUserAgentLen] +} + +func isTrackableMethod(method string) bool { + switch method { + case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodHead: + return true + default: + return false + } +} diff --git a/internal/apiserver/middleware/user_activity_path_test.go b/internal/apiserver/middleware/user_activity_path_test.go new file mode 100644 index 000000000..fc13cb669 --- /dev/null +++ b/internal/apiserver/middleware/user_activity_path_test.go @@ -0,0 +1,67 @@ +package middleware + +import "testing" + +func TestNormalizeRequestPath(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"/v1/dms/projects/1746123456789012345/db_services", "/v1/dms/projects/:uid/db_services"}, + {"/sqle/v2/projects/demo/workflows/123/tasks/456", "/sqle/v2/projects/demo/workflows/:id/tasks/:id"}, + {"/sql_query/api/gql?foo=bar", "/sql_query/api/gql"}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + got := normalizeRequestPath(tt.in) + if got != tt.want { + t.Fatalf("normalizeRequestPath(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestIsTrackableUserActivityPath(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {"/assets/index-abc123.js", false}, + {"/static/logo.png", false}, + {"/favicon.ico", false}, + {"/user-activity", false}, + {"/v1/dms/personalization/logo", false}, + {"/v1/dms/statistic/user_activity/summary", false}, + {"/v1/dms/projects", true}, + {"/sqle/v1/projects/demo/workflows", true}, + {"/sql_query/api/gql", true}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := isTrackableUserActivityPath(tt.path) + if got != tt.want { + t.Fatalf("isTrackableUserActivityPath(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} + +func TestResolveModuleCode(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"/sqle/v2/projects/:name/workflows", "WORKFLOW"}, + {"/sql_query/api/gql", "WORKBENCH"}, + {"/v1/dms/operation_records", "AUDIT_LOG"}, + {"/unknown/path", "OTHER"}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := resolveModuleCode(tt.path) + if got != tt.want { + t.Fatalf("resolveModuleCode(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} diff --git a/internal/apiserver/service/dms_controller_user_activity.go b/internal/apiserver/service/dms_controller_user_activity.go new file mode 100644 index 000000000..6cdfa9a6b --- /dev/null +++ b/internal/apiserver/service/dms_controller_user_activity.go @@ -0,0 +1,199 @@ +package service + +import ( + aV1 "github.com/actiontech/dms/api/dms/service/v1" + apiError "github.com/actiontech/dms/internal/apiserver/pkg/error" + "github.com/actiontech/dms/pkg/dms-common/api/jwt" + "github.com/labstack/echo/v4" +) + +// swagger:operation GET /v1/dms/statistic/user_activity/summary UserActivity GetUserActivitySummary +// +// Get user activity summary KPI for a date. +// +// --- +// parameters: +// - name: stat_date +// in: query +// required: true +// type: string +// responses: +// '200': +// description: GetUserActivitySummaryReply +// schema: +// "$ref": "#/definitions/GetUserActivitySummaryReply" +// default: +// description: GenericResp +// schema: +// "$ref": "#/definitions/GenericResp" +func (ctl *DMSController) GetUserActivitySummary(c echo.Context) error { + req := new(aV1.GetUserActivitySummaryReq) + if err := bindAndValidateReq(c, req); err != nil { + return NewErrResp(c, err, apiError.BadRequestErr) + } + currentUserUid, err := jwt.GetUserUidStrFromContext(c) + if err != nil { + return NewErrResp(c, err, apiError.DMSServiceErr) + } + reply, err := ctl.DMS.GetUserActivitySummary(c.Request().Context(), req, currentUserUid) + if err != nil { + return NewErrResp(c, err, apiError.DMSServiceErr) + } + return NewOkRespWithReply(c, reply) +} + +// swagger:operation GET /v1/dms/statistic/user_activity/daily_trend UserActivity ListUserActivityDailyTrend +// +// List user activity daily trend. +// +// --- +// parameters: +// - name: filter_date_from +// in: query +// required: true +// type: string +// - name: filter_date_to +// in: query +// required: true +// type: string +// responses: +// '200': +// description: ListUserActivityDailyTrendReply +// schema: +// "$ref": "#/definitions/ListUserActivityDailyTrendReply" +// default: +// description: GenericResp +// schema: +// "$ref": "#/definitions/GenericResp" +func (ctl *DMSController) ListUserActivityDailyTrend(c echo.Context) error { + req := new(aV1.ListUserActivityDailyTrendReq) + if err := bindAndValidateReq(c, req); err != nil { + return NewErrResp(c, err, apiError.BadRequestErr) + } + currentUserUid, err := jwt.GetUserUidStrFromContext(c) + if err != nil { + return NewErrResp(c, err, apiError.DMSServiceErr) + } + reply, err := ctl.DMS.ListUserActivityDailyTrend(c.Request().Context(), req, currentUserUid) + if err != nil { + return NewErrResp(c, err, apiError.DMSServiceErr) + } + return NewOkRespWithReply(c, reply) +} + +// swagger:operation GET /v1/dms/statistic/user_activity/module_distribution UserActivity ListUserActivityModuleDistribution +// +// List user activity module distribution for a date. +// +// --- +// parameters: +// - name: stat_date +// in: query +// required: true +// type: string +// responses: +// '200': +// description: ListUserActivityModuleDistributionReply +// schema: +// "$ref": "#/definitions/ListUserActivityModuleDistributionReply" +// default: +// description: GenericResp +// schema: +// "$ref": "#/definitions/GenericResp" +func (ctl *DMSController) ListUserActivityModuleDistribution(c echo.Context) error { + req := new(aV1.ListUserActivityModuleDistributionReq) + if err := bindAndValidateReq(c, req); err != nil { + return NewErrResp(c, err, apiError.BadRequestErr) + } + currentUserUid, err := jwt.GetUserUidStrFromContext(c) + if err != nil { + return NewErrResp(c, err, apiError.DMSServiceErr) + } + reply, err := ctl.DMS.ListUserActivityModuleDistribution(c.Request().Context(), req, currentUserUid) + if err != nil { + return NewErrResp(c, err, apiError.DMSServiceErr) + } + return NewOkRespWithReply(c, reply) +} + +// swagger:operation GET /v1/dms/statistic/user_activity/hourly_distribution UserActivity ListUserActivityHourlyDistribution +// +// List user activity hourly distribution for a date. +// +// --- +// parameters: +// - name: stat_date +// in: query +// required: true +// type: string +// responses: +// '200': +// description: ListUserActivityHourlyDistributionReply +// schema: +// "$ref": "#/definitions/ListUserActivityHourlyDistributionReply" +// default: +// description: GenericResp +// schema: +// "$ref": "#/definitions/GenericResp" +func (ctl *DMSController) ListUserActivityHourlyDistribution(c echo.Context) error { + req := new(aV1.ListUserActivityHourlyDistributionReq) + if err := bindAndValidateReq(c, req); err != nil { + return NewErrResp(c, err, apiError.BadRequestErr) + } + currentUserUid, err := jwt.GetUserUidStrFromContext(c) + if err != nil { + return NewErrResp(c, err, apiError.DMSServiceErr) + } + reply, err := ctl.DMS.ListUserActivityHourlyDistribution(c.Request().Context(), req, currentUserUid) + if err != nil { + return NewErrResp(c, err, apiError.DMSServiceErr) + } + return NewOkRespWithReply(c, reply) +} + +// swagger:operation GET /v1/dms/statistic/user_activity/users UserActivity ListUserActivityUsers +// +// List user activity ranking/details. +// +// --- +// parameters: +// - name: filter_date_from +// in: query +// required: true +// type: string +// - name: filter_date_to +// in: query +// required: true +// type: string +// - name: page_index +// in: query +// required: true +// type: integer +// - name: page_size +// in: query +// required: true +// type: integer +// responses: +// '200': +// description: ListUserActivityUsersReply +// schema: +// "$ref": "#/definitions/ListUserActivityUsersReply" +// default: +// description: GenericResp +// schema: +// "$ref": "#/definitions/GenericResp" +func (ctl *DMSController) ListUserActivityUsers(c echo.Context) error { + req := new(aV1.ListUserActivityUsersReq) + if err := bindAndValidateReq(c, req); err != nil { + return NewErrResp(c, err, apiError.BadRequestErr) + } + currentUserUid, err := jwt.GetUserUidStrFromContext(c) + if err != nil { + return NewErrResp(c, err, apiError.DMSServiceErr) + } + reply, err := ctl.DMS.ListUserActivityUsers(c.Request().Context(), req, currentUserUid) + if err != nil { + return NewErrResp(c, err, apiError.DMSServiceErr) + } + return NewOkRespWithReply(c, reply) +} diff --git a/internal/apiserver/service/router.go b/internal/apiserver/service/router.go index e1b1573ff..61dc0e38f 100644 --- a/internal/apiserver/service/router.go +++ b/internal/apiserver/service/router.go @@ -165,6 +165,13 @@ func (s *APIServer) initRouter() error { resourceOverviewV1.GET("/resource_list", s.DMSController.GetResourceOverviewResourceList) resourceOverviewV1.GET("/download", s.DMSController.DownloadResourceOverviewList) + userActivityV1 := v1.Group("/dms/statistic/user_activity") + userActivityV1.GET("/summary", s.DMSController.GetUserActivitySummary) + userActivityV1.GET("/daily_trend", s.DMSController.ListUserActivityDailyTrend) + userActivityV1.GET("/module_distribution", s.DMSController.ListUserActivityModuleDistribution) + userActivityV1.GET("/hourly_distribution", s.DMSController.ListUserActivityHourlyDistribution) + userActivityV1.GET("/users", s.DMSController.ListUserActivityUsers) + // oauth2 interface does not require login authentication oauth2V1 := v1.Group("/dms/oauth2") oauth2V1.GET("/tips", s.DMSController.GetOauth2Tips) @@ -483,6 +490,8 @@ func (s *APIServer) installMiddleware() error { s.echo.Use(s.DMSController.DMS.AuthAccessTokenUseCase.CheckLatestAccessToken()) + s.echo.Use(dmsMiddleware.UserActivityMiddleware(s.DMSController.DMS)) + s.echo.Use(middleware.ProxyWithConfig(middleware.ProxyConfig{ Skipper: s.DMSController.DMS.DmsProxyUsecase.GetEchoProxySkipper(), Balancer: s.DMSController.DMS.DmsProxyUsecase.GetEchoProxyBalancer(), diff --git a/internal/dms/biz/cron_task.go b/internal/dms/biz/cron_task.go index 0217cb704..9d6d0a83c 100644 --- a/internal/dms/biz/cron_task.go +++ b/internal/dms/biz/cron_task.go @@ -11,6 +11,7 @@ type CronTaskUsecase struct { workflowUsecase *DataExportWorkflowUsecase cbOperationLogUsecase *CbOperationLogUsecase operationRecordUsecase *OperationRecordUsecase + userActivityUsecase *UserActivityUsecase licenseUsecase *LicenseUsecase oauth2SessionUsecase *OAuth2SessionUsecase } @@ -18,13 +19,14 @@ type cronTask struct { cron *cron.Cron } -func NewCronTaskUsecase(log utilLog.Logger, wu *DataExportWorkflowUsecase, cu *CbOperationLogUsecase, oru *OperationRecordUsecase, os *OAuth2SessionUsecase) *CronTaskUsecase { +func NewCronTaskUsecase(log utilLog.Logger, wu *DataExportWorkflowUsecase, cu *CbOperationLogUsecase, oru *OperationRecordUsecase, uau *UserActivityUsecase, os *OAuth2SessionUsecase) *CronTaskUsecase { ctu := &CronTaskUsecase{ log: utilLog.NewHelper(log, utilLog.WithMessageKey("biz.cronTask")), cronTask: &cronTask{cron: cron.New()}, workflowUsecase: wu, cbOperationLogUsecase: cu, operationRecordUsecase: oru, + userActivityUsecase: uau, oauth2SessionUsecase: os, } return ctu @@ -55,6 +57,10 @@ func (ctu *CronTaskUsecase) InitialTask() error { return err } + if err := ctu.registerUserActivityCronTasks(); err != nil { + return err + } + ctu.cronTask.cron.Start() return nil } diff --git a/internal/dms/biz/cron_task_ce.go b/internal/dms/biz/cron_task_ce.go new file mode 100644 index 000000000..5461be603 --- /dev/null +++ b/internal/dms/biz/cron_task_ce.go @@ -0,0 +1,7 @@ +//go:build !enterprise + +package biz + +func (ctu *CronTaskUsecase) registerUserActivityCronTasks() error { + return nil +} diff --git a/internal/dms/biz/system_variable.go b/internal/dms/biz/system_variable.go index bcc315c27..ae16f470b 100644 --- a/internal/dms/biz/system_variable.go +++ b/internal/dms/biz/system_variable.go @@ -13,6 +13,7 @@ const ( SystemVariableOperationRecordExpiredHours = "system_variable_operation_record_expired_hours" SystemVariableCbOperationLogsExpiredHours = "system_variable_cb_operation_logs_expired_hours" SystemVariableSSHPrimaryKey = "system_variable_ssh_primary_key" + SystemVariableUserRequestLogExpiredHours = "system_variable_user_request_log_expired_hours" ) const ( @@ -20,6 +21,7 @@ const ( DefaultCbOperationLogsExpiredHours = 90 * 24 DefaultSystemVariableWorkflowExpiredHours = 30 * 24 DefaultSystemVariableSqlManageRawExpiredHours= 30 * 24 + DefaultUserRequestLogExpiredHours = 90 * 24 ) // SystemVariable 系统变量业务模型 diff --git a/internal/dms/biz/user_activity.go b/internal/dms/biz/user_activity.go new file mode 100644 index 000000000..54fd0aa2c --- /dev/null +++ b/internal/dms/biz/user_activity.go @@ -0,0 +1,192 @@ +package biz + +import ( + "context" + "strconv" + "time" + + pkgConst "github.com/actiontech/dms/internal/dms/pkg/constant" + utilLog "github.com/actiontech/dms/pkg/dms-common/pkg/log" +) + +const ( + userActivitySessionGapMinutes = 30 +) + +// AmbientModuleCodesForTopModule are session/bootstrap modules excluded when +// computing top_module_code for user ranking. Total request counts still include them. +var AmbientModuleCodesForTopModule = []string{"AUTH", "USER_ROLE", "SYS_CONFIG"} + +// ExcludedUserActivityUIDs returns built-in/service accounts excluded from statistics. +func ExcludedUserActivityUIDs() []string { + return []string{pkgConst.UIDOfUserSys} +} + +func IsExcludedUserActivityUID(uid string) bool { + return uid == pkgConst.UIDOfUserSys +} + +type UserRequestLog struct { + ID uint64 + EventTime time.Time + UserUID string + HTTPMethod string + NormalizedRoute string + ModuleCode string + StatusCode int + LatencyMs int + ClientIP string + UserAgent string + ProjectUID string + Node string +} + +type UserDailyActiveStat struct { + StatDate string + UserUID string + UserName string + ActiveDays int + RequestCount int + ErrorCount int + FirstActiveAt time.Time + LastActiveAt time.Time + ActiveMinutes int + TopModuleCode string +} + +type UserModuleDailyStat struct { + StatDate string + ModuleCode string + ModuleName string + RequestCount int +} + +type ActiveHourlyStat struct { + StatDate string + StatHour int + RequestCount int + ActiveUsers int +} + +type UserActivityDailyTrendItem struct { + StatDate string + DAU int + RequestCount int + ErrorCount int +} + +type UserActivitySummary struct { + DAU int + RequestCount int + AvgRequestPerUser float64 + ErrorCount int + ErrorRate float64 + PeakHour int + PeakHourRequests int +} + +type ListUserActivityUsersOption struct { + PageIndex uint32 + PageSize uint32 + FilterDateFrom string + FilterDateTo string + FilterFuzzyUser string + OrderBy string +} + +type UserActivityRepo interface { + BatchSaveUserRequestLogs(ctx context.Context, logs []*UserRequestLog) error + CleanUserRequestLogsBefore(ctx context.Context, t time.Time) (rowsAffected int64, err error) + RollupDailyStats(ctx context.Context, statDate string) error + GetUserActivitySummary(ctx context.Context, statDate string) (*UserActivitySummary, error) + ListDailyTrend(ctx context.Context, dateFrom, dateTo string) ([]*UserActivityDailyTrendItem, error) + ListModuleDistribution(ctx context.Context, statDate string) ([]*UserModuleDailyStat, error) + ListHourlyDistribution(ctx context.Context, statDate string) ([]*ActiveHourlyStat, error) + ListUserDailyStats(ctx context.Context, opt *ListUserActivityUsersOption) ([]*UserDailyActiveStat, uint64, error) +} + +type UserActivityUsecase struct { + repo UserActivityRepo + systemVariableUsecase *SystemVariableUsecase + log *utilLog.Helper +} + +func NewUserActivityUsecase(logger utilLog.Logger, repo UserActivityRepo, svu *SystemVariableUsecase) *UserActivityUsecase { + return &UserActivityUsecase{ + repo: repo, + systemVariableUsecase: svu, + log: utilLog.NewHelper(logger, utilLog.WithMessageKey("biz.userActivity")), + } +} + +func (u *UserActivityUsecase) GetLog() *utilLog.Helper { + return u.log +} + +func (u *UserActivityUsecase) DoClean() { + if u.systemVariableUsecase == nil { + u.log.Errorf("failed to clean user request logs when get systemVariableUsecase") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + variables, err := u.systemVariableUsecase.GetSystemVariables(ctx) + if err != nil { + u.log.Errorf("failed to clean user request logs when get expired duration: %v", err) + return + } + + expiredHoursVar, ok := variables[SystemVariableUserRequestLogExpiredHours] + if !ok { + expiredHoursVar = SystemVariable{ + Key: SystemVariableUserRequestLogExpiredHours, + Value: strconv.Itoa(DefaultUserRequestLogExpiredHours), + } + } + + expiredHours, err := strconv.Atoi(expiredHoursVar.Value) + if err != nil { + u.log.Errorf("failed to parse user_request_log_expired_hours value: %v", err) + return + } + if expiredHours <= 0 { + u.log.Errorf("got UserRequestLogExpiredHours: %d", expiredHours) + return + } + + cleanTime := time.Now().Add(time.Duration(-expiredHours) * time.Hour) + rowsAffected, err := u.repo.CleanUserRequestLogsBefore(ctx, cleanTime) + if err != nil { + u.log.Errorf("failed to clean user request logs: %v", err) + return + } + u.log.Infof("UserRequestLog regular cleaned rows: %d event time before: %s", rowsAffected, cleanTime.Format("2006-01-02 15:04:05")) +} + +func CalcActiveMinutes(eventTimes []time.Time, gapMinutes int) int { + if len(eventTimes) == 0 { + return 0 + } + if gapMinutes <= 0 { + gapMinutes = userActivitySessionGapMinutes + } + gap := time.Duration(gapMinutes) * time.Minute + total := time.Duration(0) + sessionStart := eventTimes[0] + sessionEnd := eventTimes[0] + for i := 1; i < len(eventTimes); i++ { + if eventTimes[i].Sub(sessionEnd) > gap { + total += sessionEnd.Sub(sessionStart) + sessionStart = eventTimes[i] + } + sessionEnd = eventTimes[i] + } + total += sessionEnd.Sub(sessionStart) + minutes := int(total.Minutes()) + if total%time.Minute != 0 { + minutes++ + } + return minutes +} diff --git a/internal/dms/biz/user_activity_ce.go b/internal/dms/biz/user_activity_ce.go new file mode 100644 index 000000000..4317d2fec --- /dev/null +++ b/internal/dms/biz/user_activity_ce.go @@ -0,0 +1,38 @@ +//go:build !enterprise + +package biz + +import ( + "context" + "errors" +) + +var errNotSupportUserActivity = errors.New("UserActivity related functions are enterprise version functions") + +func (u *UserActivityUsecase) BatchSaveUserRequestLogs(_ context.Context, _ []*UserRequestLog) error { + return errNotSupportUserActivity +} + +func (u *UserActivityUsecase) DoDailyRollup(_ context.Context) error { + return errNotSupportUserActivity +} + +func (u *UserActivityUsecase) GetUserActivitySummary(_ context.Context, _ string) (*UserActivitySummary, error) { + return nil, errNotSupportUserActivity +} + +func (u *UserActivityUsecase) ListDailyTrend(_ context.Context, _, _ string) ([]*UserActivityDailyTrendItem, error) { + return nil, errNotSupportUserActivity +} + +func (u *UserActivityUsecase) ListModuleDistribution(_ context.Context, _ string) ([]*UserModuleDailyStat, error) { + return nil, errNotSupportUserActivity +} + +func (u *UserActivityUsecase) ListHourlyDistribution(_ context.Context, _ string) ([]*ActiveHourlyStat, error) { + return nil, errNotSupportUserActivity +} + +func (u *UserActivityUsecase) ListUserDailyStats(_ context.Context, _ *ListUserActivityUsersOption) ([]*UserDailyActiveStat, uint64, error) { + return nil, 0, errNotSupportUserActivity +} diff --git a/internal/dms/biz/user_activity_test.go b/internal/dms/biz/user_activity_test.go new file mode 100644 index 000000000..6a53a7a7e --- /dev/null +++ b/internal/dms/biz/user_activity_test.go @@ -0,0 +1,48 @@ +package biz + +import ( + "testing" + "time" +) + +func TestCalcActiveMinutes(t *testing.T) { + base := time.Date(2026, 6, 22, 9, 0, 0, 0, time.Local) + tests := []struct { + name string + times []time.Time + want int + }{ + { + name: "single event", + times: []time.Time{base}, + want: 0, + }, + { + name: "continuous session", + times: []time.Time{ + base, + base.Add(10 * time.Minute), + base.Add(20 * time.Minute), + }, + want: 20, + }, + { + name: "two sessions with gap", + times: []time.Time{ + base, + base.Add(10 * time.Minute), + base.Add(50 * time.Minute), + base.Add(60 * time.Minute), + }, + want: 20, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CalcActiveMinutes(tt.times, 30) + if got != tt.want { + t.Fatalf("CalcActiveMinutes() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/internal/dms/service/configuration.go b/internal/dms/service/configuration.go index 6b5c53f9f..a4039bcf8 100644 --- a/internal/dms/service/configuration.go +++ b/internal/dms/service/configuration.go @@ -625,11 +625,17 @@ func (d *DMSService) GetSystemVariables(ctx context.Context, currentUserUid stri return nil, err } + userRequestLogExpiredHours, err := strconv.Atoi(variables[biz.SystemVariableUserRequestLogExpiredHours].Value) + if err != nil { + return nil, err + } + return &dmsCommonV1.GetSystemVariablesReply{ Data: dmsCommonV1.SystemVariablesResV1{ Url: variables[biz.SystemVariableSqleUrl].Value, OperationRecordExpiredHours: operationRecordExpiredHours, CbOperationLogsExpiredHours: cbOperationLogsExpiredHours, + UserRequestLogExpiredHours: userRequestLogExpiredHours, SystemVariableSSHPrimaryKey: variables[biz.SystemVariableSSHPrimaryKey].Value, SystemVariableWorkflowExpiredHours: systemVariableWorkflowExpiredHours, }, @@ -675,6 +681,13 @@ func (d *DMSService) UpdateSystemVariables(ctx context.Context, req *dmsCommonV1 }) } + if req.UserRequestLogExpiredHours != nil { + variables = append(variables, &biz.SystemVariable{ + Key: biz.SystemVariableUserRequestLogExpiredHours, + Value: fmt.Sprintf("%d", *req.UserRequestLogExpiredHours), + }) + } + if req.SystemVariableSSHPrimaryKey != nil { variables = append(variables, &biz.SystemVariable{ Key: biz.SystemVariableSSHPrimaryKey, diff --git a/internal/dms/service/service.go b/internal/dms/service/service.go index 73a1b8063..85031eaae 100644 --- a/internal/dms/service/service.go +++ b/internal/dms/service/service.go @@ -52,6 +52,7 @@ type DMSService struct { SystemVariableUsecase *biz.SystemVariableUsecase OperationRecordUsecase *biz.OperationRecordUsecase MaintenanceTimeUsecase *biz.MaintenanceTimeUsecase + UserActivityUsecase *biz.UserActivityUsecase log *utilLog.Helper shutdownCallback func() error } @@ -150,6 +151,8 @@ func NewAndInitDMSService(logger utilLog.Logger, opts *conf.DMSOptions) (*DMSSer maintenanceTimeUsecase := biz.NewMaintenanceTimeUsecase(logger, opPermissionVerifyUsecase) operationRecordRepo := storage.NewOperationRecordRepo(logger, st) operationRecordUsecase := biz.NewOperationRecordUsecase(logger, operationRecordRepo, systemVariableUsecase) + userActivityRepo := storage.NewUserActivityRepo(logger, st) + userActivityUsecase := biz.NewUserActivityUsecase(logger, userActivityRepo, systemVariableUsecase) cbOperationRepo := storage.NewCbOperationLogRepo(logger, st) CbOperationLogUsecase := biz.NewCbOperationLogUsecase(logger, cbOperationRepo, opPermissionVerifyUsecase, dmsProxyTargetRepo, systemVariableUsecase) workflowRepo := storage.NewWorkflowRepo(logger, st) @@ -173,7 +176,7 @@ func NewAndInitDMSService(logger utilLog.Logger, opts *conf.DMSOptions) (*DMSSer authAccessTokenUsecase := biz.NewAuthAccessTokenUsecase(logger, userUsecase) authLoginSessionUsecase := biz.NewAuthLoginSessionUsecase(logger, userUsecase, loginConfigurationUsecase) - cronTask := biz.NewCronTaskUsecase(logger, DataExportWorkflowUsecase, CbOperationLogUsecase, operationRecordUsecase, oauth2SessionUsecase) + cronTask := biz.NewCronTaskUsecase(logger, DataExportWorkflowUsecase, CbOperationLogUsecase, operationRecordUsecase, userActivityUsecase, oauth2SessionUsecase) err = cronTask.InitialTask() if err != nil { return nil, fmt.Errorf("failed to new cron task: %v", err) @@ -220,6 +223,7 @@ func NewAndInitDMSService(logger utilLog.Logger, opts *conf.DMSOptions) (*DMSSer SystemVariableUsecase: systemVariableUsecase, OperationRecordUsecase: operationRecordUsecase, MaintenanceTimeUsecase: maintenanceTimeUsecase, + UserActivityUsecase: userActivityUsecase, log: utilLog.NewHelper(logger, utilLog.WithMessageKey("dms.service")), shutdownCallback: func() error { stopDataMaskingScheduler() diff --git a/internal/dms/service/user_activity_ce.go b/internal/dms/service/user_activity_ce.go new file mode 100644 index 000000000..6b335c991 --- /dev/null +++ b/internal/dms/service/user_activity_ce.go @@ -0,0 +1,36 @@ +//go:build !enterprise + +package service + +import ( + "context" + "errors" + + aV1 "github.com/actiontech/dms/api/dms/service/v1" + apiError "github.com/actiontech/dms/internal/apiserver/pkg/error" +) + +var errNotSupportUserActivity = errors.New("UserActivity related functions are enterprise version functions") + +func (d *DMSService) GetUserActivitySummary(ctx context.Context, req *aV1.GetUserActivitySummaryReq, _ string) (*aV1.GetUserActivitySummaryReply, error) { + reply := &aV1.GetUserActivitySummaryReply{} + reply.GenericResp.SetCode(int(apiError.DMSServiceErr)) + reply.GenericResp.SetMsg(errNotSupportUserActivity.Error()) + return reply, nil +} + +func (d *DMSService) ListUserActivityDailyTrend(ctx context.Context, req *aV1.ListUserActivityDailyTrendReq, _ string) (*aV1.ListUserActivityDailyTrendReply, error) { + return nil, errNotSupportUserActivity +} + +func (d *DMSService) ListUserActivityModuleDistribution(ctx context.Context, req *aV1.ListUserActivityModuleDistributionReq, _ string) (*aV1.ListUserActivityModuleDistributionReply, error) { + return nil, errNotSupportUserActivity +} + +func (d *DMSService) ListUserActivityHourlyDistribution(ctx context.Context, req *aV1.ListUserActivityHourlyDistributionReq, _ string) (*aV1.ListUserActivityHourlyDistributionReply, error) { + return nil, errNotSupportUserActivity +} + +func (d *DMSService) ListUserActivityUsers(ctx context.Context, req *aV1.ListUserActivityUsersReq, _ string) (*aV1.ListUserActivityUsersReply, error) { + return nil, errNotSupportUserActivity +} diff --git a/internal/dms/storage/system_variable.go b/internal/dms/storage/system_variable.go index f85bf9caa..e80f3cdb2 100644 --- a/internal/dms/storage/system_variable.go +++ b/internal/dms/storage/system_variable.go @@ -100,5 +100,12 @@ func convertModelSystemVariables(variables []*model.SystemVariable) map[string]b } } + if _, ok := sysVariables[biz.SystemVariableUserRequestLogExpiredHours]; !ok { + sysVariables[biz.SystemVariableUserRequestLogExpiredHours] = biz.SystemVariable{ + Key: biz.SystemVariableUserRequestLogExpiredHours, + Value: strconv.Itoa(biz.DefaultUserRequestLogExpiredHours), + } + } + return sysVariables } diff --git a/internal/dms/storage/user_activity_ce.go b/internal/dms/storage/user_activity_ce.go new file mode 100644 index 000000000..8dd3fb1f0 --- /dev/null +++ b/internal/dms/storage/user_activity_ce.go @@ -0,0 +1,55 @@ +//go:build !enterprise + +package storage + +import ( + "context" + "errors" + "time" + + "github.com/actiontech/dms/internal/dms/biz" + utilLog "github.com/actiontech/dms/pkg/dms-common/pkg/log" +) + +var errNotSupportUserActivity = errors.New("UserActivity related functions are enterprise version functions") + +type userActivityRepo struct { + *Storage + log *utilLog.Helper +} + +func NewUserActivityRepo(log utilLog.Logger, s *Storage) biz.UserActivityRepo { + return &userActivityRepo{Storage: s, log: utilLog.NewHelper(log, utilLog.WithMessageKey("storage.userActivity"))} +} + +func (r *userActivityRepo) BatchSaveUserRequestLogs(_ context.Context, _ []*biz.UserRequestLog) error { + return errNotSupportUserActivity +} + +func (r *userActivityRepo) CleanUserRequestLogsBefore(_ context.Context, _ time.Time) (int64, error) { + return 0, errNotSupportUserActivity +} + +func (r *userActivityRepo) RollupDailyStats(_ context.Context, _ string) error { + return errNotSupportUserActivity +} + +func (r *userActivityRepo) GetUserActivitySummary(_ context.Context, _ string) (*biz.UserActivitySummary, error) { + return nil, errNotSupportUserActivity +} + +func (r *userActivityRepo) ListDailyTrend(_ context.Context, _, _ string) ([]*biz.UserActivityDailyTrendItem, error) { + return nil, errNotSupportUserActivity +} + +func (r *userActivityRepo) ListModuleDistribution(_ context.Context, _ string) ([]*biz.UserModuleDailyStat, error) { + return nil, errNotSupportUserActivity +} + +func (r *userActivityRepo) ListHourlyDistribution(_ context.Context, _ string) ([]*biz.ActiveHourlyStat, error) { + return nil, errNotSupportUserActivity +} + +func (r *userActivityRepo) ListUserDailyStats(_ context.Context, _ *biz.ListUserActivityUsersOption) ([]*biz.UserDailyActiveStat, uint64, error) { + return nil, 0, errNotSupportUserActivity +} diff --git a/pkg/dms-common/api/dms/v1/system_variable.go b/pkg/dms-common/api/dms/v1/system_variable.go index a385555bb..2918e5d59 100644 --- a/pkg/dms-common/api/dms/v1/system_variable.go +++ b/pkg/dms-common/api/dms/v1/system_variable.go @@ -7,6 +7,7 @@ type UpdateSystemVariablesReqV1 struct { Url *string `json:"url" form:"url" example:"http://10.186.61.32:8080" validate:"omitempty,url"` OperationRecordExpiredHours *int `json:"operation_record_expired_hours" form:"operation_record_expired_hours" example:"2160"` CbOperationLogsExpiredHours *int `json:"cb_operation_logs_expired_hours" form:"cb_operation_logs_expired_hours" example:"2160"` + UserRequestLogExpiredHours *int `json:"user_request_log_expired_hours" form:"user_request_log_expired_hours" example:"2160"` SystemVariableSSHPrimaryKey *string `json:"system_variable_ssh_primary_key"` SystemVariableWorkflowExpiredHours *int `json:"system_variable_workflow_expired_hours"` SystemVariableSqlManageRawExpiredHours *int `json:"system_variable_sql_manage_raw_expired_hours"` @@ -23,6 +24,7 @@ type SystemVariablesResV1 struct { Url string `json:"url"` OperationRecordExpiredHours int `json:"operation_record_expired_hours"` CbOperationLogsExpiredHours int `json:"cb_operation_logs_expired_hours"` + UserRequestLogExpiredHours int `json:"user_request_log_expired_hours"` SystemVariableSSHPrimaryKey string `json:"system_variable_ssh_primary_key"` SystemVariableWorkflowExpiredHours int `json:"system_variable_workflow_expired_hours"` SystemVariableSqlManageRawExpiredHours int `json:"system_variable_sql_manage_raw_expired_hours"`