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