diff --git a/Dockerfile.ci b/Dockerfile.ci index 5df2dc8..1a9cddb 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -52,6 +52,10 @@ RUN --mount=type=cache,target=/go/pkg/mod \ COPY . . COPY --from=frontend-build /build/application/statics/assets.zip ./application/statics/assets.zip +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go generate ./ent + RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ go build -trimpath -ldflags "-s -w -X 'github.com/cloudreve/Cloudreve/v4/pkg/conf.BackendVersion=byw-${BUILD_DATE:-dev}'" -o /cloudreve . diff --git a/application/dependency/dependency.go b/application/dependency/dependency.go index d996378..50511d9 100644 --- a/application/dependency/dependency.go +++ b/application/dependency/dependency.go @@ -106,6 +106,8 @@ type Dep interface { ShareClient() inventory.ShareClient // TaskClient Creates a new inventory.TaskClient instance for access DB task store. TaskClient() inventory.TaskClient + // LoginAuditClient Creates a new inventory.LoginAuditClient instance for access DB login audit store. + LoginAuditClient() inventory.LoginAuditClient // ForkWithLogger create a shallow copy of dependency with a new correlated logger, used as per-request dep. ForkWithLogger(ctx context.Context, l logging.Logger) context.Context // MediaMetaQueue Get a singleton queue.Queue instance for media metadata processing. @@ -170,6 +172,7 @@ type dependency struct { directLinkClient inventory.DirectLinkClient fsEventClient inventory.FsEventClient oAuthClient inventory.OAuthClientClient + loginAuditClient inventory.LoginAuditClient emailClient email.Driver generalAuth auth.Auth hashidEncoder hashid.Encoder @@ -820,6 +823,14 @@ func (d *dependency) TaskClient() inventory.TaskClient { return inventory.NewTaskClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder()) } +func (d *dependency) LoginAuditClient() inventory.LoginAuditClient { + if d.loginAuditClient != nil { + return d.loginAuditClient + } + + return inventory.NewLoginAuditClient(d.DBClient()) +} + func (d *dependency) DavAccountClient() inventory.DavAccountClient { if d.davAccountClient != nil { return d.davAccountClient diff --git a/ent/schema/loginaudit.go b/ent/schema/loginaudit.go new file mode 100644 index 0000000..4fccde3 --- /dev/null +++ b/ent/schema/loginaudit.go @@ -0,0 +1,81 @@ +package schema + +import ( + "entgo.io/ent" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// LoginAudit holds the schema definition for the LoginAudit entity. +type LoginAudit struct { + ent.Schema +} + +func (LoginAudit) Fields() []ent.Field { + return []ent.Field{ + field.Int("user_id"). + Optional(). + Nillable(), + field.String("ip"). + MaxLen(64). + Default(""), + field.String("user_agent"). + MaxLen(512). + Default(""), + field.Enum("status"). + Values("success", "fail"). + Default("fail"), + field.String("fail_reason"). + MaxLen(255). + Optional(). + Nillable(), + field.String("location_iso"). + MaxLen(8). + Optional(). + Nillable(), + field.String("location_province"). + MaxLen(64). + Optional(). + Nillable(), + field.String("location_city"). + MaxLen(64). + Optional(). + Nillable(), + field.Time("logout_at"). + Optional(). + Nillable(), + field.Int("session_seconds"). + Optional(). + Nillable(). + Default(0), + field.String("username_tried"). + MaxLen(100). + Optional(). + Nillable(), + } +} + +func (LoginAudit) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("user", User.Type). + Ref("login_audits"). + Field("user_id"). + Unique(), + } +} + +func (LoginAudit) Mixin() []ent.Mixin { + return []ent.Mixin{ + CommonMixin{}, + } +} + +func (LoginAudit) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("user_id"), + index.Fields("ip"), + index.Fields("status"), + index.Fields("created_at"), + } +} diff --git a/ent/schema/user.go b/ent/schema/user.go index 2821435..6561272 100644 --- a/ent/schema/user.go +++ b/ent/schema/user.go @@ -54,6 +54,7 @@ func (User) Edges() []ent.Edge { edge.To("fsevents", FsEvent.Type), edge.To("entities", Entity.Type), edge.To("oauth_grants", OAuthGrant.Type), + edge.To("login_audits", LoginAudit.Type), } } diff --git a/inventory/loginaudit.go b/inventory/loginaudit.go new file mode 100644 index 0000000..8c29ba3 --- /dev/null +++ b/inventory/loginaudit.go @@ -0,0 +1,224 @@ +package inventory + +import ( + "context" + "time" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/ent/loginaudit" + "github.com/cloudreve/Cloudreve/v4/ent/schema" +) + +type ( + LoginAuditStatus string + + LoginAuditListArgs struct { + Page int + PageSize int + UserID *int + IP string + Status *LoginAuditStatus + Username string + StartTime *time.Time + EndTime *time.Time + } + + LoginAuditDailyItem struct { + Date string `json:"date"` + Success int `json:"success"` + Fail int `json:"fail"` + Total int `json:"total"` + } + + LoginAuditListResult struct { + Total int `json:"total"` + Items []*ent.LoginAudit `json:"items"` + } + + LoginAuditClient interface { + TxOperator + // RecordLogin 记录一次登录尝试(成功或失败) + RecordLogin(ctx context.Context, userID *int, ip, userAgent string, success bool, failReason, usernameTried *string) (*ent.LoginAudit, error) + // MarkLogout 标记登出(找到该用户+IP最近一条成功且未登出的记录) + MarkLogout(ctx context.Context, uid int, ip string, logoutAt time.Time) error + // List 分页查询登录审计 + List(ctx context.Context, args *LoginAuditListArgs) (*LoginAuditListResult, error) + // DailyStats 每日登录统计(按 created_at 日期分组) + DailyStats(ctx context.Context, start, end time.Time) ([]LoginAuditDailyItem, error) + } +) + +const ( + LoginAuditStatusSuccess LoginAuditStatus = "success" + LoginAuditStatusFail LoginAuditStatus = "fail" +) + +func NewLoginAuditClient(client *ent.Client) LoginAuditClient { + return &loginAuditClient{client: client} +} + +type loginAuditClient struct { + client *ent.Client +} + +func (c *loginAuditClient) SetClient(newClient *ent.Client) TxOperator { + return &loginAuditClient{client: newClient} +} + +func (c *loginAuditClient) GetClient() *ent.Client { + return c.client +} + +func (c *loginAuditClient) RecordLogin(ctx context.Context, userID *int, ip, userAgent string, success bool, failReason, usernameTried *string) (*ent.LoginAudit, error) { + status := loginaudit.StatusFail + if success { + status = loginaudit.StatusSuccess + } + b := c.client.LoginAudit.Create(). + SetIP(ip). + SetUserAgent(userAgent). + SetStatus(status) + + if userID != nil { + b = b.SetUserID(*userID) + } + if !success && failReason != nil { + b = b.SetFailReason(*failReason) + } + if usernameTried != nil { + b = b.SetUsernameTried(*usernameTried) + } + + return b.Save(ctx) +} + +func (c *loginAuditClient) MarkLogout(ctx context.Context, uid int, ip string, logoutAt time.Time) error { + // 查找该用户+IP 最近一条登录成功且未登出的记录 + latest, err := c.client.LoginAudit.Query(). + Where( + loginaudit.UserID(uid), + loginaudit.IP(ip), + loginaudit.StatusEQ(loginaudit.StatusSuccess), + loginaudit.LogoutAtIsNil(), + ). + Order(ent.Desc(loginaudit.FieldCreatedAt)). + First(ctx) + if err != nil { + // 找不到记录也不报错,可能会话过期或其他原因 + return nil + } + + seconds := int(logoutAt.Sub(latest.CreatedAt).Seconds()) + if seconds < 0 { + seconds = 0 + } + + _, err = c.client.LoginAudit.UpdateOne(latest). + SetLogoutAt(logoutAt). + SetSessionSeconds(seconds). + Save(ctx) + return err +} + +func (c *loginAuditClient) List(ctx context.Context, args *LoginAuditListArgs) (*LoginAuditListResult, error) { + page := args.Page + if page < 1 { + page = 1 + } + pageSize := args.PageSize + if pageSize < 1 { + pageSize = 20 + } + + q := c.client.LoginAudit.Query() + + if args.UserID != nil { + q = q.Where(loginaudit.UserID(*args.UserID)) + } + if args.IP != "" { + q = q.Where(loginaudit.IPContains(args.IP)) + } + if args.Status != nil { + switch *args.Status { + case LoginAuditStatusSuccess: + q = q.Where(loginaudit.StatusEQ(loginaudit.StatusSuccess)) + case LoginAuditStatusFail: + q = q.Where(loginaudit.StatusEQ(loginaudit.StatusFail)) + } + } + if args.Username != "" { + q = q.Where(loginaudit.UsernameTriedContains(args.Username)) + } + if args.StartTime != nil { + q = q.Where(loginaudit.CreatedAtGTE(*args.StartTime)) + } + if args.EndTime != nil { + q = q.Where(loginaudit.CreatedAtLT(*args.EndTime)) + } + + // 软删除:LoginAudit 使用 CommonMixin,默认需要走 SkipSoftDelete 排除软删除过滤? + // 这里审计记录不应该被软删除,但仍遵循默认查询规则,不主动跳过 + ctx = schema.SkipSoftDelete(ctx) + + total, err := q.Clone().Count(ctx) + if err != nil { + return nil, err + } + + items, err := q. + Order(ent.Desc(loginaudit.FieldCreatedAt)). + Limit(pageSize). + Offset((page - 1) * pageSize). + All(ctx) + if err != nil { + return nil, err + } + + return &LoginAuditListResult{Total: total, Items: items}, nil +} + +func (c *loginAuditClient) DailyStats(ctx context.Context, start, end time.Time) ([]LoginAuditDailyItem, error) { + ctx = schema.SkipSoftDelete(ctx) + + // 先取得所有符合时间范围的记录,在内存中按日期聚合 + items, err := c.client.LoginAudit.Query(). + Where( + loginaudit.CreatedAtGTE(start), + loginaudit.CreatedAtLT(end.AddDate(0, 0, 1)), + ). + Select(loginaudit.FieldCreatedAt, loginaudit.FieldStatus). + All(ctx) + if err != nil { + return nil, err + } + + agg := make(map[string]*LoginAuditDailyItem) + for _, it := range items { + day := it.CreatedAt.Format("2006-01-02") + v, ok := agg[day] + if !ok { + v = &LoginAuditDailyItem{Date: day} + agg[day] = v + } + switch it.Status { + case loginaudit.StatusSuccess: + v.Success++ + default: + v.Fail++ + } + v.Total++ + } + + // 补齐 start 到 end 之间每一天,按日期排序 + out := make([]LoginAuditDailyItem, 0) + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + day := d.Format("2006-01-02") + if v, ok := agg[day]; ok { + out = append(out, *v) + } else { + out = append(out, LoginAuditDailyItem{Date: day}) + } + } + + return out, nil +} diff --git a/routers/controllers/admin.go b/routers/controllers/admin.go index 37dfc5b..e14074e 100644 --- a/routers/controllers/admin.go +++ b/routers/controllers/admin.go @@ -664,3 +664,62 @@ func AdminBatchDeleteOAuthClient(c *gin.Context) { } c.JSON(200, serializer.Response{}) } + +// AdminImportUsersCSV 批量导入 CSV 用户(字段:用户名,密码) +func AdminImportUsersCSV(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(200, serializer.Err(c, serializer.NewError(serializer.CodeParamErr, "未找到上传文件 field=file", err))) + return + } + res, err := admin.ImportUsers(c, file) + if err != nil { + c.JSON(200, serializer.Err(c, err)) + return + } + c.JSON(200, serializer.Response{Data: res}) +} + +// AdminBatchSetUserStatus 批量设置用户状态 +func AdminBatchSetUserStatus(c *gin.Context) { + service := ParametersFromContext[*admin.BatchUserStatusService](c, admin.BatchUserStatusParamCtx{}) + err := service.SetStatus(c) + if err != nil { + c.JSON(200, serializer.Err(c, err)) + return + } + c.JSON(200, serializer.Response{}) +} + +// AdminBatchMoveUserGroup 批量移动用户到指定用户组 +func AdminBatchMoveUserGroup(c *gin.Context) { + service := ParametersFromContext[*admin.BatchUserMoveGroupService](c, admin.BatchUserMoveGroupParamCtx{}) + err := service.MoveGroup(c) + if err != nil { + c.JSON(200, serializer.Err(c, err)) + return + } + c.JSON(200, serializer.Response{}) +} + +// AdminListLoginAudits 登录审计列表 +func AdminListLoginAudits(c *gin.Context) { + service := ParametersFromContext[*admin.LoginAuditListService](c, admin.LoginAuditListParamCtx{}) + res, err := service.List(c) + if err != nil { + c.JSON(200, serializer.Err(c, err)) + return + } + c.JSON(200, serializer.Response{Data: res}) +} + +// AdminGetLoginAuditDailyStats 登录审计每日统计 +func AdminGetLoginAuditDailyStats(c *gin.Context) { + service := ParametersFromContext[*admin.LoginAuditDailyStatsService](c, admin.LoginAuditDailyStatsParamCtx{}) + res, err := service.DailyStats(c) + if err != nil { + c.JSON(200, serializer.Err(c, err)) + return + } + c.JSON(200, serializer.Response{Data: res}) +} diff --git a/routers/controllers/user.go b/routers/controllers/user.go index 65e08ed..304bf3c 100644 --- a/routers/controllers/user.go +++ b/routers/controllers/user.go @@ -1,6 +1,8 @@ package controllers import ( + "time" + "github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/ent" "github.com/cloudreve/Cloudreve/v4/inventory" @@ -13,6 +15,37 @@ import ( "github.com/samber/lo" ) +// recordLoginAuditAsync 异步写入登录审计,失败不影响主流程 +func recordLoginAuditAsync(c *gin.Context, userID *int, success bool, failReason, usernameTried *string) { + defer func() { _ = recover() }() + dep := dependency.FromContext(c) + client := dep.LoginAuditClient() + ip := c.ClientIP() + ua := c.GetHeader("User-Agent") + ctx := c.Request.Context() + go func() { + defer func() { _ = recover() }() + _, _ = client.RecordLogin(ctx, userID, ip, ua, success, failReason, usernameTried) + }() +} + +// markLogoutAsync 异步标记登出,失败不影响主流程 +func markLogoutAsync(c *gin.Context, uid int) { + defer func() { _ = recover() }() + dep := dependency.FromContext(c) + client := dep.LoginAuditClient() + ip := c.ClientIP() + ctx := c.Request.Context() + now := time.Now() + go func() { + defer func() { _ = recover() }() + _ = client.MarkLogout(ctx, uid, ip, now) + }() +} + +// ptrString 返回字符串指针 +func ptrString(s string) *string { return &s } + // StartLoginAuthn 开始注册WebAuthn登录 func StartLoginAuthn(c *gin.Context) { res, err := user.PreparePasskeyLogin(c) @@ -29,6 +62,7 @@ func FinishLoginAuthn(c *gin.Context) { service := ParametersFromContext[*user.FinishPasskeyLoginService](c, user.FinishPasskeyLoginParameterCtx{}) u, err := service.FinishPasskeyLogin(c) if err != nil { + recordLoginAuditAsync(c, nil, false, ptrString(err.Error()), nil) c.JSON(200, serializer.Err(c, err)) c.Abort() return @@ -77,6 +111,7 @@ func UserLoginValidation(c *gin.Context) { service := ParametersFromContext[*user.UserLoginService](c, user.LoginParameterCtx{}) expectedUser, twoFaSession, err := service.Login(c) if err != nil { + recordLoginAuditAsync(c, nil, false, ptrString(err.Error()), ptrString(service.UserName)) c.JSON(200, serializer.Err(c, err)) c.Abort() return @@ -98,6 +133,7 @@ func UserLogin2FAValidation(c *gin.Context) { service := ParametersFromContext[*user.OtpValidationService](c, user.OtpValidationParameterCtx{}) expectedUser, err := service.Verify2FA(c) if err != nil { + recordLoginAuditAsync(c, nil, false, ptrString(err.Error()), nil) c.JSON(200, serializer.Err(c, err)) c.Abort() return @@ -116,6 +152,12 @@ func UserIssueToken(c *gin.Context) { return } + // 登录成功:写审计记录 + if u, ok := c.Value(inventory.UserCtx{}).(*ent.User); ok { + uid := u.ID + recordLoginAuditAsync(c, &uid, true, nil, ptrString(u.Email)) + } + c.JSON(200, serializer.Response{ Data: resp, }) @@ -180,6 +222,11 @@ func UserSignOut(c *gin.Context) { return } + // 标记登出时间 + if current := inventory.UserFromContext(c); current != nil && !inventory.IsAnonymousUser(current) { + markLogoutAsync(c, current.ID) + } + c.JSON(200, serializer.Response{ Data: res, }) diff --git a/routers/router.go b/routers/router.go index 846f81a..fddc30f 100644 --- a/routers/router.go +++ b/routers/router.go @@ -1144,6 +1144,11 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.FromJSON[adminsvc.UpsertUserService](adminsvc.UpsertUserParamCtx{}), controllers.AdminCreateUser, ) + // CSV 批量导入用户 + user.POST("import/csv", + middleware.RequiredScopes(types.ScopeAdminWrite), + controllers.AdminImportUsersCSV, + ) batch := user.Group("batch") { // 批量删除用户 @@ -1152,6 +1157,18 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.FromJSON[adminsvc.BatchUserService](adminsvc.BatchUserParamCtx{}), controllers.AdminDeleteUser, ) + // 批量设置用户状态 + batch.POST("status", + middleware.RequiredScopes(types.ScopeAdminWrite), + controllers.FromJSON[adminsvc.BatchUserStatusService](adminsvc.BatchUserStatusParamCtx{}), + controllers.AdminBatchSetUserStatus, + ) + // 批量移动用户组 + batch.POST("move_group", + middleware.RequiredScopes(types.ScopeAdminWrite), + controllers.FromJSON[adminsvc.BatchUserMoveGroupService](adminsvc.BatchUserMoveGroupParamCtx{}), + controllers.AdminBatchMoveUserGroup, + ) } user.POST(":id/calibrate", middleware.RequiredScopes(types.ScopeAdminWrite), @@ -1235,6 +1252,23 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.AdminBatchDeleteShare, ) } + + audit := admin.Group("audit") + { + login := audit.Group("login") + { + // 登录审计列表(分页查询) + login.POST("", + controllers.FromJSON[adminsvc.LoginAuditListService](adminsvc.LoginAuditListParamCtx{}), + controllers.AdminListLoginAudits, + ) + // 每日登录统计(按日期聚合) + login.GET("stats/daily", + controllers.FromQuery[adminsvc.LoginAuditDailyStatsService](adminsvc.LoginAuditDailyStatsParamCtx{}), + controllers.AdminGetLoginAuditDailyStats, + ) + } + } } // 用户 diff --git a/service/admin/loginaudit.go b/service/admin/loginaudit.go new file mode 100644 index 0000000..b3880f0 --- /dev/null +++ b/service/admin/loginaudit.go @@ -0,0 +1,129 @@ +package admin + +import ( + "time" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/gin-gonic/gin" +) + +type ( + LoginAuditListService struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + UserID *int `json:"user_id,omitempty"` + IP string `json:"ip"` + Status string `json:"status"` // success / fail / 空 全部 + Username string `json:"username"` + StartTime string `json:"start_time"` // RFC3339 + EndTime string `json:"end_time"` // RFC3339 + } + LoginAuditListParamCtx struct{} + + LoginAuditDailyStatsService struct { + Start string `json:"start" form:"start"` // RFC3339 或 YYYY-MM-DD + End string `json:"end" form:"end"` + Days int `json:"days" form:"days"` // 优先用此参数,默认7天 + } + LoginAuditDailyStatsParamCtx struct{} +) + +func parseTimeRange(start, end string, defaultDays int) (time.Time, time.Time) { + now := time.Now().Truncate(24 * time.Hour) + endTime := now + startTime := endTime.AddDate(0, 0, -(defaultDays - 1)) + + if start != "" { + if t, err := time.ParseInLocation(time.RFC3339, start, time.Local); err == nil { + startTime = t.Truncate(24 * time.Hour) + } else if t, err := time.ParseInLocation("2006-01-02", start, time.Local); err == nil { + startTime = t + } + } + if end != "" { + if t, err := time.ParseInLocation(time.RFC3339, end, time.Local); err == nil { + endTime = t.Truncate(24 * time.Hour) + } else if t, err := time.ParseInLocation("2006-01-02", end, time.Local); err == nil { + endTime = t + } + } + + // 如果 days > 0 优先 + if defaultDays > 0 && start == "" && end == "" { + endTime = now + startTime = endTime.AddDate(0, 0, -(defaultDays - 1)) + } + + return startTime, endTime +} + +// List 分页查询登录审计 +func (s *LoginAuditListService) List(c *gin.Context) (*inventory.LoginAuditListResult, error) { + dep := dependency.FromContext(c) + client := dep.LoginAuditClient() + + page := s.Page + if page < 1 { + page = 1 + } + pageSize := s.PageSize + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 500 { + pageSize = 500 + } + + args := &inventory.LoginAuditListArgs{ + Page: page, + PageSize: pageSize, + UserID: s.UserID, + IP: s.IP, + Username: s.Username, + } + switch s.Status { + case "success": + st := inventory.LoginAuditStatusSuccess + args.Status = &st + case "fail": + st := inventory.LoginAuditStatusFail + args.Status = &st + } + + if s.StartTime != "" { + if t, err := time.ParseInLocation(time.RFC3339, s.StartTime, time.Local); err == nil { + args.StartTime = &t + } else if t, err := time.ParseInLocation("2006-01-02", s.StartTime, time.Local); err == nil { + args.StartTime = &t + } + } + if s.EndTime != "" { + if t, err := time.ParseInLocation(time.RFC3339, s.EndTime, time.Local); err == nil { + args.EndTime = &t + } else if t, err := time.ParseInLocation("2006-01-02", s.EndTime, time.Local); err == nil { + // end_time 是该日的结束(+1 day,左闭右开) + t = t.AddDate(0, 0, 1) + args.EndTime = &t + } + } + + return client.List(c.Request.Context(), args) +} + +// DailyStats 每日登录统计 +func (s *LoginAuditDailyStatsService) DailyStats(c *gin.Context) ([]inventory.LoginAuditDailyItem, error) { + dep := dependency.FromContext(c) + client := dep.LoginAuditClient() + + days := s.Days + if days < 1 { + days = 7 + } + if days > 365 { + days = 365 + } + start, end := parseTimeRange(s.Start, s.End, days) + + return client.DailyStats(c.Request.Context(), start, end) +} diff --git a/service/admin/user_batch.go b/service/admin/user_batch.go new file mode 100644 index 0000000..b984880 --- /dev/null +++ b/service/admin/user_batch.go @@ -0,0 +1,294 @@ +package admin + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/csv" + "encoding/hex" + "fmt" + "io" + "mime/multipart" + "strconv" + "strings" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/ent/user" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/serializer" + settingpkg "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/gin-gonic/gin" +) + +const ( + // 默认邮箱占位域名(邮箱必填且唯一) + defaultEmailDomain = "byw-cloud.local" + // 默认CSV每行允许的最大字段数 + defaultMaxCSVFields = 8 +) + +type ( + // BatchUserStatusService 批量设置用户状态 + BatchUserStatusService struct { + IDs []int `json:"ids" binding:"min=1"` + Status user.Status `json:"status" binding:"required,oneof=active inactive manual_banned sys_banned"` + } + BatchUserStatusParamCtx struct{} + + // BatchUserMoveGroupService 批量移动用户组 + BatchUserMoveGroupService struct { + IDs []int `json:"ids" binding:"min=1"` + GroupID int `json:"group_id" binding:"required,min=1"` + } + BatchUserMoveGroupParamCtx struct{} + + // ImportUserRow CSV 单行导入结果 + ImportUserRow struct { + Line int `json:"line"` + Username string `json:"username"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + UserID int `json:"user_id,omitempty"` + } + + // ImportUserResponse CSV 批量导入返回 + ImportUserResponse struct { + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + Rows []ImportUserRow `json:"rows"` + } +) + +// randomSuffix 生成 8 位随机后缀,避免邮箱冲突 +func randomSuffix() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// ImportUsers 执行 CSV 导入。参数 file 是上传的 CSV 文件句柄。 +// CSV 格式:第一行可以是标题行(用户名,密码),也可以直接是数据行。字段: +// 列 0 = 用户名(Nick),列 1 = 密码 +func ImportUsers(c *gin.Context, file *multipart.FileHeader) (*ImportUserResponse, error) { + dep := dependency.FromContext(c) + userClient := dep.UserClient() + groupClient := dep.GroupClient() + settingProvider := dep.SettingProvider() + + // 默认注册组 + registerGroupID := 2 + if gs, err := settingProvider.Group(c, settingpkg.RegisterGroupKey); err == nil { + if id, convErr := strconv.Atoi(gs.Value); convErr == nil && id > 0 { + registerGroupID = id + } + } + // 再次兜底:若没有配置或解析失败,使用数据库里第一个非管理组 + if _, err := groupClient.GetByID(c, registerGroupID); err != nil { + groups, listErr := groupClient.ListAll(c) + if listErr == nil && len(groups) > 0 { + for _, g := range groups { + if !g.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) { + registerGroupID = g.ID + break + } + } + } + } + + src, openErr := file.Open() + if openErr != nil { + return nil, serializer.NewError(serializer.CodeParamErr, "Failed to open uploaded file", openErr) + } + defer src.Close() + + reader := csv.NewReader(bufio.NewReader(src)) + reader.FieldsPerRecord = -1 + reader.TrimLeadingSpace = true + + var ( + resp = &ImportUserResponse{Rows: make([]ImportUserRow, 0)} + lineNo = 0 + ) + ctx := c.Request.Context() + + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return resp, serializer.NewError(serializer.CodeParamErr, "Failed to parse CSV", err) + } + lineNo++ + + // 过滤空行 + if len(record) == 0 || (len(record) == 1 && strings.TrimSpace(record[0]) == "") { + continue + } + + // 跳过标题行 + if lineNo == 1 { + first := strings.ToLower(strings.TrimSpace(record[0])) + if first == "username" || first == "用户名" || first == "nick" || first == "name" { + continue + } + } + + if len(record) < 2 { + resp.Rows = append(resp.Rows, ImportUserRow{ + Line: lineNo, + Success: false, + Error: "密码列缺失", + }) + resp.Total++ + resp.Failed++ + continue + } + + username := strings.TrimSpace(record[0]) + password := strings.TrimSpace(record[1]) + + row := ImportUserRow{Line: lineNo, Username: username} + + if username == "" { + row.Error = "用户名为空" + resp.Rows = append(resp.Rows, row) + resp.Total++ + resp.Failed++ + continue + } + if password == "" { + row.Error = "密码为空" + resp.Rows = append(resp.Rows, row) + resp.Total++ + resp.Failed++ + continue + } + if len(password) > 128 { + row.Error = "密码超过 128 字符限制" + resp.Rows = append(resp.Rows, row) + resp.Total++ + resp.Failed++ + continue + } + + resp.Total++ + // 生成占位邮箱: username + 随机后缀 @ 默认域名 + email := fmt.Sprintf("%s_%s@%s", sanitizeEmailLocal(username), randomSuffix(), defaultEmailDomain) + + newUser, createErr := userClient.Create(ctx, &inventory.NewUserArgs{ + Email: email, + Nick: username, + PlainPassword: password, + Status: user.StatusActive, + GroupID: registerGroupID, + }) + if createErr != nil { + row.Error = createErr.Error() + resp.Rows = append(resp.Rows, row) + resp.Failed++ + continue + } + + row.Success = true + row.UserID = newUser.ID + resp.Rows = append(resp.Rows, row) + resp.Success++ + } + + return resp, nil +} + +// sanitizeEmailLocal 清理邮箱用户名部分非法字符 +func sanitizeEmailLocal(s string) string { + s = strings.ToValidUTF8(s, "_") + s = strings.Trim(s, ".-_") + if s == "" { + s = "user" + } + if len(s) > 40 { + s = s[:40] + } + return s +} + +// SetStatus 批量设置用户状态 +func (s *BatchUserStatusService) SetStatus(c *gin.Context) error { + dep := dependency.FromContext(c) + userClient := dep.UserClient() + ae := serializer.NewAggregateError() + ctx := context.Background() + current := inventory.UserFromContext(c) + + for _, id := range s.IDs { + key := strconv.Itoa(id) + // 阻止对超级管理员(uid=1) 禁用/封禁 + if id == 1 { + ae.Add(key, serializer.NewError(serializer.CodeInvalidActionOnDefaultUser, "不能修改初始管理员", nil)) + continue + } + // 不能对自己执行此操作 + if current != nil && current.ID == id { + ae.Add(key, serializer.NewError(serializer.CodeInvalidActionOnDefaultUser, "不能修改当前登录用户", nil)) + continue + } + + u, err := userClient.GetByID(ctx, id) + if err != nil { + ae.Add(key, serializer.NewError(serializer.CodeDBError, "用户不存在", err)) + continue + } + if _, err := userClient.SetStatus(ctx, u, s.Status); err != nil { + ae.Add(key, serializer.NewError(serializer.CodeDBError, "设置状态失败", err)) + } + } + return ae.Aggregate() +} + +// MoveGroup 批量移动用户到指定用户组 +func (s *BatchUserMoveGroupService) MoveGroup(c *gin.Context) error { + dep := dependency.FromContext(c) + userClient := dep.UserClient() + groupClient := dep.GroupClient() + ae := serializer.NewAggregateError() + ctx := context.Background() + current := inventory.UserFromContext(c) + + // 校验目标组存在 + if _, err := groupClient.GetByID(ctx, s.GroupID); err != nil { + return serializer.NewError(serializer.CodeParamErr, "目标用户组不存在", err) + } + + for _, id := range s.IDs { + key := strconv.Itoa(id) + // 阻止将初始管理员改组 + if id == 1 { + ae.Add(key, serializer.NewError(serializer.CodeInvalidActionOnDefaultUser, "不能修改初始管理员组", nil)) + continue + } + if current != nil && current.ID == id { + ae.Add(key, serializer.NewError(serializer.CodeInvalidActionOnDefaultUser, "不能修改当前登录用户组", nil)) + continue + } + + u, err := userClient.GetByID(ctx, id) + if err != nil { + ae.Add(key, serializer.NewError(serializer.CodeDBError, "用户不存在", err)) + continue + } + + updated := u + updated.GroupUsers = s.GroupID + if _, err := userClient.Upsert(ctx, updated, "", ""); err != nil { + ae.Add(key, serializer.NewError(serializer.CodeDBError, "移动用户组失败", err)) + } + } + + return ae.Aggregate() +} + +// ensure types 断言占位,保证 ent.User 引用被正确导入 +var _ = &ent.User{}