Step 12:后端轻量优化(API 稳定性 & 前端友好)
This commit is contained in:
@@ -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},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
AE_SERIOUSNESS = ["NON_SERIOUS", "SERIOUS"]
|
||||
AE_SEVERITY = ["G1", "G2", "G3", "G4", "G5"]
|
||||
AE_STATUS = ["NEW", "FOLLOW_UP", "CLOSED"]
|
||||
@@ -0,0 +1,2 @@
|
||||
FINANCE_STATUS = ["DRAFT", "SUBMITTED", "APPROVED", "REJECTED", "PAID"]
|
||||
FINANCE_CATEGORY = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"]
|
||||
@@ -0,0 +1,10 @@
|
||||
IMP_TX_TYPES = [
|
||||
"RECEIPT",
|
||||
"DISPENSE",
|
||||
"RETURN",
|
||||
"RECONCILE",
|
||||
"DESTROY",
|
||||
"TRANSFER_IN",
|
||||
"TRANSFER_OUT",
|
||||
]
|
||||
IMP_BATCH_STATUS = ["ACTIVE", "QUARANTINED", "EXPIRED", "CLOSED"]
|
||||
@@ -0,0 +1,3 @@
|
||||
ISSUE_CATEGORY = ["RISK", "ISSUE", "PROTOCOL_DEVIATION"]
|
||||
ISSUE_LEVEL = ["LOW", "MEDIUM", "HIGH", "CRITICAL"]
|
||||
ISSUE_STATUS = ["OPEN", "MITIGATING", "CLOSED"]
|
||||
@@ -0,0 +1,2 @@
|
||||
SUBJECT_STATUS = ["SCREENING", "ENROLLED", "COMPLETED", "DROPPED"]
|
||||
VISIT_STATUS = ["PLANNED", "DONE", "MISSED", "CANCELLED"]
|
||||
@@ -0,0 +1,2 @@
|
||||
TASK_STATUS = ["TODO", "DOING", "DONE", "BLOCKED"]
|
||||
TASK_PRIORITY = ["LOW", "MEDIUM", "HIGH"]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
+38
-2
@@ -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"}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
Reference in New Issue
Block a user