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_operator_role_label, get_study_member, is_system_admin, require_study_not_locked 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 contract_fee as contract_fee_crud from app.crud import drug_shipment as drug_shipment_crud from app.crud import faq_reply as faq_reply_crud from app.crud import material_equipment as material_equipment_crud from app.crud import precaution as precaution_crud from app.crud import study as study_crud from app.crud import startup as startup_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" FEE_ATTACHMENT_ENTITY_TYPES = { "contract_fee_contract", "contract_fee_voucher", "contract_fee_invoice", } FEE_ATTACHMENT_PERMISSION_BY_ACTION = { "create": "fees_contracts:create", "read": "fees_contracts:read", "delete": "fees_contracts_attachments:delete", } STARTUP_INITIATION_ATTACHMENT_PERMISSION_BY_ACTION = { "create": "startup_initiation:create", "read": "startup_initiation:read", "delete": "startup_initiation_attachments:delete", } STARTUP_ETHICS_ATTACHMENT_PERMISSION_BY_ACTION = { "create": "startup_ethics:create", "read": "startup_ethics:read", "delete": "startup_ethics_attachments:delete", } STARTUP_AUTH_ATTACHMENT_PERMISSION_BY_ACTION = { "create": "startup_auth:create", "read": "startup_auth:read", "delete": "startup_auth_attachments:delete", } DRUG_SHIPMENT_ATTACHMENT_PERMISSION_BY_ACTION = { "create": "drug_shipments:create", "read": "drug_shipments:read", "delete": "drug_shipments_attachments:delete", } MATERIAL_EQUIPMENT_ATTACHMENT_ENTITY_TYPES = { "material_equipment", } MATERIAL_EQUIPMENT_ATTACHMENT_PERMISSION_BY_ACTION = { "create": "material_equipments:update", "read": "material_equipments:read", "delete": "material_equipments_attachments:delete", } PRECAUTION_ATTACHMENT_PERMISSION_BY_ACTION = { "create": "precautions:create", "read": "precautions:read", "delete": "precautions_attachments:delete", } FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION = { "create": "faq_reply:create", "read": "faq:read", "delete": "faq_attachments:delete", } STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = { "startup_kickoff", "startup_kickoff_minutes", "startup_kickoff_signin", "startup_kickoff_ppt", "training_authorization", } 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 def _attachment_operation(action: str) -> str: if action in {"create", "read", "delete"}: return action return "read" if action == "read" else "create" async def _resolve_attachment_parent_permission( db: AsyncSession, study_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, action: str, ) -> str | None: operation = _attachment_operation(action) if entity_type in FEE_ATTACHMENT_ENTITY_TYPES: contract = await contract_fee_crud.get_contract_fee(db, entity_id) if not contract or contract.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") return FEE_ATTACHMENT_PERMISSION_BY_ACTION[operation] if entity_type == "startup_feasibility": record = await startup_crud.get_feasibility(db, entity_id) if not record or record.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在") return STARTUP_INITIATION_ATTACHMENT_PERMISSION_BY_ACTION[operation] if entity_type == "startup_ethics": record = await startup_crud.get_ethics(db, entity_id) if not record or record.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在") return STARTUP_ETHICS_ATTACHMENT_PERMISSION_BY_ACTION[operation] if entity_type in STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES: if entity_type == "training_authorization": record = await startup_crud.get_training_authorization(db, entity_id) missing_detail = "培训授权不存在" else: record = await startup_crud.get_kickoff(db, entity_id) missing_detail = "启动会不存在" if not record or record.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=missing_detail) return STARTUP_AUTH_ATTACHMENT_PERMISSION_BY_ACTION[operation] if entity_type == "drug_shipment": shipment = await drug_shipment_crud.get_shipment(db, entity_id) if not shipment or shipment.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="药物发货不存在") return DRUG_SHIPMENT_ATTACHMENT_PERMISSION_BY_ACTION[operation] if entity_type in MATERIAL_EQUIPMENT_ATTACHMENT_ENTITY_TYPES: equipment = await material_equipment_crud.get_equipment(db, entity_id) if not equipment or equipment.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在") return MATERIAL_EQUIPMENT_ATTACHMENT_PERMISSION_BY_ACTION[operation] if entity_type == "precaution": precaution = await precaution_crud.get_precaution(db, entity_id) if not precaution or precaution.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在") return PRECAUTION_ATTACHMENT_PERMISSION_BY_ACTION[operation] if entity_type == "faq_replies": reply = await faq_reply_crud.get_reply(db, entity_id) if not reply or reply.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ回复不存在") return FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION[operation] return None async def _ensure_attachment_permission( db: AsyncSession, study_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, action: str, current_user, membership=None, ) -> None: parent_permission = await _resolve_attachment_parent_permission(db, study_id, entity_type, entity_id, action) if is_system_admin(current_user): return if membership is None: membership = await member_crud.get_member(db, study_id, current_user.id) if not membership or not membership.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") if not parent_permission: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="附件类型未配置权限") allowed = await role_has_api_permission( db, study_id, membership.role_in_study, parent_permission, check_prerequisites=False, ) if not allowed: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足") @router.post( "/", response_model=AttachmentRead, status_code=status.HTTP_201_CREATED, dependencies=[ 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) await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "create", current_user) 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=await get_operator_role_label(db, study_id, current_user), ) 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], ) 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) await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "read", current_user) 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", ) 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) await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "read", current_user) 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", ) 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) await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "read", current_user) 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 is_system_admin(user): 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, membership = await _authorize_global(request, db, attachment.study_id) await _ensure_attachment_permission( db, attachment.study_id, attachment.entity_type, attachment.entity_id, "read", user, membership ) 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) await _ensure_attachment_permission( db, attachment.study_id, attachment.entity_type, attachment.entity_id, "read", user, membership ) 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) await _ensure_attachment_permission( db, attachment.study_id, attachment.entity_type, attachment.entity_id, "delete", user, membership ) can_delete = ( is_system_admin(user) 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=await get_operator_role_label(db, attachment.study_id, user), ) @router.delete( "/{attachment_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[ 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) await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "delete", current_user) 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 not is_system_admin(current_user): membership = await get_study_member(study_id, current_user=current_user, db=db) can_delete = ( is_system_admin(current_user) 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=await get_operator_role_label(db, study_id, current_user), )