init
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 URL(Google 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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬ifications, &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(¬ifications); 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(¬ification); 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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>>
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>>")
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
}
|
||||
@@ -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>>
|
||||
@@ -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 生成随机token(40个字符)
|
||||
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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¤cy); err == nil {
|
||||
user.Currency = ¤cy
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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(¤cy); err == nil {
|
||||
user.Currency = ¤cy
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化余额
|
||||
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(¤cy); err == nil {
|
||||
users[i].Currency = ¤cy
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user