package service import ( "bytes" "context" "encoding/csv" "fmt" "strings" "time" "github.com/xuri/excelize/v2" "github.com/user-management-system/internal/auth" "github.com/user-management-system/internal/domain" "github.com/user-management-system/internal/repository" ) const ( ExportFormatCSV = "csv" ExportFormatXLSX = "xlsx" ) // ExportUsersRequest defines the supported export filters and output options. type ExportUsersRequest struct { Format string Fields []string Keyword string Status *int } type exportColumn struct { Key string Header string Value func(*domain.User) string } var defaultExportColumns = []exportColumn{ {Key: "id", Header: "ID", Value: func(u *domain.User) string { return fmt.Sprintf("%d", u.ID) }}, {Key: "username", Header: "用户名", Value: func(u *domain.User) string { return u.Username }}, {Key: "email", Header: "邮箱", Value: func(u *domain.User) string { return domain.DerefStr(u.Email) }}, {Key: "phone", Header: "手机号", Value: func(u *domain.User) string { return domain.DerefStr(u.Phone) }}, {Key: "nickname", Header: "昵称", Value: func(u *domain.User) string { return u.Nickname }}, {Key: "avatar", Header: "头像", Value: func(u *domain.User) string { return u.Avatar }}, {Key: "gender", Header: "性别", Value: func(u *domain.User) string { return genderLabel(u.Gender) }}, {Key: "status", Header: "状态", Value: func(u *domain.User) string { return userStatusLabel(u.Status) }}, {Key: "region", Header: "地区", Value: func(u *domain.User) string { return u.Region }}, {Key: "bio", Header: "个人简介", Value: func(u *domain.User) string { return u.Bio }}, {Key: "totp_enabled", Header: "TOTP已启用", Value: func(u *domain.User) string { return boolLabel(u.TOTPEnabled) }}, {Key: "last_login_time", Header: "最后登录时间", Value: func(u *domain.User) string { return timeLabel(u.LastLoginTime) }}, {Key: "last_login_ip", Header: "最后登录IP", Value: func(u *domain.User) string { return u.LastLoginIP }}, {Key: "created_at", Header: "注册时间", Value: func(u *domain.User) string { return u.CreatedAt.Format("2006-01-02 15:04:05") }}, } // ExportService 用户数据导入导出服务 type ExportService struct { userRepo *repository.UserRepository roleRepo *repository.RoleRepository } // NewExportService 创建导入导出服务 func NewExportService( userRepo *repository.UserRepository, roleRepo *repository.RoleRepository, ) *ExportService { return &ExportService{ userRepo: userRepo, roleRepo: roleRepo, } } // ExportUsers exports users as CSV or XLSX. func (s *ExportService) ExportUsers(ctx context.Context, req *ExportUsersRequest) ([]byte, string, string, error) { if req == nil { req = &ExportUsersRequest{} } format, err := normalizeExportFormat(req.Format) if err != nil { return nil, "", "", err } columns, err := resolveExportColumns(req.Fields) if err != nil { return nil, "", "", err } users, err := s.listUsersForExport(ctx, req) if err != nil { return nil, "", "", err } filename := fmt.Sprintf("users_%s.%s", time.Now().Format("20060102_150405"), format) switch format { case ExportFormatCSV: data, err := buildCSVExport(columns, users) if err != nil { return nil, "", "", err } return data, filename, "text/csv; charset=utf-8", nil case ExportFormatXLSX: data, err := buildXLSXExport(columns, users) if err != nil { return nil, "", "", err } return data, filename, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", nil default: return nil, "", "", fmt.Errorf("不支持的导出格式: %s", req.Format) } } // ExportUsersCSV keeps backward compatibility for callers that still expect CSV-only export. func (s *ExportService) ExportUsersCSV(ctx context.Context) ([]byte, string, error) { data, filename, _, err := s.ExportUsers(ctx, &ExportUsersRequest{Format: ExportFormatCSV}) return data, filename, err } // ExportUsersXLSX exports users as Excel. func (s *ExportService) ExportUsersXLSX(ctx context.Context) ([]byte, string, error) { data, filename, _, err := s.ExportUsers(ctx, &ExportUsersRequest{Format: ExportFormatXLSX}) return data, filename, err } func (s *ExportService) listUsersForExport(ctx context.Context, req *ExportUsersRequest) ([]*domain.User, error) { var allUsers []*domain.User offset := 0 batchSize := 500 for { var ( users []*domain.User total int64 err error ) if req.Keyword != "" || req.Status != nil { filter := &repository.AdvancedFilter{ Keyword: req.Keyword, Status: -1, SortBy: "created_at", SortOrder: "desc", Offset: offset, Limit: batchSize, } if req.Status != nil { filter.Status = *req.Status } users, total, err = s.userRepo.AdvancedSearch(ctx, filter) if err != nil { return nil, fmt.Errorf("查询用户失败: %w", err) } allUsers = append(allUsers, users...) offset += len(users) if offset >= int(total) || len(users) == 0 { break } continue } users, _, err = s.userRepo.List(ctx, offset, batchSize) if err != nil { return nil, fmt.Errorf("查询用户失败: %w", err) } allUsers = append(allUsers, users...) if len(users) < batchSize { break } offset += batchSize } return allUsers, nil } // ImportUsers imports users from CSV or XLSX. func (s *ExportService) ImportUsers(ctx context.Context, data []byte, format string) (successCount, failCount int, errs []string) { normalized, err := normalizeExportFormat(format) if err != nil { return 0, 0, []string{err.Error()} } var records [][]string switch normalized { case ExportFormatCSV: records, err = parseCSVRecords(data) case ExportFormatXLSX: records, err = parseXLSXRecords(data) default: err = fmt.Errorf("不支持的导入格式: %s", format) } if err != nil { return 0, 0, []string{err.Error()} } return s.importUsersRecords(ctx, records) } // ImportUsersCSV keeps backward compatibility for callers that still upload CSV. func (s *ExportService) ImportUsersCSV(ctx context.Context, data []byte) (successCount, failCount int, errs []string) { return s.ImportUsers(ctx, data, ExportFormatCSV) } // ImportUsersXLSX imports users from Excel. func (s *ExportService) ImportUsersXLSX(ctx context.Context, data []byte) (successCount, failCount int, errs []string) { return s.ImportUsers(ctx, data, ExportFormatXLSX) } func (s *ExportService) importUsersRecords(ctx context.Context, records [][]string) (successCount, failCount int, errs []string) { if len(records) < 2 { return 0, 0, []string{"导入文件为空或没有数据行"} } headers := records[0] colIdx := buildColIndex(headers) getCol := func(row []string, name string) string { idx, ok := colIdx[name] if !ok || idx >= len(row) { return "" } return strings.TrimSpace(row[idx]) } for i, row := range records[1:] { lineNum := i + 2 username := getCol(row, "用户名") password := getCol(row, "密码") if username == "" || password == "" { failCount++ errs = append(errs, fmt.Sprintf("第%d行:用户名和密码不能为空", lineNum)) continue } exists, err := s.userRepo.ExistsByUsername(ctx, username) if err != nil { failCount++ errs = append(errs, fmt.Sprintf("第%d行:检查用户名失败: %v", lineNum, err)) continue } if exists { failCount++ errs = append(errs, fmt.Sprintf("第%d行:用户名 '%s' 已存在", lineNum, username)) continue } hashedPwd, err := hashPassword(password) if err != nil { failCount++ errs = append(errs, fmt.Sprintf("第%d行:密码加密失败: %v", lineNum, err)) continue } user := &domain.User{ Username: username, Email: domain.StrPtr(getCol(row, "邮箱")), Phone: domain.StrPtr(getCol(row, "手机号")), Nickname: getCol(row, "昵称"), Password: hashedPwd, Region: getCol(row, "地区"), Bio: getCol(row, "个人简介"), Status: domain.UserStatusActive, } if err := s.userRepo.Create(ctx, user); err != nil { failCount++ errs = append(errs, fmt.Sprintf("第%d行:创建用户失败: %v", lineNum, err)) continue } successCount++ } return successCount, failCount, errs } // GetImportTemplate keeps backward compatibility for callers that still expect CSV templates. func (s *ExportService) GetImportTemplate() ([]byte, string) { data, filename, _, _ := s.GetImportTemplateByFormat(ExportFormatCSV) return data, filename } // GetImportTemplateByFormat returns a CSV or XLSX template for imports. func (s *ExportService) GetImportTemplateByFormat(format string) ([]byte, string, string, error) { normalized, err := normalizeExportFormat(format) if err != nil { return nil, "", "", err } headers := []string{"用户名", "密码", "邮箱", "手机号", "昵称", "性别", "地区", "个人简介"} rows := [][]string{{ "john_doe", "Password123!", "john@example.com", "13800138000", "约翰", "男", "北京", "这是个人简介", }} switch normalized { case ExportFormatCSV: data, err := buildCSVRecords(headers, rows) if err != nil { return nil, "", "", err } return data, "user_import_template.csv", "text/csv; charset=utf-8", nil case ExportFormatXLSX: data, err := buildXLSXRecords(headers, rows) if err != nil { return nil, "", "", err } return data, "user_import_template.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", nil default: return nil, "", "", fmt.Errorf("不支持的模板格式: %s", format) } } func normalizeExportFormat(format string) (string, error) { normalized := strings.ToLower(strings.TrimSpace(format)) if normalized == "" { normalized = ExportFormatCSV } switch normalized { case ExportFormatCSV, ExportFormatXLSX: return normalized, nil default: return "", fmt.Errorf("不支持的格式: %s", format) } } func resolveExportColumns(fields []string) ([]exportColumn, error) { if len(fields) == 0 { return defaultExportColumns, nil } columnMap := make(map[string]exportColumn, len(defaultExportColumns)) for _, col := range defaultExportColumns { columnMap[col.Key] = col } selected := make([]exportColumn, 0, len(fields)) seen := make(map[string]struct{}, len(fields)) for _, field := range fields { key := strings.ToLower(strings.TrimSpace(field)) if key == "" { continue } if _, ok := seen[key]; ok { continue } col, ok := columnMap[key] if !ok { return nil, fmt.Errorf("不支持的导出字段: %s", field) } selected = append(selected, col) seen[key] = struct{}{} } if len(selected) == 0 { return defaultExportColumns, nil } return selected, nil } func buildCSVExport(columns []exportColumn, users []*domain.User) ([]byte, error) { headers := make([]string, 0, len(columns)) rows := make([][]string, 0, len(users)) for _, col := range columns { headers = append(headers, col.Header) } for _, u := range users { row := make([]string, 0, len(columns)) for _, col := range columns { row = append(row, col.Value(u)) } rows = append(rows, row) } return buildCSVRecords(headers, rows) } func buildCSVRecords(headers []string, rows [][]string) ([]byte, error) { 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) } for _, row := range rows { if err := writer.Write(row); err != nil { return nil, fmt.Errorf("写CSV行失败: %w", err) } } writer.Flush() if err := writer.Error(); err != nil { return nil, fmt.Errorf("CSV Flush 失败: %w", err) } return buf.Bytes(), nil } func buildXLSXExport(columns []exportColumn, users []*domain.User) ([]byte, error) { headers := make([]string, 0, len(columns)) rows := make([][]string, 0, len(users)) for _, col := range columns { headers = append(headers, col.Header) } for _, u := range users { row := make([]string, 0, len(columns)) for _, col := range columns { row = append(row, col.Value(u)) } rows = append(rows, row) } return buildXLSXRecords(headers, rows) } func buildXLSXRecords(headers []string, rows [][]string) ([]byte, error) { file := excelize.NewFile() defer file.Close() sheet := file.GetSheetName(file.GetActiveSheetIndex()) if sheet == "" { sheet = "Sheet1" } for idx, header := range headers { cell, err := excelize.CoordinatesToCellName(idx+1, 1) if err != nil { return nil, fmt.Errorf("生成表头单元格失败: %w", err) } if err := file.SetCellValue(sheet, cell, header); err != nil { return nil, fmt.Errorf("写入表头失败: %w", err) } } for rowIdx, row := range rows { for colIdx, value := range row { cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2) if err != nil { return nil, fmt.Errorf("生成数据单元格失败: %w", err) } if err := file.SetCellValue(sheet, cell, value); err != nil { return nil, fmt.Errorf("写入单元格失败: %w", err) } } } var buf bytes.Buffer if _, err := file.WriteTo(&buf); err != nil { return nil, fmt.Errorf("生成Excel失败: %w", err) } return buf.Bytes(), nil } func parseCSVRecords(data []byte) ([][]string, error) { if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { data = data[3:] } reader := csv.NewReader(bytes.NewReader(data)) records, err := reader.ReadAll() if err != nil { return nil, fmt.Errorf("CSV 解析失败: %w", err) } return records, nil } func parseXLSXRecords(data []byte) ([][]string, error) { file, err := excelize.OpenReader(bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("Excel 解析失败: %w", err) } defer file.Close() sheets := file.GetSheetList() if len(sheets) == 0 { return nil, fmt.Errorf("Excel 文件没有可用工作表") } rows, err := file.GetRows(sheets[0]) if err != nil { return nil, fmt.Errorf("读取Excel行失败: %w", err) } return rows, nil } // ---- 辅助函数 ---- func genderLabel(g domain.Gender) string { switch g { case domain.GenderMale: return "男" case domain.GenderFemale: return "女" default: return "未知" } } func userStatusLabel(s domain.UserStatus) string { switch s { case domain.UserStatusActive: return "已激活" case domain.UserStatusInactive: return "未激活" case domain.UserStatusLocked: return "已锁定" case domain.UserStatusDisabled: return "已禁用" default: return "未知" } } func boolLabel(b bool) string { if b { return "是" } return "否" } func timeLabel(t *time.Time) string { if t == nil { return "" } return t.Format("2006-01-02 15:04:05") } // buildColIndex 将表头列名映射到列索引 func buildColIndex(headers []string) map[string]int { idx := make(map[string]int, len(headers)) for i, h := range headers { idx[h] = i } return idx } // hashPassword hashes imported passwords with the primary runtime algorithm. func hashPassword(password string) (string, error) { return auth.HashPassword(password) }