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)}
|
||||
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.
Reference in New Issue
Block a user