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