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 }