MenuServiceImpl.kt

package io.github.lishangbu.avalon.auth.service.impl

import io.github.lishangbu.avalon.auth.entity.Menu
import io.github.lishangbu.avalon.auth.entity.Role
import io.github.lishangbu.avalon.auth.entity.dto.MenuSpecification
import io.github.lishangbu.avalon.auth.entity.dto.MenuTreeView
import io.github.lishangbu.avalon.auth.entity.dto.MenuView
import io.github.lishangbu.avalon.auth.entity.dto.SaveMenuInput
import io.github.lishangbu.avalon.auth.entity.dto.UpdateMenuInput
import io.github.lishangbu.avalon.auth.repository.AuthorizationFetchers
import io.github.lishangbu.avalon.auth.repository.MenuRepository
import io.github.lishangbu.avalon.auth.repository.RoleRepository
import io.github.lishangbu.avalon.auth.service.MenuService
import io.github.lishangbu.avalon.jimmer.support.readOrNull
import org.babyfish.jimmer.sql.ast.mutation.SaveMode
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

/**
 * 菜单服务实现。
 *
 * 除基础 CRUD 外,还负责把当前账号可见的菜单集合转换成前端可直接消费的树结构。
 */
@Service
class MenuServiceImpl(
    /** 菜单仓储。 */
    private val menuRepository: MenuRepository,
    /** 角色仓储。 */
    private val roleRepository: RoleRepository,
) : MenuService {
    /** 根据角色编码列表查询菜单树。 */
    override fun listMenuTreeByRoleCodes(roleCodes: List<String>): List<MenuTreeView> {
        if (roleCodes.isEmpty()) {
            return emptyList()
        }
        val menus = menuRepository.listViewsByRoleCodes(roleCodes)
        log.debug("根据角色编码获取到 [{}] 条菜单记录", menus.size)
        return buildRoleMenuTree(menus)
    }

    /** 查询全量菜单树。 */
    override fun listTree(): List<MenuTreeView> = menuRepository.listTreeViews()

    /** 按条件查询菜单列表。 */
    override fun listByCondition(specification: MenuSpecification): List<MenuView> = menuRepository.listViews(specification)

    /** 根据 ID 查询菜单。 */
    override fun getById(id: Long): MenuView? = menuRepository.loadViewById(id)

    /** 保存菜单。 */
    @Transactional(rollbackFor = [Exception::class])
    override fun save(command: SaveMenuInput): MenuView {
        val menu = command.toEntity()
        validateParent(menu)
        return menuRepository.save(menu, SaveMode.INSERT_ONLY).let(::reloadView)
    }

    /** 更新菜单。 */
    @Transactional(rollbackFor = [Exception::class])
    override fun update(command: UpdateMenuInput): MenuView {
        val menu = command.toEntity()
        validateParent(menu)
        return menuRepository.save(menu).let(::reloadView)
    }

    /** 根据 ID 删除菜单。 */
    @Transactional(rollbackFor = [Exception::class])
    override fun removeById(id: Long) {
        if (menuRepository.hasChildren(id)) {
            throw IllegalStateException("请先删除子菜单后再删除当前菜单")
        }
        detachMenuFromRoles(id)
        menuRepository.deleteById(id)
    }

    /**
     * 校验父菜单是否合法。
     *
     * 这里同时处理三类问题:
     * 1. 父菜单不存在
     * 2. 父菜单选择了自己
     * 3. 父菜单链路形成循环引用
     */
    private fun validateParent(menu: Menu) {
        val menuId = menu.readOrNull { id }
        val parentId = menu.readOrNull { parent }?.id ?: return

        if (menuId != null && parentId == menuId) {
            throw IllegalArgumentException("父菜单不能选择自身")
        }

        val parent =
            menuRepository.findNullable(parentId, AuthorizationFetchers.MENU)
                ?: throw IllegalArgumentException("父菜单不存在")

        if (menuId != null) {
            validateNoCircularReference(menuId, parent)
        }
    }

    /** 校验父子层级不存在循环引用。 */
    private fun validateNoCircularReference(
        menuId: Long,
        parent: Menu,
    ) {
        var currentId: Long? = parent.id
        val visited = mutableSetOf<Long>()

        while (currentId != null) {
            if (currentId == menuId) {
                throw IllegalStateException("父菜单不能选择当前菜单或其子菜单")
            }
            if (!visited.add(currentId)) {
                throw IllegalStateException("菜单层级存在循环引用")
            }
            currentId =
                menuRepository
                    .findNullable(currentId, AuthorizationFetchers.MENU)
                    ?.readOrNull { parent }
                    ?.id
        }
    }

    /** 删除菜单前先解除角色关联,避免角色仍引用已删除菜单。 */
    private fun detachMenuFromRoles(menuId: Long) {
        val roles =
            roleRepository
                .listEntitiesWithMenus(null)
                .filter { role -> role.menus.any { menu -> menu.id == menuId } }

        roles.forEach { role ->
            val remainedMenus = role.menus.filterNot { menu -> menu.id == menuId }
            roleRepository.save(
                Role(role) {
                    menus = remainedMenus
                },
            )
        }
    }

    /**
     * 根据当前角色可见菜单组装树结构。
     *
     * 这里不直接依赖数据库中的树查询,而是基于角色过滤后的扁平菜单重新组装,
     * 这样可以保证只返回当前账号真正有权看到的节点。
     */
    private fun buildRoleMenuTree(menus: List<MenuView>): List<MenuTreeView> {
        if (menus.isEmpty()) {
            return emptyList()
        }
        val ids = menus.mapTo(linkedSetOf()) { it.id }
        val childrenByParentId = menus.groupBy { it.parentId }

        fun toTree(
            node: MenuView,
            path: Set<String> = emptySet(),
        ): MenuTreeView {
            if (node.id in path) {
                throw IllegalStateException("菜单层级存在循环引用")
            }
            val children =
                childrenByParentId[node.id]
                    .orEmpty()
                    .map { child -> toTree(child, path + node.id) }
            return MenuTreeView(
                id = node.id,
                parentId = node.parentId,
                disabled = node.disabled,
                extra = node.extra,
                icon = node.icon,
                key = node.key,
                title = node.title,
                show = node.show,
                path = node.path,
                name = node.name,
                redirect = node.redirect,
                component = node.component,
                sortingOrder = node.sortingOrder,
                pinned = node.pinned,
                showTab = node.showTab,
                enableMultiTab = node.enableMultiTab,
                type = node.type,
                hidden = node.hidden,
                hideChildrenInMenu = node.hideChildrenInMenu,
                flatMenu = node.flatMenu,
                activeMenu = node.activeMenu,
                external = node.external,
                target = node.target,
                children = children,
            )
        }

        return menus
            .filter { menu -> menu.parentId == null || menu.parentId !in ids }
            .map(::toTree)
    }

    private fun reloadView(menu: Menu): MenuView =
        requireNotNull(menuRepository.loadViewById(menu.id)) {
            "未找到 ID=${menu.id} 对应的菜单"
        }

    companion object {
        private val log: Logger = LoggerFactory.getLogger(MenuServiceImpl::class.java)
    }
}