完全移除模块级权限系统,迁移至接口级权限

后端:
- 删除 StudyRolePermission 模型和 project_permissions API 文件
- 重写 project_permissions.py core,移除所有模块级权限函数
- 重写 permission_cache.py,移除模块级权限缓存逻辑
- 新增权限模板功能(PermissionTemplate 模型、API、服务层)
- 新增 permission_templates 数据库迁移
- 迁移 8 个模块(attachments、audit_logs、dashboard、faqs、
  faq_categories、fees_attachments、knowledge_notes、
  material_equipments、overview、subject_histories、subject_pds)
  至接口级权限检查
- 删除所有模块级权限相关测试文件,新增权限模板测试

前端:
- 删除 ProjectPermissions.vue 和 ProjectPermissionsModule.vue
- 重写 projectRoutePermissions.ts,改为基于接口级权限格式
- 更新 store/study.ts、router/index.ts、AuditLogs.vue、Projects.vue
  中的权限 API 调用,从 fetchProjectRolePermissions 改为
  fetchApiEndpointPermissions
- 清理 types/api.ts 中的旧模块级权限类型定义
- 新增 PermissionTemplateSelector.vue 组件
- 更新权限管理页面,移除模块级权限 tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-15 09:07:43 +08:00
parent 3b1bdc2070
commit 20ce6bccef
55 changed files with 1971 additions and 3030 deletions
+54 -53
View File
@@ -3,8 +3,8 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.core.project_permissions import role_has_project_permission
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud
from app.crud import faq_item as faq_crud
@@ -31,32 +31,16 @@ def _is_system_admin(current_user) -> bool:
return role == "ADMIN"
async def _require_project_permission(
db: AsyncSession,
study_id: uuid.UUID | None,
current_user,
action: str,
):
if not study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if _is_system_admin(current_user):
return None
member = await member_crud.get_member(db, study_id, current_user.id)
if not member or not member.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
allowed = await role_has_project_permission(db, study_id, member.role_in_study, "faq", action)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return member
@router.post(
"/",
response_model=FaqRead,
status_code=status.HTTP_201_CREATED,
summary="创建 FAQ",
description="创建项目内 FAQ,权限由项目级权限矩阵控制。",
dependencies=[Depends(require_study_not_locked())],
dependencies=[
Depends(require_api_permission("faq:create")),
Depends(require_study_not_locked())
],
)
async def create_faq(
payload: FaqCreate,
@@ -70,7 +54,6 @@ async def create_faq(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
if cat.study_id != payload.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类范围不匹配")
await _require_project_permission(db, payload.study_id, current_user, "write")
try:
item = await faq_crud.create_item(db, payload, created_by=current_user.id)
except ValueError as exc:
@@ -102,6 +85,7 @@ async def create_faq(
response_model=PaginatedResponse[FaqRead],
summary="FAQ 列表",
description="返回全局与项目 FAQ,可按关键词、分类过滤。",
dependencies=[Depends(require_api_permission("faq:read"))],
)
async def list_faqs(
study_id: uuid.UUID | None = None,
@@ -116,14 +100,23 @@ async def list_faqs(
async def _get_member_role(sid: uuid.UUID) -> str | None:
if sid in membership_cache:
return membership_cache[sid]
member = await _require_project_permission(db, sid, current_user, "read")
role = member.role_in_study if member else "ADMIN"
member = await member_crud.get_member(db, sid, current_user.id)
role = member.role_in_study if member else None
membership_cache[sid] = role
return role
if not study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
await _require_project_permission(db, study_id, current_user, "write" if is_active is False else "read")
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != "ADMIN":
member = await member_crud.get_member(db, study_id, current_user.id)
if not member or not member.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
if is_active is False:
allowed = await role_has_api_permission(db, study_id, member.role_in_study, "faq:update", check_prerequisites=False)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
items = await faq_crud.list_items(
db,
@@ -141,7 +134,7 @@ async def list_faqs(
if not role:
continue
if not it.is_active and not _is_system_admin(current_user):
allowed = await role_has_project_permission(db, it.study_id, role, "faq", "write")
allowed = await role_has_api_permission(db, it.study_id, role, "faq:update", check_prerequisites=False)
if not allowed:
continue
visible.append(FaqRead.model_validate(it))
@@ -153,6 +146,7 @@ async def list_faqs(
response_model=FaqRead,
summary="FAQ 详情",
description="获取单条 FAQ,停用 FAQ 需项目级写权限。",
dependencies=[Depends(require_api_permission("faq:read"))],
)
async def get_faq(
item_id: uuid.UUID,
@@ -164,7 +158,14 @@ async def get_faq(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
await _require_project_permission(db, item.study_id, current_user, "write" if not item.is_active else "read")
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if not item.is_active and role_value != "ADMIN":
member = await member_crud.get_member(db, item.study_id, current_user.id)
if not member or not member.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
allowed = await role_has_api_permission(db, item.study_id, member.role_in_study, "faq:update", check_prerequisites=False)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FaqRead.model_validate(item)
@@ -173,7 +174,10 @@ async def get_faq(
response_model=FaqRead,
summary="更新 FAQ",
description="更新 FAQ 内容或启停状态,权限由项目级权限矩阵控制。",
dependencies=[Depends(require_study_not_locked())],
dependencies=[
Depends(require_api_permission("faq:update")),
Depends(require_study_not_locked())
],
)
async def update_faq(
item_id: uuid.UUID,
@@ -184,10 +188,6 @@ async def update_faq(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if item.created_by != current_user.id:
await _require_project_permission(db, item.study_id, current_user, "write")
else:
await _require_project_permission(db, item.study_id, current_user, "write")
old_active = item.is_active
updated = await faq_crud.update_item(db, item, payload)
action = "UPDATE_FAQ_ITEM"
@@ -213,7 +213,10 @@ async def update_faq(
response_model=FaqRead,
summary="更新 FAQ 状态",
description="提问者或具备项目级写权限的成员可确认已解决。",
dependencies=[Depends(require_study_not_locked())],
dependencies=[
Depends(require_api_permission("faq:update")),
Depends(require_study_not_locked())
],
)
async def update_faq_status(
item_id: uuid.UUID,
@@ -226,10 +229,6 @@ async def update_faq_status(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if payload.status != "RESOLVED":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许设置为已解决")
if item.created_by != current_user.id:
await _require_project_permission(db, item.study_id, current_user, "write")
else:
await _require_project_permission(db, item.study_id, current_user, "read")
await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=True)
updated = await faq_crud.get_item(db, item_id)
return FaqRead.model_validate(updated)
@@ -240,7 +239,10 @@ async def update_faq_status(
response_model=FaqRead,
summary="设置最佳回复",
description="项目成员可设置最佳回复,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
dependencies=[
Depends(require_api_permission("faq:update")),
Depends(require_study_not_locked())
],
)
async def set_best_reply(
item_id: uuid.UUID,
@@ -253,7 +255,6 @@ async def set_best_reply(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
await _require_project_permission(db, item.study_id, current_user, "write")
if payload.best_reply_id:
reply = await reply_crud.get_reply(db, payload.best_reply_id)
if not reply or reply.faq_id != item.id:
@@ -281,6 +282,7 @@ async def set_best_reply(
response_model=PaginatedResponse[FaqReplyRead],
summary="FAQ 回复列表",
description="获取 FAQ 的回复列表。",
dependencies=[Depends(require_api_permission("faq:read"))],
)
async def list_replies(
item_id: uuid.UUID,
@@ -290,7 +292,6 @@ async def list_replies(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
await _require_project_permission(db, item.study_id, current_user, "read")
replies = await reply_crud.list_replies(db, item_id)
reply_map = {r.id: r for r in replies}
result: list[FaqReplyRead] = []
@@ -317,7 +318,10 @@ async def list_replies(
status_code=status.HTTP_201_CREATED,
summary="创建 FAQ 回复",
description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
dependencies=[
Depends(require_api_permission("faq_reply:create")),
Depends(require_study_not_locked())
],
)
async def create_reply(
item_id: uuid.UUID,
@@ -332,7 +336,6 @@ async def create_reply(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not payload.content.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复内容不能为空")
await _require_project_permission(db, item.study_id, current_user, "write")
quote = None
if payload.quote_reply_id:
quote = await reply_crud.get_reply(db, payload.quote_reply_id)
@@ -377,7 +380,10 @@ async def create_reply(
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ",
description="删除 FAQ,权限由项目级权限矩阵控制。",
dependencies=[Depends(require_study_not_locked())],
dependencies=[
Depends(require_api_permission("faq:delete")),
Depends(require_study_not_locked())
],
)
async def delete_faq(
item_id: uuid.UUID,
@@ -387,10 +393,6 @@ async def delete_faq(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if item.created_by != current_user.id:
await _require_project_permission(db, item.study_id, current_user, "write")
else:
await _require_project_permission(db, item.study_id, current_user, "write")
await reply_crud.delete_replies_by_faq_id(db, item.id)
await db.delete(item)
await db.commit()
@@ -411,7 +413,10 @@ async def delete_faq(
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ 回复",
description="删除 FAQ 回复,回复者或具备项目级写权限的成员可删除。",
dependencies=[Depends(require_study_not_locked())],
dependencies=[
Depends(require_api_permission("faq_reply:delete")),
Depends(require_study_not_locked())
],
)
async def delete_reply(
item_id: uuid.UUID,
@@ -425,10 +430,6 @@ async def delete_reply(
reply = await reply_crud.get_reply(db, reply_id)
if not reply or reply.faq_id != item.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
if reply.created_by != current_user.id:
await _require_project_permission(db, item.study_id, current_user, "write")
else:
await _require_project_permission(db, item.study_id, current_user, "read")
if item.best_reply_id == reply.id:
await faq_crud.set_best_reply(db, item.id, None)
ref_count = await reply_crud.count_quote_references(db, reply.id)