feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
2026-04-02 11:19:50 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/csv"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/xuri/excelize/v2"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/user-management-system/internal/domain"
|
2026-04-07 12:08:16 +08:00
|
|
|
|
"github.com/user-management-system/internal/pagination"
|
feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
2026-04-02 11:19:50 +08:00
|
|
|
|
"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 {
|
2026-04-07 12:08:16 +08:00
|
|
|
|
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
|
feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
2026-04-02 11:19:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:08:16 +08:00
|
|
|
|
// 按状态查询(仅当明确指定了状态时才筛选)
|
|
|
|
|
|
if req.Status != nil && (*req.Status == 0 || *req.Status == 1) {
|
|
|
|
|
|
return s.loginLogRepo.ListByStatus(ctx, *req.Status, offset, req.PageSize)
|
feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
2026-04-02 11:19:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.loginLogRepo.List(ctx, offset, req.PageSize)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:08:16 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
2026-04-02 11:19:50 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:08:16 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
2026-04-02 11:19:50 +08:00
|
|
|
|
logs, err := s.loginLogRepo.ListAllForExport(ctx, req.UserID, req.Status, startAt, endAt)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", "", fmt.Errorf("查询登录日志失败: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:08:16 +08:00
|
|
|
|
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
|
feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
2026-04-02 11:19:50 +08:00
|
|
|
|
|
2026-04-07 12:08:16 +08:00
|
|
|
|
for {
|
|
|
|
|
|
logs, hasMore, err := s.loginLogRepo.ListLogsForExportBatch(ctx, userID, status, startAt, endAt, cursor, batchSize)
|
feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
2026-04-02 11:19:50 +08:00
|
|
|
|
if err != nil {
|
2026-04-07 12:08:16 +08:00
|
|
|
|
return nil, "", fmt.Errorf("查询登录日志失败: %w", err)
|
feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
2026-04-02 11:19:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:08:16 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
2026-04-02 11:19:50 +08:00
|
|
|
|
}
|
2026-04-07 12:08:16 +08:00
|
|
|
|
|
|
|
|
|
|
filename := fmt.Sprintf("login_logs_%s.csv", time.Now().Format("20060102_150405"))
|
|
|
|
|
|
return buf.Bytes(), filename, nil
|
feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
2026-04-02 11:19:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|