Files
zkxw/docs/superpowers/plans/2026-05-17-permission-system-fix.md
T
2026-05-17 23:08:38 +08:00

33 KiB
Raw Blame History

权限系统全面修复实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 补全 SQL 权限模型(sys_menu, sys_role, sys_role_menu, sys_role_data_scope_detail, sys_user_role)定义的全部权限执行逻辑,使菜单权限和数据权限在后端 API 和前端 UI 层真正生效。

Architecture: 后端添加 data_scope.py 服务模块实现数据权限过滤,在现有路由上叠加 require_menu_code() 菜单权限守卫;前端用户管理添加角色选择器,业务页面按钮添加 hasAuth 权限控制。

Tech Stack: Python/FastAPI/SQLAlchemy (backend), Vue 3/TypeScript/Element Plus (frontend)


File Structure

后端新增/修改文件

操作 文件 职责
新增 backend/app/core/data_scope.py 数据权限过滤服务:读取用户角色 data_scope,构建 SQLAlchemy 过滤条件
修改 backend/app/api/basic_info.py 客户/样本类型/采集地区/协议号/样本编码 路由添加 require_menu_code + apply_data_scope
修改 backend/app/api/operations.py 洁净室/仓储/展厅预约 路由添加 require_menu_code + apply_data_scope
修改 backend/app/api/collection_protocol.py 采集协议路由添加 require_menu_code + apply_data_scope
修改 backend/app/api/sample_reception.py 样本接收路由添加 require_menu_code + apply_data_scope
修改 backend/app/api/admin.py 用户/角色管理路由添加 require_menu_code(仅业务部分)
修改 backend/app/api/public.py 修复 admin 无权限码返回 all:true 的安全漏洞
新增 docs/sql/sys_menu_button_perms.sql 为每个 C 类型菜单补充 F 类型按钮权限记录

前端修改文件

操作 文件 职责
修改 web-admin/src/views/system/user/index.vue 用户弹窗添加角色多选下拉框
修改 web-admin/src/views/basic-info/customer/index.vue 按钮添加 hasAuth 权限控制
修改 web-admin/src/views/basic-info/cell-sample-type/index.vue 按钮添加 hasAuth 权限控制
修改 web-admin/src/views/basic-info/collection-region/index.vue 按钮添加 hasAuth 权限控制
修改 web-admin/src/views/basic-info/collection-protocol-no/index.vue 按钮添加 hasAuth 权限控制
修改 web-admin/src/views/basic-info/sample-code/index.vue 按钮添加 hasAuth 权限控制

Task 1: 创建 F 类型按钮权限 SQL 脚本

Files:

  • Create: docs/sql/sys_menu_button_perms.sql

说明: 当前 sys_menu 中 C 类型菜单的 perms 字段(如 biz:customer:list)用于列表页面权限。需为每个 C 类型菜单新增 F 类型子记录,用于增删改操作权限控制。

  • Step 1: 编写 SQL 脚本
-- sys_menu 按钮(F)权限初始化
-- 为每个 C 类型菜单新增 list/add/edit/delete 四个 F 类型按钮权限
-- 执行前请确认 sys_menu 中已有的 C 类型菜单记录

