Files
ctms/backend/app/api/v1/fees_attachments.py
T
Cheng Zhou 939fc70532 权限管理:接入项目级权限矩阵
新增项目级角色权限矩阵,覆盖 PM、CRA、PV、IMP、QA 等项目角色,并通过迁移表持久化各业务模块的读写权限。

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

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

补充后端权限归一化与接口静态测试、前端路由/权限工具/权限管理页面/工作台等回归测试。
2026-05-13 08:58:03 +08:00

252 lines
10 KiB
Python

import os
import uuid
from pathlib import Path
import aiofiles
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request, Form
from fastapi.responses import FileResponse
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.security import decode_token
from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud
from app.crud import contract_fee_payment as payment_crud
from app.crud import fee_attachment as fee_attachment_crud
from app.crud import member as member_crud
from app.crud import user as user_crud
from app.schemas.fee_attachment import FeeAttachmentRead
from app.schemas.fee_common import FeeApiResponse
from app.schemas.user import UserDisplay
router = APIRouter()
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "fees"
ALLOWED_ENTITY_TYPES = {"contract_fee", "contract_payment"}
ALLOWED_FILE_TYPES = {
"contract_fee": {"contract", "voucher", "invoice"},
"contract_payment": {"voucher", "invoice"},
}
async def _resolve_project_id(db: AsyncSession, entity_type: str, entity_id: uuid.UUID) -> uuid.UUID:
if entity_type == "contract_fee":
contract = await contract_fee_crud.get_contract_fee(db, entity_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return contract.project_id
if entity_type == "contract_payment":
payment = await payment_crud.get_payment(db, entity_id)
if not payment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在")
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return contract.project_id
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的附件类型")
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False):
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
return None
membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
action = "write" if write else "read"
allowed = await role_has_project_permission(db, project_id, membership.role_in_study, "fees", action)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
return membership
async def _authorize_download_user(request: Request, db: AsyncSession):
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="账号不存在或已停用")
return user
@router.post(
"/attachments",
response_model=FeeApiResponse[FeeAttachmentRead],
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def upload_fee_attachment(
entity_type: str = Form(...),
entity_id: uuid.UUID = Form(...),
file_type: str = Form(...),
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[FeeAttachmentRead]:
if entity_type not in ALLOWED_ENTITY_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
if file_type not in ALLOWED_FILE_TYPES.get(entity_type, set()):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件文件类型无效")
project_id = await _resolve_project_id(db, entity_type, entity_id)
await _ensure_project_access(db, project_id, current_user, write=True)
dest_dir = UPLOAD_ROOT / 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 fee_attachment_crud.create_attachment(
db,
entity_type=entity_type,
entity_id=entity_id,
file_type=file_type,
filename=file.filename,
mime_type=file.content_type,
size=len(content),
storage_key=str(dest_path),
url=None,
uploaded_by=current_user.id,
)
await audit_crud.log_action(
db,
study_id=project_id,
entity_type=entity_type,
entity_id=entity_id,
action="UPLOAD_FEE_ATTACHMENT",
detail=f"附件已上传:{file.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(
data=FeeAttachmentRead(
id=attachment.id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
file_type=attachment.file_type,
filename=attachment.filename,
mime_type=attachment.mime_type,
size=attachment.size,
storage_key=attachment.storage_key,
url=attachment.url,
uploaded_by=UserDisplay.model_validate(current_user),
uploaded_by_id=attachment.uploaded_by,
uploaded_at=attachment.uploaded_at,
)
)
@router.get(
"/attachments",
response_model=FeeApiResponse[list[FeeAttachmentRead]],
dependencies=[Depends(get_current_user)],
)
async def list_fee_attachments(
entity_type: str,
entity_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[FeeAttachmentRead]]:
if entity_type not in ALLOWED_ENTITY_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
project_id = await _resolve_project_id(db, entity_type, entity_id)
await _ensure_project_access(db, project_id, current_user, write=False)
attachments = await fee_attachment_crud.list_attachments(db, entity_type=entity_type, entity_id=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)
items: list[FeeAttachmentRead] = []
for attachment in attachments:
user = users_map.get(attachment.uploaded_by)
items.append(
FeeAttachmentRead(
id=attachment.id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
file_type=attachment.file_type,
filename=attachment.filename,
mime_type=attachment.mime_type,
size=attachment.size,
storage_key=attachment.storage_key,
url=attachment.url,
uploaded_by=UserDisplay.model_validate(user) if user else None,
uploaded_by_id=attachment.uploaded_by,
uploaded_at=attachment.uploaded_at,
)
)
return FeeApiResponse(data=items, meta={"total": len(items)})
@router.get(
"/attachments/{attachment_id}/download",
response_class=FileResponse,
)
async def download_fee_attachment(
attachment_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
):
attachment = await fee_attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
current_user = await _authorize_download_user(request, db)
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
await _ensure_project_access(db, project_id, current_user, write=False)
if not attachment.storage_key or not os.path.exists(attachment.storage_key):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
path=attachment.storage_key,
filename=attachment.filename,
media_type=attachment.mime_type or "application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'},
)
@router.delete(
"/attachments/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def delete_fee_attachment(
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
attachment = await fee_attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
membership = await _ensure_project_access(db, project_id, current_user, write=True)
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
can_delete = (
role_value == "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 fee_attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action(
db,
study_id=project_id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
action="DELETE_FEE_ATTACHMENT",
detail=f"附件已删除:{attachment.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
)