Files
user-system/internal/repository/login_log.go
long-agent 5ca3633be4 feat: 系统全面优化 - 设备管理/登录日志导出/性能监控/设置页面
后端:
- 新增全局设备管理 API(DeviceHandler.GetAllDevices)
- 新增登录日志导出功能(LogHandler.ExportLoginLogs, CSV/XLSX)
- 新增设置服务(SettingsService)和设置页面 API
- 设备管理支持多条件筛选(状态/信任状态/关键词)
- 登录日志支持流式导出防 OOM
- 操作日志支持按方法/时间范围搜索
- 主题配置服务(ThemeService)
- 增强监控健康检查(Prometheus metrics + SLO)
- 移除旧 ratelimit.go(已迁移至 robustness)
- 修复 SocialAccount NULL 扫描问题
- 新增 API 契约测试、Handler 测试、Settings 测试

前端:
- 新增管理员设备管理页面(DevicesPage)
- 新增管理员登录日志导出功能
- 新增系统设置页面(SettingsPage)
- 设备管理支持筛选和分页
- 增强 HTTP 响应类型

测试:
- 业务逻辑测试 68 个(含并发 CONC_001~003)
- 规模测试 16 个(P99 百分位统计)
- E2E 测试、集成测试、契约测试
- 性能基准测试、鲁棒性测试

全面测试通过(38 个测试包)
2026-04-07 12:08:16 +08:00