-- ============================================================
-- 客户管理 (parent: 121, perms prefix: biz:customer)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '客户查询', '121', 'F', '', NULL, 'biz:customer:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '客户新增', '121', 'F', '', NULL, 'biz:customer:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '客户修改', '121', 'F', '', NULL, 'biz:customer:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '客户删除', '121', 'F', '', NULL, 'biz:customer:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 细胞样本类型管理 (parent: 122, perms prefix: biz:cellSampleType)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '样本类型查询', '122', 'F', '', NULL, 'biz:cellSampleType:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本类型新增', '122', 'F', '', NULL, 'biz:cellSampleType:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本类型修改', '122', 'F', '', NULL, 'biz:cellSampleType:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本类型删除', '122', 'F', '', NULL, 'biz:cellSampleType:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 采集地区及医院管理 (parent: 123, perms prefix: biz:collectionRegion)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '采集地区查询', '123', 'F', '', NULL, 'biz:collectionRegion:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '采集地区新增', '123', 'F', '', NULL, 'biz:collectionRegion:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '采集地区修改', '123', 'F', '', NULL, 'biz:collectionRegion:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '采集地区删除', '123', 'F', '', NULL, 'biz:collectionRegion:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 采集协议号管理 (parent: 124, perms prefix: biz:collectionProtocolNo)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '协议号查询', '124', 'F', '', NULL, 'biz:collectionProtocolNo:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '协议号新增', '124', 'F', '', NULL, 'biz:collectionProtocolNo:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '协议号修改', '124', 'F', '', NULL, 'biz:collectionProtocolNo:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '协议号删除', '124', 'F', '', NULL, 'biz:collectionProtocolNo:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 样本编码管理 (parent: 125, perms prefix: biz:sampleCode)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '样本编码查询', '125', 'F', '', NULL, 'biz:sampleCode:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本编码新增', '125', 'F', '', NULL, 'biz:sampleCode:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本编码修改', '125', 'F', '', NULL, 'biz:sampleCode:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本编码删除', '125', 'F', '', NULL, 'biz:sampleCode:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 租户管理 (parent: 101, perms prefix: system:tenant) — super_admin only
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '租户查询', '101', 'F', '', NULL, 'system:tenant:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '租户新增', '101', 'F', '', NULL, 'system:tenant:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '租户修改', '101', 'F', '', NULL, 'system:tenant:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '租户删除', '101', 'F', '', NULL, 'system:tenant:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 字典管理 (parent: 102, perms prefix: system:dict)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '字典查询', '102', 'F', '', NULL, 'system:dict:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '字典新增', '102', 'F', '', NULL, 'system:dict:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '字典修改', '102', 'F', '', NULL, 'system:dict:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '字典删除', '102', 'F', '', NULL, 'system:dict:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 组织管理 (parent: 103, perms prefix: system:dept)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '组织查询', '103', 'F', '', NULL, 'system:dept:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '组织新增', '103', 'F', '', NULL, 'system:dept:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '组织修改', '103', 'F', '', NULL, 'system:dept:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '组织删除', '103', 'F', '', NULL, 'system:dept:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 用户管理 (parent: 104, perms prefix: system:user)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '用户查询', '104', 'F', '', NULL, 'system:user:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '用户新增', '104', 'F', '', NULL, 'system:user:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '用户修改', '104', 'F', '', NULL, 'system:user:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '用户删除', '104', 'F', '', NULL, 'system:user:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 角色管理 (parent: 105, perms prefix: system:role)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '角色查询', '105', 'F', '', NULL, 'system:role:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '角色新增', '105', 'F', '', NULL, 'system:role:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '角色修改', '105', 'F', '', NULL, 'system:role:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '角色删除', '105', 'F', '', NULL, 'system:role:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 菜单管理 (parent: 106, perms prefix: system:menu)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '菜单查询', '106', 'F', '', NULL, 'system:menu:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '菜单新增', '106', 'F', '', NULL, 'system:menu:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '菜单修改', '106', 'F', '', NULL, 'system:menu:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '菜单删除', '106', 'F', '', NULL, 'system:menu:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 站点管理 (parent: 107, perms prefix: system:site)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '站点查询', '107', 'F', '', NULL, 'system:site:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '站点新增', '107', 'F', '', NULL, 'system:site:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '站点修改', '107', 'F', '', NULL, 'system:site:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '站点删除', '107', 'F', '', NULL, 'system:site:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 职位管理 (parent: 108, perms prefix: system:post)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '职位查询', '108', 'F', '', NULL, 'system:post:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '职位新增', '108', 'F', '', NULL, 'system:post:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '职位修改', '108', 'F', '', NULL, 'system:post:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '职位删除', '108', 'F', '', NULL, 'system:post:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 物品单位 (parent: 109, perms prefix: system:unit)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '单位查询', '109', 'F', '', NULL, 'system:unit:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '单位新增', '109', 'F', '', NULL, 'system:unit:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '单位修改', '109', 'F', '', NULL, 'system:unit:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '单位删除', '109', 'F', '', NULL, 'system:unit:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 快递公司 (parent: 110, perms prefix: system:express)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '快递公司查询', '110', 'F', '', NULL, 'system:express:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '快递公司新增', '110', 'F', '', NULL, 'system:express:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '快递公司修改', '110', 'F', '', NULL, 'system:express:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '快递公司删除', '110', 'F', '', NULL, 'system:express:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);

