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"`