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:
@@ -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 .
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用户
|
||||
|
||||
@@ -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