-- ============================================================
-- 质量检测-样本接收 (parent: 70000000000000000000000000000002, perms prefix: biz:sampleReception)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '样本接收查询', '70000000000000000000000000000002', 'F', '', NULL, 'biz:sampleReception:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本接收新增', '70000000000000000000000000000002', 'F', '', NULL, 'biz:sampleReception:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本接收修改', '70000000000000000000000000000002', 'F', '', NULL, 'biz:sampleReception:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本接收删除', '70000000000000000000000000000002', 'F', '', NULL, 'biz:sampleReception:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
  • Step 2: Commit
git add docs/sql/sys_menu_button_perms.sql
git commit -m "feat: 添加 sys_menu 按钮权限(F 类型)初始化 SQL 脚本"

Task 2: 创建后端数据权限服务 data_scope.py

Files:

  • Create: backend/app/core/data_scope.py

  • Step 1: 编写数据权限服务模块

"""数据权限过滤服务

根据用户角色的 data_scope 设置,在 SQLAlchemy query 上叠加过滤条件。
支持 5 种数据范围:全部、本部门、本部门及以下、仅本人、自定义。
多角色场景取并集。
"""
from __future__ import annotations

from typing import Any, Dict, List, Optional, Set, Tuple

from sqlalchemy import and_, or_
from sqlalchemy.orm import Session

from app.models.models import (
    SysRole,
    SysRoleDataScopeDetail,
    SysUserRole,
    SysDept,
)


def get_user_dept_id(db: Session, user_id: str) -> Optional[str]:
    """获取用户的部门ID"""
    from app.models.models import SysUser
    user = db.query(SysUser).filter(SysUser.id == user_id).first()
    return user.dept_id if user else None


def _get_child_dept_ids(db: Session, dept_id: str, tenant_no: str) -> List[str]:
    """递归获取部门及所有子部门ID列表"""
    result = [dept_id]
    children = (
        db.query(SysDept.id)
        .filter(
            SysDept.parent_id == dept_id,
            SysDept.del_flag == 0,
            SysDept.status == "1",
            SysDept.tenant_no == tenant_no,
        )
        .all()
    )
    for child in children:
        result.extend(_get_child_dept_ids(db, child.id, tenant_no))
    return result


def _resolve_user_role_scopes(
    db: Session, user_id: str, tenant_no: str
) -> List[Dict[str, Any]]:
    """获取用户所有角色的 data_scope 和自定义范围明细。

    Returns:
        [{"data_scope": "1", "scope_details": [...]}]
    """
    roles = (
        db.query(SysRole)
        .join(SysUserRole, SysUserRole.role_id == SysRole.id)
        .filter(
            SysUserRole.user_id == user_id,
            SysUserRole.del_flag == 0,
            SysUserRole.tenant_no == tenant_no,
            SysRole.del_flag == 0,
            SysRole.status == "1",
            SysRole.tenant_no == tenant_no,
        )
        .all()
    )

    result = []
    for role in roles:
        scope_details = (
            db.query(SysRoleDataScopeDetail)
            .filter(
                SysRoleDataScopeDetail.role_id == role.id,
                SysRoleDataScopeDetail.del_flag == 0,
            )
            .all()
        )
        result.append({
            "data_scope": role.data_scope or "1",
            "scope_details": [
                {"scope_type": d.scope_type, "scope_value": d.scope_value}
                for d in scope_details
            ],
        })
    return result


