diff --git a/backend/app/api/v1/constants.py b/backend/app/api/v1/constants.py new file mode 100644 index 00000000..28e7b029 --- /dev/null +++ b/backend/app/api/v1/constants.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends + +from app.constants import ae, finance, imp, issue, subject, task +from app.core.deps import get_current_user + +router = APIRouter(tags=["FAQ"], dependencies=[Depends(get_current_user)]) + + +@router.get( + "/", + summary="获取前端常量字典", + description="返回各模块状态/类型常量,便于前端一次性加载。", +) +async def get_constants(): + return { + "task": {"status": task.TASK_STATUS, "priority": task.TASK_PRIORITY}, + "subject": {"status": subject.SUBJECT_STATUS, "visit_status": subject.VISIT_STATUS}, + "ae": {"seriousness": ae.AE_SERIOUSNESS, "severity": ae.AE_SEVERITY, "status": ae.AE_STATUS}, + "issue": {"category": issue.ISSUE_CATEGORY, "level": issue.ISSUE_LEVEL, "status": issue.ISSUE_STATUS}, + "finance": {"status": finance.FINANCE_STATUS, "category": finance.FINANCE_CATEGORY}, + "imp": {"tx_types": imp.IMP_TX_TYPES, "batch_status": imp.IMP_BATCH_STATUS}, + } diff --git a/backend/app/api/v1/faq_categories.py b/backend/app/api/v1/faq_categories.py index b6ee1681..349285c0 100644 --- a/backend/app/api/v1/faq_categories.py +++ b/backend/app/api/v1/faq_categories.py @@ -7,7 +7,9 @@ from app.core.deps import get_current_user, get_db_session from app.crud import audit as audit_crud from app.crud import faq_category as category_crud from app.crud import member as member_crud +from app.schemas.common import PaginatedResponse from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate +from app.utils.pagination import paginate router = APIRouter() @@ -25,6 +27,8 @@ def _check_permission_for_scope(study_id: uuid.UUID | None, current_user, member "/", response_model=CategoryRead, status_code=status.HTTP_201_CREATED, + summary="创建 FAQ 分类", + description="创建全局或项目内 FAQ 分类,项目 PM/ADMIN 可用,全局仅 ADMIN。", ) async def create_category( payload: CategoryCreate, @@ -54,7 +58,9 @@ async def create_category( @router.get( "/", - response_model=list[CategoryRead], + response_model=PaginatedResponse[CategoryRead], + summary="FAQ 分类列表", + description="返回全局及项目内 FAQ 分类列表,可按 study_id 过滤。", ) async def list_categories( study_id: uuid.UUID | None = None, @@ -68,12 +74,14 @@ async def list_categories( if not member and current_user.role != "ADMIN": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") categories = await category_crud.list_categories(db, study_id, include_global=include_global, is_active=is_active) - return [CategoryRead.model_validate(c) for c in categories] + return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories)) @router.patch( "/{category_id}", response_model=CategoryRead, + summary="更新 FAQ 分类", + description="更新分类名称或启停状态,项目 PM/ADMIN 或全局 ADMIN。", ) async def update_category( category_id: uuid.UUID, diff --git a/backend/app/api/v1/faqs.py b/backend/app/api/v1/faqs.py index 517f1220..de2922d0 100644 --- a/backend/app/api/v1/faqs.py +++ b/backend/app/api/v1/faqs.py @@ -8,7 +8,9 @@ from app.crud import audit as audit_crud from app.crud import faq_category as category_crud from app.crud import faq_item as faq_crud from app.crud import member as member_crud +from app.schemas.common import PaginatedResponse from app.schemas.faq import FaqCreate, FaqRead, FaqUpdate +from app.utils.pagination import paginate router = APIRouter() @@ -26,6 +28,8 @@ def _check_write_permission(study_id: uuid.UUID | None, current_user, member_rol "/", response_model=FaqRead, status_code=status.HTTP_201_CREATED, + summary="创建 FAQ", + description="创建全局或项目内 FAQ,项目 FAQ 需项目 PM/ADMIN 权限。", ) async def create_faq( payload: FaqCreate, @@ -61,7 +65,9 @@ async def create_faq( @router.get( "/", - response_model=list[FaqRead], + response_model=PaginatedResponse[FaqRead], + summary="FAQ 列表", + description="返回全局与项目 FAQ,可按关键词、分类过滤。", ) async def list_faqs( study_id: uuid.UUID | None = None, @@ -115,12 +121,14 @@ async def list_faqs( if role != "PM": continue visible.append(FaqRead.model_validate(it)) - return visible + return paginate(visible, total=len(visible)) @router.get( "/{item_id}", response_model=FaqRead, + summary="FAQ 详情", + description="获取单条 FAQ,非 PM 不能查看停用 FAQ。", ) async def get_faq( item_id: uuid.UUID, @@ -147,6 +155,8 @@ async def get_faq( @router.patch( "/{item_id}", response_model=FaqRead, + summary="更新 FAQ", + description="更新 FAQ 内容或启停状态,项目内需 PM/ADMIN 权限。", ) async def update_faq( item_id: uuid.UUID, diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 97a7dc44..96aca791 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard, faq_categories, faqs +from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard, faq_categories, faqs, constants api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) @@ -28,3 +28,4 @@ api_router.include_router(finance.router, prefix="/studies/{study_id}/finance", api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"]) api_router.include_router(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"]) api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"]) +api_router.include_router(constants.router, prefix="/constants", tags=["faq"]) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index e0b55678..ea19aadd 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -4,21 +4,24 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_db_session, require_roles +from app.schemas.common import PaginatedResponse from app.crud import user as user_crud +from app.utils.pagination import paginate from app.schemas.user import UserCreate, UserRead, UserUpdate router = APIRouter() -@router.get("/", response_model=list[UserRead]) +@router.get("/", response_model=PaginatedResponse[UserRead], summary="用户列表", description="仅 ADMIN 可用,支持 skip/limit。") async def list_users( skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db_session), current_user=Depends(require_roles(["ADMIN"])), -) -> list[UserRead]: +) -> PaginatedResponse[UserRead]: users = await user_crud.list_users(db, skip=skip, limit=limit) - return list(users) + total_users = await user_crud.list_users(db, skip=0, limit=10_000_000) + return paginate(list(users), total=len(total_users)) @router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED) diff --git a/backend/app/constants/ae.py b/backend/app/constants/ae.py new file mode 100644 index 00000000..4e8e888a --- /dev/null +++ b/backend/app/constants/ae.py @@ -0,0 +1,3 @@ +AE_SERIOUSNESS = ["NON_SERIOUS", "SERIOUS"] +AE_SEVERITY = ["G1", "G2", "G3", "G4", "G5"] +AE_STATUS = ["NEW", "FOLLOW_UP", "CLOSED"] diff --git a/backend/app/constants/finance.py b/backend/app/constants/finance.py new file mode 100644 index 00000000..be96fa69 --- /dev/null +++ b/backend/app/constants/finance.py @@ -0,0 +1,2 @@ +FINANCE_STATUS = ["DRAFT", "SUBMITTED", "APPROVED", "REJECTED", "PAID"] +FINANCE_CATEGORY = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"] diff --git a/backend/app/constants/imp.py b/backend/app/constants/imp.py new file mode 100644 index 00000000..d442304c --- /dev/null +++ b/backend/app/constants/imp.py @@ -0,0 +1,10 @@ +IMP_TX_TYPES = [ + "RECEIPT", + "DISPENSE", + "RETURN", + "RECONCILE", + "DESTROY", + "TRANSFER_IN", + "TRANSFER_OUT", +] +IMP_BATCH_STATUS = ["ACTIVE", "QUARANTINED", "EXPIRED", "CLOSED"] diff --git a/backend/app/constants/issue.py b/backend/app/constants/issue.py new file mode 100644 index 00000000..10d1e760 --- /dev/null +++ b/backend/app/constants/issue.py @@ -0,0 +1,3 @@ +ISSUE_CATEGORY = ["RISK", "ISSUE", "PROTOCOL_DEVIATION"] +ISSUE_LEVEL = ["LOW", "MEDIUM", "HIGH", "CRITICAL"] +ISSUE_STATUS = ["OPEN", "MITIGATING", "CLOSED"] diff --git a/backend/app/constants/subject.py b/backend/app/constants/subject.py new file mode 100644 index 00000000..7be69108 --- /dev/null +++ b/backend/app/constants/subject.py @@ -0,0 +1,2 @@ +SUBJECT_STATUS = ["SCREENING", "ENROLLED", "COMPLETED", "DROPPED"] +VISIT_STATUS = ["PLANNED", "DONE", "MISSED", "CANCELLED"] diff --git a/backend/app/constants/task.py b/backend/app/constants/task.py new file mode 100644 index 00000000..2471f576 --- /dev/null +++ b/backend/app/constants/task.py @@ -0,0 +1,2 @@ +TASK_STATUS = ["TODO", "DOING", "DONE", "BLOCKED"] +TASK_PRIORITY = ["LOW", "MEDIUM", "HIGH"] diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index 69c64aa6..c9d5b475 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -5,6 +5,7 @@ from fastapi import Depends, HTTPException, status from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession +from app.core.exceptions import AppException from app.core.security import decode_token, oauth2_scheme from app.crud import user as user_crud from app.crud import member as member_crud @@ -75,9 +76,10 @@ def require_study_member(): return current_user membership = await member_crud.get_member(db, study_id, current_user.id) if not membership or not membership.is_active: - raise HTTPException( + raise AppException( + code="FORBIDDEN", + message="Not a member of this study", status_code=status.HTTP_403_FORBIDDEN, - detail="Not a member of this study", ) return current_user @@ -96,9 +98,10 @@ def require_study_roles(roles: Iterable[str]): return current_user membership = await member_crud.get_member(db, study_id, current_user.id) if not membership or not membership.is_active or membership.role_in_study not in roles_set: - raise HTTPException( + raise AppException( + code="FORBIDDEN", + message="Insufficient study permissions", status_code=status.HTTP_403_FORBIDDEN, - detail="Insufficient study permissions", ) return current_user diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 00000000..98a12b99 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse + + +class AppException(Exception): + def __init__(self, code: str, message: str, status_code: int = status.HTTP_400_BAD_REQUEST): + self.code = code + self.message = message + self.status_code = status_code + super().__init__(message) + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(AppException) + async def app_exception_handler(_: Request, exc: AppException) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content={"code": exc.code, "message": exc.message}, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 72f4d198..5f693507 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.v1.router import api_router from app.core.config import settings +from app.core.exceptions import register_exception_handlers from app.crud.user import ensure_admin_exists from app.db.base import Base from app.db.session import SessionLocal, engine @@ -25,10 +26,39 @@ async def lifespan(_: FastAPI): def create_app() -> FastAPI: app = FastAPI( - title="CTMS API", + title="CTMS 后端 API", + description="临床试验项目管理系统后端接口文档", version="0.1.0", debug=settings.ENV == "development", lifespan=lifespan, + openapi_tags=[ + {"name": "auth", "description": "认证与登录"}, + {"name": "users", "description": "用户管理"}, + {"name": "studies", "description": "项目管理"}, + {"name": "sites", "description": "中心管理"}, + {"name": "study-members", "description": "项目成员"}, + {"name": "comments", "description": "通用评论"}, + {"name": "attachments", "description": "通用附件"}, + {"name": "audit-logs", "description": "审计日志"}, + {"name": "milestones", "description": "里程碑"}, + {"name": "tasks", "description": "任务"}, + {"name": "dashboard", "description": "项目总览与统计"}, + {"name": "subjects", "description": "受试者"}, + {"name": "visits", "description": "访视"}, + {"name": "aes", "description": "不良事件"}, + {"name": "issues", "description": "风险 / 问题"}, + {"name": "data-queries", "description": "数据问题单"}, + {"name": "verifications", "description": "SDV/SDR 核查进度"}, + {"name": "imp-products", "description": "药品产品"}, + {"name": "imp-batches", "description": "药品批次"}, + {"name": "imp-inventory", "description": "药品库存"}, + {"name": "imp-transactions", "description": "药品台账流水"}, + {"name": "finance", "description": "费用管理"}, + {"name": "faq-categories", "description": "FAQ 分类"}, + {"name": "faqs", "description": "FAQ 条目"}, + {"name": "health", "description": "健康检查"}, + {"name": "faq", "description": "常量字典"}, + ], ) app.add_middleware( @@ -38,8 +68,14 @@ def create_app() -> FastAPI: allow_methods=["*"], allow_headers=["*"], ) + register_exception_handlers(app) - @app.get("/health", tags=["health"]) + @app.get( + "/health", + tags=["health"], + summary="健康检查", + description="返回服务存活状态。", + ) async def health() -> dict[str, str]: return {"status": "ok"} diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 00000000..299734c5 --- /dev/null +++ b/backend/app/schemas/common.py @@ -0,0 +1,16 @@ +from typing import Generic, TypeVar + +from pydantic import BaseModel +from pydantic.generics import GenericModel + +T = TypeVar("T") + + +class PaginatedResponse(GenericModel, Generic[T]): + items: list[T] + total: int + + +class MessageResponse(BaseModel): + code: str + message: str diff --git a/backend/app/utils/pagination.py b/backend/app/utils/pagination.py new file mode 100644 index 00000000..cfa06266 --- /dev/null +++ b/backend/app/utils/pagination.py @@ -0,0 +1,7 @@ +from collections.abc import Iterable +from typing import Any + + +def paginate(items: Iterable[Any], total: int | None = None) -> dict[str, Any]: + items_list = list(items) + return {"items": items_list, "total": total if total is not None else len(items_list)} diff --git a/pg_data/base/16384/1249 b/pg_data/base/16384/1249 index 7ddcdfec..b5f93d3a 100644 Binary files a/pg_data/base/16384/1249 and b/pg_data/base/16384/1249 differ diff --git a/pg_data/base/16384/24576 b/pg_data/base/16384/24576 index 4a9bf26a..21a89554 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 56c548f2..7d19d116 100644 Binary files a/pg_data/base/16384/24584 and b/pg_data/base/16384/24584 differ diff --git a/pg_data/base/16384/24590 b/pg_data/base/16384/24590 index 55b3c926..309adec8 100644 Binary files a/pg_data/base/16384/24590 and b/pg_data/base/16384/24590 differ diff --git a/pg_data/base/16384/24597 b/pg_data/base/16384/24597 index 643613c7..5d1f5b3a 100644 Binary files a/pg_data/base/16384/24597 and b/pg_data/base/16384/24597 differ diff --git a/pg_data/base/16384/24613 b/pg_data/base/16384/24613 index e5c01303..10546243 100644 Binary files a/pg_data/base/16384/24613 and b/pg_data/base/16384/24613 differ diff --git a/pg_data/base/16384/24618 b/pg_data/base/16384/24618 index a2083859..41e2abab 100644 Binary files a/pg_data/base/16384/24618 and b/pg_data/base/16384/24618 differ diff --git a/pg_data/base/16384/24620 b/pg_data/base/16384/24620 index ae039382..39f25284 100644 Binary files a/pg_data/base/16384/24620 and b/pg_data/base/16384/24620 differ diff --git a/pg_data/base/16384/24632 b/pg_data/base/16384/24632 index f0dfda5c..c0488e9a 100644 Binary files a/pg_data/base/16384/24632 and b/pg_data/base/16384/24632 differ diff --git a/pg_data/base/16384/24672 b/pg_data/base/16384/24672 index 8d4111dd..066b34a4 100644 Binary files a/pg_data/base/16384/24672 and b/pg_data/base/16384/24672 differ diff --git a/pg_data/base/16384/24672_fsm b/pg_data/base/16384/24672_fsm index 6b4fbb37..39488e54 100644 Binary files a/pg_data/base/16384/24672_fsm and b/pg_data/base/16384/24672_fsm differ diff --git a/pg_data/base/16384/24678 b/pg_data/base/16384/24678 index f481008d..9d29828f 100644 Binary files a/pg_data/base/16384/24678 and b/pg_data/base/16384/24678 differ diff --git a/pg_data/base/16384/25066 b/pg_data/base/16384/25066 index 22b66781..26ed7b28 100644 Binary files a/pg_data/base/16384/25066 and b/pg_data/base/16384/25066 differ diff --git a/pg_data/base/16384/25073 b/pg_data/base/16384/25073 index f3c45039..a35a357d 100644 Binary files a/pg_data/base/16384/25073 and b/pg_data/base/16384/25073 differ diff --git a/pg_data/base/16384/25075 b/pg_data/base/16384/25075 index 47668139..403a8afe 100644 Binary files a/pg_data/base/16384/25075 and b/pg_data/base/16384/25075 differ diff --git a/pg_data/base/16384/25082 b/pg_data/base/16384/25082 index 19111bb2..38c51a73 100644 Binary files a/pg_data/base/16384/25082 and b/pg_data/base/16384/25082 differ diff --git a/pg_data/base/16384/25083 b/pg_data/base/16384/25083 index 6d17cf9d..7271dacb 100644 Binary files a/pg_data/base/16384/25083 and b/pg_data/base/16384/25083 differ diff --git a/pg_data/base/16384/25090 b/pg_data/base/16384/25090 index 30e79a8d..7386db7c 100644 Binary files a/pg_data/base/16384/25090 and b/pg_data/base/16384/25090 differ diff --git a/pg_data/base/16384/25107 b/pg_data/base/16384/25107 index fbc0b577..843628a5 100644 Binary files a/pg_data/base/16384/25107 and b/pg_data/base/16384/25107 differ diff --git a/pg_data/base/16384/25108 b/pg_data/base/16384/25108 index 05753329..44fe5679 100644 Binary files a/pg_data/base/16384/25108 and b/pg_data/base/16384/25108 differ diff --git a/pg_data/base/16384/2604 b/pg_data/base/16384/2604 index 2e358424..982d5ecd 100644 Binary files a/pg_data/base/16384/2604 and b/pg_data/base/16384/2604 differ diff --git a/pg_data/base/16384/2606 b/pg_data/base/16384/2606 index 785bc668..cd488139 100644 Binary files a/pg_data/base/16384/2606 and b/pg_data/base/16384/2606 differ diff --git a/pg_data/base/16384/2610 b/pg_data/base/16384/2610 index cffb4074..eb94d054 100644 Binary files a/pg_data/base/16384/2610 and b/pg_data/base/16384/2610 differ diff --git a/pg_data/base/16384/2620 b/pg_data/base/16384/2620 index 94619c7c..ec05c67f 100644 Binary files a/pg_data/base/16384/2620 and b/pg_data/base/16384/2620 differ diff --git a/pg_data/base/16384/2659 b/pg_data/base/16384/2659 index 3b304d06..e8925a5d 100644 Binary files a/pg_data/base/16384/2659 and b/pg_data/base/16384/2659 differ diff --git a/pg_data/global/pg_control b/pg_data/global/pg_control index e7e9fba4..04c4f839 100644 Binary files a/pg_data/global/pg_control and b/pg_data/global/pg_control differ diff --git a/pg_data/pg_wal/000000010000000000000001 b/pg_data/pg_wal/000000010000000000000001 index b3c74466..7307df45 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 0f749b6f..0f1be393 100644 Binary files a/pg_data/pg_xact/0000 and b/pg_data/pg_xact/0000 differ