249 lines
10 KiB
Python
249 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.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="不是项目成员")
|
|
if write and membership.role_in_study != "PM":
|
|
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 and getattr(membership, "role_in_study", None) == "PM")
|
|
)
|
|
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,
|
|
)
|