Step 12:后端轻量优化(API 稳定性 & 前端友好)

This commit is contained in:
Cheng Zhou
2025-12-16 20:03:05 +08:00
parent a54d7faf19
commit 5eab324c50
44 changed files with 161 additions and 14 deletions
+22
View File
@@ -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},
}
+10 -2
View File
@@ -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,
+12 -2
View File
@@ -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,
+2 -1
View File
@@ -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"])
+6 -3
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
AE_SERIOUSNESS = ["NON_SERIOUS", "SERIOUS"]
AE_SEVERITY = ["G1", "G2", "G3", "G4", "G5"]
AE_STATUS = ["NEW", "FOLLOW_UP", "CLOSED"]
+2
View File
@@ -0,0 +1,2 @@
FINANCE_STATUS = ["DRAFT", "SUBMITTED", "APPROVED", "REJECTED", "PAID"]
FINANCE_CATEGORY = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"]
+10
View File
@@ -0,0 +1,10 @@
IMP_TX_TYPES = [
"RECEIPT",
"DISPENSE",
"RETURN",
"RECONCILE",
"DESTROY",
"TRANSFER_IN",
"TRANSFER_OUT",
]
IMP_BATCH_STATUS = ["ACTIVE", "QUARANTINED", "EXPIRED", "CLOSED"]
+3
View File
@@ -0,0 +1,3 @@
ISSUE_CATEGORY = ["RISK", "ISSUE", "PROTOCOL_DEVIATION"]
ISSUE_LEVEL = ["LOW", "MEDIUM", "HIGH", "CRITICAL"]
ISSUE_STATUS = ["OPEN", "MITIGATING", "CLOSED"]
+2
View File
@@ -0,0 +1,2 @@
SUBJECT_STATUS = ["SCREENING", "ENROLLED", "COMPLETED", "DROPPED"]
VISIT_STATUS = ["PLANNED", "DONE", "MISSED", "CANCELLED"]
+2
View File
@@ -0,0 +1,2 @@
TASK_STATUS = ["TODO", "DOING", "DONE", "BLOCKED"]
TASK_PRIORITY = ["LOW", "MEDIUM", "HIGH"]
+7 -4
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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"}
+16
View File
@@ -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
+7
View File
@@ -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)}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.