Files

535 lines
15 KiB
Go
Raw Permalink Normal View History

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)
}