def apply_data_scope(
    query: Any,
    model: Any,
    user_info: dict,
    db: Session,
    tenant_no: Optional[str] = None,
) -> Any:
    """在 SQLAlchemy query 上叠加数据权限过滤条件。

    Args:
        query: 原始 SQLAlchemy query 对象
        model: ORM 模型类(需要有 create_dept_id, create_by 字段)
        user_info: get_current_user 返回的 dict(含 user_id, dept_id 或 region_id)
        db: 数据库 session
        tenant_no: 租户编号(可选,用于部门树查询)

    Returns:
        添加了数据权限过滤条件的 query。如果用户是 super_admin 或无角色,返回原 query。
    """
    # super_admin 不过滤
    role = user_info.get("role")
    if role == "super_admin":
        return query

    user_id = user_info.get("user_id")
    if not user_id:
        return query

    tn = tenant_no or "zkxw"

    # 确保模型有必要字段
    if not hasattr(model, "create_dept_id") and not hasattr(model, "create_by"):
        return query

    # 获取用户所有角色的数据范围
    role_scopes = _resolve_user_role_scopes(db, user_id, tn)
    if not role_scopes:
        # 无角色 → 不过滤(由菜单守卫控制是否可访问)
        return query

    # 任一角色 scope=1(全部数据)→ 不过滤
    if any(rs["data_scope"] == "1" for rs in role_scopes):
        return query

    # 用户的部门ID
    user_dept_id = user_info.get("dept_id") or user_info.get("region_id") or get_user_dept_id(db, user_id)

    # 合并所有角色的过滤范围(并集)
    dept_ids: Set[str] = set()
    user_ids: Set[str] = set()

    for rs in role_scopes:
        ds = rs["data_scope"]
        if ds == "2":
            # 本部门
            if user_dept_id:
                dept_ids.add(user_dept_id)
        elif ds == "3":
            # 本部门及以下
            if user_dept_id:
                dept_ids.update(_get_child_dept_ids(db, user_dept_id, tn))
        elif ds == "4":
            # 仅本人
            user_ids.add(user_id)
        elif ds == "5":
            # 自定义
            for detail in rs["scope_details"]:
                val = detail["scope_value"]
                if detail["scope_type"] == "dept":
                    dept_ids.add(val)
                elif detail["scope_type"] == "user":
                    user_ids.add(val)

    # 构建过滤条件
    conditions = []
    has_dept_field = hasattr(model, "create_dept_id")
    has_create_by_field = hasattr(model, "create_by")

    if dept_ids and has_dept_field:
        conditions.append(model.create_dept_id.in_(dept_ids))
    if user_ids and has_create_by_field:
        conditions.append(model.create_by.in_(user_ids))

    if not conditions:
        # 没有任何有效范围 → 退回不过滤
        return query

    # 多条件取 OR(并集语义)
    if len(conditions) == 1:
        return query.filter(conditions[0])
    return query.filter(or_(*conditions))
  • Step 2: Commit
git add backend/app/core/data_scope.py
git commit -m "feat: 添加数据权限过滤服务 data_scope.py"

Task 3: 修复 admin 无权限码安全漏洞

Files:

  • Modify: backend/app/api/public.py (lines 272-279)

  • Step 1: 修改 public.py 中 menu-codes 端点

将 backend/app/api/public.py 第 272-279 行的代码:

        if role == "admin" and not menu_codes:
            return success_response(
                {
                    "isSuperAdmin": False,
                    "all": True,
                    "menuCodes": [],
                }
            )

改为:

        # admin 无权限码时返回空列表(不再赋予全部权限)
        # 安全修复:空权限码 = 无菜单访问权限

即删除整个 if role == "admin" and not menu_codes: 分支。后续的 return success_response(...) 会自动处理,返回 all: False, menuCodes: []。

  • Step 2: Commit
