diff --git a/backend/app/api/v1/attachments.py b/backend/app/api/v1/attachments.py index 8eb8086e..6cbdb00a 100644 --- a/backend/app/api/v1/attachments.py +++ b/backend/app/api/v1/attachments.py @@ -3,17 +3,21 @@ import uuid from pathlib import Path import aiofiles -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +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, require_study_member +from app.core.deps import get_current_user, get_db_session, require_study_member, get_study_member 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 router = APIRouter() +global_router = APIRouter() UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" @@ -111,3 +115,130 @@ async def download_attachment( filename=attachment.filename, media_type=attachment.content_type or "application/octet-stream", ) + + +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="Not authenticated") + 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="Inactive or missing user") + 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="Not project member") + 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="Attachment not found") + await _ensure_study_exists(db, attachment.study_id) + user, _ = await _authorize_global(request, db, attachment.study_id) + if not os.path.exists(attachment.file_path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File missing on server") + return FileResponse( + path=attachment.file_path, + filename=attachment.filename, + media_type=attachment.content_type or "application/octet-stream", + ) + + +@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="Attachment not found") + await _ensure_study_exists(db, attachment.study_id) + user, membership = await _authorize_global(request, db, attachment.study_id) + can_delete = ( + user.role == "ADMIN" + or attachment.uploaded_by == 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="No permission to delete attachment") + 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"File deleted: {attachment.filename}", + operator_id=user.id, + operator_role=user.role, + ) + + +@router.delete( + "/{attachment_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_study_member())], +) +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="Attachment not found") + + 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 and getattr(membership, "role_in_study", None) == "PM") + ) + if not can_delete: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission to delete attachment") + + 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"File deleted: {attachment.filename}", + operator_id=current_user.id, + operator_role=current_user.role, + ) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 96aca791..b574c1b0 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -10,6 +10,7 @@ api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"]) api_router.include_router(comments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/comments", tags=["comments"]) api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"]) +api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"]) api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"]) api_router.include_router(milestones.router, prefix="/studies/{study_id}/milestones", tags=["milestones"]) api_router.include_router(tasks.router, prefix="/studies/{study_id}/tasks", tags=["tasks"]) diff --git a/backend/app/crud/attachment.py b/backend/app/crud/attachment.py index 72cf739a..4f90e75a 100644 --- a/backend/app/crud/attachment.py +++ b/backend/app/crud/attachment.py @@ -57,3 +57,11 @@ async def list_attachments( async def get_attachment(db: AsyncSession, attachment_id: uuid.UUID) -> Attachment | None: result = await db.execute(select(Attachment).where(Attachment.id == attachment_id, Attachment.is_deleted.is_(False))) return result.scalar_one_or_none() + + +async def soft_delete_attachment(db: AsyncSession, attachment: Attachment) -> Attachment: + attachment.is_deleted = True + db.add(attachment) + await db.commit() + await db.refresh(attachment) + return attachment diff --git a/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/009bba02-c940-464c-b08d-921397add75a.xlsx b/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/009bba02-c940-464c-b08d-921397add75a.xlsx new file mode 100644 index 00000000..7669366c Binary files /dev/null and b/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/009bba02-c940-464c-b08d-921397add75a.xlsx differ diff --git a/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/67d8d756-d9d1-4bef-9a9b-03bfa4745759.html b/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/67d8d756-d9d1-4bef-9a9b-03bfa4745759.html new file mode 100644 index 00000000..ef26d201 --- /dev/null +++ b/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/67d8d756-d9d1-4bef-9a9b-03bfa4745759.html @@ -0,0 +1,144 @@ + + + + + + 华邦制药 - 临床试验 AI 知识库 + + + + + +
+ +
+
+
+ + + +
+ 华邦制药 +
+
+ ⌘ K 智能问答 +
+
+ +
+ +

+ 欢迎使用 临床试验 AI 知识库 +