223 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"context"
"time"
"gorm.io/gorm"
"github.com/user-management-system/internal/domain"
"github.com/user-management-system/internal/pagination"
)
// LoginLogRepository 登录日志仓储
type LoginLogRepository struct {
db *gorm.DB
}
// NewLoginLogRepository 创建登录日志仓储
func NewLoginLogRepository(db *gorm.DB) *LoginLogRepository {
return &LoginLogRepository{db: db}
}
// Create 创建登录日志
func (r *LoginLogRepository) Create(ctx context.Context, log *domain.LoginLog) error {
return r.db.WithContext(ctx).Create(log).Error
}
// GetByID 根据ID获取登录日志
func (r *LoginLogRepository) GetByID(ctx context.Context, id int64) (*domain.LoginLog, error) {
var log domain.LoginLog
if err := r.db.WithContext(ctx).First(&log, id).Error; err != nil {
return nil, err
}
return &log, nil
}
// ListByUserID 获取用户的登录日志列表
func (r *LoginLogRepository) ListByUserID(ctx context.Context, userID int64, offset, limit int) ([]*domain.LoginLog, int64, error) {
var logs []*domain.LoginLog
var total int64
query := r.db.WithContext(ctx).Model(&domain.LoginLog{}).Where("user_id = ?", userID)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
}
// List 获取登录日志列表(管理员用)
func (r *LoginLogRepository) List(ctx context.Context, offset, limit int) ([]*domain.LoginLog, int64, error) {
var logs []*domain.LoginLog
var total int64
query := r.db.WithContext(ctx).Model(&domain.LoginLog{})
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
}
// ListByStatus 按状态查询登录日志
func (r *LoginLogRepository) ListByStatus(ctx context.Context, status int, offset, limit int) ([]*domain.LoginLog, int64, error) {
var logs []*domain.LoginLog
var total int64
query := r.db.WithContext(ctx).Model(&domain.LoginLog{}).Where("status = ?", status)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
}
// ListByTimeRange 按时间范围查询登录日志
func (r *LoginLogRepository) ListByTimeRange(ctx context.Context, start, end time.Time, offset, limit int) ([]*domain.LoginLog, int64, error) {
var logs []*domain.LoginLog
var total int64
query := r.db.WithContext(ctx).Model(&domain.LoginLog{}).
Where("created_at >= ? AND created_at <= ?", start, end)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
}
// DeleteByUserID 删除用户所有登录日志
func (r *LoginLogRepository) DeleteByUserID(ctx context.Context, userID int64) error {
return r.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&domain.LoginLog{}).Error
}
// DeleteOlderThan 删除指定天数前的日志
func (r *LoginLogRepository) DeleteOlderThan(ctx context.Context, days int) error {
cutoff := time.Now().AddDate(0, 0, -days)
return r.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(&domain.LoginLog{}).Error
}
// CountByResultSince 统计指定时间之后特定结果的登录次数
// success=true 统计成功次数false 统计失败次数
func (r *LoginLogRepository) CountByResultSince(ctx context.Context, success bool, since time.Time) int64 {
status := 0 // 失败
if success {
status = 1 // 成功
}
var count int64
r.db.WithContext(ctx).Model(&domain.LoginLog{}).
Where("status = ? AND created_at >= ?", status, since).
Count(&count)
return count
}
// ListAllForExport 获取所有登录日志(用于导出,无分页)
func (r *LoginLogRepository) ListAllForExport(ctx context.Context, userID int64, status int, startAt, endAt *time.Time) ([]*domain.LoginLog, error) {
var logs []*domain.LoginLog
query := r.db.WithContext(ctx).Model(&domain.LoginLog{})
if userID > 0 {
query = query.Where("user_id = ?", userID)
}
if status == 0 || status == 1 {
query = query.Where("status = ?", status)
}
if startAt != nil {
query = query.Where("created_at >= ?", startAt)
}
if endAt != nil {
query = query.Where("created_at <= ?", endAt)
}
if err := query.Order("created_at DESC").Find(&logs).Error; err != nil {
return nil, err
}
return logs, nil
}
// ExportBatchSize 单次导出的最大记录数
const ExportBatchSize = 100000
// ListLogsForExportBatch 分批获取登录日志(用于流式导出)
// cursor 是上一次最后一条记录的 IDlimit 是每批数量
func (r *LoginLogRepository) ListLogsForExportBatch(ctx context.Context, userID int64, status int, startAt, endAt *time.Time, cursor int64, limit int) ([]*domain.LoginLog, bool, error) {
var logs []*domain.LoginLog
query := r.db.WithContext(ctx).Model(&domain.LoginLog{}).Where("id < ?", cursor)
if userID > 0 {
query = query.Where("user_id = ?", userID)
}
if status == 0 || status == 1 {
query = query.Where("status = ?", status)
}
if startAt != nil {
query = query.Where("created_at >= ?", startAt)
}
if endAt != nil {
query = query.Where("created_at <= ?", endAt)
}
if err := query.Order("id DESC").Limit(limit).Find(&logs).Error; err != nil {
return nil, false, err
}
hasMore := len(logs) == limit
return logs, hasMore, nil
}
// ListCursor 游标分页查询登录日志(管理员用)
// Uses keyset pagination: WHERE (created_at < ? OR (created_at = ? AND id < ?))
// This avoids the O(offset) deep-pagination problem of OFFSET/LIMIT.
func (r *LoginLogRepository) ListCursor(ctx context.Context, limit int, cursor *pagination.Cursor) ([]*domain.LoginLog, bool, error) {
var logs []*domain.LoginLog
query := r.db.WithContext(ctx).Model(&domain.LoginLog{})
// Apply cursor condition for keyset navigation
if cursor != nil && cursor.LastID > 0 {
query = query.Where(
"(created_at < ? OR (created_at = ? AND id < ?))",
cursor.LastValue, cursor.LastValue, cursor.LastID,
)
}
if err := query.Order("created_at DESC, id DESC").Limit(limit + 1).Find(&logs).Error; err != nil {
return nil, false, err
}
hasMore := len(logs) > limit
if hasMore {
logs = logs[:limit]
}
return logs, hasMore, nil
}
// ListByUserIDCursor 按用户ID游标分页查询登录日志
func (r *LoginLogRepository) ListByUserIDCursor(ctx context.Context, userID int64, limit int, cursor *pagination.Cursor) ([]*domain.LoginLog, bool, error) {
var logs []*domain.LoginLog
query := r.db.WithContext(ctx).Model(&domain.LoginLog{}).Where("user_id = ?", userID)
if cursor != nil && cursor.LastID > 0 {
query = query.Where(
"(created_at < ? OR (created_at = ? AND id < ?))",
cursor.LastValue, cursor.LastValue, cursor.LastID,
)
}
if err := query.Order("created_at DESC, id DESC").Limit(limit + 1).Find(&logs).Error; err != nil {
return nil, false, err
}
hasMore := len(logs) > limit
if hasMore {
logs = logs[:limit]
}
return logs, hasMore, nil
}