git add backend/app/api/public.py
git commit -m "fix: 修复 admin 无权限码时返回 all:true 的安全漏洞"

Task 4: 后端业务路由添加菜单权限守卫 + 数据权限过滤

Files:

  • Modify: backend/app/api/basic_info.py
  • Modify: backend/app/api/operations.py
  • Modify: backend/app/api/collection_protocol.py
  • Modify: backend/app/api/sample_reception.py
  • Modify: backend/app/api/admin.py (用户/角色管理部分)

原则:

  • 每个路由文件的 import 中添加 from app.core.menu_guard import require_menu_code 和 from app.core.data_scope import apply_data_scope

  • 将 Depends(require_admin_or_super_admin) 替换为 Depends(require_menu_code("xxx:list"))(list 端点)、Depends(require_menu_code("xxx:add"))(create 端点)等

  • list 端点在查询链中调用 apply_data_scope(query, Model, user, db, tenant_no)

  • Step 1: 修改 basic_info.py

  1. 替换 import:from app.core.menu_guard import require_admin_or_super_admin → from app.core.menu_guard import require_menu_code
  2. 添加 import:from app.core.data_scope import apply_data_scope
  3. 客户管理路由:
    • list_customers: Depends(require_menu_code("biz:customer:list")) + 查询添加 apply_data_scope
    • create_customer: Depends(require_menu_code("biz:customer:add"))
    • update_customer: Depends(require_menu_code("biz:customer:edit"))
    • delete_customer: Depends(require_menu_code("biz:customer:delete"))
  4. 细胞样本类型路由:同理,perms 为 biz:cellSampleType:*
  5. 采集地区路由:同理,perms 为 biz:collectionRegion:*
  6. 采集协议号路由:同理,perms 为 biz:collectionProtocolNo:*
  7. 样本编码路由:同理,perms 为 biz:sampleCode:*
  • Step 2: 修改 operations.py

洁净室路由添加 require_menu_code("biz:cleanRoom:*") + apply_data_scope。仓储设施、展厅预约同理。

注意:operations.py 中的业务目前没有对应的 sys_menu.perms,需要根据 SQL 中是否有对应菜单决定。如果 SQL 中尚无菜单记录,则保持 require_admin_or_super_admin 不变,待后续添加菜单后再改。

  • Step 3: 修改 collection_protocol.py

采集协议路由添加 require_menu_code("biz:collectionProtocol:*") + apply_data_scope。

注意:SQL 中当前没有 biz:collectionProtocol:list 的菜单记录。如果此模块菜单尚未配置,保持 require_admin_or_super_admin。

  • Step 4: 修改 sample_reception.py

样本接收路由添加 require_menu_code("biz:sampleReception:*") + apply_data_scope。

  • Step 5: 修改 admin.py

用户管理、角色管理、字典管理等系统管理接口添加 require_menu_code:

  • 用户 CRUD:system:user:list/add/edit/delete
  • 角色 CRUD:system:role:list/add/edit/delete
  • 租户 CRUD:保持 require_admin_or_super_admin(仅 super_admin)
  • 字典管理:system:dict:list/add/edit/delete
  • 组织管理:system:dept:list/add/edit/delete
  • 菜单管理:system:menu:list/add/edit/delete

用户列表添加 apply_data_scope 过滤。

  • Step 6: Commit
git add backend/app/api/basic_info.py backend/app/api/operations.py backend/app/api/collection_protocol.py backend/app/api/sample_reception.py backend/app/api/admin.py
git commit -m "feat: 所有业务路由添加菜单权限守卫和数据权限过滤"

Task 5: 前端用户管理添加角色选择器

Files:

  • Modify: web-admin/src/views/system/user/index.vue

  • Step 1: 在 formData 中添加 roleIds 字段

const formData = reactive({
  userNo: '',
  nickName: '',
  password: '',
  deptId: '',
  email: '',
  phoneNo: '',
  sex: '0',
  avatar: '',
  status: '1',
  orderNum: 0,
  remark: '',
  roleIds: [] as string[],  // 新增
})
  • Step 2: 添加角色选项加载逻辑