+ +
+ + +
+
Enter 发送
+ +
+
+ + + + + +
+ +
+ +
+ + + + \ No newline at end of file diff --git a/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/e40467fb-089d-403d-ba3d-d6a62531d36a.pdf b/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/e40467fb-089d-403d-ba3d-d6a62531d36a.pdf new file mode 100644 index 00000000..2ce0eea1 Binary files /dev/null and b/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/e40467fb-089d-403d-ba3d-d6a62531d36a.pdf differ diff --git a/frontend/src/api/attachments.ts b/frontend/src/api/attachments.ts index 8a87eb88..876d8b21 100644 --- a/frontend/src/api/attachments.ts +++ b/frontend/src/api/attachments.ts @@ -1,12 +1,21 @@ -import { apiGet, apiPost } from "./axios"; +import { apiGet, apiPost, apiDelete } from "./axios"; export const fetchAttachments = (studyId: string, entityType: string, entityId: string) => apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments`); -export const uploadAttachment = (studyId: string, entityType: string, entityId: string, file: File) => { +export const uploadAttachment = ( + studyId: string, + entityType: string, + entityId: string, + file: File, + options?: Record +) => { const formData = new FormData(); formData.append("file", file); return apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments`, formData, { headers: { "Content-Type": "multipart/form-data" }, + ...options, }); }; + +export const deleteAttachment = (attachmentId: string) => apiDelete(`/api/v1/attachments/${attachmentId}`); diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 5cf7fe7a..a3d95c25 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -52,5 +52,7 @@ export const apiPost = (url: string, data?: unknown, config?: Axios instance.post(url, data, config); export const apiPatch = (url: string, data?: unknown, config?: AxiosRequestConfig) => instance.patch(url, data, config); +export const apiDelete = (url: string, config?: AxiosRequestConfig) => + instance.delete(url, config); export default instance; diff --git a/frontend/src/components/attachments/AttachmentList.vue b/frontend/src/components/attachments/AttachmentList.vue new file mode 100644 index 00000000..0c52a4a9 --- /dev/null +++ b/frontend/src/components/attachments/AttachmentList.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/frontend/src/components/attachments/AttachmentUploader.vue b/frontend/src/components/attachments/AttachmentUploader.vue new file mode 100644 index 00000000..41b58d73 --- /dev/null +++ b/frontend/src/components/attachments/AttachmentUploader.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/frontend/src/components/attachments/attachmentUtils.ts b/frontend/src/components/attachments/attachmentUtils.ts new file mode 100644 index 00000000..db1f68c6 --- /dev/null +++ b/frontend/src/components/attachments/attachmentUtils.ts @@ -0,0 +1,6 @@ +export const formatFileSize = (bytes: number): string => { + if (!bytes && bytes !== 0) return "-"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +}; diff --git a/frontend/src/utils/permission.ts b/frontend/src/utils/permission.ts index de86c45e..e4c69481 100644 --- a/frontend/src/utils/permission.ts +++ b/frontend/src/utils/permission.ts @@ -19,6 +19,7 @@ const PERMISSIONS: Record = { "finance.pay": ["ADMIN", "PM"], "imp.transaction": ["ADMIN", "PM", "IMP"], "faq.edit": ["ADMIN", "PM"], + "dataquery.edit": ["ADMIN", "PM", "CRA"], }; const REASONS: Record = { @@ -33,6 +34,7 @@ const REASONS: Record = { "finance.pay": "支付确认需要 PM/ADMIN 权限", "imp.transaction": "仅 IMP/PM/ADMIN 可操作台账", "faq.edit": "仅 PM/ADMIN 可维护 FAQ", + "dataquery.edit": "仅 PM/CRA/ADMIN 可编辑数据问题", }; export const usePermission = () => { diff --git a/frontend/src/views/AeDetail.vue b/frontend/src/views/AeDetail.vue index 25848eb8..93c7466b 100644 --- a/frontend/src/views/AeDetail.vue +++ b/frontend/src/views/AeDetail.vue @@ -27,7 +27,12 @@ - + @@ -44,7 +49,7 @@ import { fetchAes, updateAe } from "../api/aes"; import { useStudyStore } from "../store/study"; import { useAuthStore } from "../store/auth"; import CommentList from "../components/CommentList.vue"; -import AttachmentList from "../components/AttachmentList.vue"; +import AttachmentList from "../components/attachments/AttachmentList.vue"; import PermissionAction from "../components/PermissionAction.vue"; import { usePermission } from "../utils/permission"; diff --git a/frontend/src/views/DataQueryDetail.vue b/frontend/src/views/DataQueryDetail.vue index 1ae256e5..1e116300 100644 --- a/frontend/src/views/DataQueryDetail.vue +++ b/frontend/src/views/DataQueryDetail.vue @@ -27,7 +27,12 @@ - + @@ -45,7 +50,8 @@ import { fetchSubjects } from "../api/subjects"; import { useStudyStore } from "../store/study"; import { useAuthStore } from "../store/auth"; import CommentList from "../components/CommentList.vue"; -import AttachmentList from "../components/AttachmentList.vue"; +import AttachmentList from "../components/attachments/AttachmentList.vue"; +import { usePermission } from "../utils/permission"; const route = useRoute(); const study = useStudyStore(); @@ -61,10 +67,8 @@ const subjectMap = computed(() => }, {}) ); -const canEdit = computed(() => { - const role = auth.user?.role; - return role === "ADMIN" || role === "PM" || role === "CRA"; -}); +const { can } = usePermission(); +const canEdit = computed(() => can("dataquery.edit")); const enrichOverdue = (item: any) => { if (!item) return item; diff --git a/frontend/src/views/FinanceDetail.vue b/frontend/src/views/FinanceDetail.vue index 1f0313d0..c9a5f1d0 100644 --- a/frontend/src/views/FinanceDetail.vue +++ b/frontend/src/views/FinanceDetail.vue @@ -28,6 +28,15 @@ + + + + @@ -39,6 +48,7 @@ import { ElMessage } from "element-plus"; import FinanceStatusActions from "../components/FinanceStatusActions.vue"; import FinanceForm from "../components/FinanceForm.vue"; import PermissionAction from "../components/PermissionAction.vue"; +import AttachmentList from "../components/attachments/AttachmentList.vue"; import { fetchFinanceItem } from "../api/finance"; import { useStudyStore } from "../store/study"; import { useAuthStore } from "../store/auth"; diff --git a/frontend/src/views/IssueDetail.vue b/frontend/src/views/IssueDetail.vue index dd68df83..945b17e1 100644 --- a/frontend/src/views/IssueDetail.vue +++ b/frontend/src/views/IssueDetail.vue @@ -24,7 +24,12 @@ - + @@ -43,7 +48,7 @@ import { useAuthStore } from "../store/auth"; import PermissionAction from "../components/PermissionAction.vue"; import { usePermission } from "../utils/permission"; import CommentList from "../components/CommentList.vue"; -import AttachmentList from "../components/AttachmentList.vue"; +import AttachmentList from "../components/attachments/AttachmentList.vue"; const route = useRoute(); const study = useStudyStore(); diff --git a/pg_data/base/16384/24576 b/pg_data/base/16384/24576 index 06789f86..b7500176 100644 Binary files a/pg_data/base/16384/24576 and b/pg_data/base/16384/24576 differ diff --git a/pg_data/base/16384/24584 b/pg_data/base/16384/24584 index 40bb823a..f490b984 100644 Binary files a/pg_data/base/16384/24584 and b/pg_data/base/16384/24584 differ diff --git a/pg_data/base/16384/24653 b/pg_data/base/16384/24653 index ddfe83c0..aab22617 100644 Binary files a/pg_data/base/16384/24653 and b/pg_data/base/16384/24653 differ diff --git a/pg_data/base/16384/24653_fsm b/pg_data/base/16384/24653_fsm new file mode 100644 index 00000000..39488e54 Binary files /dev/null and b/pg_data/base/16384/24653_fsm differ diff --git a/pg_data/base/16384/24659 b/pg_data/base/16384/24659 index 2412d970..cecab445 100644 Binary files a/pg_data/base/16384/24659 and b/pg_data/base/16384/24659 differ diff --git a/pg_data/base/16384/24671 b/pg_data/base/16384/24671 index e69de29b..fafa858c 100644 Binary files a/pg_data/base/16384/24671 and b/pg_data/base/16384/24671 differ diff --git a/pg_data/base/16384/24678 b/pg_data/base/16384/24678 index b2e3806c..9c876789 100644 Binary files a/pg_data/base/16384/24678 and b/pg_data/base/16384/24678 differ diff --git a/pg_data/base/16384/24727 b/pg_data/base/16384/24727 index 186d74ba..dba6e1d5 100644 Binary files a/pg_data/base/16384/24727 and b/pg_data/base/16384/24727 differ diff --git a/pg_data/base/16384/24734 b/pg_data/base/16384/24734 index 87857928..e2261d50 100644 Binary files a/pg_data/base/16384/24734 and b/pg_data/base/16384/24734 differ diff --git a/pg_data/base/16384/24736 b/pg_data/base/16384/24736 index adb46673..7f5d5c2f 100644 Binary files a/pg_data/base/16384/24736 and b/pg_data/base/16384/24736 differ diff --git a/pg_data/base/16384/24743 b/pg_data/base/16384/24743 index d399f8be..b312aa71 100644 Binary files a/pg_data/base/16384/24743 and b/pg_data/base/16384/24743 differ diff --git a/pg_data/base/16384/24774 b/pg_data/base/16384/24774 index 26b1bb49..b567d4a7 100644 Binary files a/pg_data/base/16384/24774 and b/pg_data/base/16384/24774 differ diff --git a/pg_data/base/16384/24844 b/pg_data/base/16384/24844 index 16cd3703..d74a3a32 100644 Binary files a/pg_data/base/16384/24844 and b/pg_data/base/16384/24844 differ diff --git a/pg_data/base/16384/24851 b/pg_data/base/16384/24851 index d60f87c4..9ba51b90 100644 Binary files a/pg_data/base/16384/24851 and b/pg_data/base/16384/24851 differ diff --git a/pg_data/base/16384/24863 b/pg_data/base/16384/24863 index 3602f6fe..9bb0d290 100644 Binary files a/pg_data/base/16384/24863 and b/pg_data/base/16384/24863 differ diff --git a/pg_data/base/16384/2604 b/pg_data/base/16384/2604 index aade99f3..d25b31b1 100644 Binary files a/pg_data/base/16384/2604 and b/pg_data/base/16384/2604 differ diff --git a/pg_data/base/16384/2619 b/pg_data/base/16384/2619 index b6961db9..5ebdd5c9 100644 Binary files a/pg_data/base/16384/2619 and b/pg_data/base/16384/2619 differ diff --git a/pg_data/base/16384/pg_internal.init b/pg_data/base/16384/pg_internal.init index 5b9da4ae..7704cf4b 100644 Binary files a/pg_data/base/16384/pg_internal.init and b/pg_data/base/16384/pg_internal.init differ diff --git a/pg_data/global/pg_control b/pg_data/global/pg_control index f24d8e62..678f7e3b 100644 Binary files a/pg_data/global/pg_control and b/pg_data/global/pg_control differ diff --git a/pg_data/global/pg_internal.init b/pg_data/global/pg_internal.init index a358cc6a..6eb43a48 100644 Binary files a/pg_data/global/pg_internal.init and b/pg_data/global/pg_internal.init differ diff --git a/pg_data/pg_wal/000000010000000000000001 b/pg_data/pg_wal/000000010000000000000001 index 40f86eb4..7ae3ff9e 100644 Binary files a/pg_data/pg_wal/000000010000000000000001 and b/pg_data/pg_wal/000000010000000000000001 differ diff --git a/pg_data/pg_xact/0000 b/pg_data/pg_xact/0000 index ebdf2ee7..2ddd1880 100644 Binary files a/pg_data/pg_xact/0000 and b/pg_data/pg_xact/0000 differ diff --git a/pg_data/postmaster.pid b/pg_data/postmaster.pid index 85358356..9b4aed74 100644 --- a/pg_data/postmaster.pid +++ b/pg_data/postmaster.pid @@ -1,6 +1,6 @@ 1 /var/lib/postgresql/data -1765940756 +1765957947 5432 /var/run/postgresql *