Files
user-system/internal/service/export.go

535 lines
15 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"
"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)
}