Files
ctms/backend/app/api/v1/attachments.py
T
Cheng Zhou 20ce6bccef 完全移除模块级权限系统,迁移至接口级权限
后端:
- 删除 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>
2026-05-15 09:07:43 +08:00

371 lines
14 KiB
Python

import os
import uuid
from pathlib import Path
from urllib.parse import quote
import aiofiles
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, get_study_member, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission
from app.crud import attachment as attachment_crud
from app.crud import audit as audit_crud
from app.crud import study as study_crud
from app.crud import user as user_crud
from app.crud import member as member_crud
from app.core.security import decode_token
from app.schemas.attachment import AttachmentRead
from app.schemas.user import UserDisplay
router = APIRouter()
global_router = APIRouter()
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads"
def _content_disposition(filename: str, disposition: str = "inline") -> str:
fallback = "".join((ch if ord(ch) < 128 else "_") for ch in filename) or "download"
quoted = quote(filename, safe="")
return f"{disposition}; filename=\"{fallback}\"; filename*=UTF-8''{quoted}"
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study
@router.post(
"/",
response_model=AttachmentRead,
status_code=status.HTTP_201_CREATED,
dependencies=[
Depends(require_api_permission("attachments:create")),
Depends(require_study_not_locked())
],
)
async def upload_attachment(
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> AttachmentRead:
await _ensure_study_exists(db, study_id)
dest_dir = UPLOAD_ROOT / f"study_{study_id}" / f"{entity_type}_{entity_id}"
dest_dir.mkdir(parents=True, exist_ok=True)
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
dest_path = dest_dir / unique_name
content = await file.read()
async with aiofiles.open(dest_path, "wb") as out_file:
await out_file.write(content)
attachment = await attachment_crud.create_attachment(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
filename=file.filename,
file_path=str(dest_path),
file_size=len(content),
content_type=file.content_type,
uploaded_by=current_user.id,
)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action="UPLOAD_FILE",
detail=f"文件已上传:{file.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
)
return AttachmentRead(
id=attachment.id,
filename=attachment.filename,
file_size=attachment.file_size,
content_type=attachment.content_type,
uploaded_by=UserDisplay.model_validate(current_user) if current_user else None,
uploaded_by_id=attachment.uploaded_by,
uploaded_at=attachment.uploaded_at,
)
@router.get(
"/",
response_model=list[AttachmentRead],
dependencies=[Depends(require_api_permission("attachments:read"))],
)
async def list_attachments(
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[AttachmentRead]:
await _ensure_study_exists(db, study_id)
attachments = await attachment_crud.list_attachments(db, study_id, entity_type, entity_id)
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by}
users_map = await user_crud.get_users_by_ids(db, user_ids)
result: list[AttachmentRead] = []
for a in attachments:
user = users_map.get(a.uploaded_by)
result.append(
AttachmentRead(
id=a.id,
filename=a.filename,
file_size=a.file_size,
content_type=a.content_type,
uploaded_by=UserDisplay.model_validate(user) if user else None,
uploaded_by_id=a.uploaded_by,
uploaded_at=a.uploaded_at,
)
)
return result
@router.get(
"/{attachment_id}/download",
dependencies=[Depends(require_api_permission("attachments:read"))],
)
async def download_attachment(
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FileResponse:
await _ensure_study_exists(db, study_id)
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
if not os.path.exists(attachment.file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
path=attachment.file_path,
filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(attachment.filename, disposition="attachment")},
)
@router.get(
"/{attachment_id}/preview",
dependencies=[Depends(require_api_permission("attachments:read"))],
)
async def preview_attachment(
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FileResponse:
await _ensure_study_exists(db, study_id)
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
if not os.path.exists(attachment.file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
path=attachment.file_path,
filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(attachment.filename, disposition="inline")},
)
async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.UUID):
token = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1]
if not token:
token = request.query_params.get("token")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
payload = decode_token(token)
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用")
if user.role == "ADMIN":
return user, None
membership = await member_crud.get_member(db, study_id, user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
return user, membership
@global_router.get(
"/{attachment_id}/download",
response_class=FileResponse,
)
async def global_download_attachment(
attachment_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
):
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
await _ensure_study_exists(db, attachment.study_id)
user, _ = await _authorize_global(request, db, attachment.study_id)
membership = None
if user.role != "ADMIN":
membership = await get_study_member(attachment.study_id, current_user=user, db=db)
if user.role != "ADMIN":
allowed = await role_has_api_permission(
db,
attachment.study_id,
membership.role_in_study if membership else None,
"attachments:read",
check_prerequisites=False,
)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if not os.path.exists(attachment.file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
path=attachment.file_path,
filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(attachment.filename, disposition="attachment")},
)
@global_router.get(
"/{attachment_id}/preview",
response_class=FileResponse,
)
async def global_preview_attachment(
attachment_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
):
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
await _ensure_study_exists(db, attachment.study_id)
user, membership = await _authorize_global(request, db, attachment.study_id)
if user.role != "ADMIN":
allowed = await role_has_api_permission(
db,
attachment.study_id,
membership.role_in_study if membership else None,
"attachments:read",
check_prerequisites=False,
)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if not os.path.exists(attachment.file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
path=attachment.file_path,
filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(attachment.filename, disposition="inline")},
)
@global_router.delete(
"/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def global_delete_attachment(
attachment_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
):
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
await _ensure_study_exists(db, attachment.study_id)
user, membership = await _authorize_global(request, db, attachment.study_id)
if user.role != "ADMIN":
allowed = await role_has_api_permission(
db,
attachment.study_id,
membership.role_in_study if membership else None,
"attachments:delete",
check_prerequisites=False,
)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
can_delete = (
user.role == "ADMIN"
or attachment.uploaded_by == user.id
or membership is not None
)
if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action(
db,
study_id=attachment.study_id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}",
operator_id=user.id,
operator_role=user.role,
)
@router.delete(
"/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[
Depends(require_api_permission("attachments:delete")),
Depends(require_study_not_locked())
],
)
async def delete_attachment(
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
await _ensure_study_exists(db, study_id)
attachment = await attachment_crud.get_attachment(db, attachment_id)
if (
not attachment
or attachment.study_id != study_id
or attachment.entity_id != entity_id
or attachment.entity_type != entity_type
or attachment.is_deleted
):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
membership = None
if current_user.role != "ADMIN":
membership = await get_study_member(study_id, current_user=current_user, db=db)
can_delete = (
current_user.role == "ADMIN"
or attachment.uploaded_by == current_user.id
or membership is not None
)
if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
)