Files
user-system/internal/service/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

450 lines
12 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 service
import (
"bytes"
"context"
"encoding/csv"
"fmt"
"time"
"github.com/xuri/excelize/v2"
"github.com/user-management-system/internal/domain"
"github.com/user-management-system/internal/pagination"
"github.com/user-management-system/internal/repository"
)
// LoginLogService 登录日志服务
type LoginLogService struct {
loginLogRepo *repository.LoginLogRepository
}
// NewLoginLogService 创建登录日志服务
func NewLoginLogService(loginLogRepo *repository.LoginLogRepository) *LoginLogService {
return &LoginLogService{loginLogRepo: loginLogRepo}
}
// RecordLogin 记录登录日志
func (s *LoginLogService) RecordLogin(ctx context.Context, req *RecordLoginRequest) error {
log := &domain.LoginLog{
LoginType: req.LoginType,
DeviceID: req.DeviceID,
IP: req.IP,
Location: req.Location,
Status: req.Status,
FailReason: req.FailReason,
}
if req.UserID != 0 {
log.UserID = &req.UserID
}
return s.loginLogRepo.Create(ctx, log)
}
// RecordLoginRequest 记录登录请求
type RecordLoginRequest struct {
UserID int64 `json:"user_id"`
LoginType int `json:"login_type"` // 1-用户名, 2-邮箱, 3-手机
DeviceID string `json:"device_id"`
IP string `json:"ip"`
Location string `json:"location"`
Status int `json:"status"` // 0-失败, 1-成功
FailReason string `json:"fail_reason"`
}
// ListLoginLogRequest 登录日志列表请求
type ListLoginLogRequest struct {
UserID int64 `json:"user_id" form:"user_id"`
Status *int `json:"status" form:"status"` // 0-失败, 1-成功, nil-不筛选
Page int `json:"page" form:"page"`
PageSize int `json:"page_size" form:"page_size"`
StartAt string `json:"start_at" form:"start_at"`
EndAt string `json:"end_at" form:"end_at"`
// Cursor-based pagination (preferred over Page/PageSize)
Cursor string `form:"cursor"` // Opaque cursor from previous response
Size int `form:"size"` // Page size when using cursor mode
}
// GetLoginLogs 获取登录日志列表
func (s *LoginLogService) GetLoginLogs(ctx context.Context, req *ListLoginLogRequest) ([]*domain.LoginLog, int64, error) {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
offset := (req.Page - 1) * req.PageSize
// 按用户 ID 查询
if req.UserID > 0 {
return s.loginLogRepo.ListByUserID(ctx, req.UserID, offset, req.PageSize)
}
// 按时间范围查询
if req.StartAt != "" && req.EndAt != "" {
start, err1 := time.Parse(time.RFC3339, req.StartAt)
end, err2 := time.Parse(time.RFC3339, req.EndAt)
if err1 == nil && err2 == nil {
return s.loginLogRepo.ListByTimeRange(ctx, start, end, offset, req.PageSize)
}
}
// 按状态查询(仅当明确指定了状态时才筛选)
if req.Status != nil && (*req.Status == 0 || *req.Status == 1) {
return s.loginLogRepo.ListByStatus(ctx, *req.Status, offset, req.PageSize)
}
return s.loginLogRepo.List(ctx, offset, req.PageSize)
}
// CursorResult wraps cursor-based pagination response
type CursorResult struct {
Items interface{} `json:"items"`
NextCursor string `json:"next_cursor"`
HasMore bool `json:"has_more"`
PageSize int `json:"page_size"`
}
// GetLoginLogsCursor 游标分页获取登录日志列表(推荐使用)
func (s *LoginLogService) GetLoginLogsCursor(ctx context.Context, req *ListLoginLogRequest) (*CursorResult, error) {
size := pagination.ClampPageSize(req.Size)
if req.PageSize > 0 && req.Cursor == "" {
size = pagination.ClampPageSize(req.PageSize)
}
cursor, err := pagination.Decode(req.Cursor)
if err != nil {
return nil, fmt.Errorf("invalid cursor: %w", err)
}
var items interface{}
var nextCursor string
var hasMore bool
// 按用户 ID 查询
if req.UserID > 0 {
logs, hm, err := s.loginLogRepo.ListByUserIDCursor(ctx, req.UserID, size, cursor)
if err != nil {
return nil, err
}
items = logs
hasMore = hm
} else if req.StartAt != "" && req.EndAt != "" {
// Time range: fall back to offset-based for now (cursor + time range is complex)
start, err1 := time.Parse(time.RFC3339, req.StartAt)
end, err2 := time.Parse(time.RFC3339, req.EndAt)
if err1 == nil && err2 == nil {
offset := 0
logs, _, err := s.loginLogRepo.ListByTimeRange(ctx, start, end, offset, size)
if err != nil {
return nil, err
}
items = logs
if len(logs) > 0 {
last := logs[len(logs)-1]
nextCursor = pagination.BuildNextCursor(last.ID, last.CreatedAt)
hasMore = len(logs) == size
}
} else {
items = []*domain.LoginLog{}
}
} else if req.Status != nil && (*req.Status == 0 || *req.Status == 1) {
// Status filter: use ListCursor with manual status filter
logs, hm, err := s.listByStatusCursor(ctx, *req.Status, size, cursor)
if err != nil {
return nil, err
}
items = logs
hasMore = hm
} else {
// Default: full table cursor scan
logs, hm, err := s.loginLogRepo.ListCursor(ctx, size, cursor)
if err != nil {
return nil, err
}
items = logs
hasMore = hm
}
// Build next cursor from the last item
if nextCursor == "" {
switch items := items.(type) {
case []*domain.LoginLog:
if len(items) > 0 {
last := items[len(items)-1]
nextCursor = pagination.BuildNextCursor(last.ID, last.CreatedAt)
}
}
}
return &CursorResult{
Items: items,
NextCursor: nextCursor,
HasMore: hasMore,
PageSize: size,
}, nil
}
// listByStatusCursor 游标分页按状态查询(内部方法)
// Uses iterative approach: fetch from ListCursor and post-filter by status.
func (s *LoginLogService) listByStatusCursor(ctx context.Context, status int, limit int, cursor *pagination.Cursor) ([]*domain.LoginLog, bool, error) {
var logs []*domain.LoginLog
// Since LoginLogRepository doesn't have status+cursor combined,
// we use a larger batch from ListCursor and post-filter.
batchSize := limit + 1
for attempts := 0; attempts < 10; attempts++ { // max 10 pages of skipping
batch, hm, err := s.loginLogRepo.ListCursor(ctx, batchSize, cursor)
if err != nil {
return nil, false, err
}
for _, log := range batch {
if log.Status == status {
logs = append(logs, log)
if len(logs) >= limit+1 {
break
}
}
}
if len(logs) >= limit+1 || !hm || len(batch) == 0 {
break
}
// Advance cursor to end of this batch
if len(batch) > 0 {
last := batch[len(batch)-1]
cursor = &pagination.Cursor{LastID: last.ID, LastValue: last.CreatedAt}
}
}
hasMore := len(logs) > limit
if hasMore {
logs = logs[:limit]
}
return logs, hasMore, nil
}
// GetMyLoginLogs 获取当前用户的登录日志
func (s *LoginLogService) GetMyLoginLogs(ctx context.Context, userID int64, page, pageSize int) ([]*domain.LoginLog, int64, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
offset := (page - 1) * pageSize
return s.loginLogRepo.ListByUserID(ctx, userID, offset, pageSize)
}
// CleanupOldLogs 清理旧日志(保留最近 N 天)
func (s *LoginLogService) CleanupOldLogs(ctx context.Context, retentionDays int) error {
return s.loginLogRepo.DeleteOlderThan(ctx, retentionDays)
}
// ExportLoginLogRequest 导出登录日志请求
type ExportLoginLogRequest struct {
UserID int64 `form:"user_id"`
Status int `form:"status"`
Format string `form:"format"`
StartAt string `form:"start_at"`
EndAt string `form:"end_at"`
}
// ExportLoginLogs 导出登录日志
func (s *LoginLogService) ExportLoginLogs(ctx context.Context, req *ExportLoginLogRequest) ([]byte, string, string, error) {
format := "csv"
if req.Format == "xlsx" {
format = "xlsx"
}
var startAt, endAt *time.Time
if req.StartAt != "" {
if t, err := time.Parse(time.RFC3339, req.StartAt); err == nil {
startAt = &t
}
}
if req.EndAt != "" {
if t, err := time.Parse(time.RFC3339, req.EndAt); err == nil {
endAt = &t
}
}
// CSV 使用流式分批导出XLSX 使用全量导出excelize 需要所有行)
if format == "csv" {
data, filename, err := s.exportLoginLogsCSVStream(ctx, req.UserID, req.Status, startAt, endAt)
if err != nil {
return nil, "", "", err
}
return data, filename, "text/csv; charset=utf-8", nil
}
logs, err := s.loginLogRepo.ListAllForExport(ctx, req.UserID, req.Status, startAt, endAt)
if err != nil {
return nil, "", "", fmt.Errorf("查询登录日志失败: %w", err)
}
filename := fmt.Sprintf("login_logs_%s.xlsx", time.Now().Format("20060102_150405"))
data, err := buildLoginLogXLSXExport(logs)
if err != nil {
return nil, "", "", err
}
return data, filename, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", nil
}
// exportLoginLogsCSVStream 流式导出 CSV分批处理防止 OOM
func (s *LoginLogService) exportLoginLogsCSVStream(ctx context.Context, userID int64, status int, startAt, endAt *time.Time) ([]byte, string, error) {
headers := []string{"ID", "用户ID", "登录方式", "设备ID", "IP地址", "位置", "状态", "失败原因", "时间"}
var buf bytes.Buffer
buf.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(&buf)
// 写入表头
if err := writer.Write(headers); err != nil {
return nil, "", fmt.Errorf("写CSV表头失败: %w", err)
}
// 使用游标分批获取数据
cursor := int64(1<<63 - 1) // 从最大 ID 开始
batchSize := 5000
totalWritten := 0
for {
logs, hasMore, err := s.loginLogRepo.ListLogsForExportBatch(ctx, userID, status, startAt, endAt, cursor, batchSize)
if err != nil {
return nil, "", fmt.Errorf("查询登录日志失败: %w", err)
}
for _, log := range logs {
row := []string{
fmt.Sprintf("%d", log.ID),
fmt.Sprintf("%d", derefInt64(log.UserID)),
loginTypeLabel(log.LoginType),
log.DeviceID,
log.IP,
log.Location,
loginStatusLabel(log.Status),
log.FailReason,
log.CreatedAt.Format("2006-01-02 15:04:05"),
}
if err := writer.Write(row); err != nil {
return nil, "", fmt.Errorf("写CSV行失败: %w", err)
}
totalWritten++
cursor = log.ID
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, "", fmt.Errorf("CSV Flush 失败: %w", err)
}
// 如果数据量过大,提前终止
if totalWritten >= repository.ExportBatchSize {
break
}
if !hasMore || len(logs) == 0 {
break
}
}
filename := fmt.Sprintf("login_logs_%s.csv", time.Now().Format("20060102_150405"))
return buf.Bytes(), filename, nil
}
func buildLoginLogCSVExport(logs []*domain.LoginLog) ([]byte, error) {
headers := []string{"ID", "用户ID", "登录方式", "设备ID", "IP地址", "位置", "状态", "失败原因", "时间"}
rows := make([][]string, 0, len(logs)+1)
rows = append(rows, headers)
for _, log := range logs {
rows = append(rows, []string{
fmt.Sprintf("%d", log.ID),
fmt.Sprintf("%d", derefInt64(log.UserID)),
loginTypeLabel(log.LoginType),
log.DeviceID,
log.IP,
log.Location,
loginStatusLabel(log.Status),
log.FailReason,
log.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
var buf bytes.Buffer
buf.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(&buf)
if err := writer.WriteAll(rows); err != nil {
return nil, fmt.Errorf("写CSV失败: %w", err)
}
return buf.Bytes(), nil
}
func buildLoginLogXLSXExport(logs []*domain.LoginLog) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
sheet := file.GetSheetName(file.GetActiveSheetIndex())
if sheet == "" {
sheet = "Sheet1"
}
headers := []string{"ID", "用户ID", "登录方式", "设备ID", "IP地址", "位置", "状态", "失败原因", "时间"}
for idx, header := range headers {
cell, _ := excelize.CoordinatesToCellName(idx+1, 1)
_ = file.SetCellValue(sheet, cell, header)
}
for rowIdx, log := range logs {
row := []string{
fmt.Sprintf("%d", log.ID),
fmt.Sprintf("%d", derefInt64(log.UserID)),
loginTypeLabel(log.LoginType),
log.DeviceID,
log.IP,
log.Location,
loginStatusLabel(log.Status),
log.FailReason,
log.CreatedAt.Format("2006-01-02 15:04:05"),
}
for colIdx, value := range row {
cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
_ = file.SetCellValue(sheet, cell, value)
}
}
var buf bytes.Buffer
if _, err := file.WriteTo(&buf); err != nil {
return nil, fmt.Errorf("生成Excel失败: %w", err)
}
return buf.Bytes(), nil
}
func loginTypeLabel(t int) string {
switch t {
case 1:
return "密码登录"
case 2:
return "邮箱验证码"
case 3:
return "手机验证码"
case 4:
return "OAuth"
default:
return "未知"
}
}
func loginStatusLabel(s int) string {
if s == 1 {
return "成功"
}
return "失败"
}
func derefInt64(v *int64) int64 {
if v == nil {
return 0
}
return *v
}