import { fetchRoleOptions } from '@/api/admin'

const roleOptions = ref<Array<{ label: string; value: string }>>([])

async function loadRoleOptions() {
  try {
    roleOptions.value = await fetchRoleOptions()
  } catch (e) {
    console.error('加载角色选项失败:', e)
  }
}
  • Step 3: 在弹窗表单中添加角色多选下拉框

在 <div class="form-grid-2"> 中,"状态" 选择器后面、"备注" 前面添加:

<el-form-item label="角色" style="grid-column: 1 / -1">
  <el-select
    v-model="formData.roleIds"
    multiple
    placeholder="请选择角色"
    style="width: 100%"
  >
    <el-option
      v-for="item in roleOptions"
      :key="item.value"
      :label="item.label"
      :value="item.value"
    />
  </el-select>
</el-form-item>
  • Step 4: 在 handleSubmit 中传递 roleIds
// create 分支
await fetchCreateUser({
  ...其他字段,
  roleIds: formData.roleIds.length ? formData.roleIds : undefined,
})

// update 分支
await fetchUpdateUser(editingId.value, {
  ...其他字段,
  roleIds: formData.roleIds.length ? formData.roleIds : undefined,
})
  • Step 5: 在 handleEdit 中回填 roleIds

编辑时从 row.roleIds 回填:

function handleEdit(row: Api.User.UserItem) {
  // ... 现有字段回填 ...
  formData.roleIds = (row as any).roleIds || []  // 后端 get_user_detail 返回 roleIds
}
  • Step 6: 在 resetForm 和 onMounted 中处理
function resetForm() {
  // ... 现有重置 ...
  formData.roleIds = []
}

onMounted(() => {
  loadDeptTree()
  loadData()
  loadRoleOptions()
})
  • Step 7: Commit
git add web-admin/src/views/system/user/index.vue
git commit -m "feat: 用户管理弹窗添加角色多选下拉框"

Task 6: 前端业务页面按钮添加权限控制

Files:

  • Modify: web-admin/src/views/basic-info/customer/index.vue
  • Modify: web-admin/src/views/basic-info/cell-sample-type/index.vue
  • Modify: web-admin/src/views/basic-info/collection-region/index.vue
  • Modify: web-admin/src/views/basic-info/collection-protocol-no/index.vue
  • Modify: web-admin/src/views/basic-info/sample-code/index.vue

每个页面的修改模式相同:

  • Step 1: 在 <script setup> 中导入 useAuth
import { useAuth } from '@/hooks/core/useAuth'
const { hasAuth } = useAuth()
  • Step 2: 在模板按钮上添加 v-if 权限控制

工具栏"新增"按钮:

<el-button v-if="hasAuth('add')" type="primary" @click="handleAdd">新增</el-button>

表格操作列按钮:

<el-button v-if="hasAuth('edit')" link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="hasAuth('delete')" link type="danger" @click="handleDelete(row)">删除</el-button>
  • Step 3: 对所有 5 个页面重复以上修改

每个页面的按钮权限标识通过 hasAuth('add') / hasAuth('edit') / hasAuth('delete') 控制,对应 sys_menu 中 F 类型按钮记录的 perms 尾段。

  • Step 4: Commit
git add web-admin/src/views/basic-info/
git commit -m "feat: 基础信息管理页面按钮添加 hasAuth 权限控制"

自检清单

  • Spec 覆盖:Task 1 覆盖 F 类型菜单数据;Task 2 覆盖数据权限服务;Task 3 覆盖安全漏洞修复;Task 4 覆盖后端路由守卫;Task 5 覆盖前端角色选择器;Task 6 覆盖前端按钮权限
  • Placeholder 扫描:无 TBD、TODO、"implement later"
  • 类型一致性:apply_data_scope 函数签名在 Task 2 定义,Task 4 使用时参数匹配
  • 文件路径准确:所有路径基于实际代码库结构