feat(admin): 用户管理增强 - CSV 批量导入用户、批量状态/组操作、登录审计日志
Build and Deploy / build-and-deploy (push) Canceled after 0s

This commit is contained in:
2026-09-13 12:09:55 +08:00
parent 32959c7330
commit 62e2250202
10 changed files with 884 additions and 0 deletions
+4
View File
@@ -52,6 +52,10 @@ RUN --mount=type=cache,target=/go/pkg/mod \
COPY . . COPY . .
COPY --from=frontend-build /build/application/statics/assets.zip ./application/statics/assets.zip 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 \ RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \ --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 . go build -trimpath -ldflags "-s -w -X 'github.com/cloudreve/Cloudreve/v4/pkg/conf.BackendVersion=byw-${BUILD_DATE:-dev}'" -o /cloudreve .
+11
View File
@@ -106,6 +106,8 @@ type Dep interface {
ShareClient() inventory.ShareClient ShareClient() inventory.ShareClient
// TaskClient Creates a new inventory.TaskClient instance for access DB task store. // TaskClient Creates a new inventory.TaskClient instance for access DB task store.
TaskClient() inventory.TaskClient 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 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 ForkWithLogger(ctx context.Context, l logging.Logger) context.Context
// MediaMetaQueue Get a singleton queue.Queue instance for media metadata processing. // MediaMetaQueue Get a singleton queue.Queue instance for media metadata processing.
@@ -170,6 +172,7 @@ type dependency struct {
directLinkClient inventory.DirectLinkClient directLinkClient inventory.DirectLinkClient
fsEventClient inventory.FsEventClient fsEventClient inventory.FsEventClient
oAuthClient inventory.OAuthClientClient oAuthClient inventory.OAuthClientClient
loginAuditClient inventory.LoginAuditClient
emailClient email.Driver emailClient email.Driver
generalAuth auth.Auth generalAuth auth.Auth
hashidEncoder hashid.Encoder hashidEncoder hashid.Encoder
@@ -820,6 +823,14 @@ func (d *dependency) TaskClient() inventory.TaskClient {
return inventory.NewTaskClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder()) 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 { func (d *dependency) DavAccountClient() inventory.DavAccountClient {
if d.davAccountClient != nil { if d.davAccountClient != nil {
return d.davAccountClient return d.davAccountClient
+81
View File
@@ -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"),
}
}
+1
View File
@@ -54,6 +54,7 @@ func (User) Edges() []ent.Edge {
edge.To("fsevents", FsEvent.Type), edge.To("fsevents", FsEvent.Type),
edge.To("entities", Entity.Type), edge.To("entities", Entity.Type),
edge.To("oauth_grants", OAuthGrant.Type), edge.To("oauth_grants", OAuthGrant.Type),
edge.To("login_audits", LoginAudit.Type),
} }
} }
+224
View File
@@ -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
}
+59
View File
@@ -664,3 +664,62 @@ func AdminBatchDeleteOAuthClient(c *gin.Context) {
} }
c.JSON(200, serializer.Response{}) 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})
}
+47
View File
@@ -1,6 +1,8 @@
package controllers package controllers
import ( import (
"time"
"github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent" "github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory" "github.com/cloudreve/Cloudreve/v4/inventory"
@@ -13,6 +15,37 @@ import (
"github.com/samber/lo" "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登录 // StartLoginAuthn 开始注册WebAuthn登录
func StartLoginAuthn(c *gin.Context) { func StartLoginAuthn(c *gin.Context) {
res, err := user.PreparePasskeyLogin(c) res, err := user.PreparePasskeyLogin(c)
@@ -29,6 +62,7 @@ func FinishLoginAuthn(c *gin.Context) {
service := ParametersFromContext[*user.FinishPasskeyLoginService](c, user.FinishPasskeyLoginParameterCtx{}) service := ParametersFromContext[*user.FinishPasskeyLoginService](c, user.FinishPasskeyLoginParameterCtx{})
u, err := service.FinishPasskeyLogin(c) u, err := service.FinishPasskeyLogin(c)
if err != nil { if err != nil {
recordLoginAuditAsync(c, nil, false, ptrString(err.Error()), nil)
c.JSON(200, serializer.Err(c, err)) c.JSON(200, serializer.Err(c, err))
c.Abort() c.Abort()
return return
@@ -77,6 +111,7 @@ func UserLoginValidation(c *gin.Context) {
service := ParametersFromContext[*user.UserLoginService](c, user.LoginParameterCtx{}) service := ParametersFromContext[*user.UserLoginService](c, user.LoginParameterCtx{})
expectedUser, twoFaSession, err := service.Login(c) expectedUser, twoFaSession, err := service.Login(c)
if err != nil { if err != nil {
recordLoginAuditAsync(c, nil, false, ptrString(err.Error()), ptrString(service.UserName))
c.JSON(200, serializer.Err(c, err)) c.JSON(200, serializer.Err(c, err))
c.Abort() c.Abort()
return return
@@ -98,6 +133,7 @@ func UserLogin2FAValidation(c *gin.Context) {
service := ParametersFromContext[*user.OtpValidationService](c, user.OtpValidationParameterCtx{}) service := ParametersFromContext[*user.OtpValidationService](c, user.OtpValidationParameterCtx{})
expectedUser, err := service.Verify2FA(c) expectedUser, err := service.Verify2FA(c)
if err != nil { if err != nil {
recordLoginAuditAsync(c, nil, false, ptrString(err.Error()), nil)
c.JSON(200, serializer.Err(c, err)) c.JSON(200, serializer.Err(c, err))
c.Abort() c.Abort()
return return
@@ -116,6 +152,12 @@ func UserIssueToken(c *gin.Context) {
return 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{ c.JSON(200, serializer.Response{
Data: resp, Data: resp,
}) })
@@ -180,6 +222,11 @@ func UserSignOut(c *gin.Context) {
return return
} }
// 标记登出时间
if current := inventory.UserFromContext(c); current != nil && !inventory.IsAnonymousUser(current) {
markLogoutAsync(c, current.ID)
}
c.JSON(200, serializer.Response{ c.JSON(200, serializer.Response{
Data: res, Data: res,
}) })
+34
View File
@@ -1144,6 +1144,11 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.FromJSON[adminsvc.UpsertUserService](adminsvc.UpsertUserParamCtx{}), controllers.FromJSON[adminsvc.UpsertUserService](adminsvc.UpsertUserParamCtx{}),
controllers.AdminCreateUser, controllers.AdminCreateUser,
) )
// CSV 批量导入用户
user.POST("import/csv",
middleware.RequiredScopes(types.ScopeAdminWrite),
controllers.AdminImportUsersCSV,
)
batch := user.Group("batch") batch := user.Group("batch")
{ {
// 批量删除用户 // 批量删除用户
@@ -1152,6 +1157,18 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.FromJSON[adminsvc.BatchUserService](adminsvc.BatchUserParamCtx{}), controllers.FromJSON[adminsvc.BatchUserService](adminsvc.BatchUserParamCtx{}),
controllers.AdminDeleteUser, 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", user.POST(":id/calibrate",
middleware.RequiredScopes(types.ScopeAdminWrite), middleware.RequiredScopes(types.ScopeAdminWrite),
@@ -1235,6 +1252,23 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.AdminBatchDeleteShare, 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,
)
}
}
} }
// 用户 // 用户
+129
View File
@@ -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)
}
+294
View File
@@ -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{}