This commit is contained in:
Joe
2026-01-16 15:49:34 +08:00
commit 550d3e1f42
380 changed files with 62024 additions and 0 deletions
+529
View File
@@ -0,0 +1,529 @@
package services
import (
"slices"
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
"github.com/samber/lo"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
)
type AdminService interface {
// GetByID 根据ID获取管理员
GetByID(id uint, withDepartment bool, withRoles bool) (*models.Admin, error)
// GetList 获取管理员列表
GetList(filters AdminFilters, page, pageSize int) ([]models.Admin, int64, error)
// GetAllAdminsForExport 获取所有管理员用于导出(不分页)
GetAllAdminsForExport(filters AdminFilters) ([]models.Admin, error)
// LoadRelations 加载管理员的关联数据(部门、角色)
LoadRelations(admin *models.Admin) error
// LoadRelationsWithPermissions 加载管理员的关联数据(包括权限和菜单)
LoadRelationsWithPermissions(admin *models.Admin) error
// LoadRelationsForList 批量加载管理员的关联数据
LoadRelationsForList(admins []models.Admin) error
// SyncRoles 同步管理员角色关联
SyncRoles(admin *models.Admin, roleIDs []uint) error
// GetProtectedAdminIDs 获取所有受保护的管理员ID
GetProtectedAdminIDs() map[uint]bool
// GetDepartmentAndChildrenIDs 获取部门及其子部门ID
GetDepartmentAndChildrenIDs(departmentID uint) []uint
// Update 更新管理员
Update(admin *models.Admin) error
}
// AdminFilters 管理员查询过滤器
type AdminFilters struct {
Username string
Status string
RoleID string
DepartmentID string
Is2FABound string
StartTime string
EndTime string
OrderBy string
}
type AdminServiceImpl struct {
}
func NewAdminServiceImpl() *AdminServiceImpl {
return &AdminServiceImpl{}
}
// GetByID 根据ID获取管理员
func (s *AdminServiceImpl) GetByID(id uint, withDepartment bool, withRoles bool) (*models.Admin, error) {
var admin models.Admin
query := facades.Orm().Query().Where("id", id)
// 预加载关联
if withDepartment {
query = query.With("Department")
}
if withRoles {
query = query.With("Roles")
}
if err := query.First(&admin); err != nil {
return nil, apperrors.ErrAdminNotFound.WithError(err)
}
return &admin, nil
}
// buildQuery 构建查询(公共方法,用于列表和导出)
func (s *AdminServiceImpl) buildQuery(filters AdminFilters) orm.Query {
query := facades.Orm().Query().Model(&models.Admin{})
// 排除受保护的管理员
protectedIDs := s.GetProtectedAdminIDs()
if len(protectedIDs) > 0 {
var ids []uint
for id := range protectedIDs {
ids = append(ids, id)
}
if len(ids) > 0 {
query = query.Where("id NOT IN ?", ids)
}
}
// 应用筛选条件
if filters.Username != "" {
query = query.Where("username LIKE ?", "%"+filters.Username+"%")
}
if filters.Status != "" {
query = query.Where("status", filters.Status)
}
if filters.RoleID != "" {
roleIDUint := cast.ToUint(filters.RoleID)
if roleIDUint > 0 {
query = query.Where("id IN (SELECT admin_id FROM admin_role WHERE role_id = ?)", roleIDUint)
}
}
if filters.DepartmentID != "" {
departmentIDUint := cast.ToUint(filters.DepartmentID)
if departmentIDUint > 0 {
departmentIDs := s.GetDepartmentAndChildrenIDs(departmentIDUint)
if len(departmentIDs) > 0 {
idsAny := make([]any, len(departmentIDs))
for i, id := range departmentIDs {
idsAny[i] = id
}
query = query.WhereIn("department_id", idsAny)
} else {
// 如果部门不存在,返回空结果
query = query.Where("1 = 0")
}
}
}
if filters.Is2FABound != "" {
switch filters.Is2FABound {
case "1":
// 已绑定:google_secret IS NOT NULL AND google_secret != ''
query = query.Where("google_secret IS NOT NULL AND google_secret != ?", "")
case "0":
// 未绑定:google_secret IS NULL OR google_secret = ''
query = query.Where("(google_secret IS NULL OR google_secret = ?)", "")
}
}
if filters.StartTime != "" {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != "" {
query = query.Where("created_at <= ?", filters.EndTime)
}
return query
}
// GetList 获取管理员列表
func (s *AdminServiceImpl) GetList(filters AdminFilters, page, pageSize int) ([]models.Admin, int64, error) {
query := s.buildQuery(filters)
// 应用排序
orderBy := filters.OrderBy
if orderBy == "" {
orderBy = "created_at:desc"
}
query = helpers.ApplySort(query, orderBy, "created_at:desc")
// 分页查询
var admins []models.Admin
var total int64
if err := query.With("Department").With("Roles").Paginate(page, pageSize, &admins, &total); err != nil {
return nil, 0, err
}
return admins, total, nil
}
// GetAllAdminsForExport 获取所有管理员用于导出(不分页)
func (s *AdminServiceImpl) GetAllAdminsForExport(filters AdminFilters) ([]models.Admin, error) {
query := s.buildQuery(filters)
// 应用排序
orderBy := filters.OrderBy
if orderBy == "" {
orderBy = "created_at:desc"
}
query = helpers.ApplySort(query, orderBy, "created_at:desc")
// 不分页,获取所有数据
var admins []models.Admin
if err := query.With("Department").With("Roles").Find(&admins); err != nil {
return nil, apperrors.ErrQueryFailed.WithError(err)
}
return admins, nil
}
// Update 更新管理员
func (s *AdminServiceImpl) Update(admin *models.Admin) error {
if err := facades.Orm().Query().Save(admin); err != nil {
return apperrors.ErrUpdateFailed.WithError(err)
}
return nil
}
// GetProtectedAdminIDs 获取所有受保护的管理员ID
func (s *AdminServiceImpl) GetProtectedAdminIDs() map[uint]bool {
allProtectedIDs := make(map[uint]bool)
// 添加超级管理员ID(从配置读取,默认1)
superAdminID := cast.ToUint(facades.Config().GetInt("admin.super_admin_id", 1))
allProtectedIDs[superAdminID] = true
// 添加开发者管理员ID
developerIDsStr := facades.Config().GetString("admin.developer_ids", "2")
developerIDs := s.parseProtectedIDs(developerIDsStr)
for _, did := range developerIDs {
allProtectedIDs[did] = true
}
return allProtectedIDs
}
// parseProtectedIDs 解析受保护的管理员ID字符串(支持逗号分隔)
func (s *AdminServiceImpl) parseProtectedIDs(idsStr string) []uint {
var ids []uint
if idsStr == "" {
return ids
}
// 使用字符串分割
parts := str.Of(idsStr).Split(",")
for _, part := range parts {
part = str.Of(part).Trim().String()
if !str.Of(part).IsEmpty() {
if id := cast.ToUint(part); id > 0 {
ids = append(ids, id)
}
}
}
return ids
}
// GetDepartmentAndChildrenIDs 获取部门及其子部门ID
func (s *AdminServiceImpl) GetDepartmentAndChildrenIDs(departmentID uint) []uint {
var departmentIDs []uint
departmentIDs = append(departmentIDs, departmentID)
s.getChildrenDepartmentIDs(departmentID, &departmentIDs)
return departmentIDs
}
// getChildrenDepartmentIDs 递归获取子部门ID
func (s *AdminServiceImpl) getChildrenDepartmentIDs(parentID uint, departmentIDs *[]uint) {
var children []models.Department
if err := facades.Orm().Query().Where("parent_id", parentID).Get(&children); err == nil {
for _, child := range children {
*departmentIDs = append(*departmentIDs, child.ID)
s.getChildrenDepartmentIDs(child.ID, departmentIDs)
}
}
}
// LoadRelations 加载管理员的关联数据(部门、角色)
func (s *AdminServiceImpl) LoadRelations(admin *models.Admin) error {
if admin == nil {
return nil
}
// 加载部门
if admin.DepartmentID > 0 {
var department models.Department
if err := facades.Orm().Query().Where("id", admin.DepartmentID).First(&department); err == nil {
admin.Department = department
}
}
// 加载角色关联
type AdminRole struct {
AdminID uint `gorm:"column:admin_id"`
RoleID uint `gorm:"column:role_id"`
}
var adminRoles []AdminRole
if err := facades.Orm().Query().Table("admin_role").Where("admin_id", admin.ID).Find(&adminRoles); err != nil {
return err
}
var roleIDs []uint
for _, ar := range adminRoles {
if !contains(roleIDs, ar.RoleID) {
roleIDs = append(roleIDs, ar.RoleID)
}
}
admin.Roles = nil
if len(roleIDs) > 0 {
var roles []models.Role
if err := facades.Orm().Query().Where("id IN ?", roleIDs).Find(&roles); err != nil {
return err
}
admin.Roles = roles
}
return nil
}
// LoadRelationsWithPermissions 加载管理员的关联数据(包括权限和菜单)
func (s *AdminServiceImpl) LoadRelationsWithPermissions(admin *models.Admin) error {
// 先加载基本关联
if err := s.LoadRelations(admin); err != nil {
return err
}
// 批量加载所有角色的权限和菜单,避免 N+1 查询
if len(admin.Roles) > 0 {
for i := range admin.Roles {
admin.Roles[i].Permissions = nil
admin.Roles[i].Menus = nil
}
var roleIDs []uint
for _, role := range admin.Roles {
roleIDs = append(roleIDs, role.ID)
}
// 批量加载权限
type RolePermission struct {
RoleID uint `gorm:"column:role_id"`
PermissionID uint `gorm:"column:permission_id"`
}
var rolePermissions []RolePermission
if err := facades.Orm().Query().Table("role_permission").Where("role_id IN ?", roleIDs).Find(&rolePermissions); err == nil {
var permissionIDs []uint
rolePermissionMap := make(map[uint][]uint)
for _, rp := range rolePermissions {
rolePermissionMap[rp.RoleID] = append(rolePermissionMap[rp.RoleID], rp.PermissionID)
permissionIDs = append(permissionIDs, rp.PermissionID)
}
if len(permissionIDs) > 0 {
var permissions []models.Permission
// 预加载菜单关联,用于检查菜单状态
if err := facades.Orm().Query().With("Menu").Where("id IN ?", permissionIDs).Find(&permissions); err == nil {
permissionMap := make(map[uint]models.Permission)
for _, perm := range permissions {
permissionMap[perm.ID] = perm
}
for i := range admin.Roles {
if permIDs, ok := rolePermissionMap[admin.Roles[i].ID]; ok {
for _, permID := range permIDs {
if perm, ok := permissionMap[permID]; ok {
admin.Roles[i].Permissions = append(admin.Roles[i].Permissions, perm)
}
}
}
}
}
}
}
// 批量加载菜单
type RoleMenu struct {
RoleID uint `gorm:"column:role_id"`
MenuID uint `gorm:"column:menu_id"`
}
var roleMenus []RoleMenu
if err := facades.Orm().Query().Table("role_menu").Where("role_id IN ?", roleIDs).Find(&roleMenus); err == nil {
var menuIDs []uint
roleMenuMap := make(map[uint][]uint)
for _, rm := range roleMenus {
roleMenuMap[rm.RoleID] = append(roleMenuMap[rm.RoleID], rm.MenuID)
menuIDs = append(menuIDs, rm.MenuID)
}
if len(menuIDs) > 0 {
var menus []models.Menu
if err := facades.Orm().Query().Where("id IN ?", menuIDs).Find(&menus); err == nil {
menuMap := make(map[uint]models.Menu)
for _, menu := range menus {
menuMap[menu.ID] = menu
}
for i := range admin.Roles {
if mIDs, ok := roleMenuMap[admin.Roles[i].ID]; ok {
for _, menuID := range mIDs {
if menu, ok := menuMap[menuID]; ok {
admin.Roles[i].Menus = append(admin.Roles[i].Menus, menu)
}
}
}
}
}
}
}
}
// 收集所有角色的权限和菜单(去重)
permissionMap := make(map[uint]models.Permission)
menuMap := make(map[uint]models.Menu)
for _, role := range admin.Roles {
for _, perm := range role.Permissions {
permissionMap[perm.ID] = perm
}
for _, menu := range role.Menus {
menuMap[menu.ID] = menu
}
}
// 将权限和菜单存储到 admin 的扩展字段(如果需要)
// 这里可以根据实际需求调整
return nil
}
// LoadRelationsForList 批量加载管理员的关联数据(优化版,避免 N+1 查询)
func (s *AdminServiceImpl) LoadRelationsForList(admins []models.Admin) error {
if len(admins) == 0 {
return nil
}
// 收集所有需要查询的 ID
var departmentIDs []uint
var adminIDs []uint
for _, admin := range admins {
if admin.DepartmentID > 0 {
departmentIDs = append(departmentIDs, admin.DepartmentID)
}
adminIDs = append(adminIDs, admin.ID)
}
// 批量查询部门
departmentsMap := make(map[uint]models.Department)
if len(departmentIDs) > 0 {
var departments []models.Department
// 去重
uniqueDeptIDs := make(map[uint]bool)
var uniqueIDs []uint
for _, id := range departmentIDs {
if !uniqueDeptIDs[id] {
uniqueIDs = append(uniqueIDs, id)
uniqueDeptIDs[id] = true
}
}
if err := facades.Orm().Query().Where("id IN ?", uniqueIDs).Find(&departments); err != nil {
return err
}
for _, dept := range departments {
departmentsMap[dept.ID] = dept
}
}
// 批量查询所有管理员的角色关联
// 查询中间表获取 admin_id 和 role_id 的映射
type AdminRole struct {
AdminID uint `gorm:"column:admin_id"`
RoleID uint `gorm:"column:role_id"`
}
var adminRoles []AdminRole
if err := facades.Orm().Query().Table("admin_role").Where("admin_id IN ?", adminIDs).Find(&adminRoles); err != nil {
return err
}
// 按管理员ID分组角色关联
adminRoleMap := lo.GroupBy(adminRoles, func(ar AdminRole) uint {
return ar.AdminID
})
// 提取所有角色ID并去重
allRoleIDs := lo.Map(adminRoles, func(ar AdminRole, _ int) uint {
return ar.RoleID
})
roleIDs := lo.Uniq(allRoleIDs)
// 批量查询所有角色
rolesMap := make(map[uint]models.Role)
if len(roleIDs) > 0 {
var roles []models.Role
if err := facades.Orm().Query().Where("id IN ?", roleIDs).Find(&roles); err != nil {
return err
}
rolesMap = lo.SliceToMap(roles, func(role models.Role) (uint, models.Role) {
return role.ID, role
})
}
// 填充关联数据
for i := range admins {
// 填充部门
if admins[i].DepartmentID > 0 {
if dept, ok := departmentsMap[admins[i].DepartmentID]; ok {
admins[i].Department = dept
}
}
// 填充角色(去重)
if adminRoleList, ok := adminRoleMap[admins[i].ID]; ok {
roleIDs := lo.Uniq(lo.Map(adminRoleList, func(ar AdminRole, _ int) uint {
return ar.RoleID
}))
admins[i].Roles = lo.FilterMap(roleIDs, func(roleID uint, _ int) (models.Role, bool) {
role, ok := rolesMap[roleID]
return role, ok
})
}
}
return nil
}
// contains 辅助函数,检查切片中是否包含某个值
func contains(slice []uint, val uint) bool {
return slices.Contains(slice, val)
}
// SyncRoles 同步管理员角色关联
func (s *AdminServiceImpl) SyncRoles(admin *models.Admin, roleIDs []uint) error {
// 去重角色ID,避免重复数据
deduplicatedRoleIDs := lo.Uniq(roleIDs)
// 先清空该管理员的所有角色关联(包括重复的),确保彻底清理重复数据
if err := facades.Orm().Query().Model(admin).Association("Roles").Clear(); err != nil {
return err
}
// 如果有角色需要关联,则添加去重后的角色关联
if len(deduplicatedRoleIDs) > 0 {
var roles []models.Role
if err := facades.Orm().Query().Where("id IN ?", deduplicatedRoleIDs).Find(&roles); err != nil {
return err
}
// 对查询到的角色再次去重(双重保险)
roleMap := lo.SliceToMap(roles, func(role models.Role) (uint, models.Role) {
return role.ID, role
})
// 将去重后的角色转换为切片
deduplicatedRoles := lo.Values(roleMap)
// Replace 方法会先删除所有现有关联,然后添加新的关联
if err := facades.Orm().Query().Model(admin).Association("Roles").Replace(deduplicatedRoles); err != nil {
return err
}
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
package services
import (
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/requests/admin"
"goravel/app/models"
)
type ArticleService interface {
GetByID(id uint) (*models.Article, error)
GetList(filters ArticleFilters, page, pageSize int) ([]models.Article, int64, error)
Create(req *admin.ArticleCreate) (*models.Article, error)
Update(id uint, req *admin.ArticleUpdate) (*models.Article, error)
Delete(id uint) error
}
type ArticleFilters struct {
Name string
Status string
}
type ArticleServiceImpl struct{}
func NewArticleService() ArticleService {
return &ArticleServiceImpl{}
}
func BuildArticleQuery(filters ArticleFilters) orm.Query {
query := facades.Orm().Query().Model(&models.Article{})
if filters.Name != "" {
query = query.Where("name LIKE ?", "%"+filters.Name+"%")
}
if filters.Status != "" {
query = query.Where("status = ?", filters.Status)
}
return query
}
func (s *ArticleServiceImpl) GetByID(id uint) (*models.Article, error) {
var item models.Article
if err := facades.Orm().Query().Where("id", id).FirstOrFail(&item); err != nil {
return nil, apperrors.NewBusinessError("article_not_found", "Article not found").WithError(err)
}
return &item, nil
}
func (s *ArticleServiceImpl) GetList(filters ArticleFilters, page, pageSize int) ([]models.Article, int64, error) {
query := BuildArticleQuery(filters)
var list []models.Article
var total int64
if err := query.Order("id desc").Paginate(page, pageSize, &list, &total); err != nil {
return nil, 0, err
}
return list, total, nil
}
func (s *ArticleServiceImpl) Create(req *admin.ArticleCreate) (*models.Article, error) {
item := &models.Article{
Name: req.Name,
Status: req.Status,
}
if err := facades.Orm().Query().Create(item); err != nil {
return nil, apperrors.ErrCreateFailed.WithError(err)
}
return item, nil
}
func (s *ArticleServiceImpl) Update(id uint, req *admin.ArticleUpdate) (*models.Article, error) {
item, err := s.GetByID(id)
if err != nil {
return nil, err
}
if req.Name != nil {
item.Name = *req.Name
}
if req.Status != nil {
item.Status = *req.Status
}
if err := facades.Orm().Query().Save(item); err != nil {
return nil, apperrors.ErrUpdateFailed.WithError(err)
}
return item, nil
}
func (s *ArticleServiceImpl) Delete(id uint) error {
// 使用 Soft Delete
if _, err := facades.Orm().Query().Where("id", id).Delete(&models.Article{}); err != nil {
return err
}
return nil
}
+645
View File
@@ -0,0 +1,645 @@
package services
import (
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strings"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
"goravel/app/utils"
"goravel/app/utils/errorlog"
)
type AttachmentService interface {
// GetByID 根据ID获取附件
GetByID(id uint) (*models.Attachment, error)
// GetByIDs 根据ID列表获取附件
GetByIDs(ids []uint) ([]models.Attachment, error)
// GetList 获取附件列表
GetList(filters AttachmentFilters, page, pageSize int) ([]models.Attachment, int64, error)
// InitChunkUpload 初始化分片上传(不再使用服务端缓存)
InitChunkUpload(filename string, totalSize int64, chunkSize int64, totalChunks int) (string, error)
// UploadChunk 上传分片(不再使用服务端缓存)
UploadChunk(chunkID string, chunkIndex int, chunkData []byte) error
// MergeChunks 合并分片(不再使用服务端缓存,需要传入 totalChunks
MergeChunks(chunkID string, filename string, mimeType string, totalChunks int) (*models.Attachment, error)
// GetChunkProgress 获取分片上传进度(不再使用服务端缓存,需要传入 totalChunks
GetChunkProgress(chunkID string, totalChunks int) (map[string]any, error)
// UploadFile 普通文件上传(小文件)
UploadFile(fileData []byte, filename string, mimeType string) (*models.Attachment, error)
// GetFileURL 获取文件访问URL
GetFileURL(attachment *models.Attachment) string
// GetFileType 根据MIME类型判断文件类型
GetFileType(mimeType string) string
// DeleteFile 删除文件
DeleteFile(attachment *models.Attachment) error
// UpdateDisplayName 更新显示名称
UpdateDisplayName(id uint, displayName string) error
}
// AttachmentFilters 附件查询过滤器
type AttachmentFilters struct {
AdminID string
Filename string
DisplayName string
FileType string
Extension string
StartTime string
EndTime string
OrderBy string
}
type AttachmentServiceImpl struct {
ctx http.Context
disk string
systemLogService SystemLogService
}
func NewAttachmentService(ctx http.Context) AttachmentService {
// 从数据库读取文件存储配置
// 优先使用 file_disk,如果没有则使用 storage_disk(向后兼容),最后使用默认值 local
disk := utils.GetConfigValue("storage", "file_disk", "")
if disk == "" {
disk = utils.GetConfigValue("storage", "storage_disk", "")
}
if disk == "" {
disk = "local"
}
return &AttachmentServiceImpl{
ctx: ctx,
disk: disk,
systemLogService: NewSystemLogService(),
}
}
// InitChunkUpload 初始化分片上传
// 注意:分片信息由客户端缓存,服务端只生成 chunkID
func (s *AttachmentServiceImpl) InitChunkUpload(filename string, totalSize int64, chunkSize int64, totalChunks int) (string, error) {
// 检查存储驱动:大文件分片上传仅支持本地存储
cloudStorageDrivers := []string{"s3", "oss", "cos", "minio", "qiniu"}
for _, driver := range cloudStorageDrivers {
if s.disk == driver {
return "", apperrors.ErrChunkUploadOnlyLocalStorage.WithMessage(fmt.Sprintf("大文件分片上传仅支持本地存储,当前存储驱动为: %s", driver))
}
}
// 生成唯一的分片ID
hash := md5.Sum(fmt.Appendf(nil, "%s_%d_%d", filename, totalSize, time.Now().UnixNano()))
chunkID := hex.EncodeToString(hash[:])
// 不再使用服务端缓存,分片信息由客户端管理
return chunkID, nil
}
// UploadChunk 上传分片
// 注意:不再使用服务端缓存,直接保存分片文件
func (s *AttachmentServiceImpl) UploadChunk(chunkID string, chunkIndex int, chunkData []byte) error {
if chunkIndex < 0 {
return apperrors.ErrInvalidChunkIndex
}
// 保存分片到临时目录
storage := facades.Storage().Disk(s.disk)
chunkPath := fmt.Sprintf("chunks/%s/%d", chunkID, chunkIndex)
if err := storage.Put(chunkPath, string(chunkData)); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "保存分片失败", map[string]any{
"chunk_id": chunkID,
"chunk_index": chunkIndex,
"error": err.Error(),
}, "保存分片失败: %w", err)
}
return apperrors.ErrSaveChunkFailed.WithError(err)
}
return nil
}
// MergeChunks 合并分片
// 注意:不再使用服务端缓存,通过检查实际文件系统来验证分片
func (s *AttachmentServiceImpl) MergeChunks(chunkID string, filename string, mimeType string, totalChunks int) (*models.Attachment, error) {
storage := facades.Storage().Disk(s.disk)
// 检查所有分片文件是否存在
indices := make([]int, totalChunks)
for i := range indices {
chunkPath := fmt.Sprintf("chunks/%s/%d", chunkID, i)
if !storage.Exists(chunkPath) {
return nil, apperrors.ErrChunkNotFound.WithParams(map[string]any{
"chunk_index": i,
})
}
}
// 生成最终文件路径
ext := filepath.Ext(filename)
if ext == "" {
exts, _ := mime.ExtensionsByType(mimeType)
if len(exts) > 0 {
ext = exts[0]
}
}
// 生成唯一文件名
hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", filename, time.Now().UnixNano())))
uniqueName := hex.EncodeToString(hash[:]) + ext
datePath := time.Now().Format("2006/01/02")
finalPath := fmt.Sprintf("attachments/%s/%s", datePath, uniqueName)
// 合并分片(流式写入,避免大文件内存占用过高)
// 对于本地存储,直接使用文件系统操作以提高性能
var fileSize int64
var missingChunks []int // 记录缺失的分片索引
// 获取存储根目录
storageRoot := facades.Config().GetString("filesystems.disks." + s.disk + ".root")
if storageRoot == "" {
storageRoot = "storage/app"
}
// 构建目标文件的完整路径
finalFullPath := filepath.Join(storageRoot, finalPath)
// 确保目标目录存在
if err := os.MkdirAll(filepath.Dir(finalFullPath), 0755); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "创建目标目录失败", map[string]any{
"chunk_id": chunkID,
"directory": filepath.Dir(finalFullPath),
"error": err.Error(),
}, "创建目标目录失败: %w", err)
}
return nil, apperrors.ErrCreateDirectoryFailed.WithError(err)
}
// 创建目标文件(流式写入)
outFile, err := os.Create(finalFullPath)
if err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "创建目标文件失败", map[string]any{
"chunk_id": chunkID,
"file_path": finalFullPath,
"error": err.Error(),
}, "创建目标文件失败: %w", err)
}
return nil, apperrors.ErrCreateFileFailed.WithError(err)
}
defer outFile.Close()
// 按顺序读取并写入每个分片
for i := range make([]int, totalChunks) {
chunkPath := fmt.Sprintf("chunks/%s/%d", chunkID, i)
chunkFullPath := filepath.Join(storageRoot, chunkPath)
// 检查分片文件是否存在
if _, err := os.Stat(chunkFullPath); os.IsNotExist(err) {
missingChunks = append(missingChunks, i)
continue
}
// 打开分片文件
chunkFile, err := os.Open(chunkFullPath)
if err != nil {
// 读取失败,记录到系统日志
if s.ctx != nil {
_ = s.systemLogService.RecordHTTP(s.ctx, "warning", "attachment", fmt.Sprintf("Failed to read chunk %d for chunkID %s", i, chunkID), map[string]any{
"chunk_index": i,
"chunk_id": chunkID,
"error": err.Error(),
})
}
missingChunks = append(missingChunks, i)
continue
}
// 流式复制分片内容到目标文件
written, err := io.Copy(outFile, chunkFile)
if err != nil {
chunkFile.Close()
// 如果写入失败,删除已创建的目标文件
_ = os.Remove(finalFullPath)
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "写入分片失败", map[string]any{
"chunk_id": chunkID,
"chunk_index": i,
"file_path": finalFullPath,
"error": err.Error(),
}, "写入分片 %d 失败: %w", i, err)
}
return nil, apperrors.ErrWriteChunkFailed.WithError(err).WithParams(map[string]any{
"chunk_index": i,
})
}
fileSize += written
chunkFile.Close()
}
// 关闭目标文件
if err := outFile.Close(); err != nil {
_ = os.Remove(finalFullPath)
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "关闭目标文件失败", map[string]any{
"chunk_id": chunkID,
"file_path": finalFullPath,
"error": err.Error(),
}, "关闭目标文件失败: %w", err)
}
return nil, apperrors.ErrCloseFileFailed.WithError(err)
}
// 检查是否有缺失的分片
if len(missingChunks) > 0 {
_ = os.Remove(finalFullPath)
return nil, apperrors.ErrChunkMissing.WithParams(map[string]any{
"missing_chunks": missingChunks,
"count": len(missingChunks),
})
}
// 检查是否有数据被合并
if fileSize == 0 {
_ = os.Remove(finalFullPath)
return nil, apperrors.ErrNoChunkDataToMerge
}
// 验证文件大小(从文件系统获取实际大小)
if fileInfo, err := os.Stat(finalFullPath); err == nil {
fileSize = fileInfo.Size()
}
// 创建附件记录
adminID := uint(0)
if s.ctx != nil {
if id, err := helpers.GetAdminIDFromContext(s.ctx); err == nil {
adminID = id
}
}
fileType := s.GetFileType(mimeType)
attachment := &models.Attachment{
AdminID: adminID,
Disk: s.disk,
Path: finalPath,
Filename: filename,
Extension: strings.TrimPrefix(ext, "."),
MimeType: mimeType,
Size: fileSize,
Status: 1,
FileType: fileType,
ChunkID: chunkID,
}
if err := facades.Orm().Query().Create(attachment); err != nil {
// 如果创建记录失败,删除已上传的文件
_ = storage.Delete(finalPath)
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "创建附件记录失败", map[string]any{
"chunk_id": chunkID,
"file_path": finalPath,
"filename": filename,
"error": err.Error(),
}, "创建附件记录失败: %w", err)
}
return nil, apperrors.ErrCreateFailed.WithError(err)
}
// 合并成功后才清理分片文件(确保数据已保存到数据库)
// 清理所有分片文件(包括可能缺失的)
cleanupSuccess := true
cleanupCount := 0
for i := range make([]int, totalChunks) {
chunkPath := fmt.Sprintf("chunks/%s/%d", chunkID, i)
if storage.Exists(chunkPath) {
if err := storage.Delete(chunkPath); err != nil {
// 记录删除失败到系统日志,但不影响整体流程
if s.ctx != nil {
_ = s.systemLogService.RecordHTTP(s.ctx, "warning", "attachment", fmt.Sprintf("Failed to delete chunk file %s", chunkPath), map[string]any{
"chunk_path": chunkPath,
"chunk_id": chunkID,
"error": err.Error(),
})
}
cleanupSuccess = false
} else {
cleanupCount++
}
}
}
if !cleanupSuccess && s.ctx != nil {
// 记录部分分片删除失败的警告到系统日志
_ = s.systemLogService.RecordHTTP(s.ctx, "warning", "attachment", fmt.Sprintf("Some chunk files failed to delete for chunkID %s", chunkID), map[string]any{
"chunk_id": chunkID,
"cleaned_count": cleanupCount,
"total_chunks": totalChunks,
})
}
return attachment, nil
}
// GetChunkProgress 获取分片上传进度
// 注意:不再使用服务端缓存,通过检查实际文件系统来获取进度
// 优化:如果分片数量很大,可以考虑限制返回的索引数量或使用并发检查
func (s *AttachmentServiceImpl) GetChunkProgress(chunkID string, totalChunks int) (map[string]any, error) {
storage := facades.Storage().Disk(s.disk)
uploadedCount := 0
uploadedIndices := []int{} // 已上传的分片索引数组
// 优化:如果分片数量超过1000,只返回前100个已上传的索引,减少响应大小
maxIndices := 100
if totalChunks > 1000 {
maxIndices = 100
}
// 检查每个分片文件是否存在
indices := make([]int, totalChunks)
for i := range indices {
chunkPath := fmt.Sprintf("chunks/%s/%d", chunkID, i)
if storage.Exists(chunkPath) {
uploadedCount++
// 只记录前 maxIndices 个索引,减少响应大小
if len(uploadedIndices) < maxIndices {
uploadedIndices = append(uploadedIndices, i)
}
}
}
progress := float64(uploadedCount) / float64(totalChunks) * 100
return map[string]any{
"chunk_id": chunkID,
"total_chunks": totalChunks,
"uploaded_count": uploadedCount,
"uploaded_chunks": uploadedIndices, // 返回已上传的分片索引数组(最多100个)
"progress": progress,
"completed": uploadedCount == totalChunks,
}, nil
}
// UploadFile 普通文件上传(小文件)
func (s *AttachmentServiceImpl) UploadFile(fileData []byte, filename string, mimeType string) (*models.Attachment, error) {
if mimeType == "" {
mimeType = "application/octet-stream"
}
// 生成文件路径
ext := filepath.Ext(filename)
hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", filename, time.Now().UnixNano())))
uniqueName := hex.EncodeToString(hash[:]) + ext
datePath := time.Now().Format("2006/01/02")
finalPath := fmt.Sprintf("attachments/%s/%s", datePath, uniqueName)
// 保存文件
storage := facades.Storage().Disk(s.disk)
if err := storage.Put(finalPath, string(fileData)); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "保存文件失败", map[string]any{
"filename": filename,
"file_path": finalPath,
"error": err.Error(),
}, "保存文件失败: %w", err)
}
return nil, apperrors.ErrSaveFileFailed.WithError(err)
}
// 获取文件大小
fileSize := int64(len(fileData))
if size, err := storage.Size(finalPath); err == nil {
fileSize = size
}
// 创建附件记录
adminID := uint(0)
if s.ctx != nil {
if id, err := helpers.GetAdminIDFromContext(s.ctx); err == nil {
adminID = id
}
}
fileType := s.GetFileType(mimeType)
attachment := &models.Attachment{
AdminID: adminID,
Disk: s.disk,
Path: finalPath,
Filename: filename,
Extension: strings.TrimPrefix(ext, "."),
MimeType: mimeType,
Size: fileSize,
Status: 1,
FileType: fileType,
}
if err := facades.Orm().Query().Create(attachment); err != nil {
_ = storage.Delete(finalPath)
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "创建附件记录失败", map[string]any{
"filename": filename,
"file_path": finalPath,
"error": err.Error(),
}, "创建附件记录失败: %w", err)
}
return nil, apperrors.ErrCreateFailed.WithError(err)
}
return attachment, nil
}
// GetFileURL 获取文件访问URL
func (s *AttachmentServiceImpl) GetFileURL(attachment *models.Attachment) string {
// 对于本地存储,返回下载接口URL
if attachment.Disk == "local" || attachment.Disk == "public" {
return fmt.Sprintf("/api/admin/attachments/%d/preview", attachment.ID)
}
// 对于云存储,生成临时URL或直接URL
storage := facades.Storage().Disk(attachment.Disk)
// 尝试生成临时URL(24小时有效)
if url, err := storage.TemporaryUrl(attachment.Path, time.Now().Add(24*time.Hour)); err == nil {
return url
}
// 如果生成临时URL失败,尝试从配置获取基础URL
var configURL string
switch attachment.Disk {
case "s3":
configURL = utils.GetConfigValue("storage", "s3_url", "")
case "oss":
configURL = utils.GetConfigValue("storage", "oss_url", "")
case "cos":
configURL = utils.GetConfigValue("storage", "cos_url", "")
case "minio":
configURL = utils.GetConfigValue("storage", "minio_url", "")
}
if configURL != "" {
if !strings.HasSuffix(configURL, "/") {
configURL += "/"
}
return configURL + attachment.Path
}
// 默认返回下载接口URL
return fmt.Sprintf("/api/admin/attachments/%d/preview", attachment.ID)
}
// GetFileType 根据MIME类型判断文件类型
func (s *AttachmentServiceImpl) GetFileType(mimeType string) string {
if strings.HasPrefix(mimeType, "image/") {
return "image"
}
if strings.HasPrefix(mimeType, "video/") {
return "video"
}
if strings.HasPrefix(mimeType, "application/pdf") ||
strings.HasPrefix(mimeType, "application/msword") ||
strings.HasPrefix(mimeType, "application/vnd.openxmlformats-officedocument") ||
strings.HasPrefix(mimeType, "application/vnd.ms-excel") ||
strings.HasPrefix(mimeType, "text/") {
return "document"
}
return "other"
}
// GetByID 根据ID获取附件
func (s *AttachmentServiceImpl) GetByID(id uint) (*models.Attachment, error) {
var attachment models.Attachment
if err := facades.Orm().Query().Where("id", id).First(&attachment); err != nil {
return nil, apperrors.ErrAttachmentNotFound.WithError(err)
}
return &attachment, nil
}
// GetByIDs 根据ID列表获取附件
func (s *AttachmentServiceImpl) GetByIDs(ids []uint) ([]models.Attachment, error) {
if len(ids) == 0 {
return []models.Attachment{}, nil
}
idsAny := helpers.ConvertUintSliceToAny(ids)
var attachments []models.Attachment
if err := facades.Orm().Query().WhereIn("id", idsAny).Get(&attachments); err != nil {
return nil, apperrors.ErrQueryFailed.WithError(err)
}
return attachments, nil
}
// GetList 获取附件列表
func (s *AttachmentServiceImpl) GetList(filters AttachmentFilters, page, pageSize int) ([]models.Attachment, int64, error) {
query := facades.Orm().Query().Model(&models.Attachment{})
// 应用筛选条件
if filters.AdminID != "" {
query = query.Where("admin_id", filters.AdminID)
}
if filters.Filename != "" {
query = query.Where("filename LIKE ?", "%"+filters.Filename+"%")
}
if filters.DisplayName != "" {
query = query.Where("display_name LIKE ?", "%"+filters.DisplayName+"%")
}
if filters.FileType != "" {
query = query.Where("file_type = ?", filters.FileType)
}
if filters.Extension != "" {
query = query.Where("extension = ?", filters.Extension)
}
if filters.StartTime != "" {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != "" {
query = query.Where("created_at <= ?", filters.EndTime)
}
// 应用排序
orderBy := filters.OrderBy
if orderBy == "" {
orderBy = "id:desc"
}
query = helpers.ApplySort(query, orderBy, "id:desc")
// 分页查询
var attachments []models.Attachment
var total int64
if err := query.With("Admin").Paginate(page, pageSize, &attachments, &total); err != nil {
return nil, 0, err
}
return attachments, total, nil
}
// UpdateDisplayName 更新显示名称
func (s *AttachmentServiceImpl) UpdateDisplayName(id uint, displayName string) error {
var attachment models.Attachment
if err := facades.Orm().Query().Where("id", id).First(&attachment); err != nil {
return fmt.Errorf("附件不存在: %v", err)
}
attachment.DisplayName = displayName
if err := facades.Orm().Query().Save(&attachment); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "更新附件显示名称失败", map[string]any{
"attachment_id": id,
"display_name": displayName,
"error": err.Error(),
}, "更新附件显示名称失败: %v", err)
}
return apperrors.ErrUpdateFailed.WithError(err)
}
return nil
}
// DeleteFile 删除文件
func (s *AttachmentServiceImpl) DeleteFile(attachment *models.Attachment) error {
// 删除文件
if attachment.Path != "" && attachment.Disk != "" {
storage := facades.Storage().Disk(attachment.Disk)
if err := storage.Delete(attachment.Path); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "删除文件失败", map[string]any{
"attachment_id": attachment.ID,
"file_path": attachment.Path,
"disk": attachment.Disk,
"error": err.Error(),
}, "删除文件失败: %w", err)
}
return apperrors.ErrDeleteFileFailed.WithError(err)
}
}
// 删除数据库记录
if _, err := facades.Orm().Query().Delete(attachment); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "删除附件记录失败", map[string]any{
"attachment_id": attachment.ID,
"error": err.Error(),
}, "删除附件记录失败: %w", err)
}
return apperrors.ErrDeleteFailed.WithError(err)
}
return nil
}
+401
View File
@@ -0,0 +1,401 @@
package services
import (
"context"
"encoding/json"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
"github.com/spf13/cast"
"goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
"goravel/app/utils"
"goravel/app/utils/errorlog"
"goravel/app/utils/logger"
)
type AuthService interface {
// Login 管理员登录
Login(ctx http.Context, username, password string) (*models.Admin, string, error)
// GetAdminInfo 获取管理员完整信息(包括权限和菜单)
GetAdminInfo(ctx http.Context) (*models.Admin, []models.Permission, []models.Menu, error)
// RecordLoginLog 记录登录日志
RecordLoginLog(ctx http.Context, adminID uint, username string, status uint8, message string, request string) error
}
type AuthServiceImpl struct {
adminService AdminService
tokenService TokenService
}
func NewAuthServiceImpl(adminService AdminService, tokenService TokenService) *AuthServiceImpl {
return &AuthServiceImpl{
adminService: adminService,
tokenService: tokenService,
}
}
// Login 管理员登录
//
// 参数:
// - ctx: HTTP 上下文
// - username: 用户名
// - password: 密码
//
// 返回:
// - *models.Admin: 管理员对象
// - string: JWT token
// - error: 错误信息
func (s *AuthServiceImpl) Login(ctx http.Context, username, password string) (*models.Admin, string, error) {
// 验证用户名是否存在
exists, err := facades.Orm().Query().Model(&models.Admin{}).Where("username", username).Exists()
if err != nil {
return nil, "", err
}
if !exists {
return nil, "", errors.ErrUsernameOrPasswordErr
}
// 获取管理员信息
var admin models.Admin
if err := facades.Orm().Query().Where("username", username).First(&admin); err != nil {
return nil, "", err
}
if admin.Status == 0 {
return nil, "", errors.ErrAccountDisabled
}
// 验证密码
if !facades.Hash().Check(password, admin.Password) {
// 记录登录失败日志(注意:这个方法可能不再使用,但为了兼容性保留)
requestData := ""
if allInputs := ctx.Request().All(); len(allInputs) > 0 {
if data, err := json.Marshal(allInputs); err == nil {
requestData = string(data)
}
}
s.RecordLoginLog(ctx, 0, username, 0, "password_error", requestData)
return nil, "", errors.ErrPasswordError
}
// 生成token并存入数据库(类似Laravel Sanctum
// 按配置的过期时间生成token,如果需要永久token,可以在创建token时设置 expiresAt 为 nil
var expiresAt *time.Time
ttl := facades.Config().GetInt("jwt.ttl", 60) // 默认60分钟
if ttl > 0 {
// 如果配置了过期时间,设置过期时间
exp := time.Now().Add(time.Duration(ttl) * time.Minute)
expiresAt = &exp
}
// 如果 ttl 为 0 或负数,expiresAt 为 nil,表示永不过期
// 获取浏览器和操作系统信息
browser, os := helpers.GetBrowserAndOS(ctx)
// 获取真实IP地址
ip := helpers.GetRealIP(ctx)
// sessionID将在CreateToken中自动生成
// 生成token
plainToken, _, err := s.tokenService.CreateToken("admin", admin.ID, "admin-token", expiresAt, browser, ip, os, "")
if err != nil {
return nil, "", err
}
token := plainToken
// 更新最后登录时间(ORM会自动更新UpdatedAt
facades.Orm().Query().Save(&admin)
// 记录登录成功日志
requestData := ""
if allInputs := ctx.Request().All(); len(allInputs) > 0 {
if data, err := json.Marshal(allInputs); err == nil {
requestData = string(data)
}
}
s.RecordLoginLog(ctx, admin.ID, username, 1, "login_success", requestData)
return &admin, token, nil
}
// GetAdminInfo 获取管理员完整信息(包括权限和菜单)
//
// 参数:
// - ctx: HTTP 上下文
//
// 返回:
// - *models.Admin: 管理员对象
// - []models.Permission: 权限列表
// - []models.Menu: 菜单列表
// - error: 错误信息
func (s *AuthServiceImpl) GetAdminInfo(ctx http.Context) (*models.Admin, []models.Permission, []models.Menu, error) {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
logger.ErrorfHTTP(ctx, "GetAdminInfo: admin value is nil in context")
return nil, nil, nil, errors.ErrNotLoggedIn
}
var admin models.Admin
// 尝试值类型
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
// 尝试指针类型
if adminPtr == nil {
logger.ErrorfHTTP(ctx, "GetAdminInfo: admin pointer is nil")
return nil, nil, nil, errors.ErrNotLoggedIn
}
admin = *adminPtr
} else {
logger.ErrorfHTTP(ctx, "GetAdminInfo: admin value type assertion failed, type: %T, value: %+v", adminValue, adminValue)
return nil, nil, nil, errors.ErrNotLoggedIn
}
// facades.Log().Debugf("GetAdminInfo: admin found, ID: %d, Username: %s", admin.ID, admin.Username)
// 重新查询admin并加载关联(避免使用已存在的admin对象,可能导致关联加载问题)
var adminWithRelations models.Admin
if err := facades.Orm().Query().With("Department").With("Roles").Where("id", admin.ID).First(&adminWithRelations); err != nil {
errorlog.RecordHTTP(ctx, "auth", "Failed to load admin with relations", map[string]any{
"error": err.Error(),
"admin_id": admin.ID,
}, "GetAdminInfo: failed to load admin with relations, error: %v", err)
return nil, nil, nil, err
}
admin = adminWithRelations
// 批量加载所有角色的权限和菜单,避免 N+1 查询
if len(admin.Roles) > 0 {
var roleIDs []uint
for _, role := range admin.Roles {
roleIDs = append(roleIDs, role.ID)
}
// 批量加载权限
type RolePermission struct {
RoleID uint `gorm:"column:role_id"`
PermissionID uint `gorm:"column:permission_id"`
}
var rolePermissions []RolePermission
if err := facades.Orm().Query().Table("role_permission").Where("role_id IN ?", roleIDs).Find(&rolePermissions); err == nil {
var permissionIDs []uint
rolePermissionMap := make(map[uint][]uint)
for _, rp := range rolePermissions {
rolePermissionMap[rp.RoleID] = append(rolePermissionMap[rp.RoleID], rp.PermissionID)
permissionIDs = append(permissionIDs, rp.PermissionID)
}
if len(permissionIDs) > 0 {
var permissions []models.Permission
if err := facades.Orm().Query().Where("id IN ?", permissionIDs).Find(&permissions); err == nil {
permissionMap := make(map[uint]models.Permission)
for _, perm := range permissions {
permissionMap[perm.ID] = perm
}
for i := range admin.Roles {
if permIDs, ok := rolePermissionMap[admin.Roles[i].ID]; ok {
for _, permID := range permIDs {
if perm, ok := permissionMap[permID]; ok {
admin.Roles[i].Permissions = append(admin.Roles[i].Permissions, perm)
}
}
}
}
}
}
}
// 批量加载菜单
type RoleMenu struct {
RoleID uint `gorm:"column:role_id"`
MenuID uint `gorm:"column:menu_id"`
}
var roleMenus []RoleMenu
if err := facades.Orm().Query().Table("role_menu").Where("role_id IN ?", roleIDs).Find(&roleMenus); err == nil {
var menuIDs []uint
roleMenuMap := make(map[uint][]uint)
for _, rm := range roleMenus {
roleMenuMap[rm.RoleID] = append(roleMenuMap[rm.RoleID], rm.MenuID)
menuIDs = append(menuIDs, rm.MenuID)
}
if len(menuIDs) > 0 {
var menus []models.Menu
if err := facades.Orm().Query().Where("id IN ?", menuIDs).Find(&menus); err == nil {
menuMap := make(map[uint]models.Menu)
for _, menu := range menus {
menuMap[menu.ID] = menu
}
for i := range admin.Roles {
if mIDs, ok := roleMenuMap[admin.Roles[i].ID]; ok {
for _, menuID := range mIDs {
if menu, ok := menuMap[menuID]; ok {
admin.Roles[i].Menus = append(admin.Roles[i].Menus, menu)
}
}
}
}
}
}
}
}
// 检查是否是超级管理员
const SuperAdminRoleSlug = "super-admin"
isSuperAdmin := false
for _, role := range admin.Roles {
if role.Slug == SuperAdminRoleSlug && role.Status == 1 {
isSuperAdmin = true
break
}
}
// 收集所有角色的权限和菜单(去重)
permissionMap := make(map[uint]models.Permission)
menuMap := make(map[uint]models.Menu)
for _, role := range admin.Roles {
for _, perm := range role.Permissions {
permissionMap[perm.ID] = perm
}
for _, menu := range role.Menus {
menuMap[menu.ID] = menu
}
}
// 如果是超级管理员,返回所有菜单(用于前端显示)
// 但不需要返回所有权限,因为权限检查在中间件中会跳过
if isSuperAdmin {
var allMenus []models.Menu
if err := facades.Orm().Query().Where("status", 1).Order("sort ASC").Find(&allMenus); err == nil {
for _, menu := range allMenus {
menuMap[menu.ID] = menu
}
}
}
// 转换为切片
var permissions []models.Permission
var menus []models.Menu
for _, perm := range permissionMap {
permissions = append(permissions, perm)
}
for _, menu := range menuMap {
menus = append(menus, menu)
}
// 检查是否需要隐藏服务监控菜单
// 只有当配置值不为空且不等于 "0" 时才隐藏("0" 表示不隐藏)
monitorHidden := facades.Config().GetString("admin.monitor_hidden", "")
if monitorHidden != "" && monitorHidden != "0" {
// 检查是否是开发者管理员
developerIDsStr := facades.Config().GetString("admin.developer_ids", "2")
isDeveloperAdmin := s.isDeveloperAdmin(admin.ID, developerIDsStr)
// 如果不是开发者管理员,则过滤掉服务监控菜单
if !isDeveloperAdmin {
var filteredMenus []models.Menu
for _, menu := range menus {
if menu.Slug != "monitor" {
filteredMenus = append(filteredMenus, menu)
}
}
menus = filteredMenus
}
}
// 检查是否需要隐藏开发工具菜单
enableDevTool := facades.Config().GetBool("app.enable_dev_tool")
if !enableDevTool {
var filteredMenus []models.Menu
for _, menu := range menus {
if menu.Slug != "dev" {
filteredMenus = append(filteredMenus, menu)
}
}
menus = filteredMenus
}
return &admin, permissions, menus, nil
}
// RecordLoginLog 记录登录日志
func (s *AuthServiceImpl) RecordLoginLog(ctx http.Context, adminID uint, username string, status uint8, message string, request string) error {
ip := ctx.Request().Ip()
// 先创建登录日志记录(Location 字段先为空,避免阻塞登录流程)
loginLog := models.LoginLog{
AdminID: adminID,
Username: username,
IP: ip,
UserAgent: ctx.Request().Header("User-Agent", ""),
Location: "", // 先为空,异步更新
Status: status,
Message: message,
Request: request,
}
if err := facades.Orm().Query().Create(&loginLog); err != nil {
return err
}
// 异步查询 IP 地理位置信息并更新日志记录
// 这样不会阻塞登录流程
go func() {
// 添加 panic 恢复机制
defer func() {
if r := recover(); r != nil {
facades.Log().Errorf("Recovered from panic in IP location update: %v", r)
}
}()
// 添加上下文超时控制(5秒超时)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
location := utils.GetIPLocation(ip)
if location != "" {
// 更新登录日志的 Location 字段
if _, err := facades.Orm().Query().
Model(&models.LoginLog{}).
Where("id", loginLog.ID).
Update("location", location); err != nil {
facades.Log().Errorf("Failed to update login log location: %v", err)
}
}
// 检查上下文是否超时
select {
case <-ctx.Done():
if ctx.Err() == context.DeadlineExceeded {
facades.Log().Errorf("IP location update timeout for login log ID: %d", loginLog.ID)
}
default:
}
}()
return nil
}
// isDeveloperAdmin 检查是否是开发者管理员
func (s *AuthServiceImpl) isDeveloperAdmin(adminID uint, developerIDsStr string) bool {
if developerIDsStr == "" {
return false
}
// 解析开发者ID列表
parts := str.Of(developerIDsStr).Split(",")
for _, part := range parts {
part = str.Of(part).Trim().String()
if !str.Of(part).IsEmpty() {
if id := cast.ToUint(part); id > 0 && id == adminID {
return true
}
}
}
return false
}
+114
View File
@@ -0,0 +1,114 @@
package services
import (
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
)
type BlacklistService interface {
// GetByID 根据ID获取黑名单
GetByID(id uint) (*models.Blacklist, error)
// GetList 获取黑名单列表
GetList(filters BlacklistFilters, page, pageSize int) ([]models.Blacklist, int64, error)
// Create 创建黑名单
Create(ip, remark string, status uint8) (*models.Blacklist, error)
// Update 更新黑名单
Update(blacklist *models.Blacklist) error
// Delete 删除黑名单
Delete(blacklist *models.Blacklist) error
}
// BlacklistFilters 黑名单查询过滤器
type BlacklistFilters struct {
IP string
Status string
StartTime string
EndTime string
OrderBy string
}
type BlacklistServiceImpl struct {
}
func NewBlacklistService() BlacklistService {
return &BlacklistServiceImpl{}
}
// GetByID 根据ID获取黑名单
func (s *BlacklistServiceImpl) GetByID(id uint) (*models.Blacklist, error) {
var blacklist models.Blacklist
if err := facades.Orm().Query().Where("id", id).FirstOrFail(&blacklist); err != nil {
return nil, apperrors.ErrBlacklistNotFound.WithError(err)
}
return &blacklist, nil
}
// GetList 获取黑名单列表
func (s *BlacklistServiceImpl) GetList(filters BlacklistFilters, page, pageSize int) ([]models.Blacklist, int64, error) {
query := facades.Orm().Query().Model(&models.Blacklist{})
// 应用筛选条件
if filters.IP != "" {
query = query.Where("ip LIKE ?", "%"+filters.IP+"%")
}
if filters.Status != "" {
query = query.Where("status", filters.Status)
}
if filters.StartTime != "" {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != "" {
query = query.Where("created_at <= ?", filters.EndTime)
}
// 应用排序
orderBy := filters.OrderBy
if orderBy == "" {
orderBy = "id:desc"
}
query = helpers.ApplySort(query, orderBy, "id:desc")
// 分页查询
var blacklists []models.Blacklist
var total int64
if err := query.Paginate(page, pageSize, &blacklists, &total); err != nil {
return nil, 0, err
}
return blacklists, total, nil
}
// Create 创建黑名单
func (s *BlacklistServiceImpl) Create(ip, remark string, status uint8) (*models.Blacklist, error) {
blacklist := &models.Blacklist{}
createData := map[string]any{
"ip": ip,
"remark": remark,
"status": status,
}
if err := facades.Orm().Query().Model(blacklist).Create(createData); err != nil {
return nil, apperrors.ErrCreateFailed.WithError(err)
}
return blacklist, nil
}
// Update 更新黑名单
func (s *BlacklistServiceImpl) Update(blacklist *models.Blacklist) error {
if err := facades.Orm().Query().Save(blacklist); err != nil {
return apperrors.ErrUpdateFailed.WithError(err)
}
return nil
}
// Delete 删除黑名单
func (s *BlacklistServiceImpl) Delete(blacklist *models.Blacklist) error {
if _, err := facades.Orm().Query().Delete(blacklist); err != nil {
return apperrors.ErrDeleteFailed.WithError(err)
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
package services
import (
"strings"
"time"
"github.com/mojocn/base64Captcha"
"goravel/app/utils"
)
type CaptchaService interface {
Enabled() bool
Generate() (string, string, error)
Verify(id, answer string) (bool, string)
}
type CaptchaServiceImpl struct {
driver base64Captcha.Driver
store base64Captcha.Store
expireSeconds int
initialized bool
}
func NewCaptchaServiceImpl() CaptchaService {
// 延迟初始化,不在构造函数中查询数据库
// 这样可以避免在构建时访问数据库
return &CaptchaServiceImpl{
expireSeconds: 120, // 默认值
initialized: false,
}
}
// initDriver 延迟初始化 driver 和 store
func (s *CaptchaServiceImpl) initDriver() {
if s.initialized {
return
}
// 从数据库读取验证码配置,如果不存在则使用默认值
expireSeconds := utils.GetConfigValueInt("captcha", "captcha_expire", 120)
if expireSeconds <= 0 {
expireSeconds = 120
}
s.expireSeconds = expireSeconds
s.driver = base64Captcha.NewDriverString(
50, // height
180, // width
5, // noise count
base64Captcha.OptionShowHollowLine|base64Captcha.OptionShowSineLine,
5, // length
"2345678ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz",
nil,
nil,
nil,
)
s.store = base64Captcha.NewMemoryStore(1024, time.Duration(expireSeconds)*time.Second)
s.initialized = true
}
func (s *CaptchaServiceImpl) Enabled() bool {
// 从数据库读取验证码配置,如果不存在则使用默认值
return utils.GetConfigValueBool("captcha", "captcha_enabled", false)
}
func (s *CaptchaServiceImpl) Generate() (string, string, error) {
s.initDriver()
c := base64Captcha.NewCaptcha(s.driver, s.store)
id, b64s, _, err := c.Generate()
if err != nil {
return "", "", err
}
return id, b64s, nil
}
func (s *CaptchaServiceImpl) Verify(id, answer string) (bool, string) {
if id == "" || strings.TrimSpace(answer) == "" {
return false, "captcha_required"
}
s.initDriver()
expected := s.store.Get(id, true)
if expected == "" {
return false, "captcha_expired"
}
if !strings.EqualFold(expected, strings.TrimSpace(answer)) {
return false, "captcha_invalid"
}
return true, ""
}
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
package services
import (
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
)
type DepartmentService interface {
// GetByID 根据ID获取部门
GetByID(id uint) (*models.Department, error)
// GetList 获取部门列表
GetList(filters DepartmentFilters, page, pageSize int) ([]models.Department, int64, error)
// HasAdmins 检查部门是否有管理员
HasAdmins(departmentID uint) (bool, error)
// Create 创建部门
Create(parentID uint, name, code, leader, phone, email, remark string, status uint8, sort int) (*models.Department, error)
// Update 更新部门
Update(department *models.Department) error
// Delete 删除部门
Delete(department *models.Department) error
}
// DepartmentFilters 部门查询过滤器
type DepartmentFilters struct {
Name string
Status string
StartTime string
EndTime string
OrderBy string
}
type DepartmentServiceImpl struct {
treeService TreeService
}
func NewDepartmentServiceImpl(treeService TreeService) *DepartmentServiceImpl {
return &DepartmentServiceImpl{
treeService: treeService,
}
}
// GetByID 根据ID获取部门
func (s *DepartmentServiceImpl) GetByID(id uint) (*models.Department, error) {
var department models.Department
if err := facades.Orm().Query().Where("id", id).First(&department); err != nil {
return nil, apperrors.ErrDepartmentNotFound.WithError(err)
}
return &department, nil
}
// GetList 获取部门列表
func (s *DepartmentServiceImpl) GetList(filters DepartmentFilters, page, pageSize int) ([]models.Department, int64, error) {
query := facades.Orm().Query().Model(&models.Department{})
// 应用筛选条件
if filters.Name != "" {
query = query.Where("name LIKE ?", "%"+filters.Name+"%")
}
if filters.Status != "" {
query = query.Where("status", filters.Status)
}
if filters.StartTime != "" {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != "" {
query = query.Where("created_at <= ?", filters.EndTime)
}
// 应用排序
orderBy := filters.OrderBy
if orderBy == "" {
orderBy = "sort:asc,id:asc"
}
query = helpers.ApplySort(query, orderBy, "sort:asc,id:asc")
// 分页查询
var departments []models.Department
var total int64
if err := query.Paginate(page, pageSize, &departments, &total); err != nil {
return nil, 0, err
}
return departments, total, nil
}
// HasAdmins 检查部门是否有管理员
func (s *DepartmentServiceImpl) HasAdmins(departmentID uint) (bool, error) {
count, err := facades.Orm().Query().Model(&models.Admin{}).Where("department_id", departmentID).Count()
if err != nil {
return false, err
}
return count > 0, nil
}
// Create 创建部门
func (s *DepartmentServiceImpl) Create(parentID uint, name, code, leader, phone, email, remark string, status uint8, sort int) (*models.Department, error) {
department := &models.Department{}
createData := map[string]any{
"parent_id": parentID,
"name": name,
"code": code,
"leader": leader,
"phone": phone,
"email": email,
"remark": remark,
"status": status,
"sort": sort,
}
if err := facades.Orm().Query().Model(department).Create(createData); err != nil {
return nil, apperrors.ErrCreateFailed.WithError(err)
}
return department, nil
}
// Update 更新部门
func (s *DepartmentServiceImpl) Update(department *models.Department) error {
if err := facades.Orm().Query().Save(department); err != nil {
return apperrors.ErrUpdateFailed.WithError(err)
}
return nil
}
// Delete 删除部门
func (s *DepartmentServiceImpl) Delete(department *models.Department) error {
if _, err := facades.Orm().Query().Delete(department); err != nil {
return apperrors.ErrDeleteFailed.WithError(err)
}
return nil
}
+145
View File
@@ -0,0 +1,145 @@
package services
import (
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
)
type DictionaryService interface {
// GetByID 根据ID获取字典
GetByID(id uint) (*models.Dictionary, error)
// GetList 获取字典列表
GetList(filters DictionaryFilters, page, pageSize int) ([]models.Dictionary, int64, error)
// GetByType 根据类型获取字典列表
GetByType(dictType string) ([]models.Dictionary, error)
// GetAllTypes 获取所有字典类型
GetAllTypes() ([]string, error)
// Create 创建字典
Create(dictType, label, value, translationKey, description, remark string, status uint8, sort int) (*models.Dictionary, error)
// Update 更新字典
Update(dictionary *models.Dictionary) error
// Delete 删除字典
Delete(dictionary *models.Dictionary) error
}
// DictionaryFilters 字典查询过滤器
type DictionaryFilters struct {
Type string
Status string
StartTime string
EndTime string
OrderBy string
}
type DictionaryServiceImpl struct {
}
func NewDictionaryService() DictionaryService {
return &DictionaryServiceImpl{}
}
// GetByID 根据ID获取字典
func (s *DictionaryServiceImpl) GetByID(id uint) (*models.Dictionary, error) {
var dictionary models.Dictionary
if err := facades.Orm().Query().Where("id", id).FirstOrFail(&dictionary); err != nil {
return nil, apperrors.ErrDictionaryNotFound.WithError(err)
}
return &dictionary, nil
}
// GetList 获取字典列表
func (s *DictionaryServiceImpl) GetList(filters DictionaryFilters, page, pageSize int) ([]models.Dictionary, int64, error) {
query := facades.Orm().Query().Model(&models.Dictionary{})
// 应用筛选条件
if filters.Type != "" {
query = query.Where("type LIKE ?", "%"+filters.Type+"%")
}
if filters.Status != "" {
query = query.Where("status", filters.Status)
}
if filters.StartTime != "" {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != "" {
query = query.Where("created_at <= ?", filters.EndTime)
}
// 应用排序
orderBy := filters.OrderBy
if orderBy == "" {
orderBy = "sort:asc,id:desc"
}
query = helpers.ApplySort(query, orderBy, "sort:asc,id:desc")
// 分页查询
var dictionaries []models.Dictionary
var total int64
if err := query.Paginate(page, pageSize, &dictionaries, &total); err != nil {
return nil, 0, err
}
return dictionaries, total, nil
}
// GetByType 根据类型获取字典列表
func (s *DictionaryServiceImpl) GetByType(dictType string) ([]models.Dictionary, error) {
var dictionaries []models.Dictionary
if err := facades.Orm().Query().
Where("type", dictType).
Where("status", 1).
Order("sort asc, id asc").
Find(&dictionaries); err != nil {
return nil, err
}
return dictionaries, nil
}
// GetAllTypes 获取所有字典类型
func (s *DictionaryServiceImpl) GetAllTypes() ([]string, error) {
var types []string
if err := facades.Orm().Query().Model(&models.Dictionary{}).Distinct("type").Pluck("type", &types); err != nil {
return []string{}, nil
}
return types, nil
}
// Create 创建字典
func (s *DictionaryServiceImpl) Create(dictType, label, value, translationKey, description, remark string, status uint8, sort int) (*models.Dictionary, error) {
dictionary := &models.Dictionary{}
createData := map[string]any{
"type": dictType,
"label": label,
"value": value,
"translation_key": translationKey,
"description": description,
"remark": remark,
"status": status,
"sort": sort,
}
if err := facades.Orm().Query().Model(dictionary).Create(createData); err != nil {
return nil, apperrors.ErrCreateFailed.WithError(err)
}
return dictionary, nil
}
// Update 更新字典
func (s *DictionaryServiceImpl) Update(dictionary *models.Dictionary) error {
if err := facades.Orm().Query().Save(dictionary); err != nil {
return apperrors.ErrUpdateFailed.WithError(err)
}
return nil
}
// Delete 删除字典
func (s *DictionaryServiceImpl) Delete(dictionary *models.Dictionary) error {
if _, err := facades.Orm().Query().Delete(dictionary); err != nil {
return apperrors.ErrDeleteFailed.WithError(err)
}
return nil
}
+216
View File
@@ -0,0 +1,216 @@
package services
import (
"errors"
"fmt"
"path/filepath"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/spf13/cast"
"goravel/app/models"
"goravel/app/utils/errorlog"
)
// ExportOrderService 订单导出服务
type ExportOrderService struct {
ctx http.Context
}
func NewExportOrderService(ctx http.Context) *ExportOrderService {
return &ExportOrderService{ctx: ctx}
}
// ExportOrders 同步导出订单
func (s *ExportOrderService) ExportOrders(exportID uint, filters OrderFilters) error {
// 更新导出状态为处理中
var exportRecord models.Export
if err := facades.Orm().Query().Where("id", exportID).FirstOrFail(&exportRecord); err != nil {
return fmt.Errorf("查询导出记录失败: %v", err)
}
facades.Log().Infof("开始处理导出任务: export_id=%d", exportID)
exportRecord.Status = models.ExportStatusProcessing
exportRecord.ErrorMsg = ""
if err := facades.Orm().Query().Save(&exportRecord); err != nil {
return fmt.Errorf("更新导出状态失败: %v", err)
}
// 获取订单数据
orderService := NewOrderService()
ordersWithDetails, err := orderService.GetAllOrdersWithDetailsForExport(filters)
if err != nil {
errorMsg := fmt.Sprintf("获取订单数据失败: %v", err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = errorMsg
facades.Orm().Query().Save(&exportRecord)
return errors.New(errorMsg)
}
// 检查是否有数据
if len(ordersWithDetails) == 0 {
facades.Log().Infof("导出订单: 没有符合条件的订单数据, export_id=%d", exportID)
}
// 准备表头(翻译键,需要翻译)
headerKeys := []string{
"export_header_id",
"export_header_order_no",
"export_header_user_id",
"export_header_amount",
"export_header_status",
"export_header_item_index",
"export_header_product_id",
"export_header_product_name",
"export_header_price",
"export_header_quantity",
"export_header_subtotal",
"export_header_remark",
"export_header_created_at",
}
// 翻译表头
headers := make([]string, len(headerKeys))
for i, key := range headerKeys {
translated := facades.Lang(s.ctx).Get("messages." + key)
if translated == "messages."+key || translated == "" {
headers[i] = key
} else {
headers[i] = translated
}
}
// 准备数据(展开模式:每个商品一行)
var data [][]string
for _, orderWithDetails := range ordersWithDetails {
order := orderWithDetails.Order
details := orderWithDetails.Details
// 格式化订单状态
statusText := order.Status
switch order.Status {
case "pending":
statusText = "待支付"
case "paid":
statusText = "已支付"
case "cancelled":
statusText = "已取消"
}
// 格式化时间
timeStr := ""
if order.CreatedAt != nil && !order.CreatedAt.IsZero() {
timeStr = order.CreatedAt.ToDateTimeString()
}
// 如果订单没有商品,至少输出一行订单信息
if len(details) == 0 {
row := []string{
cast.ToString(order.ID),
order.OrderNo,
cast.ToString(order.UserID),
fmt.Sprintf("%.2f", order.Amount),
statusText,
"",
"",
"",
"",
"",
"",
order.Remark,
timeStr,
}
data = append(data, row)
} else {
// 每个商品一行,添加商品序号
totalItems := len(details)
for idx, detail := range details {
itemIndex := fmt.Sprintf("%d/%d", idx+1, totalItems)
row := []string{
cast.ToString(order.ID),
order.OrderNo,
cast.ToString(order.UserID),
fmt.Sprintf("%.2f", order.Amount),
statusText,
itemIndex,
cast.ToString(detail.ProductID),
detail.ProductName,
fmt.Sprintf("%.2f", detail.Price),
cast.ToString(detail.Quantity),
fmt.Sprintf("%.2f", detail.Subtotal),
order.Remark,
timeStr,
}
data = append(data, row)
}
}
}
// 使用 ExportService 导出(跳过自动创建记录)
exportService := NewExportService(s.ctx)
filename := fmt.Sprintf("orders_%d", time.Now().Unix())
facades.Log().Infof("开始导出文件: export_id=%d, filename=%s, data_rows=%d", exportID, filename, len(data))
filePath, err := exportService.ExportToCSV(headers, data, filename, true) // skipAutoCreate=true
if err != nil {
errorMsg := fmt.Sprintf("导出文件失败: %v", err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = errorMsg
facades.Orm().Query().Save(&exportRecord)
errorlog.RecordHTTP(s.ctx, "export", "导出文件失败", map[string]any{
"export_id": exportID,
"filename": filename,
"error": err.Error(),
}, "导出文件失败: %v", err)
return fmt.Errorf("导出文件失败: %v", err)
}
facades.Log().Infof("文件导出成功: export_id=%d, file_path=%s", exportID, filePath)
// 更新导出记录的文件路径和大小
if err := facades.Orm().Query().Where("id", exportID).FirstOrFail(&exportRecord); err != nil {
facades.Log().Errorf("查询导出记录失败: export_id=%d, error=%v", exportID, err)
return fmt.Errorf("查询导出记录失败: %v", err)
}
facades.Log().Infof("开始更新导出记录: export_id=%d, file_path=%s", exportID, filePath)
exportRecord.Path = filePath
exportRecord.Filename = filepath.Base(filePath)
// 获取文件扩展名
if ext := filepath.Ext(filePath); ext != "" {
exportRecord.Extension = ext[1:]
} else {
exportRecord.Extension = "csv"
}
// 获取文件大小
storage := facades.Storage().Disk(exportRecord.Disk)
if fileInfo, err := storage.Size(filePath); err == nil {
exportRecord.Size = fileInfo
facades.Log().Infof("文件大小: export_id=%d, size=%d", exportID, fileInfo)
} else {
facades.Log().Warningf("获取文件大小失败: export_id=%d, error=%v", exportID, err)
exportRecord.Size = 0
}
// 更新状态为成功
exportRecord.Status = models.ExportStatusSuccess
exportRecord.ErrorMsg = ""
// 保存更新
if err := facades.Orm().Query().Save(&exportRecord); err != nil {
facades.Log().Errorf("保存导出记录失败: export_id=%d, error=%v", exportID, err)
return fmt.Errorf("更新导出记录失败: %v", err)
}
facades.Log().Infof("导出成功: export_id=%d, file_path=%s, filename=%s, size=%d, extension=%s",
exportID, filePath, exportRecord.Filename, exportRecord.Size, exportRecord.Extension)
return nil
}
+149
View File
@@ -0,0 +1,149 @@
package services
import (
"context"
"fmt"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
"goravel/app/utils/errorlog"
)
type ExportRecordService interface {
// GetByID 根据ID获取导出记录
GetByID(id uint) (*models.Export, error)
// GetByIDs 根据ID列表获取导出记录
GetByIDs(ids []uint) ([]models.Export, error)
// GetList 获取导出记录列表
GetList(filters ExportRecordFilters, page, pageSize int) ([]models.Export, int64, error)
// Delete 删除导出记录
Delete(id uint) error
// BatchDelete 批量删除导出记录
BatchDelete(ids []uint) error
}
// ExportRecordFilters 导出记录查询过滤器
type ExportRecordFilters struct {
AdminID string
Type string
Filename string
Disk string
Status string
StartTime string
EndTime string
OrderBy string
}
type ExportRecordServiceImpl struct {
}
func NewExportRecordService() ExportRecordService {
return &ExportRecordServiceImpl{}
}
// GetByID 根据ID获取导出记录
func (s *ExportRecordServiceImpl) GetByID(id uint) (*models.Export, error) {
var export models.Export
if err := facades.Orm().Query().Where("id", id).FirstOrFail(&export); err != nil {
return nil, apperrors.ErrExportRecordNotFound.WithError(err)
}
return &export, nil
}
// GetByIDs 根据ID列表获取导出记录
func (s *ExportRecordServiceImpl) GetByIDs(ids []uint) ([]models.Export, error) {
if len(ids) == 0 {
return []models.Export{}, nil
}
idsAny := helpers.ConvertUintSliceToAny(ids)
var exports []models.Export
if err := facades.Orm().Query().WhereIn("id", idsAny).Get(&exports); err != nil {
return nil, apperrors.ErrQueryFailed.WithError(err)
}
return exports, nil
}
// GetList 获取导出记录列表
func (s *ExportRecordServiceImpl) GetList(filters ExportRecordFilters, page, pageSize int) ([]models.Export, int64, error) {
query := facades.Orm().Query().Model(&models.Export{})
// 应用筛选条件
if filters.AdminID != "" {
query = query.Where("admin_id", filters.AdminID)
}
if filters.Type != "" {
query = query.Where("type = ?", filters.Type)
}
if filters.Filename != "" {
query = query.Where("filename LIKE ?", "%"+filters.Filename+"%")
}
if filters.Disk != "" {
query = query.Where("disk = ?", filters.Disk)
}
if filters.Status != "" {
query = query.Where("status = ?", filters.Status)
}
if filters.StartTime != "" {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != "" {
query = query.Where("created_at <= ?", filters.EndTime)
}
// 应用排序
orderBy := filters.OrderBy
if orderBy == "" {
orderBy = "id:desc"
}
query = helpers.ApplySort(query, orderBy, "id:desc")
// 分页查询
var exports []models.Export
var total int64
if err := query.With("Admin").Paginate(page, pageSize, &exports, &total); err != nil {
return nil, 0, err
}
return exports, total, nil
}
// Delete 删除导出记录
func (s *ExportRecordServiceImpl) Delete(id uint) error {
var export models.Export
if err := facades.Orm().Query().Where("id", id).FirstOrFail(&export); err != nil {
return fmt.Errorf("导出记录不存在: %v", err)
}
if _, err := facades.Orm().Query().Delete(&export); err != nil {
errorlog.Record(context.Background(), "export-record", "删除导出记录失败", map[string]any{
"export_id": id,
"error": err.Error(),
}, "删除导出记录失败: %v", err)
return apperrors.ErrDeleteFailed.WithError(err)
}
return nil
}
// BatchDelete 批量删除导出记录
func (s *ExportRecordServiceImpl) BatchDelete(ids []uint) error {
if len(ids) == 0 {
return nil
}
idsAny := helpers.ConvertUintSliceToAny(ids)
if _, err := facades.Orm().Query().WhereIn("id", idsAny).Delete(&models.Export{}); err != nil {
errorlog.Record(context.Background(), "export-record", "批量删除导出记录失败", map[string]any{
"ids": ids,
"count": len(ids),
"error": err.Error(),
}, "批量删除导出记录失败: %v", err)
return apperrors.ErrBatchDeleteExportFailed.WithError(err)
}
return nil
}
+665
View File
@@ -0,0 +1,665 @@
package services
import (
"bytes"
"context"
"encoding/csv"
"fmt"
"io"
stdhttp "net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/minio/minio-go/v7"
miniocreds "github.com/minio/minio-go/v7/pkg/credentials"
"github.com/tencentyun/cos-go-sdk-v5"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
"goravel/app/utils"
"goravel/app/utils/errorlog"
)
type ExportService interface {
// ExportToCSV 导出数据到CSV文件
// headers: CSV表头
// data: 数据行,每行是一个字符串切片
// filename: 文件名(不含扩展名)
// skipAutoCreate: 是否跳过自动创建导出记录(用于异步任务)
// 返回: 文件路径和错误
ExportToCSV(headers []string, data [][]string, filename string, skipAutoCreate ...bool) (string, error)
// ExportToCSVStream 流式导出到 CSV(只支持 local/public 磁盘,避免百万级导出把内存打爆)
// headers: CSV 表头
// filename: 文件名(不含扩展名)
// write: 回调里持续调用 writer.Write(row) 写入数据;回调返回 error 会终止导出
// skipAutoCreate: 是否跳过自动创建导出记录(用于异步任务)
ExportToCSVStream(headers []string, filename string, write func(writer *csv.Writer) error, skipAutoCreate ...bool) (string, error)
// ExportToCSVStreamAt 流式导出到指定 filePath(包含目录+文件名,如 exports/orders_1_20260107.csv
// 用于“导出中”就先写入 exports 表的 Path/Filename 等字段,完成后只更新 size/status。
ExportToCSVStreamAt(headers []string, filePath string, write func(writer *csv.Writer) error, skipAutoCreate ...bool) (string, error)
// ExportToCSVStreamAtWithProgress 流式导出到指定 filePath,并通过回调返回已写入字节数(可用于实时更新 exports.size
ExportToCSVStreamAtWithProgress(headers []string, filePath string, write func(writer *csv.Writer) error, onProgress func(writtenBytes int64), skipAutoCreate ...bool) (string, error)
// ExportToFile 导出数据到文件(根据配置的格式)
// headers: 表头
// data: 数据行
// filename: 文件名(不含扩展名)
// 返回: 文件路径和错误
ExportToFile(headers []string, data [][]string, filename string) (string, error)
// GetExportURL 获取导出文件的访问URL
// filePath: 文件路径
// 返回: 访问URL
GetExportURL(filePath string) string
}
type ExportServiceImpl struct {
ctx http.Context
disk string
path string
format string
}
func NewExportService(ctx http.Context) ExportService {
// 从数据库读取文件存储配置,如果不存在则使用默认值
// 优先使用 file_disk,如果没有则使用 storage_disk(向后兼容),再尝试 export_disk,最后使用默认值 local
disk := utils.GetConfigValue("storage", "file_disk", "")
if disk == "" {
disk = utils.GetConfigValue("storage", "storage_disk", "")
}
if disk == "" {
// 向后兼容 export_disk
disk = utils.GetConfigValue("storage", "export_disk", "")
}
// 如果都不存在,使用默认值 local
if disk == "" {
disk = "local"
}
// 记录使用的存储驱动(用于调试)
// facades.Log().Debugf("ExportService: using storage disk: %s", disk)
// 文件路径默认使用 exports,不再从配置读取
path := "exports"
// 文件格式默认使用 csv,不再从配置读取
format := "csv"
return &ExportServiceImpl{
ctx: ctx,
disk: disk,
path: path,
format: format,
}
}
// ExportToCSVStream 流式导出 CSV(仅 local/public
func (s *ExportServiceImpl) ExportToCSVStream(headers []string, filename string, write func(writer *csv.Writer) error, skipAutoCreate ...bool) (string, error) {
timestamp := time.Now().Format("20060102_150405")
filename = fmt.Sprintf("%s_%s.csv", filename, timestamp)
// 注意:存储路径统一使用 "/",避免 Windows 下 filepath.Join 生成 "\" 导致云存储对象 key 异常
filePath := path.Join(s.path, filename)
return s.ExportToCSVStreamAt(headers, filePath, write, skipAutoCreate...)
}
func (s *ExportServiceImpl) ExportToCSVStreamAt(headers []string, filePath string, write func(writer *csv.Writer) error, skipAutoCreate ...bool) (string, error) {
return s.ExportToCSVStreamAtWithProgress(headers, filePath, write, nil, skipAutoCreate...)
}
type progressWriter struct {
w io.Writer
written int64
lastTick time.Time
interval time.Duration
cb func(int64)
}
func (p *progressWriter) Write(b []byte) (int, error) {
n, err := p.w.Write(b)
if n > 0 {
p.written += int64(n)
if p.cb != nil && (p.interval <= 0 || time.Since(p.lastTick) >= p.interval) {
p.lastTick = time.Now()
p.cb(p.written)
}
}
return n, err
}
func (s *ExportServiceImpl) ExportToCSVStreamAtWithProgress(headers []string, filePath string, write func(writer *csv.Writer) error, onProgress func(writtenBytes int64), skipAutoCreate ...bool) (string, error) {
// 规范化
filePath = path.Clean(strings.ReplaceAll(filePath, "\\", "/"))
displayName := path.Base(filePath)
// local/public:直接写到磁盘 root 下(最省资源)
if s.disk == "local" || s.disk == "public" {
// 获取磁盘 root,直接写到 root 下,避免先缓存在内存再 Put
root := facades.Config().GetString(fmt.Sprintf("filesystems.disks.%s.root", s.disk), "")
if root == "" {
return "", fmt.Errorf("filesystems.disks.%s.root is empty, can't stream write", s.disk)
}
absPath := filepath.Join(root, filepath.FromSlash(filePath))
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "export", "创建导出目录失败", map[string]any{
"abs_path": absPath,
"error": err.Error(),
}, "创建导出目录失败: %w", err)
}
return "", fmt.Errorf("创建导出目录失败: %w", err)
}
f, err := os.Create(absPath)
if err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "export", "创建导出文件失败", map[string]any{
"abs_path": absPath,
"error": err.Error(),
}, "创建导出文件失败: %w", err)
}
return "", fmt.Errorf("创建导出文件失败: %w", err)
}
defer func() {
_ = f.Close()
}()
pw := &progressWriter{
w: f,
lastTick: time.Now(),
interval: 2 * time.Second,
cb: onProgress,
}
writer := csv.NewWriter(pw)
// 写入表头
if len(headers) > 0 {
if err := writer.Write(headers); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "export", "写入CSV表头失败", map[string]any{
"filename": displayName,
"error": err.Error(),
}, "写入CSV表头失败: %w", err)
}
return "", apperrors.ErrWriteCSVHeaderFailed.WithError(err)
}
}
// 由调用方流式写入数据
if err := write(writer); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "export", "写入CSV数据失败", map[string]any{
"filename": displayName,
"error": err.Error(),
}, "写入CSV数据失败: %w", err)
}
return "", apperrors.ErrWriteCSVDataFailed.WithError(err)
}
writer.Flush()
if onProgress != nil {
onProgress(pw.written)
}
if err := writer.Error(); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "export", "CSV写入失败", map[string]any{
"filename": displayName,
"error": err.Error(),
}, "CSV写入失败: %w", err)
}
return "", apperrors.ErrCSVWriteFailed.WithError(err)
}
// 记录导出日志到数据库(尽量避免影响主流程,错误仅记日志)
shouldSkip := len(skipAutoCreate) > 0 && skipAutoCreate[0]
if !shouldSkip {
// 复用 ExportToCSV 的异步记录逻辑:这里先简单沿用(不阻塞主流程)
go s.recordExportLog(filePath, absPath)
}
return filePath, nil
}
// 云存储(s3/oss/cos/minio):先流式写到本地临时文件,再“流式/文件上传”到云盘,然后删除临时文件
tmpFile, err := os.CreateTemp(os.TempDir(), "export-*.csv")
if err != nil {
return "", fmt.Errorf("创建临时导出文件失败: %w", err)
}
tmpPath := tmpFile.Name()
defer func() {
_ = tmpFile.Close()
}()
pw := &progressWriter{
w: tmpFile,
lastTick: time.Now(),
interval: 2 * time.Second,
cb: onProgress,
}
writer := csv.NewWriter(pw)
if len(headers) > 0 {
if err := writer.Write(headers); err != nil {
_ = os.Remove(tmpPath)
return "", apperrors.ErrWriteCSVHeaderFailed.WithError(err)
}
}
if err := write(writer); err != nil {
writer.Flush()
if onProgress != nil {
onProgress(pw.written)
}
_ = os.Remove(tmpPath)
return "", apperrors.ErrWriteCSVDataFailed.WithError(err)
}
writer.Flush()
if onProgress != nil {
onProgress(pw.written)
}
if err := writer.Error(); err != nil {
_ = os.Remove(tmpPath)
return "", apperrors.ErrCSVWriteFailed.WithError(err)
}
if err := tmpFile.Close(); err != nil {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("关闭临时导出文件失败: %w", err)
}
if err := s.uploadLocalFileToCloudDisk(tmpPath, filePath); err != nil {
// 上传失败:保留临时文件便于排查(同时记录日志)
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "export", "上传云存储失败", map[string]any{
"disk": s.disk,
"tmp_path": tmpPath,
"dest_path": filePath,
"error": err.Error(),
}, "上传云存储失败: %v", err)
} else {
facades.Log().Errorf("export upload failed: disk=%s tmp=%s dest=%s err=%v", s.disk, tmpPath, filePath, err)
}
return "", err
}
_ = os.Remove(tmpPath)
shouldSkip := len(skipAutoCreate) > 0 && skipAutoCreate[0]
if !shouldSkip {
go s.recordExportLog(filePath, tmpPath)
}
return filePath, nil
}
// ExportToCSV 导出数据到CSV文件
// skipAutoCreate: 是否跳过自动创建导出记录(用于异步任务,避免重复创建)
func (s *ExportServiceImpl) ExportToCSV(headers []string, data [][]string, filename string, skipAutoCreate ...bool) (string, error) {
timestamp := time.Now().Format("20060102_150405")
filename = fmt.Sprintf("%s_%s.csv", filename, timestamp)
filePath := path.Join(s.path, filename)
// 创建CSV内容缓冲区
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
// 写入表头
if len(headers) > 0 {
if err := writer.Write(headers); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "export", "写入CSV表头失败", map[string]any{
"filename": filename,
"error": err.Error(),
}, "写入CSV表头失败: %w", err)
}
return "", apperrors.ErrWriteCSVHeaderFailed.WithError(err)
}
}
// 写入数据
for _, row := range data {
if err := writer.Write(row); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "export", "写入CSV数据失败", map[string]any{
"filename": filename,
"error": err.Error(),
}, "写入CSV数据失败: %w", err)
}
return "", apperrors.ErrWriteCSVDataFailed.WithError(err)
}
}
writer.Flush()
if err := writer.Error(); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "export", "CSV写入失败", map[string]any{
"filename": filename,
"error": err.Error(),
}, "CSV写入失败: %w", err)
}
return "", apperrors.ErrCSVWriteFailed.WithError(err)
}
// 获取存储驱动
storage := facades.Storage().Disk(s.disk)
// 写入文件
if err := storage.Put(filePath, buf.String()); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "export", "保存文件失败", map[string]any{
"filename": filename,
"file_path": filePath,
"error": err.Error(),
}, "保存文件失败: %w", err)
}
return "", apperrors.ErrSaveFileFailed.WithError(err)
}
// 获取文件大小(如果存储驱动支持 Size 方法)
var size int64
if fileInfo, err := storage.Size(filePath); err == nil {
size = fileInfo
}
// 记录导出日志到数据库(尽量避免影响主流程,错误仅记日志)
// 如果 skipAutoCreate 为 true,则跳过自动创建(用于异步任务)
shouldSkip := len(skipAutoCreate) > 0 && skipAutoCreate[0]
if !shouldSkip {
go func() {
defer func() {
if r := recover(); r != nil {
facades.Log().Errorf("ExportService: panic while recording export log: %v", r)
}
}()
adminID := uint(0)
if s.ctx != nil {
if id, err := helpers.GetAdminIDFromContext(s.ctx); err == nil {
adminID = id
}
}
ext := ""
if dot := strings.LastIndex(filename, "."); dot != -1 {
ext = filename[dot+1:]
} else if dot := strings.LastIndex(filePath, "."); dot != -1 {
ext = filePath[dot+1:]
}
// 尝试从 context 中获取导出类型
exportType := ""
if s.ctx != nil {
if typeValue := s.ctx.Value("export_type"); typeValue != nil {
if typeStr, ok := typeValue.(string); ok {
exportType = typeStr
}
}
}
exportRecord := models.Export{
AdminID: adminID,
Type: exportType,
Disk: s.disk,
Path: filePath,
Filename: filepath.Base(filePath),
Extension: ext,
Size: size,
Status: 1,
}
if err := facades.Orm().Query().Create(&exportRecord); err != nil {
facades.Log().Errorf("ExportService: failed to record export log: %v", err)
}
}()
}
return filePath, nil
}
func (s *ExportServiceImpl) recordExportLog(filePath string, absOrTmpPathForSize string) {
defer func() {
if r := recover(); r != nil {
facades.Log().Errorf("ExportService: panic while recording export log: %v", r)
}
}()
size := int64(0)
if fi, err := os.Stat(absOrTmpPathForSize); err == nil {
size = fi.Size()
}
adminID := uint(0)
if s.ctx != nil {
if id, err := helpers.GetAdminIDFromContext(s.ctx); err == nil {
adminID = id
}
}
ext := "csv"
exportType := ""
if s.ctx != nil {
if typeValue := s.ctx.Value("export_type"); typeValue != nil {
if typeStr, ok := typeValue.(string); ok {
exportType = typeStr
}
}
}
exportRecord := models.Export{
AdminID: adminID,
Type: exportType,
Disk: s.disk,
Path: filePath,
Filename: path.Base(filePath),
Extension: ext,
Size: size,
Status: 1,
}
if err := facades.Orm().Query().Create(&exportRecord); err != nil {
facades.Log().Errorf("ExportService: failed to record export log: %v", err)
}
}
func (s *ExportServiceImpl) uploadLocalFileToCloudDisk(localFilePath string, destPath string) error {
destPath = strings.TrimPrefix(destPath, "/")
switch s.disk {
case "s3":
return s.uploadToS3(localFilePath, destPath)
case "minio":
return s.uploadToMinio(localFilePath, destPath)
case "oss":
return s.uploadToOss(localFilePath, destPath)
case "cos":
return s.uploadToCos(localFilePath, destPath)
default:
return fmt.Errorf("unsupported cloud disk for stream export: %s", s.disk)
}
}
func (s *ExportServiceImpl) uploadToS3(localFilePath, key string) error {
cfg := facades.Config()
accessKeyId := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.key", s.disk))
accessKeySecret := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.secret", s.disk))
region := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.region", s.disk))
bucket := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.bucket", s.disk))
token := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.token", s.disk))
endpoint := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.endpoint", s.disk))
usePathStyle := cfg.GetBool(fmt.Sprintf("filesystems.disks.%s.use_path_style", s.disk), true)
objectCannedACL := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.object_canned_acl", s.disk))
if accessKeyId == "" || accessKeySecret == "" || region == "" || bucket == "" {
return fmt.Errorf("please set %s configuration first", s.disk)
}
options := s3.Options{
Region: region,
Credentials: aws.NewCredentialsCache(
credentials.NewStaticCredentialsProvider(accessKeyId, accessKeySecret, token)),
UsePathStyle: usePathStyle,
}
if endpoint != "" {
options.BaseEndpoint = aws.String(endpoint)
}
client := s3.New(options)
f, err := os.Open(localFilePath)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
fi, err := f.Stat()
if err != nil {
return err
}
input := &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: f,
ContentLength: aws.Int64(fi.Size()),
ContentType: aws.String("text/csv; charset=utf-8"),
}
if objectCannedACL != "" {
input.ACL = types.ObjectCannedACL(objectCannedACL)
}
_, err = client.PutObject(context.Background(), input)
return err
}
func (s *ExportServiceImpl) uploadToMinio(localFilePath, key string) error {
cfg := facades.Config()
accessKeyId := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.key", s.disk))
accessKeySecret := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.secret", s.disk))
region := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.region", s.disk))
bucket := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.bucket", s.disk))
endpoint := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.endpoint", s.disk))
ssl := cfg.GetBool(fmt.Sprintf("filesystems.disks.%s.ssl", s.disk), false)
if accessKeyId == "" || accessKeySecret == "" || bucket == "" || endpoint == "" {
return fmt.Errorf("please set %s configuration first", s.disk)
}
endpoint = strings.TrimPrefix(endpoint, "http://")
endpoint = strings.TrimPrefix(endpoint, "https://")
client, err := minio.New(endpoint, &minio.Options{
Creds: miniocreds.NewStaticV4(accessKeyId, accessKeySecret, ""),
Secure: ssl,
Region: region,
})
if err != nil {
return err
}
_, err = client.FPutObject(context.Background(), bucket, key, localFilePath, minio.PutObjectOptions{
ContentType: "text/csv; charset=utf-8",
})
return err
}
func (s *ExportServiceImpl) uploadToOss(localFilePath, key string) error {
cfg := facades.Config()
accessKeyId := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.key", s.disk))
accessKeySecret := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.secret", s.disk))
bucket := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.bucket", s.disk))
endpoint := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.endpoint", s.disk))
if accessKeyId == "" || accessKeySecret == "" || bucket == "" || endpoint == "" {
return fmt.Errorf("please set %s configuration first", s.disk)
}
client, err := oss.New(endpoint, accessKeyId, accessKeySecret)
if err != nil {
return err
}
bucketInstance, err := client.Bucket(bucket)
if err != nil {
return err
}
return bucketInstance.PutObjectFromFile(key, localFilePath)
}
func (s *ExportServiceImpl) uploadToCos(localFilePath, key string) error {
cfg := facades.Config()
accessKeyId := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.key", s.disk))
accessKeySecret := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.secret", s.disk))
cosUrl := cfg.GetString(fmt.Sprintf("filesystems.disks.%s.url", s.disk))
if accessKeyId == "" || accessKeySecret == "" || cosUrl == "" {
return fmt.Errorf("please set %s configuration first", s.disk)
}
u, err := url.Parse(cosUrl)
if err != nil {
return err
}
b := &cos.BaseURL{BucketURL: u}
client := cos.NewClient(b, &stdhttp.Client{
Transport: &cos.AuthorizationTransport{
SecretID: accessKeyId,
SecretKey: accessKeySecret,
},
})
_, _, err = client.Object.Upload(context.Background(), key, localFilePath, nil)
return err
}
// ExportToFile 导出数据到文件(根据配置的格式)
func (s *ExportServiceImpl) ExportToFile(headers []string, data [][]string, filename string) (string, error) {
switch s.format {
case "csv":
return s.ExportToCSV(headers, data, filename)
case "xlsx":
return "", apperrors.ErrExcelNotImplemented
default:
return s.ExportToCSV(headers, data, filename)
}
}
func (s *ExportServiceImpl) GetExportURL(filePath string) string {
// 根据不同的存储类型从配置读取 URL
var configURL string
switch s.disk {
case "s3":
configURL = utils.GetConfigValue("storage", "s3_url", "")
case "oss":
configURL = utils.GetConfigValue("storage", "oss_url", "")
case "cos":
configURL = utils.GetConfigValue("storage", "cos_url", "")
case "qiniu":
configURL = utils.GetConfigValue("storage", "qiniu_domain", "")
case "minio":
configURL = utils.GetConfigValue("storage", "minio_url", "")
}
if configURL != "" {
// 确保 URL 以 / 结尾,然后拼接文件路径
if !strings.HasSuffix(configURL, "/") {
configURL += "/"
}
return configURL + filePath
}
// 对于 local 和 public 存储,使用下载接口而不是直接文件路径
// 这样可以避免被前端路由拦截
if s.disk == "local" || s.disk == "public" {
// 返回下载接口 URL,需要从 context 中获取导出记录 ID
// 但这里没有 ID,所以需要修改调用方式
// 暂时返回一个占位符,实际 URL 在 ExportController.Index 中生成
return ""
}
storage := facades.Storage().Disk(s.disk)
if url, err := storage.TemporaryUrl(filePath, time.Now().Add(24*time.Hour)); err == nil {
return url
}
return "/storage/" + filePath
}
@@ -0,0 +1,193 @@
package services
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"image/png"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/goravel/framework/facades"
"goravel/app/utils/errorlog"
)
type GoogleAuthenticatorService interface {
// GenerateSecret 生成密钥
GenerateSecret(accountName string) (secret string, qrCodeURL string, err error)
// GenerateQRCodeImage 生成二维码图片(base64
GenerateQRCodeImage(accountName, secret string) (string, error)
// Verify 验证验证码
Verify(secret, code string) bool
// IsBound 检查管理员是否绑定了谷歌验证码
IsBound(adminID uint) (bool, error)
// GetSecret 获取管理员的密钥(用于绑定确认)
GetSecret(adminID uint) (string, error)
// Bind 绑定谷歌验证码
Bind(adminID uint, secret, code string) error
// Unbind 解绑谷歌验证码
Unbind(adminID uint) error
}
type GoogleAuthenticatorServiceImpl struct {
}
func NewGoogleAuthenticatorServiceImpl() GoogleAuthenticatorService {
return &GoogleAuthenticatorServiceImpl{}
}
// GenerateSecret 生成密钥
func (s *GoogleAuthenticatorServiceImpl) GenerateSecret(accountName string) (secret string, qrCodeURL string, err error) {
// 获取应用名称(从配置中读取,如果没有则使用默认值)
appName := facades.Config().GetString("app.name", "Goravel Admin")
if appName == "" {
appName = "Goravel Admin"
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: appName,
AccountName: accountName,
})
if err != nil {
return "", "", err
}
// 生成二维码URL
qrCodeURL = key.URL()
return key.Secret(), qrCodeURL, nil
}
// GenerateQRCodeImage 生成二维码图片(base64
// 使用标准的TOTP格式(RFC 6238),完全兼容Google Authenticator
func (s *GoogleAuthenticatorServiceImpl) GenerateQRCodeImage(accountName, secret string) (string, error) {
appName := facades.Config().GetString("app.name", "Goravel Admin")
if appName == "" {
appName = "Goravel Admin"
}
// 构建标准的otpauth URLGoogle Authenticator标准格式)
// 格式:otpauth://totp/Issuer:AccountName?secret=SECRET&issuer=Issuer
otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s",
appName, accountName, secret, appName)
// 从URL创建key对象(这样可以确保格式完全符合标准)
key, err := otp.NewKeyFromURL(otpURL)
if err != nil {
errorlog.Record(context.Background(), "google-authenticator", "创建密钥失败", map[string]any{
"app_name": appName,
"account_name": accountName,
"error": err.Error(),
}, "failed to create key from URL: %w", err)
return "", fmt.Errorf("failed to create key from URL: %w", err)
}
// 生成二维码图片(200x200像素,Google Authenticator推荐尺寸)
var buf bytes.Buffer
img, err := key.Image(200, 200)
if err != nil {
errorlog.Record(context.Background(), "google-authenticator", "生成二维码图片失败", map[string]any{
"app_name": appName,
"account_name": accountName,
"error": err.Error(),
}, "failed to generate QR code image: %w", err)
return "", fmt.Errorf("failed to generate QR code image: %w", err)
}
// 将图片编码为PNG
if err := png.Encode(&buf, img); err != nil {
errorlog.Record(context.Background(), "google-authenticator", "编码PNG失败", map[string]any{
"app_name": appName,
"account_name": accountName,
"error": err.Error(),
}, "failed to encode PNG: %w", err)
return "", fmt.Errorf("failed to encode PNG: %w", err)
}
// 转换为base64
base64Str := base64.StdEncoding.EncodeToString(buf.Bytes())
return "data:image/png;base64," + base64Str, nil
}
// Verify 验证验证码
func (s *GoogleAuthenticatorServiceImpl) Verify(secret, code string) bool {
if secret == "" || code == "" {
return false
}
return totp.Validate(code, secret)
}
// IsBound 检查管理员是否绑定了谷歌验证码
func (s *GoogleAuthenticatorServiceImpl) IsBound(adminID uint) (bool, error) {
// 先检查列是否存在
columns, err := facades.Schema().GetColumns("admins")
if err != nil {
return false, err
}
hasGoogleSecretColumn := false
for _, column := range columns {
if column.Name == "google_secret" {
hasGoogleSecretColumn = true
break
}
}
// 如果列不存在,返回 false(未绑定)
if !hasGoogleSecretColumn {
return false, nil
}
// 列存在,检查是否有值
count, err := facades.Orm().Query().Table("admins").
Where("id", adminID).
Where("google_secret IS NOT NULL").
Where("google_secret != ?", "").
Count()
if err != nil {
return false, err
}
return count > 0, nil
}
// GetSecret 获取管理员的密钥(用于绑定确认)
func (s *GoogleAuthenticatorServiceImpl) GetSecret(adminID uint) (string, error) {
var admin struct {
GoogleSecret string
}
err := facades.Orm().Query().Table("admins").
Select("google_secret").
Where("id", adminID).
First(&admin)
if err != nil {
return "", err
}
return admin.GoogleSecret, nil
}
// Bind 绑定谷歌验证码
func (s *GoogleAuthenticatorServiceImpl) Bind(adminID uint, secret, code string) error {
// 先验证验证码是否正确
if !s.Verify(secret, code) {
return fmt.Errorf("invalid_code")
}
// 更新管理员的google_secret
_, err := facades.Orm().Query().Table("admins").
Where("id", adminID).
Update("google_secret", secret)
return err
}
// Unbind 解绑谷歌验证码
func (s *GoogleAuthenticatorServiceImpl) Unbind(adminID uint) error {
_, err := facades.Orm().Query().Table("admins").
Where("id", adminID).
Update("google_secret", nil)
return err
}
+357
View File
@@ -0,0 +1,357 @@
package services
import (
"encoding/csv"
"fmt"
"strings"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/samber/lo"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/utils"
"goravel/app/utils/errorlog"
)
// ImportOrderService 订单导入服务
type ImportOrderService struct {
ctx http.Context
}
func NewImportOrderService(ctx http.Context) *ImportOrderService {
return &ImportOrderService{ctx: ctx}
}
// ImportOrderRow 导入订单行数据
type ImportOrderRow struct {
OrderID string // 订单ID(可选,如果为空则自动生成订单号)
OrderNo string // 订单号(可选)
UserID string // 用户ID
Amount string // 订单金额
Status string // 订单状态
ItemIndex string // 商品序号(如:1/2
ProductID string // 商品ID
ProductName string // 商品名称
Price string // 单价
Quantity string // 数量
Subtotal string // 小计
Remark string // 备注
CreatedAt string // 创建时间(可选)
}
// ImportResult 导入结果
type ImportResult struct {
TotalRows int // 总行数
SuccessCount int // 成功数量
FailedCount int // 失败数量
Errors []string // 错误信息列表
}
// ImportOrders 从CSV内容导入订单
func (s *ImportOrderService) ImportOrders(csvContent string) (*ImportResult, error) {
// 解析CSV内容
reader := csv.NewReader(strings.NewReader(csvContent))
reader.TrimLeadingSpace = true
reader.LazyQuotes = true
records, err := reader.ReadAll()
if err != nil {
errorlog.RecordHTTP(s.ctx, "import", "解析CSV失败", map[string]any{
"error": err.Error(),
}, "解析CSV失败: %v", err)
return nil, apperrors.ErrInvalidCSVFormat.WithError(err)
}
if len(records) < 2 {
return nil, apperrors.ErrInvalidCSVFormat.WithMessage("CSV文件至少需要表头和数据行")
}
// 解析表头,确定列索引
headers := records[0]
headerMap := make(map[string]int)
for i, header := range headers {
headerMap[strings.TrimSpace(strings.ToLower(header))] = i
}
// 定义列索引(支持中英文表头)
colIndices := s.getColumnIndices(headerMap)
// 解析数据行
dataRows := records[1:]
result := &ImportResult{
TotalRows: len(dataRows),
Errors: []string{},
}
// 解析并过滤有效的数据行,同时保留索引信息用于分组
type RowWithIndex struct {
Row ImportOrderRow
Index int
}
validRows := lo.FilterMap(lo.Range(len(dataRows)), func(rowIndex int, _ int) (RowWithIndex, bool) {
row := dataRows[rowIndex]
// 跳过空行
if len(row) == 0 || (len(row) == 1 && strings.TrimSpace(row[0]) == "") {
return RowWithIndex{}, false
}
// 解析行数据
orderRow := s.parseRow(row, colIndices, rowIndex+2) // +2 因为表头是第1行,数据从第2行开始
if orderRow == nil {
result.FailedCount++
result.Errors = append(result.Errors, fmt.Sprintf("第%d行:数据格式错误", rowIndex+2))
return RowWithIndex{}, false
}
return RowWithIndex{Row: *orderRow, Index: rowIndex}, true
})
// 按订单分组数据(同一订单可能有多行,每行一个商品)
orderMap := lo.GroupBy(validRows, func(item RowWithIndex) string {
orderRow := item.Row
// 确定订单标识(优先使用订单号,其次使用订单ID)
orderKey := orderRow.OrderNo
if orderKey == "" {
orderKey = orderRow.OrderID
}
if orderKey == "" {
// 如果都没有,使用行号作为临时标识
orderKey = fmt.Sprintf("temp_%d", item.Index)
}
return orderKey
})
// 将分组结果转换为 []ImportOrderRow
orderMapRows := lo.MapValues(orderMap, func(items []RowWithIndex, _ string) []ImportOrderRow {
return lo.Map(items, func(item RowWithIndex, _ int) ImportOrderRow {
return item.Row
})
})
// 导入订单
orderService := NewOrderService()
for orderKey, rows := range orderMapRows {
if err := s.importOrderGroup(orderService, orderKey, rows, result); err != nil {
result.FailedCount++
result.Errors = append(result.Errors, fmt.Sprintf("订单 %s: %v", orderKey, err))
} else {
result.SuccessCount++
}
}
return result, nil
}
// getColumnIndices 获取列索引映射(支持中英文表头)
func (s *ImportOrderService) getColumnIndices(headerMap map[string]int) map[string]int {
indices := make(map[string]int)
// 定义可能的表头名称(中英文)
headerMappings := map[string][]string{
"order_id": {"id", "订单id", "order id", "订单编号"},
"order_no": {"order_no", "订单号", "order no", "订单编号", "order number"},
"user_id": {"user_id", "用户id", "user id", "用户编号"},
"amount": {"amount", "金额", "订单金额", "total amount"},
"status": {"status", "状态", "订单状态", "order status"},
"item_index": {"item_index", "商品序号", "item index", "序号"},
"product_id": {"product_id", "商品id", "product id", "商品编号"},
"product_name": {"product_name", "商品名称", "product name", "商品名"},
"price": {"price", "单价", "商品单价", "unit price"},
"quantity": {"quantity", "数量", "商品数量", "qty"},
"subtotal": {"subtotal", "小计", "商品小计", "item total"},
"remark": {"remark", "备注", "订单备注", "note"},
"created_at": {"created_at", "创建时间", "created at", "创建日期"},
}
// 查找每个字段的列索引
for field, possibleHeaders := range headerMappings {
for _, header := range possibleHeaders {
if idx, exists := headerMap[header]; exists {
indices[field] = idx
break
}
}
}
return indices
}
// parseRow 解析单行数据
func (s *ImportOrderService) parseRow(row []string, colIndices map[string]int, rowNum int) *ImportOrderRow {
orderRow := &ImportOrderRow{}
// 安全获取列值
getValue := func(field string) string {
if idx, exists := colIndices[field]; exists && idx < len(row) {
return strings.TrimSpace(row[idx])
}
return ""
}
orderRow.OrderID = getValue("order_id")
orderRow.OrderNo = getValue("order_no")
orderRow.UserID = getValue("user_id")
orderRow.Amount = getValue("amount")
orderRow.Status = getValue("status")
orderRow.ItemIndex = getValue("item_index")
orderRow.ProductID = getValue("product_id")
orderRow.ProductName = getValue("product_name")
orderRow.Price = getValue("price")
orderRow.Quantity = getValue("quantity")
orderRow.Subtotal = getValue("subtotal")
orderRow.Remark = getValue("remark")
orderRow.CreatedAt = getValue("created_at")
return orderRow
}
// importOrderGroup 导入一组订单数据(一个订单可能有多行,每行一个商品)
func (s *ImportOrderService) importOrderGroup(orderService OrderService, orderKey string, rows []ImportOrderRow, result *ImportResult) error {
if len(rows) == 0 {
return fmt.Errorf("订单数据为空")
}
// 使用第一行作为订单主信息
firstRow := rows[0]
// 验证必填字段
if firstRow.UserID == "" {
return fmt.Errorf("用户ID不能为空")
}
userID := cast.ToUint(firstRow.UserID)
if userID == 0 {
return fmt.Errorf("用户ID格式错误: %s", firstRow.UserID)
}
// 解析订单金额(如果为空,则从商品计算)
amount := cast.ToFloat64(firstRow.Amount)
if amount == 0 {
// 从商品明细计算总金额
for _, row := range rows {
if row.Subtotal != "" {
amount += cast.ToFloat64(row.Subtotal)
} else if row.Price != "" && row.Quantity != "" {
amount += cast.ToFloat64(row.Price) * cast.ToFloat64(row.Quantity)
}
}
}
// 解析订单状态(默认为pending)
status := strings.ToLower(strings.TrimSpace(firstRow.Status))
if status == "" {
status = "pending"
}
// 支持中英文状态转换
status = s.normalizeStatus(status)
// 解析商品列表
products := []OrderProduct{}
for _, row := range rows {
// 跳过没有商品信息的行
if row.ProductID == "" && row.ProductName == "" {
continue
}
productID := cast.ToUint(row.ProductID)
productName := strings.TrimSpace(row.ProductName)
if productName == "" {
productName = fmt.Sprintf("商品%d", productID)
}
price := cast.ToFloat64(row.Price)
if price == 0 && row.Subtotal != "" && row.Quantity != "" {
// 如果单价为空,从小计和数量计算
subtotal := cast.ToFloat64(row.Subtotal)
quantity := cast.ToInt(row.Quantity)
if quantity > 0 {
price = subtotal / float64(quantity)
}
}
quantity := cast.ToInt(row.Quantity)
if quantity == 0 {
quantity = 1
}
products = append(products, OrderProduct{
ProductID: productID,
ProductName: productName,
Price: price,
Quantity: quantity,
})
}
if len(products) == 0 {
return fmt.Errorf("订单没有商品信息")
}
// 如果金额仍为0,从商品计算
if amount == 0 {
for _, product := range products {
amount += product.Price * float64(product.Quantity)
}
}
// 解析备注
remark := strings.TrimSpace(firstRow.Remark)
// 创建订单(使用空字符串作为requestID,让服务自动生成)
order, _, err := orderService.CreateOrder(userID, amount, products, "", remark)
if err != nil {
errorlog.RecordHTTP(s.ctx, "import", "创建订单失败", map[string]any{
"order_key": orderKey,
"user_id": userID,
"amount": amount,
"error": err.Error(),
}, "创建订单失败: %v", err)
return fmt.Errorf("创建订单失败: %v", err)
}
// 如果导入的订单有状态且不是pending,更新状态
if status != "pending" && status != order.Status {
// 解析创建时间(如果提供)
orderTime := time.Time{}
if firstRow.CreatedAt != "" {
if t, err := utils.ParseDateTime(firstRow.CreatedAt); err == nil {
orderTime = t
} else if t, err := utils.ParseDate(firstRow.CreatedAt); err == nil {
orderTime = t
}
}
if err := orderService.UpdateOrder(order.ID, orderTime, status, remark); err != nil {
// 状态更新失败不影响导入,只记录日志
facades.Log().Warningf("导入订单后更新状态失败: order_id=%d, status=%s, error=%v", order.ID, status, err)
}
}
facades.Log().Infof("成功导入订单: order_id=%d, order_no=%s, user_id=%d, amount=%.2f", order.ID, order.OrderNo, userID, amount)
return nil
}
// normalizeStatus 规范化订单状态(支持中英文)
func (s *ImportOrderService) normalizeStatus(status string) string {
status = strings.ToLower(strings.TrimSpace(status))
// 中文状态映射
statusMap := map[string]string{
"待支付": "pending",
"已支付": "paid",
"已取消": "cancelled",
"pending": "pending",
"paid": "paid",
"cancelled": "cancelled",
}
if normalized, exists := statusMap[status]; exists {
return normalized
}
// 默认返回pending
return "pending"
}
+43
View File
@@ -0,0 +1,43 @@
package services
import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/goravel/framework/facades"
)
// GeneratePermanentToken 为永久token用户生成一个永不过期的JWT token
// 注意:为了兼容框架的guard,我们设置一个很远的过期时间(100年后)
// 这样框架的guard能够识别token,同时逻辑上视为永久有效
func GeneratePermanentToken(userID uint) (string, error) {
secret := facades.Config().GetString("jwt.secret")
if secret == "" {
return "", jwt.ErrInvalidKey
}
// 创建token claims
// 设置一个很远的过期时间(100年后),这样框架的guard能够识别token
// 同时逻辑上视为永久有效(因为100年足够长)
now := time.Now()
expiresAt := now.AddDate(100, 0, 0) // 100年后过期
claims := jwt.MapClaims{
"key": userID,
"sub": "admin",
"iat": now.Unix(),
"exp": expiresAt.Unix(), // 设置一个很远的过期时间,让框架能够识别
}
// 创建token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// 签名token
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
return "", err
}
return tokenString, nil
}
+92
View File
@@ -0,0 +1,92 @@
package services
import (
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
)
type LoginLogService interface {
// GetByID 根据ID获取登录日志
GetByID(id uint, withAdmin bool) (*models.LoginLog, error)
// GetList 获取登录日志列表
GetList(filters LoginLogFilters, page, pageSize int) ([]models.LoginLog, int64, error)
}
// LoginLogFilters 登录日志查询过滤器
type LoginLogFilters struct {
AdminID string
Username string
IP string
Status string
StartTime string
EndTime string
OrderBy string
}
type LoginLogServiceImpl struct {
}
func NewLoginLogService() LoginLogService {
return &LoginLogServiceImpl{}
}
// GetByID 根据ID获取登录日志
func (s *LoginLogServiceImpl) GetByID(id uint, withAdmin bool) (*models.LoginLog, error) {
var log models.LoginLog
query := facades.Orm().Query().Where("id", id)
// 预加载关联
if withAdmin {
query = query.With("Admin")
}
if err := query.FirstOrFail(&log); err != nil {
return nil, apperrors.ErrLogNotFound.WithError(err)
}
return &log, nil
}
// GetList 获取登录日志列表
func (s *LoginLogServiceImpl) GetList(filters LoginLogFilters, page, pageSize int) ([]models.LoginLog, int64, error) {
query := facades.Orm().Query().Model(&models.LoginLog{})
// 应用筛选条件
if filters.AdminID != "" {
query = query.Where("admin_id", filters.AdminID)
}
if filters.Username != "" {
query = query.Where("username LIKE ?", "%"+filters.Username+"%")
}
if filters.IP != "" {
query = query.Where("ip LIKE ?", "%"+filters.IP+"%")
}
if filters.Status != "" {
query = query.Where("status = ?", filters.Status)
}
if filters.StartTime != "" {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != "" {
query = query.Where("created_at <= ?", filters.EndTime)
}
// 应用排序
orderBy := filters.OrderBy
if orderBy == "" {
orderBy = "id:desc"
}
query = helpers.ApplySort(query, orderBy, "id:desc")
// 分页查询
var logs []models.LoginLog
var total int64
if err := query.With("Admin").Paginate(page, pageSize, &logs, &total); err != nil {
return nil, 0, err
}
return logs, total, nil
}
+76
View File
@@ -0,0 +1,76 @@
package services
import (
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/models"
)
type MenuService interface {
// GetByID 根据ID获取菜单
GetByID(id uint) (*models.Menu, error)
// Create 创建菜单
Create(parentID uint, title, slug, icon, path, component, permission string, menuType uint8, status uint8, sort int, isHidden uint8, linkType, openType uint8) (*models.Menu, error)
// Update 更新菜单
Update(menu *models.Menu) error
// Delete 删除菜单
Delete(menu *models.Menu) error
}
type MenuServiceImpl struct {
}
func NewMenuService() MenuService {
return &MenuServiceImpl{}
}
// GetByID 根据ID获取菜单
func (s *MenuServiceImpl) GetByID(id uint) (*models.Menu, error) {
var menu models.Menu
if err := facades.Orm().Query().Where("id", id).FirstOrFail(&menu); err != nil {
return nil, apperrors.ErrMenuNotFound.WithError(err)
}
return &menu, nil
}
// Create 创建菜单
func (s *MenuServiceImpl) Create(parentID uint, title, slug, icon, path, component, permission string, menuType uint8, status uint8, sort int, isHidden uint8, linkType, openType uint8) (*models.Menu, error) {
menu := &models.Menu{
ParentID: parentID,
Title: title,
Slug: slug,
Icon: icon,
Path: path,
Component: component,
Permission: permission,
Type: menuType,
Status: status,
Sort: sort,
IsHidden: isHidden,
LinkType: linkType,
OpenType: openType,
}
if err := facades.Orm().Query().Create(menu); err != nil {
return nil, apperrors.ErrCreateFailed.WithError(err)
}
return menu, nil
}
// Update 更新菜单
func (s *MenuServiceImpl) Update(menu *models.Menu) error {
if err := facades.Orm().Query().Save(menu); err != nil {
return apperrors.ErrUpdateFailed.WithError(err)
}
return nil
}
// Delete 删除菜单
func (s *MenuServiceImpl) Delete(menu *models.Menu) error {
if _, err := facades.Orm().Query().Delete(menu); err != nil {
return apperrors.ErrDeleteFailed.WithError(err)
}
return nil
}
+202
View File
@@ -0,0 +1,202 @@
package services
import (
"time"
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/models"
wsnotifications "goravel/app/websocket/notifications"
)
type NotificationService interface {
Create(title, content, notifType string, senderID *uint, receiverID *uint) (*models.Notification, error)
List(adminID uint, page int, pageSize int, notifType string, isRead string) ([]models.Notification, int64, error)
ListRecent(adminID uint, limit int) ([]models.Notification, error)
MarkRead(adminID uint, notificationID uint) error
MarkAllRead(adminID uint) error
UnreadCount(adminID uint) (int64, error)
}
type NotificationServiceImpl struct{}
func NewNotificationServiceImpl() NotificationService {
return &NotificationServiceImpl{}
}
func (s *NotificationServiceImpl) Create(title, content, notifType string, senderID *uint, receiverID *uint) (*models.Notification, error) {
if receiverID == nil {
// 批量创建通知给所有管理员,使用事务确保原子性
var admins []models.Admin
if err := facades.Orm().Query().Find(&admins); err != nil {
return nil, err
}
if len(admins) == 0 {
return nil, apperrors.ErrRecordNotFound.WithMessage("no admins found")
}
var first *models.Notification
var notifications []*models.Notification
var createdIDs []uint
// 使用循环创建通知,如果失败则手动回滚已创建的通知
// 注意:这不是真正的事务,但在框架可能不支持事务的情况下提供基本的回滚机制
for _, admin := range admins {
rid := admin.ID
notification := &models.Notification{
Title: title,
Content: content,
Type: notifType,
SenderID: senderID,
ReceiverID: &rid,
}
if err := facades.Orm().Query().Create(notification); err != nil {
// 如果创建失败,尝试删除已创建的通知(手动回滚)
for _, id := range createdIDs {
_, _ = facades.Orm().Query().Where("id", id).Delete(&models.Notification{})
}
return nil, err
}
if first == nil {
first = notification
}
notifications = append(notifications, notification)
createdIDs = append(createdIDs, notification.ID)
}
// 事务成功后,在事务外进行 WebSocket 广播(避免阻塞事务)
for _, notification := range notifications {
wsnotifications.Hub().Broadcast(notification)
}
return first, nil
}
// 单个通知创建
notification := &models.Notification{
Title: title,
Content: content,
Type: notifType,
SenderID: senderID,
ReceiverID: receiverID,
}
if err := facades.Orm().Query().Create(notification); err != nil {
return nil, err
}
wsnotifications.Hub().Broadcast(notification)
return notification, nil
}
// buildNotificationQuery 构建通知查询条件(消除代码重复)
// 对于私信类型,需要同时查询发送和接收的消息
// 对于其他类型,只查询接收的消息
func (s *NotificationServiceImpl) buildNotificationQuery(adminID uint, notifType, isRead string) orm.Query {
query := facades.Orm().Query().Model(&models.Notification{})
if notifType == "message" {
// 私信:查询发送或接收的消息
query = query.Where("(receiver_id = ? OR sender_id = ?) AND type = ?", adminID, adminID, "message")
} else if notifType != "" {
// 指定了其他类型:只查询接收的消息
query = query.Where("receiver_id = ? AND type = ?", adminID, notifType)
} else {
// 没有指定类型:查询接收的所有消息 + 发送的私信
query = query.Where("receiver_id = ? OR (sender_id = ? AND type = ?)", adminID, adminID, "message")
}
// 如果指定了已读/未读状态,添加状态筛选
if isRead == "true" {
query = query.Where("is_read = ?", true)
} else if isRead == "false" {
query = query.Where("is_read = ?", false)
}
return query
}
func (s *NotificationServiceImpl) List(adminID uint, page int, pageSize int, notifType string, isRead string) ([]models.Notification, int64, error) {
var notifications []models.Notification
if page < 1 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
// 分页查询
var total int64
query := s.buildNotificationQuery(adminID, notifType, isRead).With("Sender").With("Receiver").Order("created_at desc")
if err := query.Paginate(page, pageSize, &notifications, &total); err != nil {
return nil, 0, err
}
return notifications, total, nil
}
func (s *NotificationServiceImpl) ListRecent(adminID uint, limit int) ([]models.Notification, error) {
var notifications []models.Notification
if limit <= 0 || limit > 10 {
limit = 5
}
// 查询最近的通知,包括接收的消息和发送的私信
if err := facades.Orm().Query().Model(&models.Notification{}).With("Sender").With("Receiver").
Where("(receiver_id = ? OR (sender_id = ? AND type = ?))", adminID, adminID, "message").
Order("created_at desc").
Limit(limit).
Find(&notifications); err != nil {
return nil, err
}
return notifications, nil
}
func (s *NotificationServiceImpl) MarkRead(adminID uint, notificationID uint) error {
var notification models.Notification
if err := facades.Orm().Query().Where("id = ?", notificationID).
Where("receiver_id = ?", adminID).
First(&notification); err != nil {
return apperrors.ErrRecordNotFound.WithMessage("notification not found")
}
if notification.IsRead {
return nil
}
now := time.Now()
_, err := facades.Orm().Query().
Model(&models.Notification{}).
Where("id = ?", notificationID).
Update(map[string]any{
"is_read": true,
"read_at": now,
})
return err
}
func (s *NotificationServiceImpl) MarkAllRead(adminID uint) error {
now := time.Now()
_, err := facades.Orm().Query().
Table("notifications").
Where("receiver_id = ?", adminID).
Where("is_read = ?", false).
Update(map[string]any{
"is_read": true,
"read_at": now,
})
return err
}
func (s *NotificationServiceImpl) UnreadCount(adminID uint) (int64, error) {
query := facades.Orm().Query().Model(&models.Notification{}).
Where("receiver_id = ?", adminID).
Where("is_read = ?", false)
return query.Count()
}
+123
View File
@@ -0,0 +1,123 @@
package services
import (
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
"goravel/app/utils"
)
type OperationLogService interface {
// GetByID 根据ID获取操作日志
GetByID(id uint, withAdmin bool) (*models.OperationLog, error)
// GetList 获取操作日志列表
GetList(filters OperationLogFilters, page, pageSize int) ([]models.OperationLog, int64, error)
}
// OperationLogFilters 操作日志查询过滤器
type OperationLogFilters struct {
AdminID string
Username string
Method string
Path string
Title string
IP string
Status string
Request string
StartTime string
EndTime string
OrderBy string
}
type OperationLogServiceImpl struct {
}
func NewOperationLogService() OperationLogService {
return &OperationLogServiceImpl{}
}
// GetByID 根据ID获取操作日志
func (s *OperationLogServiceImpl) GetByID(id uint, withAdmin bool) (*models.OperationLog, error) {
var log models.OperationLog
query := facades.Orm().Query().Where("id", id)
// 预加载关联
if withAdmin {
query = query.With("Admin")
}
if err := query.FirstOrFail(&log); err != nil {
return nil, apperrors.ErrLogNotFound.WithError(err)
}
return &log, nil
}
// GetList 获取操作日志列表
func (s *OperationLogServiceImpl) GetList(filters OperationLogFilters, page, pageSize int) ([]models.OperationLog, int64, error) {
query := facades.Orm().Query().Model(&models.OperationLog{})
// 应用筛选条件
if filters.AdminID != "" {
query = query.Where("admin_id", filters.AdminID)
}
if filters.Username != "" {
// 通过用户名查找管理员ID
var adminIDs []uint
var admins []models.Admin
if err := facades.Orm().Query().Where("username LIKE ?", "%"+filters.Username+"%").Get(&admins); err == nil {
for _, admin := range admins {
adminIDs = append(adminIDs, admin.ID)
}
if len(adminIDs) > 0 {
idsAny := helpers.ConvertUintSliceToAny(adminIDs)
query = query.WhereIn("admin_id", idsAny)
} else {
query = query.Where("admin_id", 0)
}
}
}
if filters.Method != "" {
query = query.Where("method = ?", filters.Method)
}
if filters.Path != "" {
query = query.Where("path LIKE ?", "%"+filters.Path+"%")
}
if filters.Title != "" {
query = query.Where("title LIKE ?", "%"+filters.Title+"%")
}
if filters.IP != "" {
query = query.Where("ip LIKE ?", "%"+filters.IP+"%")
}
if filters.Status != "" {
query = query.Where("status = ?", filters.Status)
}
if filters.Request != "" {
// 使用工具函数应用全文索引搜索
query = utils.ApplyFulltextSearch(query, "request", filters.Request)
}
if filters.StartTime != "" {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != "" {
query = query.Where("created_at <= ?", filters.EndTime)
}
// 应用排序
orderBy := filters.OrderBy
if orderBy == "" {
orderBy = "id:desc"
}
query = helpers.ApplySort(query, orderBy, "id:desc")
// 分页查询
var logs []models.OperationLog
var total int64
if err := query.With("Admin").Paginate(page, pageSize, &logs, &total); err != nil {
return nil, 0, err
}
return logs, total, nil
}
+15
View File
@@ -0,0 +1,15 @@
package services
import (
"github.com/goravel/framework/contracts/http"
)
// OptionProvider 选项提供者接口
// 所有选项提供者都需要实现此接口
type OptionProvider interface {
// GetOptions 获取选项列表
// 返回的 map 应该包含 "options" 键,值为选项数组
// 可以包含其他额外的数据,如 "list" 等
GetOptions(ctx http.Context) (map[string]any, error)
}
@@ -0,0 +1,70 @@
package option_providers
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
"github.com/samber/lo"
"github.com/spf13/cast"
"goravel/app/models"
)
type AdminOptionProvider struct{}
func NewAdminOptionProvider() *AdminOptionProvider {
return &AdminOptionProvider{}
}
func (p *AdminOptionProvider) GetOptions(ctx http.Context) (map[string]any, error) {
var admins []models.Admin
// 排除开发者ID
developerIDsStr := facades.Config().GetString("admin.developer_ids", "2")
developerIDs := parseProtectedIDs(developerIDsStr)
query := facades.Orm().Query().Where("status", 1)
if len(developerIDs) > 0 {
query = query.Where("id NOT IN ?", developerIDs)
}
if err := query.Order("id asc").Get(&admins); err != nil {
return nil, err
}
options := lo.Map(admins, func(admin models.Admin, _ int) map[string]any {
label := admin.Username
if admin.Nickname != "" {
label = admin.Nickname + " (" + admin.Username + ")"
}
return map[string]any{
"label": label,
"value": cast.ToString(admin.ID),
}
})
return map[string]any{
"options": options,
}, nil
}
// parseProtectedIDs 解析受保护的管理员ID字符串(支持逗号分隔)
func parseProtectedIDs(idsStr string) []uint {
var ids []uint
if idsStr == "" {
return ids
}
// 使用字符串分割
parts := str.Of(idsStr).Split(",")
for _, part := range parts {
part = str.Of(part).Trim().String()
if !str.Of(part).IsEmpty() {
if id := cast.ToUint(part); id > 0 {
ids = append(ids, id)
}
}
}
return ids
}
@@ -0,0 +1,47 @@
package option_providers
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"goravel/app/models"
)
type DepartmentOptionProvider struct{}
func NewDepartmentOptionProvider() *DepartmentOptionProvider {
return &DepartmentOptionProvider{}
}
func (p *DepartmentOptionProvider) GetOptions(ctx http.Context) (map[string]any, error) {
var departments []models.Department
if err := facades.Orm().Query().Where("status", 1).Order("sort asc, id asc").Get(&departments); err != nil {
return nil, err
}
tree := p.buildDepartmentTree(departments, 0)
return map[string]any{
"options": tree,
"list": departments,
}, nil
}
func (p *DepartmentOptionProvider) buildDepartmentTree(departments []models.Department, parentID uint) []map[string]any {
var tree []map[string]any
for _, dept := range departments {
if dept.ParentID == parentID {
node := map[string]any{
"id": dept.ID,
"name": dept.Name,
}
children := p.buildDepartmentTree(departments, dept.ID)
if len(children) > 0 {
node["children"] = children
}
tree = append(tree, node)
}
}
return tree
}
@@ -0,0 +1,57 @@
package option_providers
import (
"github.com/goravel/framework/contracts/http"
"goravel/app/models"
"goravel/app/services"
)
type MenuOptionProvider struct {
treeService services.TreeService
}
func NewMenuOptionProvider(treeService services.TreeService) *MenuOptionProvider {
return &MenuOptionProvider{
treeService: treeService,
}
}
func (p *MenuOptionProvider) GetOptions(ctx http.Context) (map[string]any, error) {
menus, err := p.treeService.BuildMenuTree(0)
if err != nil {
return nil, err
}
tree := p.buildMenuTree(menus)
return map[string]any{
"options": tree,
}, nil
}
func (p *MenuOptionProvider) buildMenuTree(menus []models.Menu) []map[string]any {
var tree []map[string]any
for _, menu := range menus {
// 使用菜单标题和路径构建显示标签
label := menu.Title
if menu.Path != "" {
label = label + " (" + menu.Path + ")"
}
node := map[string]any{
"id": menu.ID,
"name": menu.Title,
"label": label,
"value": menu.ID,
}
if len(menu.Children) > 0 {
node["children"] = p.buildMenuTree(menu.Children)
}
tree = append(tree, node)
}
return tree
}
@@ -0,0 +1,26 @@
package option_providers
import (
"github.com/goravel/framework/contracts/http"
)
type MethodOptionProvider struct{}
func NewMethodOptionProvider() *MethodOptionProvider {
return &MethodOptionProvider{}
}
func (p *MethodOptionProvider) GetOptions(ctx http.Context) (map[string]any, error) {
options := []map[string]any{
{"label": "GET", "value": "GET"},
{"label": "POST", "value": "POST"},
{"label": "PUT", "value": "PUT"},
{"label": "DELETE", "value": "DELETE"},
{"label": "PATCH", "value": "PATCH"},
}
return map[string]any{
"options": options,
}, nil
}
@@ -0,0 +1,38 @@
package option_providers
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/samber/lo"
"github.com/spf13/cast"
"goravel/app/models"
)
type PaymentMethodOptionProvider struct{}
func NewPaymentMethodOptionProvider() *PaymentMethodOptionProvider {
return &PaymentMethodOptionProvider{}
}
func (p *PaymentMethodOptionProvider) GetOptions(ctx http.Context) (map[string]any, error) {
var paymentMethods []models.PaymentMethod
// 只查询已启用的支付方式
query := facades.Orm().Query().Model(&models.PaymentMethod{}).Where("is_active", true).Order("sort asc").Order("id asc")
if err := query.Get(&paymentMethods); err != nil {
return nil, err
}
options := lo.Map(paymentMethods, func(pm models.PaymentMethod, _ int) map[string]any {
return map[string]any{
"label": pm.Name,
"value": cast.ToString(pm.ID),
}
})
return map[string]any{
"options": options,
}, nil
}
@@ -0,0 +1,35 @@
package option_providers
import (
"github.com/samber/lo"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/spf13/cast"
"goravel/app/models"
)
type RoleOptionProvider struct{}
func NewRoleOptionProvider() *RoleOptionProvider {
return &RoleOptionProvider{}
}
func (p *RoleOptionProvider) GetOptions(ctx http.Context) (map[string]any, error) {
var roles []models.Role
if err := facades.Orm().Query().Where("status", 1).Order("id asc").Get(&roles); err != nil {
return nil, err
}
options := lo.Map(roles, func(role models.Role, _ int) map[string]any {
return map[string]any{
"label": role.Name,
"value": cast.ToString(role.ID),
}
})
return map[string]any{
"options": options,
}, nil
}
@@ -0,0 +1,25 @@
package option_providers
import (
"github.com/goravel/framework/contracts/http"
"goravel/app/http/trans"
)
type StatusOptionProvider struct{}
func NewStatusOptionProvider() *StatusOptionProvider {
return &StatusOptionProvider{}
}
func (p *StatusOptionProvider) GetOptions(ctx http.Context) (map[string]any, error) {
options := []map[string]any{
{"label": trans.Get(ctx, "common.enabled"), "value": "1"},
{"label": trans.Get(ctx, "common.disabled"), "value": "0"},
}
return map[string]any{
"options": options,
}, nil
}
@@ -0,0 +1,25 @@
package option_providers
import (
"github.com/goravel/framework/contracts/http"
"goravel/app/http/trans"
)
type YesNoOptionProvider struct{}
func NewYesNoOptionProvider() *YesNoOptionProvider {
return &YesNoOptionProvider{}
}
func (p *YesNoOptionProvider) GetOptions(ctx http.Context) (map[string]any, error) {
options := []map[string]any{
{"label": trans.Get(ctx, "common.yes"), "value": "1"},
{"label": trans.Get(ctx, "common.no"), "value": "0"},
}
return map[string]any{
"options": options,
}, nil
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
package services
import (
"github.com/goravel/framework/facades"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
)
type PermissionService interface {
// GetByID 根据ID获取权限
GetByID(id uint, withMenu bool) (*models.Permission, error)
// GetList 获取权限列表
GetList(filters PermissionFilters, page, pageSize int) ([]models.Permission, int64, error)
// Create 创建权限
Create(name, slug, method, path, description string, status uint8, sort int, menuID uint) (*models.Permission, error)
// Update 更新权限
Update(permission *models.Permission) error
// Delete 删除权限
Delete(permission *models.Permission) error
}
// PermissionFilters 权限查询过滤器
type PermissionFilters struct {
Name string
Slug string
Method string
Path string
Status string
MenuID string
StartTime string
EndTime string
OrderBy string
}
type PermissionServiceImpl struct {
treeService TreeService
}
func NewPermissionService() PermissionService {
return &PermissionServiceImpl{
treeService: NewTreeServiceImpl(),
}
}
// GetByID 根据ID获取权限
func (s *PermissionServiceImpl) GetByID(id uint, withMenu bool) (*models.Permission, error) {
var permission models.Permission
query := facades.Orm().Query().Where("id", id)
// 预加载关联
if withMenu {
query = query.With("Menu")
}
if err := query.FirstOrFail(&permission); err != nil {
return nil, apperrors.ErrPermissionNotFound.WithError(err)
}
return &permission, nil
}
// GetList 获取权限列表
func (s *PermissionServiceImpl) GetList(filters PermissionFilters, page, pageSize int) ([]models.Permission, int64, error) {
query := facades.Orm().Query().Model(&models.Permission{})
// 应用筛选条件
if filters.Name != "" {
query = query.Where("name LIKE ?", "%"+filters.Name+"%")
}
if filters.Slug != "" {
query = query.Where("slug LIKE ?", "%"+filters.Slug+"%")
}
if filters.Path != "" {
query = query.Where("path LIKE ?", "%"+filters.Path+"%")
}
if filters.Method != "" {
query = query.Where("method", filters.Method)
}
if filters.Status != "" {
query = query.Where("status", filters.Status)
}
if filters.MenuID != "" {
// 获取菜单及其所有子菜单的ID列表
menuIDUint := cast.ToUint(filters.MenuID)
if menuIDUint > 0 {
menuIDs, err := s.treeService.GetMenuChildrenIDs(menuIDUint)
if err == nil && len(menuIDs) > 0 {
idsAny := helpers.ConvertUintSliceToAny(menuIDs)
query = query.WhereIn("menu_id", idsAny)
} else {
// 如果获取菜单ID失败,返回空查询
query = query.Where("1 = 0")
}
}
}
if filters.StartTime != "" {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != "" {
query = query.Where("created_at <= ?", filters.EndTime)
}
// 应用排序
orderBy := filters.OrderBy
if orderBy == "" {
orderBy = "sort:asc,id:desc"
}
query = helpers.ApplySort(query, orderBy, "sort:asc,id:desc")
// 分页查询
var permissions []models.Permission
var total int64
if err := query.With("Menu").Paginate(page, pageSize, &permissions, &total); err != nil {
return nil, 0, err
}
return permissions, total, nil
}
// Create 创建权限
func (s *PermissionServiceImpl) Create(name, slug, method, path, description string, status uint8, sort int, menuID uint) (*models.Permission, error) {
permission := &models.Permission{}
createData := map[string]any{
"name": name,
"slug": slug,
"method": method,
"path": path,
"description": description,
"status": status,
"sort": sort,
"menu_id": menuID,
}
if err := facades.Orm().Query().Model(permission).Create(createData); err != nil {
return nil, apperrors.ErrCreateFailed.WithError(err)
}
return permission, nil
}
// Update 更新权限
func (s *PermissionServiceImpl) Update(permission *models.Permission) error {
if err := facades.Orm().Query().Save(permission); err != nil {
return apperrors.ErrUpdateFailed.WithError(err)
}
return nil
}
// Delete 删除权限
func (s *PermissionServiceImpl) Delete(permission *models.Permission) error {
if _, err := facades.Orm().Query().Delete(permission); err != nil {
return apperrors.ErrDeleteFailed.WithError(err)
}
return nil
}
+185
View File
@@ -0,0 +1,185 @@
package services
import (
"strconv"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
)
type RoleService interface {
// GetByID 根据ID获取角色
GetByID(id uint, withRelations bool) (*models.Role, error)
// GetList 获取角色列表
GetList(filters RoleFilters, page, pageSize int) ([]models.Role, int64, error)
// LoadRelations 加载角色的关联数据(权限、菜单)
LoadRelations(role *models.Role) error
// SyncPermissions 同步角色权限关联
SyncPermissions(role *models.Role, permissionIDs []uint) error
// SyncMenus 同步角色菜单关联
SyncMenus(role *models.Role, menuIDs []uint) error
// ParseIDsFromRequest 从请求中解析ID数组
ParseIDsFromRequest(ctx http.Context, key string) []uint
// Create 创建角色
Create(name, slug, description string, status uint8, sort int) (*models.Role, error)
// Update 更新角色
Update(role *models.Role) error
// Delete 删除角色
Delete(role *models.Role) error
}
// RoleFilters 角色查询过滤器
type RoleFilters struct {
Name string
Status string
StartTime string
EndTime string
OrderBy string
}
type RoleServiceImpl struct {
}
func NewRoleServiceImpl() *RoleServiceImpl {
return &RoleServiceImpl{}
}
// GetByID 根据ID获取角色
func (s *RoleServiceImpl) GetByID(id uint, withRelations bool) (*models.Role, error) {
var role models.Role
query := facades.Orm().Query().Where("id", id)
// 预加载关联
if withRelations {
query = query.With("Permissions").With("Menus")
}
if err := query.FirstOrFail(&role); err != nil {
return nil, apperrors.ErrRoleNotFound.WithError(err)
}
return &role, nil
}
// GetList 获取角色列表
func (s *RoleServiceImpl) GetList(filters RoleFilters, page, pageSize int) ([]models.Role, int64, error) {
query := facades.Orm().Query().Model(&models.Role{})
// 应用筛选条件
if filters.Name != "" {
query = query.Where("name LIKE ?", "%"+filters.Name+"%")
}
if filters.Status != "" {
query = query.Where("status", filters.Status)
}
if filters.StartTime != "" {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != "" {
query = query.Where("created_at <= ?", filters.EndTime)
}
// 应用排序
orderBy := filters.OrderBy
if orderBy == "" {
orderBy = "sort:asc,created_at:desc"
}
query = helpers.ApplySort(query, orderBy, "sort:asc,created_at:desc")
// 分页查询
var roles []models.Role
var total int64
if err := query.Paginate(page, pageSize, &roles, &total); err != nil {
return nil, 0, err
}
return roles, total, nil
}
// LoadRelations 加载角色的关联数据(权限、菜单)
func (s *RoleServiceImpl) LoadRelations(role *models.Role) error {
// 加载权限
if err := facades.Orm().Query().Model(role).Association("Permissions").Find(&role.Permissions); err != nil {
return err
}
// 加载菜单
if err := facades.Orm().Query().Model(role).Association("Menus").Find(&role.Menus); err != nil {
return err
}
return nil
}
// SyncPermissions 同步角色权限关联
func (s *RoleServiceImpl) SyncPermissions(role *models.Role, permissionIDs []uint) error {
var permissions []models.Permission
if len(permissionIDs) > 0 {
if err := facades.Orm().Query().Where("id IN ?", permissionIDs).Find(&permissions); err != nil {
return err
}
}
return facades.Orm().Query().Model(role).Association("Permissions").Replace(permissions)
}
// SyncMenus 同步角色菜单关联
func (s *RoleServiceImpl) SyncMenus(role *models.Role, menuIDs []uint) error {
var menus []models.Menu
if len(menuIDs) > 0 {
if err := facades.Orm().Query().Where("id IN ?", menuIDs).Find(&menus); err != nil {
return err
}
}
return facades.Orm().Query().Model(role).Association("Menus").Replace(menus)
}
// ParseIDsFromRequest 从请求中解析ID数组
func (s *RoleServiceImpl) ParseIDsFromRequest(ctx http.Context, key string) []uint {
var ids []uint
if idsStr := ctx.Request().Input(key); idsStr != "" {
for _, idStr := range ctx.Request().InputArray(key) {
if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
ids = append(ids, uint(id))
}
}
}
return ids
}
// Create 创建角色
func (s *RoleServiceImpl) Create(name, slug, description string, status uint8, sort int) (*models.Role, error) {
role := &models.Role{}
createData := map[string]any{
"name": name,
"slug": slug,
"description": description,
"status": status,
"sort": sort,
}
if err := facades.Orm().Query().Model(role).Create(createData); err != nil {
return nil, apperrors.ErrCreateFailed.WithError(err)
}
return role, nil
}
// Update 更新角色
func (s *RoleServiceImpl) Update(role *models.Role) error {
if err := facades.Orm().Query().Save(role); err != nil {
return apperrors.ErrUpdateFailed.WithError(err)
}
return nil
}
// Delete 删除角色
func (s *RoleServiceImpl) Delete(role *models.Role) error {
if _, err := facades.Orm().Query().Delete(role); err != nil {
return apperrors.ErrDeleteFailed.WithError(err)
}
return nil
}
+351
View File
@@ -0,0 +1,351 @@
package services
import (
"context"
"fmt"
"reflect"
"strings"
"github.com/goravel/framework/facades"
"github.com/samber/lo"
apperrors "goravel/app/errors"
"goravel/app/utils"
"goravel/app/utils/errorlog"
)
// ShardingQueryConfig 分表查询配置
type ShardingQueryConfig struct {
// BaseTableName 基础表名,如 "orders"
BaseTableName string
// GetColumns 获取表的所有列名(用于 UNION ALL 查询)
// 返回格式:如 "id, order_no, user_id, created_at, updated_at, deleted_at"
GetColumns func() string
// BuildWhereClause 构建 WHERE 条件
// 返回:WHERE 子句(不包含 WHERE 关键字)和参数列表
// 例如:返回 ("user_id = ? AND status = ?", []any{1, "paid"})
BuildWhereClause func(filters any) (string, []any)
// GetAllowedOrderFields 获取允许排序的字段列表
// 返回:字段名到 bool 的映射,如 map[string]bool{"id": true, "created_at": true}
GetAllowedOrderFields func() map[string]bool
// DefaultOrderBy 默认排序,格式:字段:方向,如 "created_at:desc"
DefaultOrderBy string
// ModuleName 模块名称,用于日志记录,如 "order"
ModuleName string
// CountThreshold count 查询优化阈值,超过此值使用执行计划估算(默认 10000)
// 不同模块可以设置不同的阈值
CountThreshold int64
}
// ShardingQueryService 分表查询服务接口
type ShardingQueryService interface {
// QueryMultipleTables 查询多个分表(带分页)
// tableNames: 分表名称列表
// filters: 筛选条件(类型由具体实现决定)
// page: 页码(从1开始)
// pageSize: 每页数量
// result: 查询结果(必须是指针类型)
// 返回:结果列表、总数、错误
QueryMultipleTables(tableNames []string, filters any, page, pageSize int, result any) (int64, error)
// QueryMultipleTablesForExport 查询多个分表(不分页,用于导出)
// tableNames: 分表名称列表
// filters: 筛选条件(类型由具体实现决定)
// result: 查询结果(必须是指针类型)
// 返回:结果列表、错误
QueryMultipleTablesForExport(tableNames []string, filters any, result any) error
}
type ShardingQueryServiceImpl struct {
config ShardingQueryConfig
}
// NewShardingQueryService 创建分表查询服务
func NewShardingQueryService(config ShardingQueryConfig) ShardingQueryService {
// 设置默认值
if config.DefaultOrderBy == "" {
config.DefaultOrderBy = "created_at:desc"
}
if config.ModuleName == "" {
config.ModuleName = "sharding"
}
return &ShardingQueryServiceImpl{
config: config,
}
}
// QueryMultipleTables 查询多个分表(带分页)
func (s *ShardingQueryServiceImpl) QueryMultipleTables(tableNames []string, filters any, page, pageSize int, result any) (int64, error) {
// 构建 WHERE 条件
whereClause, whereConditions := s.config.BuildWhereClause(filters)
if whereClause == "" {
whereClause = "1=1"
} else {
whereClause = "1=1 AND " + whereClause
}
// 构建排序
orderField, orderDir := s.parseOrderBy(filters)
// 获取列名
columnsStr := s.config.GetColumns()
// 优化:每个分表先排序和限制,然后再合并(避免合并大量数据后再排序)
// 计算每个分表需要查询的数量
// 为了确保合并后有足够的数据进行分页,每个分表查询更多数据
// 公式:limitPerTable = (page * pageSize) + pageSize,确保有足够数据
offset := (page - 1) * pageSize
limitPerTable := offset + pageSize + pageSize // 额外查询一页数据,确保有足够数据
// 如果 limitPerTable 太大(超过10000),限制为10000,避免单个查询太慢
if limitPerTable > 10000 {
limitPerTable = 10000
}
// 构建 UNION ALL 查询
// 过滤掉不存在的分表,避免查询错误
existingTableNames := lo.Filter(tableNames, func(tableName string, _ int) bool {
return facades.Schema().HasTable(tableName)
})
if len(existingTableNames) == 0 {
return 0, nil
}
// 为每个存在的表构建查询
unionQueries := lo.Map(existingTableNames, func(tableName string, _ int) string {
return fmt.Sprintf(
"(SELECT %s FROM `%s` WHERE %s ORDER BY `%s` %s LIMIT %d)",
columnsStr, tableName, whereClause, orderField, orderDir, limitPerTable,
)
})
// 每个查询都需要相同的参数
allArgs := lo.Flatten(lo.Map(lo.Range(len(existingTableNames)), func(_ int, _ int) []any {
return whereConditions
}))
// 合并所有查询
unionSQL := strings.Join(unionQueries, " UNION ALL ")
// 优化:分别对每个分表执行 COUNT,然后相加(性能更好,可以利用索引)
// 而不是对 UNION ALL 结果进行 COUNT(需要先合并所有数据)
var total int64
threshold := s.config.CountThreshold
// 如果配置了阈值,使用执行计划优化;否则直接使用 count
if threshold > 0 {
// 使用优化的 count 查询(先估算,超过阈值用估算值,否则用实际 count)
countOptimizer := utils.NewCountOptimizer(threshold, s.config.ModuleName)
for _, tableName := range existingTableNames {
// 使用对应的参数(每个分表使用相同的参数)
args := whereConditions
tableTotal, _, err := countOptimizer.OptimizedCountWithTable(tableName, whereClause, args...)
if err != nil {
errorlog.Record(context.Background(), s.config.ModuleName, "查询分表总数失败", map[string]any{
"table_name": tableName,
"error": err.Error(),
}, "查询分表 %s 总数失败: %v", tableName, err)
// 如果某个分表查询失败,继续查询其他分表,但记录错误
continue
}
total += tableTotal
}
} else {
// 没有配置阈值,直接使用传统的 count 统计
// 根据数据库类型决定表名引号(MySQL 用反引号,PostgreSQL 不用)
dbConnection := facades.Config().GetString("database.default", "sqlite")
tableQuote := ""
if dbConnection == "mysql" {
tableQuote = "`"
}
for _, tableName := range existingTableNames {
// 每个分表使用相同的 WHERE 条件
countSQL := fmt.Sprintf("SELECT COUNT(*) as total FROM %s%s%s WHERE %s", tableQuote, tableName, tableQuote, whereClause)
var countResult struct {
Total int64
}
// 使用对应的参数(每个分表使用相同的参数)
args := whereConditions
if err := facades.Orm().Query().Raw(countSQL, args...).Scan(&countResult); err != nil {
errorlog.Record(context.Background(), s.config.ModuleName, "查询分表总数失败", map[string]any{
"table_name": tableName,
"error": err.Error(),
}, "查询分表 %s 总数失败: %v", tableName, err)
// 如果某个分表查询失败,继续查询其他分表,但记录错误
continue
}
total += countResult.Total
}
}
// 如果没有数据,直接返回
if total == 0 {
return 0, nil
}
// 分页查询(在外层再次排序和分页)
// 注意:虽然每个分表已经排序,但合并后需要重新排序以确保全局顺序正确
// 但由于每个分表已经限制了数量,合并后的数据量大大减少,排序会快很多
paginatedSQL := fmt.Sprintf(
"SELECT %s FROM (%s) as combined ORDER BY `%s` %s LIMIT ? OFFSET ?",
columnsStr,
unionSQL,
orderField,
orderDir,
)
// 添加 LIMIT 和 OFFSET 参数
paginatedArgs := append(allArgs, pageSize, offset)
// 执行查询
if err := facades.Orm().Query().Raw(paginatedSQL, paginatedArgs...).Scan(result); err != nil {
errorlog.Record(context.Background(), s.config.ModuleName, "查询列表失败", map[string]any{
"table_count": len(tableNames),
"page": page,
"page_size": pageSize,
"error": err.Error(),
}, "查询列表失败: %v", err)
return 0, apperrors.ErrQueryFailed.WithError(err)
}
return total, nil
}
// QueryMultipleTablesForExport 查询多个分表(不分页,用于导出)
func (s *ShardingQueryServiceImpl) QueryMultipleTablesForExport(tableNames []string, filters any, result any) error {
// 构建 WHERE 条件
whereClause, whereConditions := s.config.BuildWhereClause(filters)
if whereClause == "" {
whereClause = "1=1"
} else {
whereClause = "1=1 AND " + whereClause
}
// 构建排序
orderField, orderDir := s.parseOrderBy(filters)
// 获取列名
columnsStr := s.config.GetColumns()
// 优化:每个分表先排序,然后再合并(对于导出,虽然需要所有数据,但先排序可以减少合并后的排序成本)
// 构建 UNION ALL 查询
// 过滤掉不存在的分表,避免查询错误
existingTableNames := lo.Filter(tableNames, func(tableName string, _ int) bool {
return facades.Schema().HasTable(tableName)
})
if len(existingTableNames) == 0 {
return nil
}
// 为每个存在的表构建查询
unionQueries := lo.Map(existingTableNames, func(tableName string, _ int) string {
// 优化:每个分表先排序,然后再合并
// 使用子查询包装,确保每个分表先排序
// 注意:导出需要所有数据,所以不限制数量,但先排序可以优化合并后的排序性能
return fmt.Sprintf(
"(SELECT %s FROM `%s` WHERE %s ORDER BY `%s` %s)",
columnsStr, tableName, whereClause, orderField, orderDir,
)
})
// 每个查询都需要相同的参数
allArgs := lo.Flatten(lo.Map(lo.Range(len(existingTableNames)), func(_ int, _ int) []any {
return whereConditions
}))
if len(unionQueries) == 0 {
return nil
}
// 合并所有查询
unionSQL := strings.Join(unionQueries, " UNION ALL ")
// 导出查询(不分页,但需要排序)
// 注意:虽然每个分表已经排序,但合并后需要重新排序以确保全局顺序正确
// 但由于每个分表已经排序,合并后的排序会更快(归并排序)
exportSQL := fmt.Sprintf(
"SELECT %s FROM (%s) as combined ORDER BY `%s` %s",
columnsStr,
unionSQL,
orderField,
orderDir,
)
// 执行查询
if err := facades.Orm().Query().Raw(exportSQL, allArgs...).Scan(result); err != nil {
errorlog.Record(context.Background(), s.config.ModuleName, "导出查询失败", map[string]any{
"table_count": len(tableNames),
"error": err.Error(),
}, "导出查询失败: %v", err)
return apperrors.ErrQueryFailed.WithError(err)
}
return nil
}
// parseOrderBy 解析排序字段
// 从 filters 中提取 OrderBy 字段(如果 filters 有 OrderBy 字段)
// 返回:排序字段名和方向
func (s *ShardingQueryServiceImpl) parseOrderBy(filters any) (string, string) {
// 尝试从 filters 中提取 OrderBy 字段
// 使用类型断言或反射来获取 OrderBy 字段
// 这里使用一个辅助函数来处理
orderBy := s.extractOrderBy(filters)
if orderBy == "" {
orderBy = s.config.DefaultOrderBy
}
orderParts := strings.Split(orderBy, ":")
orderField := "created_at"
orderDir := "desc"
if len(orderParts) == 2 {
field := orderParts[0]
direction := strings.ToLower(orderParts[1])
// 验证排序字段是否允许
allowedFields := s.config.GetAllowedOrderFields()
if allowedFields != nil && allowedFields[field] {
orderField = field
if direction == "asc" {
orderDir = "asc"
} else {
orderDir = "desc"
}
}
}
return orderField, orderDir
}
// extractOrderBy 从 filters 中提取 OrderBy 字段
// 支持结构体和 map[string]any
func (s *ShardingQueryServiceImpl) extractOrderBy(filters any) string {
if filters == nil {
return ""
}
// 尝试类型断言为 map[string]any
if m, ok := filters.(map[string]any); ok {
if orderBy, ok := m["OrderBy"].(string); ok {
return orderBy
}
}
// 使用反射从结构体中提取 OrderBy 字段
v := reflect.ValueOf(filters)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() == reflect.Struct {
field := v.FieldByName("OrderBy")
if field.IsValid() && field.Kind() == reflect.String {
return field.String()
}
}
return ""
}
+97
View File
@@ -0,0 +1,97 @@
package services
import (
"context"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/utils/errorlog"
"goravel/database/migrations"
)
// TableCreator 表创建函数类型
type TableCreator func(tableName string) error
// ShardingService 分表服务接口
type ShardingService interface {
// RegisterTableCreator 注册表的创建函数
RegisterTableCreator(baseTableName string, creator TableCreator)
// CreateShardingTable 创建分表
CreateShardingTable(tableName, baseTableName string) error
// EnsureShardingTable 确保分表存在,不存在则创建
EnsureShardingTable(tableName, baseTableName string) error
}
type ShardingServiceImpl struct {
creators map[string]TableCreator
}
func NewShardingService() ShardingService {
service := &ShardingServiceImpl{
creators: make(map[string]TableCreator),
}
// 注册订单相关表的创建函数
service.registerOrderTables()
// 注册用户余额变动记录表的创建函数
service.registerUserBalanceLogTables()
return service
}
// registerOrderTables 注册订单表的创建函数
func (s *ShardingServiceImpl) registerOrderTables() {
// 注册订单主表(调用 migrations 中的函数)
s.RegisterTableCreator("orders", migrations.CreateOrdersShardingTable)
// 注册订单详情表(调用 migrations 中的函数)
s.RegisterTableCreator("order_details", migrations.CreateOrderDetailsShardingTable)
}
// registerUserBalanceLogTables 注册用户余额变动记录表的创建函数
func (s *ShardingServiceImpl) registerUserBalanceLogTables() {
// 注册用户余额变动记录表(调用 migrations 中的函数)
s.RegisterTableCreator("user_balance_logs", migrations.CreateUserBalanceLogsShardingTable)
}
// RegisterTableCreator 注册表的创建函数
func (s *ShardingServiceImpl) RegisterTableCreator(baseTableName string, creator TableCreator) {
s.creators[baseTableName] = creator
}
// CreateShardingTable 创建分表
func (s *ShardingServiceImpl) CreateShardingTable(tableName, baseTableName string) error {
creator, exists := s.creators[baseTableName]
if !exists {
return apperrors.ErrBaseTableNotRegistered.WithParams(map[string]any{
"base_table_name": baseTableName,
})
}
return creator(tableName)
}
// EnsureShardingTable 确保分表存在,不存在则创建
func (s *ShardingServiceImpl) EnsureShardingTable(tableName, baseTableName string) error {
// 检查表是否已存在
if facades.Schema().HasTable(tableName) {
return nil
}
// 创建表
if err := s.CreateShardingTable(tableName, baseTableName); err != nil {
errorlog.Record(context.Background(), "sharding", "创建分表失败", map[string]any{
"table_name": tableName,
"base_table_name": baseTableName,
"error": err.Error(),
}, "创建分表 %s 失败: %v", tableName, err)
return apperrors.ErrCreateShardingTableFailed.WithError(err).WithParams(map[string]any{
"table_name": tableName,
})
}
facades.Log().Infof("自动创建分表: %s", tableName)
return nil
}
+153
View File
@@ -0,0 +1,153 @@
package services
import (
"context"
"encoding/json"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
"goravel/app/utils/traceid"
)
type SystemLogService interface {
// GetByID 根据ID获取系统日志
GetByID(id uint) (*models.SystemLog, error)
// GetList 获取系统日志列表
GetList(filters SystemLogFilters, page, pageSize int) ([]models.SystemLog, int64, error)
// RecordHTTP 记录系统日志(HTTP context
RecordHTTP(ctx http.Context, level, module, message string, attributes map[string]any) error
// Record 记录系统日志(标准 context)
Record(ctx context.Context, level, module, message string, attributes map[string]any) error
}
// SystemLogFilters 系统日志查询过滤器
type SystemLogFilters struct {
Level string
Module string
TraceID string
Message string
StartTime string
EndTime string
OrderBy string
}
type SystemLogServiceImpl struct {
}
func NewSystemLogService() SystemLogService {
return &SystemLogServiceImpl{}
}
// GetByID 根据ID获取系统日志
func (s *SystemLogServiceImpl) GetByID(id uint) (*models.SystemLog, error) {
var log models.SystemLog
if err := facades.Orm().Query().Where("id", id).FirstOrFail(&log); err != nil {
return nil, apperrors.ErrLogNotFound.WithError(err)
}
return &log, nil
}
// GetList 获取系统日志列表
func (s *SystemLogServiceImpl) GetList(filters SystemLogFilters, page, pageSize int) ([]models.SystemLog, int64, error) {
query := facades.Orm().Query().Model(&models.SystemLog{})
// 应用筛选条件
if filters.Level != "" {
query = query.Where("level = ?", filters.Level)
}
if filters.Module != "" {
query = query.Where("module LIKE ?", "%"+filters.Module+"%")
}
if filters.TraceID != "" {
query = query.Where("trace_id LIKE ?", "%"+filters.TraceID+"%")
}
if filters.Message != "" {
query = query.Where("message LIKE ?", "%"+filters.Message+"%")
}
if filters.StartTime != "" {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != "" {
query = query.Where("created_at <= ?", filters.EndTime)
}
// 应用排序
orderBy := filters.OrderBy
if orderBy == "" {
orderBy = "id:desc"
}
query = helpers.ApplySort(query, orderBy, "id:desc")
// 分页查询
var logs []models.SystemLog
var total int64
if err := query.Paginate(page, pageSize, &logs, &total); err != nil {
return nil, 0, err
}
return logs, total, nil
}
// RecordHTTP 记录系统日志(HTTP context
func (s *SystemLogServiceImpl) RecordHTTP(ctx http.Context, level, module, message string, attributes map[string]any) error {
var contextJSON string
if len(attributes) > 0 {
if data, err := json.Marshal(attributes); err == nil {
contextJSON = string(data)
}
}
traceID := traceid.FromHTTPContext(ctx)
if traceID == "" {
traceID = traceid.EnsureHTTPContext(ctx, "")
}
log := models.SystemLog{
Level: level,
Module: module,
TraceID: traceID,
Message: message,
Context: contextJSON,
IP: ctx.Request().Ip(),
UserAgent: ctx.Request().Header("User-Agent", ""),
}
if err := facades.Orm().Query().Create(&log); err != nil {
return err
}
return nil
}
// Record 记录系统日志(标准 context)
func (s *SystemLogServiceImpl) Record(ctx context.Context, level, module, message string, attributes map[string]any) error {
var contextJSON string
if len(attributes) > 0 {
if data, err := json.Marshal(attributes); err == nil {
contextJSON = string(data)
}
}
traceID := traceid.FromContext(ctx)
if traceID == "" {
var newCtx context.Context
newCtx, traceID = traceid.EnsureContext(ctx)
ctx = newCtx
}
log := models.SystemLog{
Level: level,
Module: module,
TraceID: traceID,
Message: message,
Context: contextJSON,
}
if err := facades.Orm().Query().Create(&log); err != nil {
return err
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
import request from '../utils/request'
export function get<<.ModelName>>List(params) {
return request({
url: '/<<.ModuleName>>s',
method: 'get',
params
})
}
export function get<<.ModelName>>Detail(id) {
return request({
url: `/<<.ModuleName>>s/${id}`,
method: 'get'
})
}
<<if .HasCreate>>
export function create<<.ModelName>>(data) {
return request({
url: '/<<.ModuleName>>s',
method: 'post',
data
})
}
<<end>>
<<if .HasEdit>>
export function update<<.ModelName>>(id, data) {
return request({
url: `/<<.ModuleName>>s/${id}`,
method: 'put',
data
})
}
<<end>>
<<if .HasDelete>>
export function delete<<.ModelName>>(id) {
return request({
url: `/<<.ModuleName>>s/${id}`,
method: 'delete'
})
}
<<end>>
+143
View File
@@ -0,0 +1,143 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/services"
)
type <<.ControllerName>> struct {
<<.ServiceName>> services.<<.ServiceName>>
}
func New<<.ControllerName>>() *<<.ControllerName>> {
return &<<.ControllerName>>{
<<.ServiceName>>: services.New<<.ServiceName>>(),
}
}
// Index <<.ModelName>>列表
func (c *<<.ControllerName>>) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page",1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
<<range .SearchableFields>>
<<.Name>> := ctx.Request().Query("<<.Name>>", "")
<<- end>>
filters := services.<<.ModelName>>Filters{
<<range .SearchableFields>>
<<.PascalName>>: <<.Name>>,
<<- end>>
}
list, total, err := c.<<.ServiceName>>.GetList(filters, page, pageSize)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"list": list,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show <<.ModelName>>详情
func (c *<<.ControllerName>>) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
item, err := c.<<.ServiceName>>.GetByID(id)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusNotFound, businessErr.Code)
}
return response.Error(ctx, http.StatusNotFound, err.Error())
}
return response.Success(ctx, http.Json{
"<<.ModuleName>>": item,
})
}
// Store 创建<<.ModelName>>
func (c *<<.ControllerName>>) Store(ctx http.Context) http.Response {
<<if .HasCreate>>
var req adminrequests.<<.RequestCreateName>>
errors, err := ctx.Request().ValidateRequest(&req)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
item, err := c.<<.ServiceName>>.Create(&req)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"<<.ModuleName>>": item,
})
<<else>>
return response.Error(ctx, http.StatusForbidden, "create_not_allowed")
<<end>>
}
// Update 更新<<.ModelName>>
func (c *<<.ControllerName>>) Update(ctx http.Context) http.Response {
<<if .HasEdit>>
id := helpers.GetUintRoute(ctx, "id")
var req adminrequests.<<.RequestUpdateName>>
errors, err := ctx.Request().ValidateRequest(&req)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
item, err := c.<<.ServiceName>>.Update(id, &req)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"<<.ModuleName>>": item,
})
<<else>>
return response.Error(ctx, http.StatusForbidden, "update_not_allowed")
<<end>>
}
// Destroy 删除<<.ModelName>>
func (c *<<.ControllerName>>) Destroy(ctx http.Context) http.Response {
<<if .HasDelete>>
id := helpers.GetUintRoute(ctx, "id")
if err := c.<<.ServiceName>>.Delete(id); err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, "delete_success", http.Json{})
<<else>>
return response.Error(ctx, http.StatusForbidden, "delete_not_allowed")
<<end>>
}
+264
View File
@@ -0,0 +1,264 @@
<template>
<el-dialog
v-model="visible"
:title="editId ? $t('common.edit') : $t('common.add')"
width="600px"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<<range .FormFields>>
<el-form-item :label="$t('<<$.ModuleName>>.<<.Name>>')" prop="<<.Name>>">
<<if eq .FormType "input">>
<el-input v-model="form.<<.Name>>" :placeholder="$t('<<$.ModuleName>>.<<.Name>>')" />
<<else if eq .FormType "textarea">>
<el-input
v-model="form.<<.Name>>"
type="textarea"
:rows="4"
:placeholder="$t('<<$.ModuleName>>.<<.Name>>')" />
<<else if eq .FormType "select">>
<el-select v-model="form.<<.Name>>" :placeholder="$t('common.select')">
<el-option
v-for="item in <<.Name>>Options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<<else if eq .FormType "switch">>
<el-switch v-model="form.<<.Name>>" />
<<else if eq .FormType "date-picker">>
<el-date-picker
v-model="form.<<.Name>>"
type="date"
:placeholder="$t('common.select_date')" style="width: 100%" />
<<else if eq .FormType "datetime-picker">>
<el-date-picker
v-model="form.<<.Name>>"
type="datetime"
:placeholder="$t('common.select_datetime')" style="width: 100%" />
<<else if eq .FormType "image-upload">>
<el-upload
class="image-uploader"
action="/api/admin/attachments/upload"
:show-file-list="false"
:on-success="handleImageSuccess"
>
<img v-if="form.<<.Name>>" :src="form.<<.Name>>" class="image-preview" />
<el-icon v-else class="image-uploader-icon"><Plus /></el-icon>
</el-upload>
<<else if eq .FormType "file-upload">>
<el-upload
action="/api/admin/attachments/upload"
:show-file-list="true"
:on-success="handleFileSuccess"
>
<el-button type="primary">{{ $t('common.upload') }}</el-button>
</el-upload>
<<end>>
</el-form-item>
<<- end>>
</el-form>
<template #footer>
<el-button @click="handleClose">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
{{ $t('common.confirm') }}
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import {
<<if .HasCreate>>create<<.ModelName>>,<<end>>
<<if .HasEdit>>update<<.ModelName>>,<<end>>
get<<.ModelName>>Detail
} from '../../api/<<.ModuleName>>'
import { getOptions } from '../../api/option'
import ErrorHandler from '../../utils/errorHandler'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
editId: {
type: Number,
default: null
}
})
const emit = defineEmits(['update:modelValue', 'success'])
const { t } = useI18n()
const formRef = ref(null)
const submitting = ref(false)
<<range .FormFields>>
<<if eq .FormType "select">>
const <<.Name>>Options = ref([])
<<end>>
<<- end>>
const visible = ref(props.modelValue)
watch(() => props.modelValue, (val) => {
visible.value = val
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
const form = ref({
<<range .FormFields>>
<<.Name>>: null,
<<- end>>
})
const rules = {
<<range .FormFields>>
<<.Name>>: [
{ required: <<.Required>>, message: t('<<$.ModuleName>>.<<.Name>>_required'), trigger: 'blur' }
],
<<- end>>
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
submitting.value = true
if (props.editId) {
<<if .HasEdit>>
await update<<.ModelName>>(props.editId, form.value)
ElMessage.success(t('common.update_success'))
<<else>>
ElMessage.error(t('common.operation_failed'))
<<end>>
} else {
<<if .HasCreate>>
await create<<.ModelName>>(form.value)
ElMessage.success(t('common.create_success'))
<<else>>
ElMessage.error(t('common.operation_failed'))
<<end>>
}
emit('success')
handleClose()
} catch (error) {
ErrorHandler.handle(error)
} finally {
submitting.value = false
}
}
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
const handleImageSuccess = (response) => {
form.value.image = response.data.url
}
const handleFileSuccess = (response) => {
form.value.file = response.data.url
}
const loadOptions = async () => {
<<range .FormFields>>
<<if eq .FormType "select">>
try {
<<if eq .Dictionary "">>
//
// const res = await getOptions('<<.Name>>')
// <<.Name>>Options.value = res.data
<<else>>
const res = await getOptions('dictionary', { dictionary_type: '<<.Dictionary>>' })
if (res.data) {
<<.Name>>Options.value = res.data
}
<<end>>
} catch (error) {
console.error('Failed to load <<.Name>> options:', error)
}
<<end>>
<<- end>>
}
const loadData = async () => {
if (!props.editId) return
try {
const res = await get<<.ModelName>>Detail(props.editId)
if (res.data && res.data.<<.ModuleName>>) {
const data = res.data.<<.ModuleName>>
form.value = {
<<range .FormFields>>
<<.Name>>: data.<<.Name>>,
<<- end>>
}
}
} catch (error) {
ErrorHandler.handle(error)
}
}
watch(visible, (val) => {
if (val) {
loadOptions()
if (props.editId) {
loadData()
} else {
formRef.value?.resetFields()
form.value = {
<<range .FormFields>>
<<.Name>>: null,
<<- end>>
}
}
}
})
</script>
<style scoped>
.image-uploader {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
width: 178px;
height: 178px;
display: flex;
align-items: center;
justify-content: center;
}
.image-uploader:hover {
border-color: #409EFF;
}
.image-preview {
width: 178px;
height: 178px;
object-fit: cover;
}
.image-uploader-icon {
font-size: 28px;
color: #8c939d;
}
</style>
+201
View File
@@ -0,0 +1,201 @@
<template>
<div class="<<.ModuleName>>-list">
<el-card>
<template #header>
<div class="card-header">
<span>{{ $t('menu.<<$.ModuleName>>') }}</span>
<<if .HasCreate>>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
{{ $t('common.add') }}
</el-button>
<<end>>
</div>
</template>
<SearchForm
:model="searchForm"
:fields="searchFields"
:initial-values="initialSearchForm"
i18n-prefix="<<.ModuleName>>"
@search="handleSearch"
@reset="handleReset"
/>
<VxeTable
ref="tableRef"
:data="tableData"
:loading="loading"
:columns="tableColumns"
:height="600"
@sort-change="handleSortChange"
>
<template #operation="{ row }">
<<if .HasEdit>>
<el-button type="primary" size="small" @click="handleEdit(row)">
{{ $t('common.edit') }}
</el-button>
<<end>>
<<if .HasDelete>>
<el-button type="danger" size="small" @click="handleDelete(row)">
{{ $t('common.delete') }}
</el-button>
<<end>>
</template>
</VxeTable>
<Pagination
v-model="pagination"
:auto-load="true"
:on-page-change="loadData"
/>
<<.ModelName>>Form
ref="formRef"
v-model="dialogVisible"
:edit-id="editId"
@success="handleFormSuccess"
/>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import SearchForm from '../../components/SearchForm.vue'
import Pagination from '../../components/Pagination.vue'
import VxeTable from '../../components/VxeTable.vue'
import <<.ModelName>>Form from './<<.ModelName>>Form.vue'
import { useListPage } from '../../composables/useListPage'
import { useCrud } from '../../composables/useCrud'
import {
get<<.ModelName>>List,
delete<<.ModelName>>
} from '../../api/<<.ModuleName>>'
import logger from '../../utils/logger'
import ErrorHandler from '../../utils/errorHandler'
const { t } = useI18n()
const tableRef = ref(null)
const formRef = ref(null)
const {
dialogVisible,
editId,
handleAdd,
handleClose,
handleDelete: handleDeleteCrud
} = useCrud({
deleteApi: delete<<.ModelName>>
})
const initialSearchForm = {
<<range .SearchableFields>>
<<.Name>>: '',
<<- end>>
}
const {
pagination,
tableData,
loading,
searchForm,
loadData,
handleSearch,
handleReset,
handleSortChange
} = useListPage({
fetchApi: get<<.ModelName>>List,
initialSearchForm,
fieldMapping: {},
defaultSort: 'id:desc',
tableRef: computed(() => tableRef.value?.tableRef)
})
const searchFields = computed(() => [
<<range .SearchableFields>>
{
prop: '<<.Name>>',
label: t('<<$.ModuleName>>.<<.Name>>'),
type: '<<.SearchUIType>>',
<<if .ApiUrl>> apiUrl: '<<.ApiUrl>>',<<end>>
<<if eq .SearchUIType "select">>
<<if eq .Dictionary "">>
// role, department等apiUrl已由.ApiUrl提供
<<else>>
apiUrl: '/options?type=dictionary&dictionary_type=<<.Dictionary>>',
<<end>>
<<end>>
width: '200px',
advanced: false
},
<<- end>>
])
const tableColumns = computed(() => [
{
field: 'id',
title: t('table.id'),
width: 80,
sortable: true
},
<<range .ListFields>>
{
field: '<<.Name>>',
<<if .Relation>>
title: t('<<$.Relation.Table>>.<<$.Relation.DisplayField>>'),
<<else>>
title: t('<<$.ModuleName>>.<<.Name>>'),
<<end>>
sortable: <<.Sortable>>
},
<<if .Relation>>
{
field: '<<.Relation.Table>>_<<.Relation.DisplayField>>',
title: t('<<$.Relation.Table>>.<<$.Relation.DisplayField>>'),
sortable: false
},
<<end>>
<<- end>>
{
field: 'created_at',
title: t('table.created_at'),
width: 180,
sortable: true
},
{
field: 'operation',
title: t('table.operation'),
width: 180,
fixed: 'right',
slot: 'operation'
}
])
const handleEdit = (row) => {
editId.value = row.id
dialogVisible.value = true
}
const handleDelete = async (row) => {
await handleDeleteCrud(row, loadData)
}
const handleFormSuccess = () => {
handleClose()
loadData()
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.<<.ModuleName>>-list {
padding: 20px;
}
</style>
+27
View File
@@ -0,0 +1,27 @@
package migrations
import (
"github.com/goravel/framework/contracts/database/schema"
"github.com/goravel/framework/facades"
)
type M<<.Timestamp>>Create<<.ModelName>>Table struct {
}
func (m *M<<.Timestamp>>Create<<.ModelName>>Table) Signature() string {
return "<<.Timestamp>>_create_<<.TableName>>_table"
}
func (m *M<<.Timestamp>>Create<<.ModelName>>Table) Up() error {
return facades.Schema().Create("<<.TableName>>", func(table schema.Blueprint) {
table.ID()
<<range .Fields>>
table.<<.MigrationMethod>>("<<.Name>>")<<if .Comment>>.Comment("<<.Comment>>")<<end>>
<<- end>>
table.Timestamps()
table.SoftDeletes()
})
}
func (m *M<<.Timestamp>>Create<<.ModelName>>Table) Down() error {
return facades.Schema().DropIfExists("<<.TableName>>")
}
+36
View File
@@ -0,0 +1,36 @@
package models
import (
"github.com/goravel/framework/database/orm"
)
type <<.ModelName>> struct {
orm.Model
orm.SoftDeletes
<<range .Fields>>
<<.FieldName>> <<.GoType>> `gorm:"<<.Name>>" json:"<<.JsonName>>"<<if .Comment>> comment:"<<.Comment>>"<<end>><<if .Relation>> json:"<<.Relation.Table>>_<<.Relation.DisplayField>>" gorm:"<<.Relation.Table>>;foreignKey:<<.Relation.ForeignKey>>"<<end>>`
<<- end>>
}
func (<<.ModelName>>) TableName() string {
return "<<.TableName>>"
}
func (r *<<.ModelName>>) Serialize() map[string]any {
return map[string]any{
"id": r.ID,
"created_at": r.CreatedAt,
"updated_at": r.UpdatedAt,
<<range .Fields>>
"<<.JsonName>>": r.<<.FieldName>>,
<<end>>
}
}
func (r *<<.ModelName>>) Deserialize(data map[string]any) {
<<range .Fields>>
if val, ok := data["<<.JsonName>>"]; ok {
r.<<.FieldName>> = val.(<<.GoType>>)
}
<<end>>
}
+42
View File
@@ -0,0 +1,42 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type <<.RequestCreateName>> struct {
<<range .FormFields>>
<<.FieldName>> <<.GoType>> `form:"<<.JsonName>>" json:"<<.JsonName>>"`
<<- end>>
}
func (r *<<.RequestCreateName>>) Authorize(ctx http.Context) error {
return nil
}
func (r *<<.RequestCreateName>>) Rules(ctx http.Context) map[string]string {
rules := map[string]string{
<<range .FormFields>>
"<<.JsonName>>": "<<if .Required>>required<<end>><<if and .Required .Validators>>|<<end>><<range $i, $v := .Validators>><<if $i>>|<<end>><<$v>><<end>>",
<<- end>>
}
return rules
}
func (r *<<.RequestCreateName>>) Messages(ctx http.Context) map[string]string {
return map[string]string{
<<range .FormFields>>
"<<.JsonName>>.required": trans.Get(ctx, "validation_<<.Name>>_required"),
<<- end>>
}
}
func (r *<<.RequestCreateName>>) Attributes(ctx http.Context) map[string]string {
return map[string]string{
<<range .FormFields>>
"<<.JsonName>>": trans.Get(ctx, "validation_<<.Name>>"),
<<- end>>
}
}
+42
View File
@@ -0,0 +1,42 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type <<.RequestUpdateName>> struct {
<<range .FormFields>>
<<.FieldName>> *<<.GoType>> `form:"<<.JsonName>>" json:"<<.JsonName>>"`
<<- end>>
}
func (r *<<.RequestUpdateName>>) Authorize(ctx http.Context) error {
return nil
}
func (r *<<.RequestUpdateName>>) Rules(ctx http.Context) map[string]string {
rules := map[string]string{
<<range .FormFields>>
"<<.JsonName>>": "<<if .Required>>required<<end>><<if and .Required .Validators>>|<<end>><<range $i, $v := .Validators>><<if $i>>|<<end>><<$v>><<end>>",
<<- end>>
}
return rules
}
func (r *<<.RequestUpdateName>>) Messages(ctx http.Context) map[string]string {
return map[string]string{
<<range .FormFields>>
"<<.JsonName>>.required": trans.Get(ctx, "validation_<<.Name>>_required"),
<<- end>>
}
}
func (r *<<.RequestUpdateName>>) Attributes(ctx http.Context) map[string]string {
return map[string]string{
<<range .FormFields>>
"<<.JsonName>>": trans.Get(ctx, "validation_<<.Name>>"),
<<- end>>
}
}
+131
View File
@@ -0,0 +1,131 @@
package services
import (
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/requests/admin"
"goravel/app/models"
)
type <<.ServiceName>> interface {
GetByID(id uint) (*models.<<.ModelName>>, error)
GetList(filters <<.ModelName>>Filters, page, pageSize int) ([]models.<<.ModelName>>, int64, error)
<<if .HasCreate>>
Create(req *admin.<<.RequestCreateName>>) (*models.<<.ModelName>>, error)
<<end>>
<<if .HasEdit>>
Update(id uint, req *admin.<<.RequestUpdateName>>) (*models.<<.ModelName>>, error)
<<end>>
<<if .HasDelete>>
Delete(id uint) error
<<end>>
}
type <<.ModelName>>Filters struct {
<<range .SearchableFields>>
<<.PascalName>> string
<<- end>>
}
type <<.ServiceName>>Impl struct{}
func New<<.ServiceName>>() <<.ServiceName>> {
return &<<.ServiceName>>Impl{}
}
func Build<<.ModelName>>Query(filters <<.ModelName>>Filters) orm.Query {
query := facades.Orm().Query().Model(&models.<<.ModelName>>{})
<<range .SearchableFields>>
if filters.<<.PascalName>> != "" {
<<if eq .SearchType "like">>
query = query.Where("<<.Name>> LIKE ?", "%"+filters.<<.PascalName>>+"%")
<<else if eq .SearchType "=">>
query = query.Where("<<.Name>> = ?", filters.<<.PascalName>>)
<<else if eq .SearchType ">" >>
query = query.Where("<<.Name>> > ?", filters.<<.PascalName>>)
<<else if eq .SearchType ">=" >>
query = query.Where("<<.Name>> >= ?", filters.<<.PascalName>>)
<<else if eq .SearchType "<" >>
query = query.Where("<<.Name>> < ?", filters.<<.PascalName>>)
<<else if eq .SearchType "<=" >>
query = query.Where("<<.Name>> <= ?", filters.<<.PascalName>>)
<<else if eq .SearchType "!=" >>
query = query.Where("<<.Name>> != ?", filters.<<.PascalName>>)
<<else if eq .SearchType "in">>
query = query.Where("<<.Name>> IN ?", filters.<<.PascalName>>)
<<else>>
query = query.Where("<<.Name>> LIKE ?", "%"+filters.<<.PascalName>>+"%")
<<end>>
}
<<- end>>
return query
}
func (s *<<.ServiceName>>Impl) GetByID(id uint) (*models.<<.ModelName>>, error) {
var item models.<<.ModelName>>
if err := facades.Orm().Query().Where("id", id).FirstOrFail(&item); err != nil {
return nil, apperrors.NewBusinessError("<<.ModuleName>>_not_found", "<<.ModelName>> not found").WithError(err)
}
return &item, nil
}
func (s *<<.ServiceName>>Impl) GetList(filters <<.ModelName>>Filters, page, pageSize int) ([]models.<<.ModelName>>, int64, error) {
query := Build<<.ModelName>>Query(filters)
var list []models.<<.ModelName>>
var total int64
if err := query.Order("id desc").Paginate(page, pageSize, &list, &total); err != nil {
return nil, 0, err
}
return list, total, nil
}
<<if .HasCreate>>
func (s *<<.ServiceName>>Impl) Create(req *admin.<<.RequestCreateName>>) (*models.<<.ModelName>>, error) {
item := &models.<<.ModelName>>{
<<range .FormFields>>
<<.FieldName>>: req.<<.FieldName>>,
<<- end>>
}
if err := facades.Orm().Query().Create(item); err != nil {
return nil, apperrors.ErrCreateFailed.WithError(err)
}
return item, nil
}
<<end>>
<<if .HasEdit>>
func (s *<<.ServiceName>>Impl) Update(id uint, req *admin.<<.RequestUpdateName>>) (*models.<<.ModelName>>, error) {
item, err := s.GetByID(id)
if err != nil {
return nil, err
}
<<range .FormFields>>
if req.<<.FieldName>> != nil {
item.<<.FieldName>> = *req.<<.FieldName>>
}
<<- end>>
if err := facades.Orm().Query().Save(item); err != nil {
return nil, apperrors.ErrUpdateFailed.WithError(err)
}
return item, nil
}
<<end>>
<<if .HasDelete>>
func (s *<<.ServiceName>>Impl) Delete(id uint) error {
if _, err := facades.Orm().Query().Where("id", id).Delete(&models.<<.ModelName>>{}); err != nil {
return err
}
return nil
}
<<end>>
+162
View File
@@ -0,0 +1,162 @@
package services
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"time"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/models"
"goravel/app/utils/errorlog"
"goravel/app/utils/traceid"
)
type TokenService interface {
// CreateToken 创建token并存入数据库
CreateToken(tokenableType string, tokenableID uint, name string, expiresAt *time.Time, browser, ip, os, sessionID string) (string, *models.PersonalAccessToken, error)
// FindToken 根据token值查找token记录
FindToken(token string) (*models.PersonalAccessToken, error)
// DeleteToken 删除token
DeleteToken(token string) error
// DeleteTokensByUser 删除用户的所有token
DeleteTokensByUser(tokenableType string, tokenableID uint) error
// GetTokensByUser 获取用户的所有token
GetTokensByUser(tokenableType string, tokenableID uint) ([]models.PersonalAccessToken, error)
// UpdateLastUsedAt 更新最后使用时间
UpdateLastUsedAt(token string) error
}
type TokenServiceImpl struct {
}
func NewTokenServiceImpl() *TokenServiceImpl {
return &TokenServiceImpl{}
}
// CreateToken 创建token并存入数据库
func (s *TokenServiceImpl) CreateToken(tokenableType string, tokenableID uint, name string, expiresAt *time.Time, browser, ip, os, sessionID string) (string, *models.PersonalAccessToken, error) {
// 生成随机token(类似Laravel Sanctum
plainToken := s.generateRandomToken()
tokenHash := s.hashToken(plainToken)
// 如果没有提供sessionID,使用tokenHash的前16位作为sessionID
if sessionID == "" {
if len(tokenHash) >= 16 {
sessionID = tokenHash[:16]
} else {
sessionID = tokenHash
}
}
// 创建token记录,立即设置last_used_at为当前时间
now := time.Now()
accessToken := &models.PersonalAccessToken{
TokenableType: tokenableType,
TokenableID: tokenableID,
Name: name,
Token: tokenHash,
ExpiresAt: expiresAt,
LastUsedAt: &now, // 登录时立即设置最后使用时间
Browser: browser,
IP: ip,
OS: os,
SessionID: sessionID,
}
if err := facades.Orm().Query().Create(accessToken); err != nil {
return "", nil, err
}
return plainToken, accessToken, nil
}
// FindToken 根据token值查找token记录
func (s *TokenServiceImpl) FindToken(token string) (*models.PersonalAccessToken, error) {
if token == "" {
return nil, apperrors.ErrInvalidArgument.WithMessage("token is empty")
}
tokenHash := s.hashToken(token)
var accessToken models.PersonalAccessToken
if err := facades.Orm().Query().Where("token", tokenHash).FirstOrFail(&accessToken); err != nil {
// 记录调试信息
// facades.Log().Debugf("TokenService: FindToken failed, hash: %s, error: %v", tokenHash[:min(20, len(tokenHash))], err)
return nil, err
}
// 检查是否过期
if accessToken.ExpiresAt != nil && accessToken.ExpiresAt.Before(time.Now()) {
if _, err := facades.Orm().Query().Delete(&accessToken); err != nil {
// 使用 traceid.EnsureContext 确保有 trace_id,即使使用 context.Background()
// 这样可以保证日志的可追踪性
ctx, _ := traceid.EnsureContext(context.Background())
errorlog.Record(ctx, "token", "Failed to delete expired token", map[string]any{
"token_id": accessToken.ID,
"tokenable_id": accessToken.TokenableID,
"expires_at": accessToken.ExpiresAt,
"error": err.Error(),
}, "Failed to delete expired token (ID: %d): %v", accessToken.ID, err)
}
return nil, apperrors.ErrInvalidArgument.WithMessage("token expired")
}
return &accessToken, nil
}
// DeleteToken 删除token
func (s *TokenServiceImpl) DeleteToken(token string) error {
tokenHash := s.hashToken(token)
_, err := facades.Orm().Query().Where("token", tokenHash).Delete(&models.PersonalAccessToken{})
return err
}
// DeleteTokensByUser 删除用户的所有token
func (s *TokenServiceImpl) DeleteTokensByUser(tokenableType string, tokenableID uint) error {
_, err := facades.Orm().Query().
Where("tokenable_type", tokenableType).
Where("tokenable_id", tokenableID).
Delete(&models.PersonalAccessToken{})
return err
}
// GetTokensByUser 获取用户的所有token
func (s *TokenServiceImpl) GetTokensByUser(tokenableType string, tokenableID uint) ([]models.PersonalAccessToken, error) {
var tokens []models.PersonalAccessToken
err := facades.Orm().Query().
Where("tokenable_type", tokenableType).
Where("tokenable_id", tokenableID).
Order("created_at desc").
Find(&tokens)
return tokens, err
}
// UpdateLastUsedAt 更新最后使用时间
func (s *TokenServiceImpl) UpdateLastUsedAt(token string) error {
tokenHash := s.hashToken(token)
now := time.Now()
_, err := facades.Orm().Query().
Model(&models.PersonalAccessToken{}).
Where("token", tokenHash).
Update("last_used_at", now)
return err
}
// generateRandomToken 生成随机token40个字符)
func (s *TokenServiceImpl) generateRandomToken() string {
// 生成40个随机字节
b := make([]byte, 40)
_, _ = rand.Read(b)
// 转换为十六进制字符串(80个字符),然后取前40个字符
return hex.EncodeToString(b)[:40]
}
// hashToken 对token进行SHA256哈希
func (s *TokenServiceImpl) hashToken(token string) string {
hash := sha256.Sum256([]byte(token))
return hex.EncodeToString(hash[:])
}
+114
View File
@@ -0,0 +1,114 @@
package services
import (
"github.com/goravel/framework/facades"
"goravel/app/models"
)
type TreeService interface {
// BuildMenuTree 构建菜单树形结构
BuildMenuTree(parentID uint) ([]models.Menu, error)
// BuildDepartmentTree 构建部门树形结构
BuildDepartmentTree(parentID uint) ([]models.Department, error)
// HasMenuChildren 检查菜单是否有子节点
HasMenuChildren(menuID uint) (bool, error)
// HasDepartmentChildren 检查部门是否有子节点
HasDepartmentChildren(departmentID uint) (bool, error)
// GetMenuChildrenIDs 获取菜单及其所有子菜单的ID列表
GetMenuChildrenIDs(menuID uint) ([]uint, error)
}
type TreeServiceImpl struct {
}
func NewTreeServiceImpl() *TreeServiceImpl {
return &TreeServiceImpl{}
}
// BuildMenuTree 构建菜单树形结构
func (s *TreeServiceImpl) BuildMenuTree(parentID uint) ([]models.Menu, error) {
var menus []models.Menu
if err := facades.Orm().Query().Where("parent_id", parentID).Order("sort asc, id asc").Get(&menus); err != nil {
return nil, err
}
// 递归加载子菜单
for i := range menus {
children, err := s.BuildMenuTree(menus[i].ID)
if err != nil {
return nil, err
}
menus[i].Children = children
}
return menus, nil
}
// BuildDepartmentTree 构建部门树形结构
func (s *TreeServiceImpl) BuildDepartmentTree(parentID uint) ([]models.Department, error) {
var departments []models.Department
if err := facades.Orm().Query().Where("parent_id", parentID).Order("sort asc, id asc").Get(&departments); err != nil {
return nil, err
}
// 递归加载子部门
for i := range departments {
children, err := s.BuildDepartmentTree(departments[i].ID)
if err != nil {
return nil, err
}
departments[i].Children = children
}
return departments, nil
}
// HasMenuChildren 检查菜单是否有子节点
func (s *TreeServiceImpl) HasMenuChildren(menuID uint) (bool, error) {
count, err := facades.Orm().Query().Model(&models.Menu{}).Where("parent_id", menuID).Count()
if err != nil {
return false, err
}
return count > 0, nil
}
// HasDepartmentChildren 检查部门是否有子节点
func (s *TreeServiceImpl) HasDepartmentChildren(departmentID uint) (bool, error) {
count, err := facades.Orm().Query().Model(&models.Department{}).Where("parent_id", departmentID).Count()
if err != nil {
return false, err
}
return count > 0, nil
}
// GetMenuChildrenIDs 获取菜单及其所有子菜单的ID列表(递归)
func (s *TreeServiceImpl) GetMenuChildrenIDs(menuID uint) ([]uint, error) {
var menuIDs []uint
menuIDs = append(menuIDs, menuID)
// 递归获取所有子菜单ID
var getChildren func(parentID uint) error
getChildren = func(parentID uint) error {
var children []models.Menu
if err := facades.Orm().Query().Model(&models.Menu{}).Where("parent_id", parentID).Select("id").Get(&children); err != nil {
return err
}
for _, child := range children {
menuIDs = append(menuIDs, child.ID)
// 递归获取子菜单的子菜单
if err := getChildren(child.ID); err != nil {
return err
}
}
return nil
}
if err := getChildren(menuID); err != nil {
return nil, err
}
return menuIDs, nil
}
+282
View File
@@ -0,0 +1,282 @@
package services
import (
"context"
"time"
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/models"
"goravel/app/utils"
"goravel/app/utils/errorlog"
)
type UserBalanceLogService interface {
// CreateLog 创建余额变动记录(使用自定义分表逻辑,必须提供 user_id)
CreateLog(userID uint, logType string, amount float64, balance float64, source string, sourceID *uint, description string, operatorID *uint, status string, remark string) (*models.UserBalanceLog, error)
// GetLogs 查询余额变动记录列表(必须提供 user_id)
GetLogs(filters UserBalanceLogFilters, page, pageSize int) ([]models.UserBalanceLog, int64, error)
// GetUserBalance 获取用户当前余额(从 users 表获取)
GetUserBalance(userID uint) (float64, error)
// GetUserStatistics 获取用户余额统计
GetUserStatistics(userID uint, startTime, endTime time.Time) (*UserBalanceStatistics, error)
}
type UserBalanceLogFilters struct {
UserID uint // 用户ID(必填,用于分表路由)
Type string // 变动类型
Source string // 来源
Status string // 状态
StartTime time.Time // 开始时间
EndTime time.Time // 结束时间
OperatorID *uint // 操作员ID
}
type UserBalanceStatistics struct {
TotalIncome float64 `json:"total_income"` // 总收入
TotalExpense float64 `json:"total_expense"` // 总支出
TotalRefund float64 `json:"total_refund"` // 总退款
CurrentBalance float64 `json:"current_balance"` // 当前余额
}
type UserBalanceLogServiceImpl struct {
shardingService ShardingService
}
func NewUserBalanceLogService() UserBalanceLogService {
return &UserBalanceLogServiceImpl{
shardingService: NewShardingService(),
}
}
// CreateLog 创建余额变动记录
// 注意:必须提供 user_id,会根据 user_id 自动路由到对应分表
func (s *UserBalanceLogServiceImpl) CreateLog(
userID uint,
logType string,
amount float64,
balance float64,
source string,
sourceID *uint,
description string,
operatorID *uint,
status string,
remark string,
) (*models.UserBalanceLog, error) {
if userID == 0 {
return nil, apperrors.ErrUserIDRequired.WithMessage("user_id 不能为空")
}
// 默认状态为 success
if status == "" {
status = "success"
}
// 根据 user_id 计算分表名称
tableName := utils.GetHashShardingTableNameByConfig(utils.UserBalanceLogsShardingConfig, userID)
// 检查分表是否存在,不存在则创建
if err := s.shardingService.EnsureShardingTable(tableName, "user_balance_logs"); err != nil {
errorlog.Record(context.Background(), "user-balance-log", "创建分表失败", map[string]any{
"table_name": tableName,
"user_id": userID,
"error": err.Error(),
}, "创建分表 %s 失败: %v", tableName, err)
return nil, apperrors.ErrCreateFailed.WithError(err)
}
log := &models.UserBalanceLog{
UserID: userID,
Type: logType,
Amount: amount,
Balance: balance,
Source: source,
SourceID: sourceID,
Description: description,
OperatorID: operatorID,
Status: status,
Remark: remark,
}
// 使用 Goravel ORM,通过 Table() 方法指定分表名称
err := facades.Orm().Query().Table(tableName).Create(log)
if err != nil {
errorlog.Record(context.Background(), "user-balance-log", "创建余额变动记录失败", map[string]any{
"user_id": userID,
"log_type": logType,
"amount": amount,
"table_name": tableName,
"error": err.Error(),
}, "创建余额变动记录失败: %v", err)
return nil, apperrors.ErrCreateFailed.WithError(err)
}
return log, nil
}
// GetLogs 查询余额变动记录列表
// 注意:必须提供 user_id,会根据 user_id 自动路由到对应分表
func (s *UserBalanceLogServiceImpl) GetLogs(filters UserBalanceLogFilters, page, pageSize int) ([]models.UserBalanceLog, int64, error) {
if filters.UserID == 0 {
return nil, 0, apperrors.ErrUserIDRequired.WithMessage("user_id 不能为空")
}
// 根据 user_id 计算分表名称
tableName := utils.GetHashShardingTableNameByConfig(utils.UserBalanceLogsShardingConfig, filters.UserID)
// 构建基础查询(用于 Count 和 Get)
buildQuery := func() orm.Query {
query := facades.Orm().Query().Table(tableName).
Where("user_id", filters.UserID)
// 添加其他筛选条件
if filters.Type != "" {
query = query.Where("type", filters.Type)
}
if filters.Source != "" {
query = query.Where("source", filters.Source)
}
if filters.Status != "" {
query = query.Where("status", filters.Status)
}
if !filters.StartTime.IsZero() {
query = query.Where("created_at >= ?", filters.StartTime)
}
if !filters.EndTime.IsZero() {
query = query.Where("created_at <= ?", filters.EndTime)
}
if filters.OperatorID != nil && *filters.OperatorID > 0 {
query = query.Where("operator_id", *filters.OperatorID)
}
return query
}
// 获取总数
total, err := buildQuery().Count()
if err != nil {
return nil, 0, apperrors.ErrQueryFailed.WithError(err)
}
// 分页查询
// 使用 Find() 而不是 Get(),因为 Find() 会保持 Table() 设置的表名
var logs []models.UserBalanceLog
err = buildQuery().
Order("created_at desc").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&logs)
if err != nil {
return nil, 0, apperrors.ErrQueryFailed.WithError(err)
}
return logs, total, nil
}
// GetUserBalance 获取用户当前余额(从 users 表获取)
func (s *UserBalanceLogServiceImpl) GetUserBalance(userID uint) (float64, error) {
if userID == 0 {
return 0, apperrors.ErrUserIDRequired
}
var user models.User
err := facades.Orm().Query().Where("id", userID).First(&user)
if err != nil {
return 0, apperrors.ErrUserNotFound.WithError(err)
}
return user.Balance, nil
}
// GetUserStatistics 获取用户余额统计
func (s *UserBalanceLogServiceImpl) GetUserStatistics(userID uint, startTime, endTime time.Time) (*UserBalanceStatistics, error) {
if userID == 0 {
return nil, apperrors.ErrUserIDRequired
}
// 根据 user_id 计算分表名称
tableName := utils.GetHashShardingTableNameByConfig(utils.UserBalanceLogsShardingConfig, userID)
// 使用 Goravel ORM,通过 Table() 方法指定分表名称
query := facades.Orm().Query().Table(tableName).
Where("user_id", userID).
Where("status", "success")
if !startTime.IsZero() {
query = query.Where("created_at >= ?", startTime)
}
if !endTime.IsZero() {
query = query.Where("created_at <= ?", endTime)
}
var stats UserBalanceStatistics
// 构建基础查询条件(用于构建 SQL)
baseConditions := []any{userID, "success"}
baseWhere := "user_id = ? AND status = ?"
if !startTime.IsZero() {
baseWhere += " AND created_at >= ?"
baseConditions = append(baseConditions, startTime)
}
if !endTime.IsZero() {
baseWhere += " AND created_at <= ?"
baseConditions = append(baseConditions, endTime)
}
// 统计收入
var incomeResult struct {
Total float64
}
incomeSQL := "SELECT COALESCE(SUM(amount), 0) as total FROM " + tableName + " WHERE " + baseWhere + " AND type = ?"
incomeArgs := append(baseConditions, "income")
if err := facades.Orm().Query().Raw(incomeSQL, incomeArgs...).Scan(&incomeResult); err == nil {
stats.TotalIncome = incomeResult.Total
}
// 统计支出
var expenseResult struct {
Total float64
}
expenseSQL := "SELECT COALESCE(SUM(amount), 0) as total FROM " + tableName + " WHERE " + baseWhere + " AND type = ?"
expenseArgs := append(baseConditions, "expense")
if err := facades.Orm().Query().Raw(expenseSQL, expenseArgs...).Scan(&expenseResult); err == nil {
stats.TotalExpense = expenseResult.Total
}
// 统计退款
var refundResult struct {
Total float64
}
refundSQL := "SELECT COALESCE(SUM(amount), 0) as total FROM " + tableName + " WHERE " + baseWhere + " AND type = ?"
refundArgs := append(baseConditions, "refund")
if err := facades.Orm().Query().Raw(refundSQL, refundArgs...).Scan(&refundResult); err == nil {
stats.TotalRefund = refundResult.Total
}
// 获取当前余额
balance, err := s.GetUserBalance(userID)
if err == nil {
// 获取用户信息以获取货币信息
var user models.User
if err := facades.Orm().Query().Where("id", userID).First(&user); err == nil {
// 加载货币信息
if user.CurrencyID > 0 {
var currency models.Currency
if err := facades.Orm().Query().Where("id", user.CurrencyID).First(&currency); err == nil {
user.Currency = &currency
}
}
stats.CurrentBalance = utils.FormatBalance(balance, user.Currency)
// 格式化统计金额
stats.TotalIncome = utils.FormatBalance(stats.TotalIncome, user.Currency)
stats.TotalExpense = utils.FormatBalance(stats.TotalExpense, user.Currency)
stats.TotalRefund = utils.FormatBalance(stats.TotalRefund, user.Currency)
} else {
stats.CurrentBalance = balance
}
}
return &stats, nil
}
+391
View File
@@ -0,0 +1,391 @@
package services
import (
"context"
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/models"
"goravel/app/utils"
"goravel/app/utils/errorlog"
)
type UserService interface {
// GetByID 根据ID获取用户
GetByID(id uint) (*models.User, error)
// GetList 获取用户列表
GetList(filters UserFilters, page, pageSize int) ([]models.User, int64, error)
// Create 创建用户
Create(user *models.User) error
// CreateWithValidation 创建用户(包含验证、密码加密、默认货币设置)
CreateWithValidation(username, password, nickname, email, phone string, status uint8) (*models.User, error)
// Update 更新用户
Update(id uint, user *models.User) error
// Delete 删除用户(软删除)
Delete(id uint) error
// UpdateBalance 更新用户余额(同时创建余额变动记录)
UpdateBalance(userID uint, amount float64, logType string, source string, sourceID *uint, description string, operatorID *uint, remark string) error
// ResetPassword 重置用户密码
ResetPassword(userID uint, newPassword string) error
// ValidateUserExists 验证用户是否存在(用户名、邮箱、手机号)
ValidateUserExists(username, email, phone string, excludeID uint) error
}
type UserFilters struct {
Username string
Email string
Phone string
Nickname string
Status string
}
// BuildUserQuery 构建用户查询(通用查询构建,供列表和导出复用)
func BuildUserQuery(filters UserFilters) orm.Query {
query := facades.Orm().Query().Model(&models.User{})
if filters.Username != "" {
query = query.Where("username LIKE ?", "%"+filters.Username+"%")
}
if filters.Nickname != "" {
query = query.Where("nickname LIKE ?", "%"+filters.Nickname+"%")
}
if filters.Email != "" {
query = query.Where("email LIKE ?", "%"+filters.Email+"%")
}
if filters.Phone != "" {
query = query.Where("phone LIKE ?", "%"+filters.Phone+"%")
}
if filters.Status != "" {
query = query.Where("status", filters.Status)
}
return query
}
type UserServiceImpl struct {
balanceLogService UserBalanceLogService
}
func NewUserService() UserService {
return &UserServiceImpl{
balanceLogService: NewUserBalanceLogService(),
}
}
// GetByID 根据ID获取用户
func (s *UserServiceImpl) GetByID(id uint) (*models.User, error) {
var user models.User
if err := facades.Orm().Query().Where("id", id).FirstOrFail(&user); err != nil {
return nil, apperrors.ErrUserNotFound.WithError(err)
}
// 加载货币信息
if user.CurrencyID > 0 {
var currency models.Currency
if err := facades.Orm().Query().Where("id", user.CurrencyID).First(&currency); err == nil {
user.Currency = &currency
}
}
// 格式化余额
user.Balance = utils.FormatBalance(user.Balance, user.Currency)
return &user, nil
}
// GetList 获取用户列表
func (s *UserServiceImpl) GetList(filters UserFilters, page, pageSize int) ([]models.User, int64, error) {
query := BuildUserQuery(filters)
// 分页查询
var users []models.User
var total int64
if err := query.Order("created_at desc").Paginate(page, pageSize, &users, &total); err != nil {
return nil, 0, err
}
// 加载货币信息并格式化每个用户的余额
for i := range users {
if users[i].CurrencyID > 0 {
var currency models.Currency
if err := facades.Orm().Query().Where("id", users[i].CurrencyID).First(&currency); err == nil {
users[i].Currency = &currency
}
}
users[i].Balance = utils.FormatBalance(users[i].Balance, users[i].Currency)
}
return users, total, nil
}
// Create 创建用户
func (s *UserServiceImpl) Create(user *models.User) error {
// 如果未设置货币ID,默认使用人民币
if user.CurrencyID == 0 {
var cnyCurrency models.Currency
if err := facades.Orm().Query().Where("code", "CNY").First(&cnyCurrency); err == nil {
user.CurrencyID = cnyCurrency.ID
}
}
// 使用 map 创建,确保 Status 为 0 时也能正确保存(与管理员创建方式一致)
// GORM 在处理结构体时可能会忽略零值字段,使用 map 可以确保所有字段都被保存
userData := map[string]any{
"username": user.Username,
"password": user.Password,
"nickname": user.Nickname,
"avatar": user.Avatar,
"email": user.Email,
"phone": user.Phone,
"balance": user.Balance,
"currency_id": user.CurrencyID,
"status": user.Status,
"last_login_at": user.LastLoginAt,
}
if err := facades.Orm().Query().Table("users").Create(userData); err != nil {
return err
}
// 将创建后的 ID 赋值回 user 对象(GORM 会将生成的 ID 填充到 map 中)
if id, ok := userData["id"].(uint); ok {
user.ID = id
} else if id, ok := userData["id"].(uint64); ok {
user.ID = uint(id)
} else {
// 如果 map 中没有 ID,通过用户名查询获取(与管理员创建方式一致)
var createdUser models.User
if err := facades.Orm().Query().Where("username", user.Username).First(&createdUser); err == nil {
user.ID = createdUser.ID
}
}
return nil
}
// Update 更新用户
func (s *UserServiceImpl) Update(id uint, user *models.User) error {
// 如果密码为空,则不更新密码字段
updateData := map[string]any{
"nickname": user.Nickname,
"avatar": user.Avatar,
"email": user.Email,
"phone": user.Phone,
"status": user.Status,
"currency_id": user.CurrencyID,
}
// 只有用户名不为空时才更新用户名(通常用户名不允许修改,但保留此逻辑以防需要)
if user.Username != "" {
updateData["username"] = user.Username
}
// 只有密码不为空时才更新密码
if user.Password != "" {
updateData["password"] = user.Password
}
_, err := facades.Orm().Query().Model(&models.User{}).Where("id", id).Update(updateData)
return err
}
// Delete 删除用户(软删除)
func (s *UserServiceImpl) Delete(id uint) error {
_, err := facades.Orm().Query().Where("id", id).Delete(&models.User{})
return err
}
// UpdateBalance 更新用户余额(同时创建余额变动记录)
func (s *UserServiceImpl) UpdateBalance(userID uint, amount float64, logType string, source string, sourceID *uint, description string, operatorID *uint, remark string) error {
// 获取当前用户
user, err := s.GetByID(userID)
if err != nil {
return err
}
// 计算新余额
var newBalance float64
switch logType {
case "income", "refund":
newBalance = user.Balance + amount
case "expense":
newBalance = user.Balance - amount
if newBalance < 0 {
return apperrors.ErrInsufficientBalance.WithParams(map[string]any{
"balance": user.Balance,
})
}
default:
return apperrors.ErrInvalidBalanceType.WithParams(map[string]any{
"type": logType,
})
}
// 1. 更新用户余额
_, err = facades.Orm().Query().Model(&models.User{}).Where("id", userID).Update(map[string]any{
"balance": newBalance,
})
if err != nil {
errorlog.Record(context.Background(), "user", "更新用户余额失败", map[string]any{
"user_id": userID,
"amount": amount,
"log_type": logType,
"new_balance": newBalance,
"error": err.Error(),
}, "更新用户余额失败: %v", err)
return apperrors.ErrUpdateFailed.WithError(err)
}
// 2. 创建余额变动记录(使用自定义分表逻辑)
_, err = s.balanceLogService.CreateLog(userID, logType, amount, newBalance, source, sourceID, description, operatorID, "success", remark)
if err != nil {
// 如果创建记录失败,尝试回滚余额更新
// 注意:由于涉及分表,无法使用跨表事务,这里手动回滚
rollbackErr := s.rollbackBalance(userID, user.Balance)
if rollbackErr != nil {
errorlog.Record(context.Background(), "user", "回滚余额失败", map[string]any{
"user_id": userID,
"old_balance": user.Balance,
"new_balance": newBalance,
"rollback_err": rollbackErr.Error(),
}, "回滚余额失败: %v", rollbackErr)
}
errorlog.Record(context.Background(), "user", "创建余额变动记录失败", map[string]any{
"user_id": userID,
"amount": amount,
"log_type": logType,
"new_balance": newBalance,
"error": err.Error(),
}, "创建余额变动记录失败: %v", err)
return apperrors.ErrCreateFailed.WithError(err)
}
return nil
}
// rollbackBalance 回滚余额到指定值
func (s *UserServiceImpl) rollbackBalance(userID uint, balance float64) error {
_, err := facades.Orm().Query().Model(&models.User{}).Where("id", userID).Update(map[string]any{
"balance": balance,
})
return err
}
// ValidateUserExists 验证用户是否存在(用户名、邮箱、手机号)
func (s *UserServiceImpl) ValidateUserExists(username, email, phone string, excludeID uint) error {
// 检查用户名是否已存在
if username != "" {
query := facades.Orm().Query().Model(&models.User{}).Where("username", username)
if excludeID > 0 {
query = query.Where("id != ?", excludeID)
}
exists, err := query.Exists()
if err != nil {
return apperrors.ErrCreateFailed.WithError(err)
}
if exists {
return apperrors.ErrUsernameExists
}
}
// 检查邮箱是否已存在(如果提供了邮箱)
if email != "" {
query := facades.Orm().Query().Model(&models.User{}).Where("email", email)
if excludeID > 0 {
query = query.Where("id != ?", excludeID)
}
exists, err := query.Exists()
if err != nil {
return apperrors.ErrCreateFailed.WithError(err)
}
if exists {
return apperrors.NewBusinessError("email_already_exists", "邮箱已存在")
}
}
// 检查手机号是否已存在(如果提供了手机号)
if phone != "" {
query := facades.Orm().Query().Model(&models.User{}).Where("phone", phone)
if excludeID > 0 {
query = query.Where("id != ?", excludeID)
}
exists, err := query.Exists()
if err != nil {
return apperrors.ErrCreateFailed.WithError(err)
}
if exists {
return apperrors.NewBusinessError("phone_already_exists", "手机号已存在")
}
}
return nil
}
// CreateWithValidation 创建用户(包含验证、密码加密、默认货币设置)
func (s *UserServiceImpl) CreateWithValidation(username, password, nickname, email, phone string, status uint8) (*models.User, error) {
// 验证用户是否存在
if err := s.ValidateUserExists(username, email, phone, 0); err != nil {
return nil, err
}
// 密码加密
hashedPassword, err := facades.Hash().Make(password)
if err != nil {
return nil, apperrors.NewBusinessError("password_encrypt_failed", "密码加密失败").WithError(err)
}
// 如果未设置货币ID,默认使用人民币
var currencyID uint
var cnyCurrency models.Currency
if err := facades.Orm().Query().Where("code", "CNY").First(&cnyCurrency); err == nil {
currencyID = cnyCurrency.ID
}
// 创建用户
user := &models.User{
Username: username,
Password: hashedPassword,
Nickname: nickname,
Avatar: "",
Email: email,
Phone: phone,
Balance: 0,
CurrencyID: currencyID,
Status: status,
}
if err := s.Create(user); err != nil {
return nil, apperrors.ErrCreateFailed.WithError(err)
}
// 查询创建后的用户(确保获取完整信息)
createdUser, err := s.GetByID(user.ID)
if err != nil {
return nil, err
}
return createdUser, nil
}
// ResetPassword 重置用户密码
func (s *UserServiceImpl) ResetPassword(userID uint, newPassword string) error {
// 检查用户是否存在
_, err := s.GetByID(userID)
if err != nil {
return err
}
// 密码加密
hashedPassword, err := facades.Hash().Make(newPassword)
if err != nil {
return apperrors.ErrPasswordEncryptFailed.WithError(err)
}
// 更新密码
_, err = facades.Orm().Query().Model(&models.User{}).Where("id", userID).Update(map[string]any{
"password": hashedPassword,
})
if err != nil {
return apperrors.ErrUpdateFailed.WithError(err)
}
return nil
}