feat(admin): 用户管理增强 - CSV 批量导入用户、批量状态/组操作、登录审计日志
Build and Deploy / build-and-deploy (push) Canceled after 0s
Build and Deploy / build-and-deploy (push) Canceled after 0s
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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{}
|
||||
Reference in New Issue
Block a user