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