Files
BYWCloudreve/service/admin/user_batch.go
T
2026-09-13 12:09:55 +08:00

295 lines
7.9 KiB
Go

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