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