diff --git a/backend/biz/file/handler/v1/file.go b/backend/biz/file/handler/v1/file.go index ad3beae0..de0155ec 100644 --- a/backend/biz/file/handler/v1/file.go +++ b/backend/biz/file/handler/v1/file.go @@ -30,6 +30,7 @@ type FileHandler struct { func NewFileHandler(i *do.Injector) (*FileHandler, error) { w := do.MustInvoke[*web.Web](i) auth := do.MustInvoke[*middleware.AuthMiddleware](i) + targetActive := do.MustInvoke[*middleware.TargetActiveMiddleware](i) f := &FileHandler{ logger: do.MustInvoke[*slog.Logger](i).With("module", "handler.file"), @@ -38,7 +39,7 @@ func NewFileHandler(i *do.Injector) (*FileHandler, error) { } g := w.Group("/api/v1/users") - g.Use(auth.Auth()) + g.Use(auth.Auth(), targetActive.TargetActive()) g.GET("/folders", web.BindHandler(f.ListFolder)) g.POST("/folders", web.BindHandler(f.Mkdir)) diff --git a/backend/biz/git/handler/v1/gitbot.go b/backend/biz/git/handler/v1/gitbot.go index d597d5ab..6b5394ea 100644 --- a/backend/biz/git/handler/v1/gitbot.go +++ b/backend/biz/git/handler/v1/gitbot.go @@ -22,6 +22,7 @@ type GitBotHandler struct { func NewGitBotHandler(i *do.Injector) (*GitBotHandler, error) { w := do.MustInvoke[*web.Web](i) auth := do.MustInvoke[*middleware.AuthMiddleware](i) + targetActive := do.MustInvoke[*middleware.TargetActiveMiddleware](i) h := &GitBotHandler{ usecase: do.MustInvoke[domain.GitBotUsecase](i), @@ -29,7 +30,7 @@ func NewGitBotHandler(i *do.Injector) (*GitBotHandler, error) { } g := w.Group("/api/v1/users/git-bots") - g.Use(auth.Auth()) + g.Use(auth.Auth(), targetActive.TargetActive()) g.GET("", web.BaseHandler(h.List)) g.POST("", web.BindHandler(h.Create)) g.PUT("", web.BindHandler(h.Update)) diff --git a/backend/biz/git/handler/v1/identity.go b/backend/biz/git/handler/v1/identity.go index 59660f81..64dbcfae 100644 --- a/backend/biz/git/handler/v1/identity.go +++ b/backend/biz/git/handler/v1/identity.go @@ -22,6 +22,7 @@ type GitIdentityHandler struct { func NewGitIdentityHandler(i *do.Injector) (*GitIdentityHandler, error) { w := do.MustInvoke[*web.Web](i) auth := do.MustInvoke[*middleware.AuthMiddleware](i) + targetActive := do.MustInvoke[*middleware.TargetActiveMiddleware](i) h := &GitIdentityHandler{ usecase: do.MustInvoke[domain.GitIdentityUsecase](i), @@ -29,7 +30,7 @@ func NewGitIdentityHandler(i *do.Injector) (*GitIdentityHandler, error) { } g := w.Group("/api/v1/users/git-identities") - g.Use(auth.Auth()) + g.Use(auth.Auth(), targetActive.TargetActive()) g.GET("", web.BaseHandler(h.List)) g.GET("/:id", web.BindHandler(h.Get)) g.POST("", web.BindHandler(h.Add)) diff --git a/backend/biz/host/handler/v1/host.go b/backend/biz/host/handler/v1/host.go index abcbb923..66bd2c8d 100644 --- a/backend/biz/host/handler/v1/host.go +++ b/backend/biz/host/handler/v1/host.go @@ -30,6 +30,7 @@ type HostHandler struct { func NewHostHandler(i *do.Injector) (*HostHandler, error) { w := do.MustInvoke[*web.Web](i) auth := do.MustInvoke[*middleware.AuthMiddleware](i) + targetActive := do.MustInvoke[*middleware.TargetActiveMiddleware](i) h := &HostHandler{ usecase: do.MustInvoke[domain.HostUsecase](i), @@ -47,7 +48,7 @@ func NewHostHandler(i *do.Injector) (*HostHandler, error) { g.GET("/install", web.BindHandler(h.Install)) g.GET("/vms/terminals/join", web.BindHandler(h.JoinTerminal)) - g.Use(auth.Auth()) + g.Use(auth.Auth(), targetActive.TargetActive()) g.GET("/install-command", web.BaseHandler(h.GetInstallCommand)) g.DELETE("/:id", web.BindHandler(h.DeleteHost)) g.PUT("/:id", web.BindHandler(h.UpdateHost)) diff --git a/backend/biz/notify/handler/v1/notify.go b/backend/biz/notify/handler/v1/notify.go index aff018b8..ff720ae2 100644 --- a/backend/biz/notify/handler/v1/notify.go +++ b/backend/biz/notify/handler/v1/notify.go @@ -22,6 +22,7 @@ type NotifyHandler struct { func NewNotifyHandler(i *do.Injector) (*NotifyHandler, error) { w := do.MustInvoke[*web.Web](i) auth := do.MustInvoke[*middleware.AuthMiddleware](i) + targetActive := do.MustInvoke[*middleware.TargetActiveMiddleware](i) h := &NotifyHandler{ channelUsecase: do.MustInvoke[domain.NotifyChannelUsecase](i), @@ -30,7 +31,7 @@ func NewNotifyHandler(i *do.Injector) (*NotifyHandler, error) { // 用户接口 usr := w.Group("/api/v1/users/notify") - usr.Use(auth.Auth()) + usr.Use(auth.Auth(), targetActive.TargetActive()) usr.POST("/channels", web.BindHandler(h.CreateUserChannel)) usr.GET("/channels", web.BaseHandler(h.ListUserChannels)) usr.PUT("/channels/:id", web.BindHandler(h.UpdateUserChannel)) diff --git a/backend/biz/project/handler/v1/project.go b/backend/biz/project/handler/v1/project.go index 3e528342..e2e0615b 100644 --- a/backend/biz/project/handler/v1/project.go +++ b/backend/biz/project/handler/v1/project.go @@ -23,6 +23,7 @@ type ProjectHandler struct { func NewProjectHandler(i *do.Injector) (*ProjectHandler, error) { w := do.MustInvoke[*web.Web](i) auth := do.MustInvoke[*middleware.AuthMiddleware](i) + targetActive := do.MustInvoke[*middleware.TargetActiveMiddleware](i) h := &ProjectHandler{ usecase: do.MustInvoke[domain.ProjectUsecase](i), @@ -30,7 +31,7 @@ func NewProjectHandler(i *do.Injector) (*ProjectHandler, error) { } g := w.Group("/api/v1/users/projects") - g.Use(auth.Auth()) + g.Use(auth.Auth(), targetActive.TargetActive()) g.GET("", web.BindHandler(h.List)) g.GET("/:id", web.BindHandler(h.Get)) g.POST("", web.BindHandler(h.Create)) diff --git a/backend/biz/setting/handler/v1/image.go b/backend/biz/setting/handler/v1/image.go index 16632602..3daea442 100644 --- a/backend/biz/setting/handler/v1/image.go +++ b/backend/biz/setting/handler/v1/image.go @@ -23,6 +23,7 @@ func NewImageHandler(i *do.Injector) (*ImageHandler, error) { logger := do.MustInvoke[*slog.Logger](i) usecase := do.MustInvoke[domain.ImageUsecase](i) auth := do.MustInvoke[*middleware.AuthMiddleware](i) + targetActive := do.MustInvoke[*middleware.TargetActiveMiddleware](i) h := &ImageHandler{ logger: logger.With("component", "handler.images"), @@ -31,7 +32,7 @@ func NewImageHandler(i *do.Injector) (*ImageHandler, error) { v1 := w.Group("/api/v1/users/images") - v1.Use(auth.Auth()) + v1.Use(auth.Auth(), targetActive.TargetActive()) v1.GET("", web.BindHandler(h.List)) v1.POST("", web.BindHandler(h.Create)) v1.DELETE("/:id", web.BindHandler(h.Delete)) diff --git a/backend/biz/setting/handler/v1/model.go b/backend/biz/setting/handler/v1/model.go index 779f52a5..0c66b9bc 100644 --- a/backend/biz/setting/handler/v1/model.go +++ b/backend/biz/setting/handler/v1/model.go @@ -24,6 +24,7 @@ func NewModelHandler(i *do.Injector) (*ModelHandler, error) { logger := do.MustInvoke[*slog.Logger](i) usecase := do.MustInvoke[domain.ModelUsecase](i) auth := do.MustInvoke[*middleware.AuthMiddleware](i) + targetActive := do.MustInvoke[*middleware.TargetActiveMiddleware](i) h := &ModelHandler{ logger: logger.With("component", "handler.models"), @@ -34,7 +35,7 @@ func NewModelHandler(i *do.Injector) (*ModelHandler, error) { v1.GET("/providers", web.BindHandler(h.GetProviderModelList)) - v1.Use(auth.Auth()) + v1.Use(auth.Auth(), targetActive.TargetActive()) v1.GET("", web.BindHandler(h.List)) v1.POST("", web.BindHandler(h.Create)) v1.PUT("/:id", web.BindHandler(h.Update)) diff --git a/backend/biz/task/handler/v1/task.go b/backend/biz/task/handler/v1/task.go index c94f6782..185325f3 100644 --- a/backend/biz/task/handler/v1/task.go +++ b/backend/biz/task/handler/v1/task.go @@ -56,6 +56,7 @@ func NewTaskHandler(i *do.Injector) (*TaskHandler, error) { tf := do.MustInvoke[taskflow.Clienter](i) lok := do.MustInvoke[*loki.Client](i) auth := do.MustInvoke[*middleware.AuthMiddleware](i) + targetActive := do.MustInvoke[*middleware.TargetActiveMiddleware](i) tc := do.MustInvoke[*ws.TaskConn](i) cc := do.MustInvoke[*ws.ControlConn](i) ts := do.MustInvoke[*service.TaskSummaryService](i) @@ -92,7 +93,7 @@ func NewTaskHandler(i *do.Injector) (*TaskHandler, error) { v1.GET("/public-stream", web.BindHandler(h.PublicStream), auth.Check()) - v1.Use(auth.Auth()) + v1.Use(auth.Auth(), targetActive.TargetActive()) // 任务管理接口 v1.GET("", web.BindHandler(h.List, web.WithPage())) diff --git a/backend/biz/team/usecase/user.go b/backend/biz/team/usecase/user.go index 976b615c..f9129f9a 100644 --- a/backend/biz/team/usecase/user.go +++ b/backend/biz/team/usecase/user.go @@ -11,6 +11,7 @@ import ( "github.com/samber/do" "github.com/chaitin/MonkeyCode/backend/config" + "github.com/chaitin/MonkeyCode/backend/consts" "github.com/chaitin/MonkeyCode/backend/db" "github.com/chaitin/MonkeyCode/backend/domain" "github.com/chaitin/MonkeyCode/backend/errcode" @@ -21,6 +22,7 @@ import ( // TeamGroupUserUsecase 团队分组成员业务逻辑层 type TeamGroupUserUsecase struct { repo domain.TeamGroupUserRepo + activeRepo domain.UserActiveRepo logger *slog.Logger config *config.Config smtpClient domain.EmailSender @@ -33,6 +35,7 @@ func NewTeamGroupUserUsecase(i *do.Injector) (domain.TeamGroupUserUsecase, error t := &TeamGroupUserUsecase{ repo: do.MustInvoke[domain.TeamGroupUserRepo](i), + activeRepo: do.MustInvoke[domain.UserActiveRepo](i), logger: do.MustInvoke[*slog.Logger](i).With("module", "usecase.team_group_user"), config: cfg, smtpClient: do.MustInvoke[domain.EmailSender](i), @@ -207,11 +210,21 @@ func (u *TeamGroupUserUsecase) MemberList(ctx context.Context, teamUser *domain. return &domain.MemberListResp{ MemberLimit: team.MemberLimit, Members: cvt.Iter(members, func(_ int, member *db.TeamMember) *domain.TeamMemberInfo { + var lastActiveAtTs int64 + if member.Edges.User != nil && u.activeRepo != nil { + lastActiveAt, err := u.activeRepo.GetActiveRecord(ctx, consts.UserActiveKey, member.Edges.User.ID.String()) + if err != nil { + u.logger.ErrorContext(ctx, "get last active time failed", "error", err) + } + if !lastActiveAt.IsZero() { + lastActiveAtTs = lastActiveAt.Unix() + } + } return &domain.TeamMemberInfo{ User: cvt.From(member.Edges.User, &domain.User{}), Role: member.Role, CreatedAt: member.CreatedAt.Unix(), - LastActiveAt: 0, + LastActiveAt: lastActiveAtTs, } }), }, nil diff --git a/backend/biz/user/handler/v1/auth.go b/backend/biz/user/handler/v1/auth.go index 65373120..30980105 100644 --- a/backend/biz/user/handler/v1/auth.go +++ b/backend/biz/user/handler/v1/auth.go @@ -36,6 +36,7 @@ func NewAuthHandler(i *do.Injector) (*AuthHandler, error) { usecase := do.MustInvoke[domain.UserUsecase](i) redisClient := do.MustInvoke[*redis.Client](i) auth := do.MustInvoke[*middleware.AuthMiddleware](i) + targetActive := do.MustInvoke[*middleware.TargetActiveMiddleware](i) captchaSvc := do.MustInvoke[*captcha.Captcha](i) h := &AuthHandler{ @@ -58,10 +59,10 @@ func NewAuthHandler(i *do.Injector) (*AuthHandler, error) { v1.POST("/password-login", web.BindHandler(h.PasswordLogin)) v1.PUT("/passwords/change", web.BindHandler(h.ChangePassword), auth.Check()) v1.GET("/status", web.BaseHandler(h.Status), auth.Check()) - v1.POST("/logout", web.BaseHandler(h.Logout), auth.Auth()) + v1.POST("/logout", web.BaseHandler(h.Logout), auth.Auth(), targetActive.TargetActive()) // 邮箱绑定接口 - v1.PUT("/email/bind-request", web.BindHandler(h.SendBindEmailVerification), auth.Auth()) + v1.PUT("/email/bind-request", web.BindHandler(h.SendBindEmailVerification), auth.Auth(), targetActive.TargetActive()) v1.GET("/email/verify", web.BindHandler(h.VerifyBindEmail)) return h, nil diff --git a/backend/biz/user/register.go b/backend/biz/user/register.go index a74d13f3..ee3458dd 100644 --- a/backend/biz/user/register.go +++ b/backend/biz/user/register.go @@ -11,6 +11,7 @@ import ( // ProvideUser 注册 user 模块的服务工厂 func ProvideUser(i *do.Injector) { do.Provide(i, repo.NewUserRepo) + do.Provide(i, repo.NewUserActiveRepo) do.Provide(i, usecase.NewUserUsecase) do.Provide(i, v1.NewAuthHandler) } diff --git a/backend/biz/user/repo/active.go b/backend/biz/user/repo/active.go new file mode 100644 index 00000000..b8c52290 --- /dev/null +++ b/backend/biz/user/repo/active.go @@ -0,0 +1,52 @@ +package repo + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/redis/go-redis/v9" + "github.com/samber/do" + + "github.com/chaitin/MonkeyCode/backend/consts" + "github.com/chaitin/MonkeyCode/backend/domain" +) + +// UserActiveRepo 用户活跃记录数据访问层 +type UserActiveRepo struct { + redis *redis.Client + logger *slog.Logger +} + +// NewUserActiveRepo 创建用户活跃记录数据访问层实例 +func NewUserActiveRepo(i *do.Injector) (domain.UserActiveRepo, error) { + return &UserActiveRepo{ + redis: do.MustInvoke[*redis.Client](i), + logger: do.MustInvoke[*slog.Logger](i).With("module", "repo.user_active"), + }, nil +} + +// RecordActiveRecord 记录用户最后活跃时间 +func (r *UserActiveRepo) RecordActiveRecord(ctx context.Context, key consts.RedisKey, field string, score time.Time) error { + if err := r.redis.ZAdd(ctx, string(key), redis.Z{ + Score: float64(score.Unix()), + Member: field, + }).Err(); err != nil { + r.logger.ErrorContext(ctx, "failed to record user active time", "error", err, "field", field) + return fmt.Errorf("failed to record user active time: %w", err) + } + return nil +} + +// GetActiveRecord 获取用户最后活跃时间 +func (r *UserActiveRepo) GetActiveRecord(ctx context.Context, key consts.RedisKey, userID string) (time.Time, error) { + score, err := r.redis.ZScore(ctx, string(key), userID).Result() + if err != nil { + if err == redis.Nil { + return time.Time{}, nil + } + return time.Time{}, err + } + return time.Unix(int64(score), 0), nil +} diff --git a/backend/consts/user.go b/backend/consts/user.go index 5316c8f3..ce646d2d 100644 --- a/backend/consts/user.go +++ b/backend/consts/user.go @@ -26,6 +26,12 @@ const ( UserRoleAdmin UserRole = "admin" // MonkeyCode-AI 管理员账号,用来配置公共资源. 如公共宿主机等 ) +type RedisKey string + +const ( + UserActiveKey RedisKey = "monkeycode_ai:user:active" +) + type DefaultConfigType string const ( diff --git a/backend/domain/user.go b/backend/domain/user.go index 63fd50ec..751868f1 100644 --- a/backend/domain/user.go +++ b/backend/domain/user.go @@ -2,6 +2,7 @@ package domain import ( "context" + "time" "github.com/google/uuid" @@ -33,6 +34,12 @@ type UserRepo interface { SetEmail(ctx context.Context, userID uuid.UUID, email string) error } +// UserActiveRepo 用户活跃记录仓储接口 +type UserActiveRepo interface { + RecordActiveRecord(ctx context.Context, key consts.RedisKey, field string, score time.Time) error + GetActiveRecord(ctx context.Context, key consts.RedisKey, userID string) (time.Time, error) +} + type User struct { ID uuid.UUID `json:"id"` Name string `json:"name"` diff --git a/backend/middleware/target_active.go b/backend/middleware/target_active.go new file mode 100644 index 00000000..6f35aed8 --- /dev/null +++ b/backend/middleware/target_active.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "log/slog" + "time" + + "github.com/labstack/echo/v4" + + "github.com/chaitin/MonkeyCode/backend/consts" + "github.com/chaitin/MonkeyCode/backend/domain" +) + +// TargetActiveMiddleware 用户活动时间追踪中间件 +type TargetActiveMiddleware struct { + logger *slog.Logger + activeRepo domain.UserActiveRepo +} + +// NewTargetActiveMiddleware 创建用户活动时间追踪中间件 +func NewTargetActiveMiddleware(logger *slog.Logger, activeRepo domain.UserActiveRepo) *TargetActiveMiddleware { + return &TargetActiveMiddleware{ + logger: logger.With("module", "middleware.target_active"), + activeRepo: activeRepo, + } +} + +// TargetActive 记录用户活动时间 +func (t *TargetActiveMiddleware) TargetActive() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + user := GetUser(c) + + if user != nil && t.activeRepo != nil { + if err := t.activeRepo.RecordActiveRecord( + ctx, + consts.UserActiveKey, + user.ID.String(), + time.Now(), + ); err != nil { + t.logger.WarnContext(ctx, "failed to record user active time", "error", err, "user_id", user.ID) + } + } + return next(c) + } + } +} diff --git a/backend/pkg/register.go b/backend/pkg/register.go index cf6af8eb..f8ae203d 100644 --- a/backend/pkg/register.go +++ b/backend/pkg/register.go @@ -81,6 +81,13 @@ func RegisterInfra(i *do.Injector, w ...*web.Web) error { return middleware.NewAuthMiddleware(sess, nil, l), nil }) + // TargetActive Middleware + do.Provide(i, func(i *do.Injector) (*middleware.TargetActiveMiddleware, error) { + l := do.MustInvoke[*slog.Logger](i) + activeRepo := do.MustInvoke[domain.UserActiveRepo](i) + return middleware.NewTargetActiveMiddleware(l, activeRepo), nil + }) + // Audit Middleware do.Provide(i, func(i *do.Injector) (*middleware.AuditMiddleware, error) { l := do.MustInvoke[*slog.Logger](i)