权限管理:接入项目级权限矩阵

新增项目级角色权限矩阵,覆盖 PM、CRA、PV、IMP、QA 等项目角色,并通过迁移表持久化各业务模块的读写权限。

后端将参与者、合同费用、物资、启动、里程碑、风险问题、监查稽查、FAQ、共享库、文件版本等接口接入矩阵校验,修复审计日志导出权限和登录 502 的依赖导入问题。

前端新增权限管理页面和项目路由权限映射,按矩阵控制导航显示、直接访问拦截、操作按钮启停,并限制管理后台仅 admin 和授权 PM 可进入。

补充后端权限归一化与接口静态测试、前端路由/权限工具/权限管理页面/工作台等回归测试。
This commit is contained in:
Cheng Zhou
2026-05-13 08:58:03 +08:00
parent 77e842637d
commit 939fc70532
63 changed files with 1952 additions and 387 deletions
+6 -33
View File
@@ -5,7 +5,7 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_not_locked
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_study_permission
from app.crud import ae as ae_crud
from app.crud import audit as audit_crud
from app.crud import member as member_crud
@@ -16,9 +16,6 @@ from app.schemas.ae import AECreate, AERead, AEUpdate
router = APIRouter()
ALLOWED_CREATE_ROLES = {"PM", "CRA", "PV"}
ALLOWED_UPDATE_ROLES = {"PM", "PV", "CRA"}
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
@@ -43,11 +40,6 @@ async def _ensure_ae_visible(db: AsyncSession, study_id: uuid.UUID, ae: AERead |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
async def _get_member_role(db: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID) -> str | None:
member = await member_crud.get_member(db, study_id, user_id)
return member.role_in_study if member else None
def _is_overdue(ae: AERead) -> bool:
return bool(ae.report_due_date and date.today() > ae.report_due_date and ae.status != "CLOSED")
@@ -56,7 +48,7 @@ def _is_overdue(ae: AERead) -> bool:
"/",
response_model=AERead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())],
)
async def create_ae(
study_id: uuid.UUID,
@@ -75,9 +67,6 @@ async def create_ae(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if ae_in.onset_date and ae_in.onset_date > date.today():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发生日期不能晚于今天")
member_role = await _get_member_role(db, study_id, current_user.id)
if current_user.role != "ADMIN" and member_role not in ALLOWED_CREATE_ROLES:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
try:
ae = await ae_crud.create_ae(db, study_id, ae_in, created_by=current_user.id)
except ValueError as exc:
@@ -100,7 +89,7 @@ async def create_ae(
@router.get(
"/",
response_model=list[AERead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_study_permission("risk_issues", "read"))],
)
async def list_ae(
study_id: uuid.UUID,
@@ -138,7 +127,7 @@ async def list_ae(
@router.get(
"/{ae_id}",
response_model=AERead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_study_permission("risk_issues", "read"))],
)
async def get_ae(
study_id: uuid.UUID,
@@ -166,7 +155,7 @@ async def get_ae(
@router.patch(
"/{ae_id}",
response_model=AERead,
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())],
)
async def update_ae(
study_id: uuid.UUID,
@@ -183,19 +172,6 @@ async def update_ae(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
await _ensure_ae_visible(db, study_id, AERead.model_validate(ae))
member_role = await _get_member_role(db, study_id, current_user.id)
if current_user.role == "ADMIN":
pass
elif member_role in ALLOWED_UPDATE_ROLES:
pass
elif member_role == "CRA":
if ae.created_by != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅创建人可更新 AE")
if ae_in.status == "CLOSED":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CRA 无法关闭 AE")
else:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
old_status = ae.status
detail_before = {
"event": ae.term,
@@ -234,7 +210,7 @@ async def update_ae(
@router.delete(
"/{ae_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())],
)
async def delete_ae(
study_id: uuid.UUID,
@@ -247,9 +223,6 @@ async def delete_ae(
if not ae or ae.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
await _ensure_ae_visible(db, study_id, AERead.model_validate(ae))
member_role = await _get_member_role(db, study_id, current_user.id)
if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await ae_crud.delete_ae(db, ae)
await audit_crud.log_action(
db,