refactor: improve code structure and optimize key functions
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /code
|
||||||
|
|
||||||
|
COPY requirements.txt /code/requirements.txt
|
||||||
|
RUN pip install --upgrade pip && pip install -r /code/requirements.txt
|
||||||
|
|
||||||
|
COPY app /code/app
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_access_token
|
||||||
|
from app.schemas.user import Token
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=1)
|
||||||
|
password: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
async def login_for_access_token(payload: LoginRequest) -> Token:
|
||||||
|
if not (payload.username == "admin" and payload.password == "admin"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = create_access_token(
|
||||||
|
subject="00000000-0000-0000-0000-000000000001",
|
||||||
|
role="PM",
|
||||||
|
expires_delta=timedelta(minutes=settings.JWT_EXPIRE_MINUTES),
|
||||||
|
)
|
||||||
|
return Token(access_token=access_token, token_type="bearer")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1 import auth
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
|
DATABASE_URL: str = Field(default="postgresql+asyncpg://postgres:postgres@db:5432/ctms")
|
||||||
|
ENV: Literal["development", "production", "test"] = "development"
|
||||||
|
JWT_SECRET_KEY: str = "dev-secret"
|
||||||
|
JWT_EXPIRE_MINUTES: int = 60
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from typing import Annotated, AsyncGenerator
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.security import decode_access_token, oauth2_scheme
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.schemas.user import TokenPayload
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> TokenPayload:
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
try:
|
||||||
|
return TokenPayload(**payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
) from exc
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", scheme_name="Bearer")
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(*, subject: str, role: str, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES))
|
||||||
|
to_encode: Dict[str, Any] = {"sub": subject, "role": role, "exp": expire}
|
||||||
|
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
except JWTError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
) from exc
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import UserCreate
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_username(db: AsyncSession, username: str) -> User | None:
|
||||||
|
result = await db.execute(select(User).where(User.username == username))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def create(db: AsyncSession, user_in: UserCreate, hashed_password: str) -> User:
|
||||||
|
user = User(
|
||||||
|
username=user_in.username,
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
role=user_in.role,
|
||||||
|
is_active=user_in.is_active,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def list_users(db: AsyncSession, limit: int = 100) -> Sequence[User]:
|
||||||
|
result = await db.execute(select(User).limit(limit))
|
||||||
|
return result.scalars().all()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, future=True, echo=False)
|
||||||
|
SessionLocal = async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.v1.router import api_router
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(
|
||||||
|
title="CTMS API",
|
||||||
|
version="0.1.0",
|
||||||
|
debug=settings.ENV == "development",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/health", tags=["health"])
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
username: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
username: str = Field(min_length=1)
|
||||||
|
role: Literal["PM", "CRA", "PV", "IMP"]
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(UserBase):
|
||||||
|
id: uuid.UUID
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserInDB(UserBase):
|
||||||
|
id: uuid.UUID
|
||||||
|
hashed_password: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPayload(BaseModel):
|
||||||
|
sub: uuid.UUID
|
||||||
|
role: str
|
||||||
|
exp: Optional[int] = None
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0.post1
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
asyncpg==0.29.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
debugpy==1.8.0
|
||||||
Executable
+116
@@ -0,0 +1,116 @@
|
|||||||
|
services:
|
||||||
|
# =========================================
|
||||||
|
# [主演] 业务服务 (日常开发一直开着)
|
||||||
|
# =========================================
|
||||||
|
|
||||||
|
# 1. 数据库 (PostgreSQL)
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: ctms_db
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ctms_user
|
||||||
|
POSTGRES_PASSWORD: secret_password
|
||||||
|
POSTGRES_DB: ctms_db
|
||||||
|
volumes:
|
||||||
|
# 持久化数据到 Linux 本地,防止重启丢失
|
||||||
|
- ./pg_data:/var/lib/postgresql/data
|
||||||
|
# 自动建表脚本
|
||||||
|
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ctms_user -d ctms_db"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- ctms_net
|
||||||
|
|
||||||
|
# 2. 后端 (FastAPI)
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: ctms_backend
|
||||||
|
command: python -m debugpy --listen 0.0.0.0:5678 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --proxy-headers --forwarded-allow-ips '*'
|
||||||
|
volumes:
|
||||||
|
- ./backend:/code
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
- "5678:5678"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- ctms_net
|
||||||
|
|
||||||
|
# 3. 前端 (Vue3)
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: ctms_frontend
|
||||||
|
# 开启 Host 模式以支持 Vite 热更新
|
||||||
|
command: npm run dev -- --host
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
# [关键] 让容器使用自己的 node_modules,而不是宿主机的
|
||||||
|
- /app/node_modules
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
environment:
|
||||||
|
VITE_API_BASE_URL: /
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- ctms_net
|
||||||
|
|
||||||
|
# =========================================
|
||||||
|
# [剧务] 初始化工具 (只在需要时召唤)
|
||||||
|
# profiles: ["init"] 标记了它们平时不会启动
|
||||||
|
# =========================================
|
||||||
|
|
||||||
|
# Node 工具箱:用于生成 Vue 代码、安装 npm 包
|
||||||
|
frontend-init:
|
||||||
|
image: node:latest
|
||||||
|
profiles: ["init"]
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
networks:
|
||||||
|
- ctms_net
|
||||||
|
|
||||||
|
# Python 工具箱:用于生成 requirements.txt 等
|
||||||
|
backend-init:
|
||||||
|
image: python:3.10-slim
|
||||||
|
profiles: ["init"]
|
||||||
|
working_dir: /code
|
||||||
|
volumes:
|
||||||
|
- ./backend:/code
|
||||||
|
networks:
|
||||||
|
- ctms_net
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:latest
|
||||||
|
container_name: ctms_nginx
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80" # HTTP
|
||||||
|
- "443:443" # HTTPS
|
||||||
|
- "8888:8888"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./nginx/certs:/etc/nginx/certs:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
networks:
|
||||||
|
- ctms_net
|
||||||
|
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ctms_net:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
15
|
||||||
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.
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.
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user