diff --git a/.gitignore b/.gitignore index 7451755..4aee804 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ dist/ *.local # TestSprite 运行时(含可能敏感配置) -testsprite_tests/tmp/ # IDE / OS .idea/ diff --git a/111 b/111 new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7165055 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# 简易前后端项目 + +## 结构 + +- **后端**:`backend/main.py`(单文件 FastAPI,内存存储) +- **前端**:`frontend/src/App.tsx` + `main.tsx`(单页待办) + +## 启动 + +**后端**(终端 1): +```bash +cd backend +pip install -r requirements.txt +uvicorn main:app --reload --host 127.0.0.1 --port 8000 +``` + +**前端**(终端 2): +```bash +cd frontend +npm install +npm run dev +``` + +浏览器打开 http://localhost:3000 ,前端会通过 Vite 代理访问 http://localhost:8000 的 `/api`。 diff --git a/backend/README.md b/backend/README.md index adafcc4..6e16b08 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,89 +1,10 @@ -# 示例后端项目 (FastAPI) - -基于 **FastAPI** 的异步后端,包含用户、分类、商品、订单模块与 JWT 认证,代码量较多,适合学习或二次开发。 - -## 技术栈 - -- **框架**: FastAPI -- **数据库**: SQLAlchemy 2.0(异步),默认 SQLite -- **认证**: JWT (python-jose + passlib/bcrypt) -- **校验**: Pydantic v2 - -## 项目结构 - -``` -backend/ -├── app/ -│ ├── __init__.py -│ ├── config.py # 配置(环境变量) -│ ├── database.py # 异步数据库与会话 -│ ├── main.py # 应用入口 -│ ├── exceptions.py # 自定义异常与全局处理 -│ ├── api/ -│ │ └── routes/ # 路由:health, auth, users, items, categories, orders -│ ├── models/ # SQLAlchemy 模型:User, Category, Item, Order, OrderItem, AuditLog -│ ├── schemas/ # Pydantic 请求/响应模型 -│ ├── services/ # 业务逻辑层 -│ ├── utils/ # 安全、依赖注入等 -│ └── middleware/ # 日志、耗时中间件 -├── requirements.txt -└── README.md -``` - -## 快速开始 - -### 1. 安装依赖 +# 简易后端(单文件) ```bash -cd backend pip install -r requirements.txt +uvicorn main:app --reload --host 127.0.0.1 --port 8000 ``` -### 2. 启动服务 - -```bash -uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 -``` - -- 接口文档: http://127.0.0.1:8000/docs -- ReDoc: http://127.0.0.1:8000/redoc - -### 3. 环境变量(可选) - -在项目根目录创建 `.env`,例如: - -```env -SECRET_KEY=your-secret-key -DATABASE_URL=sqlite+aiosqlite:///./app.db -DEBUG=false -ACCESS_TOKEN_EXPIRE_MINUTES=30 -``` - -## API 概览 - -| 模块 | 前缀 | 说明 | -|------------|--------------------|----------------| -| 健康检查 | `/api/v1/health` | 基础 / DB 检查 | -| 认证 | `/api/v1/auth` | 登录、刷新、me | -| 用户 | `/api/v1/users` | 注册、列表、CRUD | -| 分类 | `/api/v1/categories` | 分类 CRUD、分页 | -| 商品 | `/api/v1/items` | 商品 CRUD、分页、按分类 | -| 订单 | `/api/v1/orders` | 下单、我的订单、管理员列表 | - -除注册、登录、健康检查、商品列表/详情外,其余接口需在请求头携带: - -```http -Authorization: Bearer -``` - -## 测试(示例) - -```bash -pytest tests/ -v -``` - -(需先编写 `tests/` 下用例;当前项目已预留依赖 `pytest`、`pytest-asyncio`、`httpx`。) - -## 许可证 - -MIT +- `GET /api/health` 健康检查 +- `GET /api/items` 列表 +- `POST /api/items` 添加(body: `{"title": "xxx"}`) diff --git a/backend/app/__init__.py b/backend/app/__init__.py deleted file mode 100644 index a320d0c..0000000 --- a/backend/app/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -后端应用主包 -""" -__version__ = "1.0.0" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py deleted file mode 100644 index f198672..0000000 --- a/backend/app/api/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -API 路由包 -""" diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py deleted file mode 100644 index beea877..0000000 --- a/backend/app/api/routes/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -路由模块 -""" -from fastapi import APIRouter - -from app.api.routes import auth, users, items, categories, orders, health - -api_router = APIRouter() - -api_router.include_router(health.router, prefix="/health", tags=["健康检查"]) -api_router.include_router(auth.router, prefix="/auth", tags=["认证"]) -api_router.include_router(users.router, prefix="/users", tags=["用户"]) -api_router.include_router(categories.router, prefix="/categories", tags=["分类"]) -api_router.include_router(items.router, prefix="/items", tags=["商品"]) -api_router.include_router(orders.router, prefix="/orders", tags=["订单"]) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py deleted file mode 100644 index ef8fbf5..0000000 --- a/backend/app/api/routes/auth.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -认证相关接口 -""" -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.schemas.auth import LoginRequest, Token, RefreshRequest -from app.schemas.user import UserResponse -from app.services.auth_service import AuthService -from app.utils.dependencies import get_current_active_user -from app.models.user import User - -router = APIRouter() - - -@router.post("/login", response_model=Token) -async def login( - body: LoginRequest, - db: Annotated[AsyncSession, Depends(get_db)], -) -> Token: - """用户名/邮箱 + 密码登录""" - user = await AuthService.authenticate(db, body.username, body.password) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="用户名或密码错误", - ) - access_token, refresh_token = AuthService.create_tokens(user) - return Token( - access_token=access_token, - refresh_token=refresh_token, - token_type="bearer", - expires_in=AuthService.get_expires_in_seconds(), - ) - - -@router.post("/refresh", response_model=Token) -async def refresh( - body: RefreshRequest, - db: Annotated[AsyncSession, Depends(get_db)], -) -> Token: - """使用 refresh token 换取新的 access token""" - user = await AuthService.get_user_from_refresh_token(db, body.refresh_token) - if not user or not user.is_active: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="无效的刷新 Token 或用户已禁用", - ) - access_token, refresh_token = AuthService.create_tokens(user) - return Token( - access_token=access_token, - refresh_token=refresh_token, - token_type="bearer", - expires_in=AuthService.get_expires_in_seconds(), - ) - - -@router.get("/me", response_model=UserResponse) -async def me( - current_user: Annotated[User, Depends(get_current_active_user)], -) -> User: - """获取当前登录用户信息""" - return current_user diff --git a/backend/app/api/routes/categories.py b/backend/app/api/routes/categories.py deleted file mode 100644 index aae7add..0000000 --- a/backend/app/api/routes/categories.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -分类 CRUD 接口 -""" -from typing import Annotated, Optional - -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.models.user import User -from app.models.category import Category -from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse -from app.schemas.common import PageParams, PaginatedResponse -from app.services.category_service import CategoryService -from app.utils.dependencies import get_current_active_user - -router = APIRouter() - - -@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED) -async def create_category( - body: CategoryCreate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Category: - """创建分类""" - if await CategoryService.get_by_slug(db, body.slug): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="slug 已存在") - return await CategoryService.create(db, body) - - -@router.get("", response_model=list[CategoryResponse]) -async def list_categories( - db: Annotated[AsyncSession, Depends(get_db)], - parent_id: Optional[str] = Query(None), -) -> list[Category]: - """分类列表(可选按父级筛选)""" - categories = await CategoryService.list_all(db, parent_id=parent_id) - return categories - - -@router.get("/paginated", response_model=PaginatedResponse[CategoryResponse]) -async def list_categories_paginated( - db: Annotated[AsyncSession, Depends(get_db)], - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - parent_id: Optional[str] = Query(None), -) -> PaginatedResponse[CategoryResponse]: - """分类列表(分页)""" - params = PageParams(page=page, page_size=page_size) - categories, total = await CategoryService.list_paginated(db, params, parent_id=parent_id) - return PaginatedResponse.create( - [CategoryResponse.model_validate(c) for c in categories], - total=total, - page=params.page, - page_size=params.page_size, - ) - - -@router.get("/{category_id}", response_model=CategoryResponse) -async def get_category( - category_id: str, - db: Annotated[AsyncSession, Depends(get_db)], -) -> Category: - """根据 ID 获取分类""" - category = await CategoryService.get_by_id(db, category_id) - if not category: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") - return category - - -@router.patch("/{category_id}", response_model=CategoryResponse) -async def update_category( - category_id: str, - body: CategoryUpdate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Category: - """更新分类""" - category = await CategoryService.get_by_id(db, category_id) - if not category: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") - if body.slug and body.slug != category.slug and await CategoryService.get_by_slug(db, body.slug): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="slug 已存在") - return await CategoryService.update(db, category, body) - - -@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_category( - category_id: str, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> None: - """删除分类""" - category = await CategoryService.get_by_id(db, category_id) - if not category: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") - await db.delete(category) - await db.flush() diff --git a/backend/app/api/routes/health.py b/backend/app/api/routes/health.py deleted file mode 100644 index ba13ec2..0000000 --- a/backend/app/api/routes/health.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -健康检查接口 -""" -from datetime import datetime, timezone -from typing import Any - -from fastapi import APIRouter, Depends -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.config import get_settings - -router = APIRouter() -settings = get_settings() - - -@router.get("") -async def health() -> dict[str, Any]: - """基础健康检查""" - return { - "status": "ok", - "app": settings.APP_NAME, - "version": settings.APP_VERSION, - "timestamp": datetime.now(timezone.utc).isoformat(), - } - - -@router.get("/db") -async def health_db(db: AsyncSession = Depends(get_db)) -> dict[str, Any]: - """数据库连通性检查""" - try: - await db.execute(text("SELECT 1")) - return {"status": "ok", "database": "connected"} - except Exception as e: - return {"status": "error", "database": "disconnected", "detail": str(e)} diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py deleted file mode 100644 index e408d04..0000000 --- a/backend/app/api/routes/items.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -商品 CRUD 接口 -""" -from typing import Annotated, Optional - -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.models.user import User -from app.models.item import Item -from app.schemas.item import ItemCreate, ItemUpdate, ItemResponse -from app.schemas.common import PageParams, PaginatedResponse -from app.services.item_service import ItemService -from app.utils.dependencies import get_current_user, get_current_active_user - -router = APIRouter() - - -@router.post("", response_model=ItemResponse, status_code=status.HTTP_201_CREATED) -async def create_item( - body: ItemCreate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Item: - """创建商品(需登录)""" - if await ItemService.get_by_slug(db, body.slug): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="slug 已存在") - return await ItemService.create(db, body) - - -@router.get("", response_model=PaginatedResponse[ItemResponse]) -async def list_items( - db: Annotated[AsyncSession, Depends(get_db)], - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - category_id: Optional[str] = Query(None), - is_active: Optional[bool] = Query(None), -) -> PaginatedResponse[ItemResponse]: - """商品列表(分页,可按分类、是否上架筛选)""" - params = PageParams(page=page, page_size=page_size) - items, total = await ItemService.list_paginated(db, params, category_id=category_id, is_active=is_active) - return PaginatedResponse.create( - [ItemResponse.model_validate(i) for i in items], - total=total, - page=params.page, - page_size=params.page_size, - ) - - -@router.get("/{item_id}", response_model=ItemResponse) -async def get_item( - item_id: str, - db: Annotated[AsyncSession, Depends(get_db)], -) -> Item: - """根据 ID 获取商品""" - item = await ItemService.get_by_id(db, item_id) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="商品不存在") - return item - - -@router.get("/slug/{slug}", response_model=ItemResponse) -async def get_item_by_slug( - slug: str, - db: Annotated[AsyncSession, Depends(get_db)], -) -> Item: - """根据 slug 获取商品""" - item = await ItemService.get_by_slug(db, slug) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="商品不存在") - return item - - -@router.patch("/{item_id}", response_model=ItemResponse) -async def update_item( - item_id: str, - body: ItemUpdate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Item: - """更新商品""" - item = await ItemService.get_by_id(db, item_id) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="商品不存在") - if body.slug and body.slug != item.slug and await ItemService.get_by_slug(db, body.slug): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="slug 已存在") - return await ItemService.update(db, item, body) - - -@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_item( - item_id: str, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> None: - """删除商品""" - item = await ItemService.get_by_id(db, item_id) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="商品不存在") - await ItemService.delete(db, item) diff --git a/backend/app/api/routes/orders.py b/backend/app/api/routes/orders.py deleted file mode 100644 index 3a4be1a..0000000 --- a/backend/app/api/routes/orders.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -订单 CRUD 接口 -""" -from typing import Annotated, Optional - -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.models.user import User -from app.models.order import Order -from app.schemas.order import OrderCreate, OrderUpdate, OrderResponse -from app.schemas.common import PageParams, PaginatedResponse -from app.services.order_service import OrderService -from app.utils.dependencies import get_current_active_user - -router = APIRouter() - - -@router.post("", response_model=OrderResponse, status_code=status.HTTP_201_CREATED) -async def create_order( - body: OrderCreate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Order: - """创建订单""" - try: - order = await OrderService.create(db, current_user, body) - return order - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - - -@router.get("", response_model=PaginatedResponse[OrderResponse]) -async def list_my_orders( - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - status_filter: Optional[str] = Query(None, alias="status"), -) -> PaginatedResponse[OrderResponse]: - """当前用户的订单列表(分页)""" - params = PageParams(page=page, page_size=page_size) - orders, total = await OrderService.list_by_user_paginated( - db, current_user.id, params, status_filter=status_filter - ) - return PaginatedResponse.create( - [OrderResponse.model_validate(o) for o in orders], - total=total, - page=params.page, - page_size=params.page_size, - ) - - -@router.get("/admin", response_model=PaginatedResponse[OrderResponse]) -async def list_all_orders( - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - user_id: Optional[str] = Query(None), - status_filter: Optional[str] = Query(None, alias="status"), -) -> PaginatedResponse[OrderResponse]: - """全部订单列表(管理员)""" - if not current_user.is_superuser: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限") - params = PageParams(page=page, page_size=page_size) - orders, total = await OrderService.list_paginated( - db, params, user_id=user_id, status_filter=status_filter - ) - return PaginatedResponse.create( - [OrderResponse.model_validate(o) for o in orders], - total=total, - page=params.page, - page_size=params.page_size, - ) - - -@router.get("/{order_id}", response_model=OrderResponse) -async def get_order( - order_id: str, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Order: - """获取订单详情(本人或管理员)""" - order = await OrderService.get_by_id(db, order_id) - if not order: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="订单不存在") - if order.user_id != current_user.id and not current_user.is_superuser: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限") - return order - - -@router.patch("/{order_id}", response_model=OrderResponse) -async def update_order( - order_id: str, - body: OrderUpdate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> Order: - """更新订单(如状态、地址等)""" - order = await OrderService.get_by_id(db, order_id) - if not order: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="订单不存在") - if order.user_id != current_user.id and not current_user.is_superuser: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限") - return await OrderService.update(db, order, body) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py deleted file mode 100644 index b75aba7..0000000 --- a/backend/app/api/routes/users.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -用户 CRUD 接口 -""" -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.models.user import User -from app.schemas.user import UserCreate, UserUpdate, UserResponse -from app.schemas.common import PageParams, PaginatedResponse -from app.services.user_service import UserService -from app.utils.dependencies import get_current_user, get_current_active_user - -router = APIRouter() - - -@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) -async def create_user( - body: UserCreate, - db: Annotated[AsyncSession, Depends(get_db)], -) -> User: - """注册新用户""" - if await UserService.get_by_email(db, body.email): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮箱已被注册") - if await UserService.get_by_username(db, body.username): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已被占用") - return await UserService.create(db, body) - - -@router.get("", response_model=PaginatedResponse[UserResponse]) -async def list_users( - db: Annotated[AsyncSession, Depends(get_db)], - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), -) -> PaginatedResponse[UserResponse]: - """用户列表(分页)""" - params = PageParams(page=page, page_size=page_size) - from sqlalchemy import select, func - count_result = await db.execute(select(func.count()).select_from(User)) - total = count_result.scalar() or 0 - q = select(User).offset((params.page - 1) * params.page_size).limit(params.page_size) - result = await db.execute(q) - items = result.scalars().all() - return PaginatedResponse.create( - [UserResponse.model_validate(u) for u in items], - total=total or len(items), - page=params.page, - page_size=params.page_size, - ) - - -@router.get("/{user_id}", response_model=UserResponse) -async def get_user( - user_id: str, - db: Annotated[AsyncSession, Depends(get_db)], -) -> User: - """根据 ID 获取用户""" - user = await UserService.get_by_id(db, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") - return user - - -@router.patch("/{user_id}", response_model=UserResponse) -async def update_user( - user_id: str, - body: UserUpdate, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> User: - """更新用户(本人或管理员)""" - if current_user.id != user_id and not current_user.is_superuser: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限") - user = await UserService.get_by_id(db, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") - if body.email and body.email != user.email and await UserService.get_by_email(db, body.email): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮箱已被注册") - if body.username and body.username != user.username and await UserService.get_by_username(db, body.username): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已被占用") - return await UserService.update(db, user, body) - - -@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_user( - user_id: str, - db: Annotated[AsyncSession, Depends(get_db)], - current_user: Annotated[User, Depends(get_current_active_user)], -) -> None: - """删除用户(仅管理员或本人)""" - if current_user.id != user_id and not current_user.is_superuser: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限") - user = await UserService.get_by_id(db, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") - await db.delete(user) - await db.flush() diff --git a/backend/app/config.py b/backend/app/config.py deleted file mode 100644 index 53039f3..0000000 --- a/backend/app/config.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -应用配置 - 从环境变量与默认值加载 -""" -from functools import lru_cache -from typing import List, Optional - -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - """应用配置类""" - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - extra="ignore", - ) - - # 应用基础 - APP_NAME: str = "Demo Backend API" - APP_VERSION: str = "1.0.0" - DEBUG: bool = False - API_V1_PREFIX: str = "/api/v1" - - # 服务 - HOST: str = "0.0.0.0" - PORT: int = 8000 - - # 数据库 - DATABASE_URL: str = "sqlite+aiosqlite:///./app.db" - DATABASE_ECHO: bool = False - DATABASE_POOL_SIZE: int = 5 - DATABASE_MAX_OVERFLOW: int = 10 - - # JWT 认证 - SECRET_KEY: str = "your-super-secret-key-change-in-production" - ALGORITHM: str = "HS256" - ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 - REFRESH_TOKEN_EXPIRE_DAYS: int = 7 - - # CORS - CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://127.0.0.1:3000"] - CORS_ALLOW_CREDENTIALS: bool = True - CORS_ALLOW_METHODS: List[str] = ["*"] - CORS_ALLOW_HEADERS: List[str] = ["*"] - - # 分页 - DEFAULT_PAGE_SIZE: int = 20 - MAX_PAGE_SIZE: int = 100 - - # 限流(可选) - RATE_LIMIT_REQUESTS: int = 100 - RATE_LIMIT_WINDOW_SECONDS: int = 60 - - # 日志 - LOG_LEVEL: str = "INFO" - LOG_FORMAT: str = "{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}" - - -@lru_cache -def get_settings() -> Settings: - """获取单例配置""" - return Settings() diff --git a/backend/app/database.py b/backend/app/database.py deleted file mode 100644 index dcf3b7f..0000000 --- a/backend/app/database.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -数据库连接与会话管理(异步) -""" -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from typing import AsyncGenerator as TypingAsyncGenerator - -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from sqlalchemy.orm import DeclarativeBase - -from app.config import get_settings - -settings = get_settings() - -# 异步引擎(SQLite 不使用 pool 参数) -_engine_kw: dict = { - "echo": settings.DATABASE_ECHO, -} -if "sqlite" not in settings.DATABASE_URL: - _engine_kw["pool_size"] = settings.DATABASE_POOL_SIZE - _engine_kw["max_overflow"] = settings.DATABASE_MAX_OVERFLOW -engine = create_async_engine(settings.DATABASE_URL, **_engine_kw) - -# 异步会话工厂 -async_session_factory = async_sessionmaker( - engine, - class_=AsyncSession, - expire_on_commit=False, - autocommit=False, - autoflush=False, -) - - -class Base(DeclarativeBase): - """ORM 基类""" - pass - - -async def get_db() -> TypingAsyncGenerator[AsyncSession, None]: - """依赖注入:获取数据库会话""" - async with async_session_factory() as session: - try: - yield session - await session.commit() - except Exception: - await session.rollback() - raise - finally: - await session.close() - - -@asynccontextmanager -async def get_db_context() -> AsyncGenerator[AsyncSession, None]: - """上下文管理器方式获取会话""" - async with async_session_factory() as session: - try: - yield session - await session.commit() - except Exception: - await session.rollback() - raise - - -async def init_db() -> None: - """初始化数据库表""" - from app.models import User, Item, Order, OrderItem, Category, AuditLog # noqa: F401 - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - -async def close_db() -> None: - """关闭数据库连接""" - await engine.dispose() diff --git a/backend/app/exceptions.py b/backend/app/exceptions.py deleted file mode 100644 index ca4b78f..0000000 --- a/backend/app/exceptions.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -自定义异常与全局异常处理 -""" -from typing import Any, Optional - -from fastapi import Request, status -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse - - -class AppException(Exception): - """应用基础异常""" - - def __init__( - self, - message: str = "服务器内部错误", - status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR, - detail: Optional[Any] = None, - ): - self.message = message - self.status_code = status_code - self.detail = detail - super().__init__(message) - - -class NotFoundError(AppException): - """资源不存在""" - - def __init__(self, message: str = "资源不存在", detail: Optional[Any] = None): - super().__init__(message=message, status_code=status.HTTP_404_NOT_FOUND, detail=detail) - - -class ForbiddenError(AppException): - """无权限""" - - def __init__(self, message: str = "无权限", detail: Optional[Any] = None): - super().__init__(message=message, status_code=status.HTTP_403_FORBIDDEN, detail=detail) - - -class BadRequestError(AppException): - """错误请求""" - - def __init__(self, message: str = "请求参数错误", detail: Optional[Any] = None): - super().__init__(message=message, status_code=status.HTTP_400_BAD_REQUEST, detail=detail) - - -async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse: - """统一处理 AppException""" - return JSONResponse( - status_code=exc.status_code, - content={ - "success": False, - "message": exc.message, - "detail": exc.detail, - }, - ) - - -async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: - """校验错误(Pydantic)统一格式""" - return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content={ - "success": False, - "message": "请求体验证失败", - "detail": exc.errors(), - }, - ) diff --git a/backend/app/main.py b/backend/app/main.py deleted file mode 100644 index adc3f2f..0000000 --- a/backend/app/main.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -FastAPI 应用入口 -""" -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.exceptions import RequestValidationError - -from app.config import get_settings -from app.database import init_db, close_db -from app.api.routes import api_router -from app.exceptions import AppException, app_exception_handler, validation_exception_handler -from app.middleware import LoggingMiddleware, RequestTimingMiddleware - -settings = get_settings() - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """应用生命周期:启动时建表,关闭时断开 DB""" - await init_db() - yield - await close_db() - - -app = FastAPI( - title=settings.APP_NAME, - version=settings.APP_VERSION, - description="示例后端 API:用户、分类、商品、订单与 JWT 认证", - lifespan=lifespan, - docs_url="/docs", - redoc_url="/redoc", -) - -# CORS -app.add_middleware( - CORSMiddleware, - allow_origins=settings.CORS_ORIGINS, - allow_credentials=settings.CORS_ALLOW_CREDENTIALS, - allow_methods=settings.CORS_ALLOW_METHODS, - allow_headers=settings.CORS_ALLOW_HEADERS, -) -app.add_middleware(RequestTimingMiddleware) -app.add_middleware(LoggingMiddleware) - -# 异常处理 -app.add_exception_handler(AppException, app_exception_handler) -app.add_exception_handler(RequestValidationError, validation_exception_handler) - -# 路由 -app.include_router(api_router, prefix=settings.API_V1_PREFIX) - - -@app.get("/") -async def root(): - """根路径""" - return { - "app": settings.APP_NAME, - "version": settings.APP_VERSION, - "docs": "/docs", - "api": settings.API_V1_PREFIX, - } diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py deleted file mode 100644 index 8f0b657..0000000 --- a/backend/app/middleware/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -中间件包 -""" -from app.middleware.logging import LoggingMiddleware -from app.middleware.timing import RequestTimingMiddleware - -__all__ = ["LoggingMiddleware", "RequestTimingMiddleware"] diff --git a/backend/app/middleware/logging.py b/backend/app/middleware/logging.py deleted file mode 100644 index 55ad5c2..0000000 --- a/backend/app/middleware/logging.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -请求日志中间件 -""" -import time -from typing import Callable - -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import Response - - -class LoggingMiddleware(BaseHTTPMiddleware): - """记录请求方法、路径、状态码与耗时""" - - async def dispatch(self, request: Request, call_next: Callable) -> Response: - start = time.perf_counter() - response = await call_next(request) - duration = time.perf_counter() - start - # 可接入 loguru 等 - print(f"[{request.method}] {request.url.path} -> {response.status_code} ({duration:.3f}s)") - return response diff --git a/backend/app/middleware/timing.py b/backend/app/middleware/timing.py deleted file mode 100644 index c9b6985..0000000 --- a/backend/app/middleware/timing.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -请求耗时中间件(在 Response 头中返回耗时) -""" -import time -from typing import Callable - -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import Response - - -class RequestTimingMiddleware(BaseHTTPMiddleware): - """在 X-Process-Time 响应头中返回处理耗时(秒)""" - - async def dispatch(self, request: Request, call_next: Callable) -> Response: - start = time.perf_counter() - response = await call_next(request) - duration = time.perf_counter() - start - response.headers["X-Process-Time"] = f"{duration:.3f}" - return response diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index 5c0f03d..0000000 --- a/backend/app/models/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -数据模型包 -""" -from app.models.user import User -from app.models.item import Item -from app.models.order import Order, OrderItem -from app.models.category import Category -from app.models.audit import AuditLog - -__all__ = [ - "User", - "Item", - "Order", - "OrderItem", - "Category", - "AuditLog", -] diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py deleted file mode 100644 index 4a37941..0000000 --- a/backend/app/models/audit.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -审计日志模型 -""" -from typing import TYPE_CHECKING, Optional - -from sqlalchemy import ForeignKey, String, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database import Base -from app.models.base import TimestampMixin, UUIDMixin - -if TYPE_CHECKING: - from app.models.user import User - - -class AuditLog(Base, UUIDMixin, TimestampMixin): - """审计日志表""" - - __tablename__ = "audit_logs" - - user_id: Mapped[Optional[str]] = mapped_column( - String(36), - ForeignKey("users.id", ondelete="SET NULL"), - nullable=True, - index=True, - ) - action: Mapped[str] = mapped_column(String(64), nullable=False, index=True) - resource_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) - resource_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True, index=True) - details: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) - user_agent: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) - - user: Mapped[Optional["User"]] = relationship( - "User", - back_populates="audit_logs", - lazy="joined", - foreign_keys=[user_id], - ) - - def __repr__(self) -> str: - return f"" diff --git a/backend/app/models/base.py b/backend/app/models/base.py deleted file mode 100644 index 7d1e6b0..0000000 --- a/backend/app/models/base.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -模型基类与公共字段 -""" -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, func -from sqlalchemy.dialects.sqlite import CHAR -from sqlalchemy.orm import Mapped, mapped_column - -from app.database import Base - - -def generate_uuid() -> str: - return str(uuid4()) - - -class TimestampMixin: - """创建/更新时间混入""" - - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - nullable=False, - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - nullable=False, - ) - - -class UUIDMixin: - """UUID 主键混入""" - - id: Mapped[str] = mapped_column( - CHAR(36), - primary_key=True, - default=generate_uuid, - ) diff --git a/backend/app/models/category.py b/backend/app/models/category.py deleted file mode 100644 index 9a0ac86..0000000 --- a/backend/app/models/category.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -分类模型 -""" -from typing import TYPE_CHECKING, List, Optional - -from sqlalchemy import String, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database import Base -from app.models.base import TimestampMixin, UUIDMixin - -if TYPE_CHECKING: - from app.models.item import Item - - -class Category(Base, UUIDMixin, TimestampMixin): - """分类表""" - - __tablename__ = "categories" - - name: Mapped[str] = mapped_column(String(128), nullable=False, index=True) - slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) - description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - parent_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True, index=True) - - items: Mapped[List["Item"]] = relationship( - "Item", - back_populates="category", - lazy="selectin", - ) - - def __repr__(self) -> str: - return f"" diff --git a/backend/app/models/item.py b/backend/app/models/item.py deleted file mode 100644 index 7b1b309..0000000 --- a/backend/app/models/item.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -商品/条目模型 -""" -from decimal import Decimal -from typing import TYPE_CHECKING, List, Optional - -from sqlalchemy import DECIMAL, ForeignKey, Integer, String, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database import Base -from app.models.base import TimestampMixin, UUIDMixin - -if TYPE_CHECKING: - from app.models.category import Category - from app.models.order import OrderItem - - -class Item(Base, UUIDMixin, TimestampMixin): - """商品表""" - - __tablename__ = "items" - - title: Mapped[str] = mapped_column(String(256), nullable=False, index=True) - slug: Mapped[str] = mapped_column(String(256), unique=True, nullable=False, index=True) - description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - price: Mapped[Decimal] = mapped_column(DECIMAL(12, 2), nullable=False) - stock: Mapped[int] = mapped_column(Integer, default=0, nullable=False) - image_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) - category_id: Mapped[Optional[str]] = mapped_column( - String(36), - ForeignKey("categories.id", ondelete="SET NULL"), - nullable=True, - index=True, - ) - is_active: Mapped[bool] = mapped_column(default=True, nullable=False) - - category: Mapped[Optional["Category"]] = relationship( - "Category", - back_populates="items", - lazy="joined", - ) - order_items: Mapped[List["OrderItem"]] = relationship( - "OrderItem", - back_populates="item", - lazy="selectin", - ) - - def __repr__(self) -> str: - return f"" diff --git a/backend/app/models/order.py b/backend/app/models/order.py deleted file mode 100644 index 36475bb..0000000 --- a/backend/app/models/order.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -订单模型 -""" -from decimal import Decimal -from typing import TYPE_CHECKING, List, Optional - -from sqlalchemy import DECIMAL, ForeignKey, Integer, String -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database import Base -from app.models.base import TimestampMixin, UUIDMixin - -if TYPE_CHECKING: - from app.models.user import User - from app.models.item import Item - - -class Order(Base, UUIDMixin, TimestampMixin): - """订单表""" - - __tablename__ = "orders" - - user_id: Mapped[str] = mapped_column( - String(36), - ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - index=True, - ) - status: Mapped[str] = mapped_column( - String(32), - default="pending", - nullable=False, - index=True, - ) # pending, paid, shipped, completed, cancelled - total_amount: Mapped[Decimal] = mapped_column(DECIMAL(12, 2), default=0, nullable=False) - shipping_address: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) - note: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) - - user: Mapped["User"] = relationship( - "User", - back_populates="orders", - lazy="joined", - ) - order_items: Mapped[List["OrderItem"]] = relationship( - "OrderItem", - back_populates="order", - lazy="selectin", - cascade="all, delete-orphan", - ) - - def __repr__(self) -> str: - return f"" - - -class OrderItem(Base, UUIDMixin, TimestampMixin): - """订单明细表""" - - __tablename__ = "order_items" - - order_id: Mapped[str] = mapped_column( - String(36), - ForeignKey("orders.id", ondelete="CASCADE"), - nullable=False, - index=True, - ) - item_id: Mapped[str] = mapped_column( - String(36), - ForeignKey("items.id", ondelete="CASCADE"), - nullable=False, - index=True, - ) - quantity: Mapped[int] = mapped_column(Integer, nullable=False) - unit_price: Mapped[Decimal] = mapped_column(DECIMAL(12, 2), nullable=False) - subtotal: Mapped[Decimal] = mapped_column(DECIMAL(12, 2), nullable=False) - - order: Mapped["Order"] = relationship( - "Order", - back_populates="order_items", - lazy="joined", - ) - item: Mapped["Item"] = relationship( - "Item", - back_populates="order_items", - lazy="joined", - ) - - def __repr__(self) -> str: - return f"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py deleted file mode 100644 index 43c7ee4..0000000 --- a/backend/app/models/user.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -用户模型 -""" -from typing import TYPE_CHECKING, List, Optional - -from sqlalchemy import Boolean, String, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database import Base -from app.models.base import TimestampMixin, UUIDMixin - -if TYPE_CHECKING: - from app.models.order import Order - from app.models.audit import AuditLog - - -class User(Base, UUIDMixin, TimestampMixin): - """用户表""" - - __tablename__ = "users" - - email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) - username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) - hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) - full_name: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) - avatar_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) - bio: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - - orders: Mapped[List["Order"]] = relationship( - "Order", - back_populates="user", - lazy="selectin", - cascade="all, delete-orphan", - ) - audit_logs: Mapped[List["AuditLog"]] = relationship( - "AuditLog", - back_populates="user", - lazy="selectin", - foreign_keys="AuditLog.user_id", - ) - - def __repr__(self) -> str: - return f"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py deleted file mode 100644 index 2d4c774..0000000 --- a/backend/app/schemas/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Pydantic 模式包 -""" -from app.schemas.common import PageParams, PaginatedResponse -from app.schemas.user import UserCreate, UserUpdate, UserResponse, UserInDB -from app.schemas.auth import Token, TokenPayload -from app.schemas.item import ItemCreate, ItemUpdate, ItemResponse -from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse -from app.schemas.order import OrderCreate, OrderUpdate, OrderResponse, OrderItemCreate, OrderItemResponse - -__all__ = [ - "PageParams", - "PaginatedResponse", - "UserCreate", - "UserUpdate", - "UserResponse", - "UserInDB", - "Token", - "TokenPayload", - "ItemCreate", - "ItemUpdate", - "ItemResponse", - "CategoryCreate", - "CategoryUpdate", - "CategoryResponse", - "OrderCreate", - "OrderUpdate", - "OrderResponse", - "OrderItemCreate", - "OrderItemResponse", -] diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py deleted file mode 100644 index 373002b..0000000 --- a/backend/app/schemas/auth.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -认证相关模式 -""" -from typing import Optional - -from pydantic import BaseModel, EmailStr, Field - - -class LoginRequest(BaseModel): - """登录请求""" - - username: str # 支持用户名或邮箱 - password: str = Field(..., min_length=1) - - -class Token(BaseModel): - """Token 响应""" - - access_token: str - refresh_token: Optional[str] = None - token_type: str = "bearer" - expires_in: int # 秒 - - -class TokenPayload(BaseModel): - """JWT 载荷""" - - sub: str # user id - username: str - exp: int - iat: int - type: str = "access" # access | refresh - - -class RefreshRequest(BaseModel): - """刷新 Token 请求""" - - refresh_token: str - - -class PasswordChange(BaseModel): - """修改密码""" - - old_password: str - new_password: str = Field(..., min_length=8) diff --git a/backend/app/schemas/category.py b/backend/app/schemas/category.py deleted file mode 100644 index 24ed2b1..0000000 --- a/backend/app/schemas/category.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -分类相关模式 -""" -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, Field - - -class CategoryBase(BaseModel): - """分类基础字段""" - - name: str = Field(..., min_length=1, max_length=128) - slug: str = Field(..., min_length=1, max_length=128) - description: Optional[str] = None - parent_id: Optional[str] = None - - -class CategoryCreate(CategoryBase): - """创建分类""" - pass - - -class CategoryUpdate(BaseModel): - """更新分类""" - - name: Optional[str] = Field(None, min_length=1, max_length=128) - slug: Optional[str] = Field(None, min_length=1, max_length=128) - description: Optional[str] = None - parent_id: Optional[str] = None - - -class CategoryResponse(CategoryBase): - """分类响应""" - - id: str - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py deleted file mode 100644 index 66a5507..0000000 --- a/backend/app/schemas/common.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -通用请求/响应模式 -""" -from typing import Generic, List, TypeVar - -from pydantic import BaseModel, Field - -T = TypeVar("T") - - -class PageParams(BaseModel): - """分页参数""" - - page: int = Field(1, ge=1, description="页码") - page_size: int = Field(20, ge=1, le=100, description="每页条数") - - -class PaginatedResponse(BaseModel, Generic[T]): - """分页响应""" - - items: List[T] - total: int - page: int - page_size: int - pages: int - - @classmethod - def create(cls, items: List[T], total: int, page: int, page_size: int) -> "PaginatedResponse[T]": - pages = (total + page_size - 1) // page_size if page_size else 0 - return cls( - items=items, - total=total, - page=page, - page_size=page_size, - pages=pages, - ) - - -class MessageResponse(BaseModel): - """简单消息响应""" - - message: str - success: bool = True diff --git a/backend/app/schemas/item.py b/backend/app/schemas/item.py deleted file mode 100644 index bbaf7d8..0000000 --- a/backend/app/schemas/item.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -商品相关模式 -""" -from datetime import datetime -from decimal import Decimal -from typing import Optional - -from pydantic import BaseModel, Field - - -class ItemBase(BaseModel): - """商品基础字段""" - - title: str = Field(..., min_length=1, max_length=256) - slug: str = Field(..., min_length=1, max_length=256) - description: Optional[str] = None - price: Decimal = Field(..., ge=0) - stock: int = Field(0, ge=0) - image_url: Optional[str] = None - category_id: Optional[str] = None - is_active: bool = True - - -class ItemCreate(ItemBase): - """创建商品""" - pass - - -class ItemUpdate(BaseModel): - """更新商品(全部可选)""" - - title: Optional[str] = Field(None, min_length=1, max_length=256) - slug: Optional[str] = Field(None, min_length=1, max_length=256) - description: Optional[str] = None - price: Optional[Decimal] = Field(None, ge=0) - stock: Optional[int] = Field(None, ge=0) - image_url: Optional[str] = None - category_id: Optional[str] = None - is_active: Optional[bool] = None - - -class ItemResponse(ItemBase): - """商品响应""" - - id: str - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True diff --git a/backend/app/schemas/order.py b/backend/app/schemas/order.py deleted file mode 100644 index f37399f..0000000 --- a/backend/app/schemas/order.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -订单相关模式 -""" -from datetime import datetime -from decimal import Decimal -from typing import List, Optional - -from pydantic import BaseModel, Field - - -class OrderItemBase(BaseModel): - """订单项基础""" - - item_id: str - quantity: int = Field(..., ge=1) - unit_price: Decimal = Field(..., ge=0) - - -class OrderItemCreate(OrderItemBase): - """创建订单项""" - pass - - -class OrderItemResponse(OrderItemBase): - """订单项响应""" - - id: str - order_id: str - subtotal: Decimal - created_at: datetime - - class Config: - from_attributes = True - - -class OrderBase(BaseModel): - """订单基础""" - - shipping_address: Optional[str] = None - note: Optional[str] = None - - -class OrderCreate(OrderBase): - """创建订单(含订单项)""" - - items: List[OrderItemCreate] = Field(..., min_length=1) - - -class OrderUpdate(BaseModel): - """更新订单""" - - status: Optional[str] = Field(None, pattern="^(pending|paid|shipped|completed|cancelled)$") - shipping_address: Optional[str] = None - note: Optional[str] = None - - -class OrderResponse(OrderBase): - """订单响应""" - - id: str - user_id: str - status: str - total_amount: Decimal - order_items: List[OrderItemResponse] = [] - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py deleted file mode 100644 index 57e7294..0000000 --- a/backend/app/schemas/user.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -用户相关模式 -""" -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, EmailStr, Field - - -class UserBase(BaseModel): - """用户基础字段""" - - email: EmailStr - username: str = Field(..., min_length=2, max_length=64) - full_name: Optional[str] = Field(None, max_length=128) - bio: Optional[str] = None - is_active: bool = True - - -class UserCreate(UserBase): - """创建用户""" - - password: str = Field(..., min_length=8, max_length=128) - - -class UserUpdate(BaseModel): - """更新用户(全部可选)""" - - email: Optional[EmailStr] = None - username: Optional[str] = Field(None, min_length=2, max_length=64) - full_name: Optional[str] = None - avatar_url: Optional[str] = None - bio: Optional[str] = None - is_active: Optional[bool] = None - password: Optional[str] = Field(None, min_length=8) - - -class UserResponse(UserBase): - """用户响应(不含密码)""" - - id: str - avatar_url: Optional[str] = None - is_superuser: bool = False - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - - -class UserInDB(UserResponse): - """数据库中的用户(含哈希密码,仅内部使用)""" - - hashed_password: str diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py deleted file mode 100644 index 5f28436..0000000 --- a/backend/app/services/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -业务逻辑服务包 -""" -from app.services.user_service import UserService -from app.services.auth_service import AuthService -from app.services.item_service import ItemService -from app.services.category_service import CategoryService -from app.services.order_service import OrderService - -__all__ = [ - "UserService", - "AuthService", - "ItemService", - "CategoryService", - "OrderService", -] diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py deleted file mode 100644 index 87ef51f..0000000 --- a/backend/app/services/auth_service.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -认证业务逻辑 -""" -from typing import Optional - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.user import User -from app.schemas.auth import TokenPayload -from app.utils.security import ( - verify_password, - create_access_token, - create_refresh_token, - decode_token, -) -from app.config import get_settings - -settings = get_settings() - - -class AuthService: - """认证服务""" - - @staticmethod - async def authenticate( - db: AsyncSession, - username_or_email: str, - password: str, - ) -> Optional[User]: - """用户名/邮箱 + 密码认证""" - from app.services.user_service import UserService - - user = await UserService.get_by_username(db, username_or_email) - if not user: - user = await UserService.get_by_email(db, username_or_email) - if not user or not verify_password(password, user.hashed_password): - return None - if not user.is_active: - return None - return user - - @staticmethod - def create_tokens(user: User) -> tuple[str, str]: - """生成 access + refresh token""" - access = create_access_token(str(user.id), user.username) - refresh = create_refresh_token(str(user.id), user.username) - return access, refresh - - @staticmethod - def get_expires_in_seconds() -> int: - return settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 - - @staticmethod - async def get_user_from_refresh_token( - db: AsyncSession, - refresh_token: str, - ) -> Optional[User]: - """用 refresh token 取用户""" - payload = decode_token(refresh_token) - if not payload or payload.get("type") != "refresh": - return None - user_id = payload.get("sub") - if not user_id: - return None - from app.services.user_service import UserService - return await UserService.get_by_id(db, user_id) diff --git a/backend/app/services/category_service.py b/backend/app/services/category_service.py deleted file mode 100644 index f6b0940..0000000 --- a/backend/app/services/category_service.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -分类业务逻辑 -""" -from typing import List, Optional - -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.category import Category -from app.schemas.category import CategoryCreate, CategoryUpdate -from app.schemas.common import PageParams - - -class CategoryService: - """分类服务""" - - @staticmethod - async def get_by_id(db: AsyncSession, category_id: str) -> Optional[Category]: - result = await db.execute(select(Category).where(Category.id == category_id)) - return result.scalar_one_or_none() - - @staticmethod - async def get_by_slug(db: AsyncSession, slug: str) -> Optional[Category]: - result = await db.execute(select(Category).where(Category.slug == slug)) - return result.scalar_one_or_none() - - @staticmethod - async def create(db: AsyncSession, data: CategoryCreate) -> Category: - category = Category( - name=data.name, - slug=data.slug, - description=data.description, - parent_id=data.parent_id, - ) - db.add(category) - await db.flush() - await db.refresh(category) - return category - - @staticmethod - async def update(db: AsyncSession, category: Category, data: CategoryUpdate) -> Category: - update_data = data.model_dump(exclude_unset=True) - for key, value in update_data.items(): - setattr(category, key, value) - await db.flush() - await db.refresh(category) - return category - - @staticmethod - async def list_all(db: AsyncSession, parent_id: Optional[str] = None) -> List[Category]: - q = select(Category) - if parent_id is not None: - q = q.where(Category.parent_id == parent_id) - q = q.order_by(Category.name) - result = await db.execute(q) - return list(result.scalars().all()) - - @staticmethod - async def list_paginated( - db: AsyncSession, - params: PageParams, - parent_id: Optional[str] = None, - ) -> tuple[List[Category], int]: - q = select(Category) - count_q = select(func.count()).select_from(Category) - if parent_id is not None: - q = q.where(Category.parent_id == parent_id) - count_q = count_q.where(Category.parent_id == parent_id) - total = (await db.execute(count_q)).scalar() or 0 - q = q.order_by(Category.name).offset((params.page - 1) * params.page_size).limit(params.page_size) - result = await db.execute(q) - return list(result.scalars().all()), total diff --git a/backend/app/services/item_service.py b/backend/app/services/item_service.py deleted file mode 100644 index fde4e0b..0000000 --- a/backend/app/services/item_service.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -商品业务逻辑 -""" -from decimal import Decimal -from typing import List, Optional - -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.item import Item -from app.schemas.item import ItemCreate, ItemUpdate -from app.schemas.common import PageParams - - -class ItemService: - """商品服务""" - - @staticmethod - async def get_by_id(db: AsyncSession, item_id: str) -> Optional[Item]: - result = await db.execute(select(Item).where(Item.id == item_id)) - return result.scalar_one_or_none() - - @staticmethod - async def get_by_slug(db: AsyncSession, slug: str) -> Optional[Item]: - result = await db.execute(select(Item).where(Item.slug == slug)) - return result.scalar_one_or_none() - - @staticmethod - async def create(db: AsyncSession, data: ItemCreate) -> Item: - item = Item( - title=data.title, - slug=data.slug, - description=data.description, - price=data.price, - stock=data.stock, - image_url=data.image_url, - category_id=data.category_id, - is_active=data.is_active, - ) - db.add(item) - await db.flush() - await db.refresh(item) - return item - - @staticmethod - async def update(db: AsyncSession, item: Item, data: ItemUpdate) -> Item: - update_data = data.model_dump(exclude_unset=True) - for key, value in update_data.items(): - setattr(item, key, value) - await db.flush() - await db.refresh(item) - return item - - @staticmethod - async def list_paginated( - db: AsyncSession, - params: PageParams, - category_id: Optional[str] = None, - is_active: Optional[bool] = None, - ) -> tuple[List[Item], int]: - q = select(Item) - count_q = select(func.count()).select_from(Item) - if category_id is not None: - q = q.where(Item.category_id == category_id) - count_q = count_q.where(Item.category_id == category_id) - if is_active is not None: - q = q.where(Item.is_active == is_active) - count_q = count_q.where(Item.is_active == is_active) - total = (await db.execute(count_q)).scalar() or 0 - q = q.offset((params.page - 1) * params.page_size).limit(params.page_size) - result = await db.execute(q) - items = list(result.scalars().all()) - return items, total - - @staticmethod - async def delete(db: AsyncSession, item: Item) -> None: - await db.delete(item) - await db.flush() diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py deleted file mode 100644 index 0000ada..0000000 --- a/backend/app/services/order_service.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -订单业务逻辑 -""" -from decimal import Decimal -from typing import List, Optional - -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.order import Order, OrderItem -from app.models.item import Item -from app.models.user import User -from app.schemas.order import OrderCreate, OrderUpdate, OrderItemCreate -from app.schemas.common import PageParams - - -class OrderService: - """订单服务""" - - @staticmethod - async def get_by_id(db: AsyncSession, order_id: str) -> Optional[Order]: - result = await db.execute(select(Order).where(Order.id == order_id)) - return result.scalar_one_or_none() - - @staticmethod - async def create(db: AsyncSession, user: User, data: OrderCreate) -> Order: - order = Order( - user_id=user.id, - status="pending", - shipping_address=data.shipping_address, - note=data.note, - ) - db.add(order) - await db.flush() - total = Decimal("0") - for oi in data.items: - item_result = await db.execute(select(Item).where(Item.id == oi.item_id)) - item = item_result.scalar_one_or_none() - if not item: - raise ValueError(f"商品不存在: {oi.item_id}") - if item.stock < oi.quantity: - raise ValueError(f"商品 {item.title} 库存不足") - subtotal = oi.unit_price * oi.quantity - total += subtotal - order_item = OrderItem( - order_id=order.id, - item_id=item.id, - quantity=oi.quantity, - unit_price=oi.unit_price, - subtotal=subtotal, - ) - db.add(order_item) - item.stock -= oi.quantity - order.total_amount = total - await db.flush() - await db.refresh(order) - return order - - @staticmethod - async def update(db: AsyncSession, order: Order, data: OrderUpdate) -> Order: - update_data = data.model_dump(exclude_unset=True) - for key, value in update_data.items(): - setattr(order, key, value) - await db.flush() - await db.refresh(order) - return order - - @staticmethod - async def list_by_user_paginated( - db: AsyncSession, - user_id: str, - params: PageParams, - status_filter: Optional[str] = None, - ) -> tuple[List[Order], int]: - q = select(Order).where(Order.user_id == user_id) - count_q = select(func.count()).select_from(Order).where(Order.user_id == user_id) - if status_filter: - q = q.where(Order.status == status_filter) - count_q = count_q.where(Order.status == status_filter) - total = (await db.execute(count_q)).scalar() or 0 - q = q.order_by(Order.created_at.desc()).offset((params.page - 1) * params.page_size).limit(params.page_size) - result = await db.execute(q) - return list(result.scalars().all()), total - - @staticmethod - async def list_paginated( - db: AsyncSession, - params: PageParams, - user_id: Optional[str] = None, - status_filter: Optional[str] = None, - ) -> tuple[List[Order], int]: - q = select(Order) - count_q = select(func.count()).select_from(Order) - if user_id: - q = q.where(Order.user_id == user_id) - count_q = count_q.where(Order.user_id == user_id) - if status_filter: - q = q.where(Order.status == status_filter) - count_q = count_q.where(Order.status == status_filter) - total = (await db.execute(count_q)).scalar() or 0 - q = q.order_by(Order.created_at.desc()).offset((params.page - 1) * params.page_size).limit(params.page_size) - result = await db.execute(q) - return list(result.scalars().all()), total diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py deleted file mode 100644 index 8839095..0000000 --- a/backend/app/services/user_service.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -用户业务逻辑 -""" -from typing import Optional - -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.user import User -from app.schemas.user import UserCreate, UserUpdate -from app.utils.security import get_password_hash - - -class UserService: - """用户服务""" - - @staticmethod - async def get_by_id(db: AsyncSession, user_id: str) -> Optional[User]: - result = await db.execute(select(User).where(User.id == user_id)) - return result.scalar_one_or_none() - - @staticmethod - async def get_by_email(db: AsyncSession, email: str) -> Optional[User]: - result = await db.execute(select(User).where(User.email == email)) - return result.scalar_one_or_none() - - @staticmethod - async def get_by_username(db: AsyncSession, username: str) -> Optional[User]: - result = await db.execute(select(User).where(User.username == username)) - return result.scalar_one_or_none() - - @staticmethod - async def create(db: AsyncSession, data: UserCreate) -> User: - user = User( - email=data.email, - username=data.username, - hashed_password=get_password_hash(data.password), - full_name=data.full_name, - bio=data.bio, - is_active=data.is_active, - ) - db.add(user) - await db.flush() - await db.refresh(user) - return user - - @staticmethod - async def update(db: AsyncSession, user: User, data: UserUpdate) -> User: - update_data = data.model_dump(exclude_unset=True) - if "password" in update_data and update_data["password"]: - update_data["hashed_password"] = get_password_hash(update_data.pop("password")) - for key, value in update_data.items(): - setattr(user, key, value) - await db.flush() - await db.refresh(user) - return user - - @staticmethod - async def count(db: AsyncSession) -> int: - result = await db.execute(select(func.count()).select_from(User)) - return result.scalar() or 0 diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py deleted file mode 100644 index 9846889..0000000 --- a/backend/app/utils/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -工具包 -""" -from app.utils.security import get_password_hash, verify_password, create_access_token, create_refresh_token -from app.utils.dependencies import get_current_user, get_current_active_user, get_optional_user - -__all__ = [ - "get_password_hash", - "verify_password", - "create_access_token", - "create_refresh_token", - "get_current_user", - "get_current_active_user", - "get_optional_user", -] diff --git a/backend/app/utils/dependencies.py b/backend/app/utils/dependencies.py deleted file mode 100644 index 117656b..0000000 --- a/backend/app/utils/dependencies.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -FastAPI 依赖注入 -""" -from typing import Annotated, Optional - -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordBearer -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.models.user import User -from app.utils.security import decode_token - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False) -http_bearer = HTTPBearer(auto_error=False) - - -async def get_current_user( - db: Annotated[AsyncSession, Depends(get_db)], - credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(http_bearer)] = None, - token: Annotated[Optional[str], Depends(oauth2_scheme)] = None, -) -> User: - """从 JWT 解析当前用户,未认证则 401""" - raw = None - if credentials: - raw = credentials.credentials - if not raw and token: - raw = token - if not raw: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="未提供认证信息", - headers={"WWW-Authenticate": "Bearer"}, - ) - payload = decode_token(raw) - if not payload or payload.get("type") != "access": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="无效或过期的 Token", - headers={"WWW-Authenticate": "Bearer"}, - ) - user_id = payload.get("sub") - if not user_id: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的 Token 载荷") - result = await db.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - if not user: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在") - return user - - -async def get_current_active_user( - current_user: Annotated[User, Depends(get_current_user)], -) -> User: - """当前用户且已激活""" - if not current_user.is_active: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="用户已被禁用") - return current_user - - -async def get_optional_user( - db: Annotated[AsyncSession, Depends(get_db)], - credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(http_bearer)] = None, - token: Annotated[Optional[str], Depends(oauth2_scheme)] = None, -) -> Optional[User]: - """可选当前用户:有 Token 则解析,没有则返回 None""" - raw = credentials.credentials if credentials else token - if not raw: - return None - payload = decode_token(raw) - if not payload or payload.get("type") != "access": - return None - user_id = payload.get("sub") - if not user_id: - return None - result = await db.execute(select(User).where(User.id == user_id)) - return result.scalar_one_or_none() diff --git a/backend/app/utils/logging_config.py b/backend/app/utils/logging_config.py deleted file mode 100644 index 6ec94ba..0000000 --- a/backend/app/utils/logging_config.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -日志配置(可选接入 loguru) -""" -import sys -from typing import Optional - -from app.config import get_settings - -settings = get_settings() - - -def setup_logging( - level: Optional[str] = None, - format_string: Optional[str] = None, -) -> None: - """配置日志级别与格式;若已安装 loguru 可在此初始化""" - level = level or settings.LOG_LEVEL - format_string = format_string or settings.LOG_FORMAT - # 示例:若使用 loguru - # import loguru - # loguru.logger.remove() - # loguru.logger.add(sys.stderr, format=format_string, level=level) - # 当前仅占位,实际可接 loguru 或 logging - pass diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py deleted file mode 100644 index f39e3d7..0000000 --- a/backend/app/utils/security.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -安全相关:密码哈希、JWT -""" -from datetime import datetime, timedelta, timezone -from typing import Any, Optional - -from jose import JWTError, jwt -from passlib.context import CryptContext - -from app.config import get_settings - -settings = get_settings() -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - - -def get_password_hash(password: str) -> str: - """生成密码哈希""" - return pwd_context.hash(password) - - -def verify_password(plain: str, hashed: str) -> bool: - """验证密码""" - return pwd_context.verify(plain, hashed) - - -def create_access_token( - subject: str, - username: str, - expires_delta: Optional[timedelta] = None, - extra: Optional[dict[str, Any]] = None, -) -> str: - """创建访问 Token""" - if expires_delta is None: - expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - expire = datetime.now(timezone.utc) + expires_delta - to_encode = { - "sub": subject, - "username": username, - "exp": expire, - "iat": datetime.now(timezone.utc), - "type": "access", - } - if extra: - to_encode.update(extra) - return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) - - -def create_refresh_token(subject: str, username: str) -> str: - """创建刷新 Token""" - expires_delta = timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) - expire = datetime.now(timezone.utc) + expires_delta - to_encode = { - "sub": subject, - "username": username, - "exp": expire, - "iat": datetime.now(timezone.utc), - "type": "refresh", - } - return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) - - -def decode_token(token: str) -> Optional[dict[str, Any]]: - """解码 Token,失败返回 None""" - try: - return jwt.decode( - token, - settings.SECRET_KEY, - algorithms=[settings.ALGORITHM], - ) - except JWTError: - return None diff --git a/backend/app/utils/validators.py b/backend/app/utils/validators.py deleted file mode 100644 index 776a282..0000000 --- a/backend/app/utils/validators.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -通用校验函数 -""" -import re -from typing import Optional - - -def slug_valid(slug: Optional[str]) -> bool: - """校验 slug:小写字母、数字、连字符""" - if not slug: - return True - return bool(re.match(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", slug)) - - -def username_valid(username: Optional[str]) -> bool: - """校验用户名:字母数字下划线,2-64 位""" - if not username: - return False - return bool(re.match(r"^[a-zA-Z0-9_]{2,64}$", username)) - - -def strong_password(password: Optional[str]) -> bool: - """简单强度:至少 8 位""" - if not password: - return False - return len(password) >= 8 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..a521d68 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,33 @@ +""" +极简后端:单文件 FastAPI,内存存储 +""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +app = FastAPI(title="简易 API", version="1.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +# 内存存储 +items_db: list[dict] = [{"id": 1, "title": "示例事项"}] + + +class ItemCreate(BaseModel): + title: str + + +@app.get("/api/health") +def health(): + return {"status": "ok"} + + +@app.get("/api/items") +def list_items(): + return {"items": items_db} + + +@app.post("/api/items") +def add_item(item: ItemCreate): + new_id = max((x["id"] for x in items_db), default=0) + 1 + items_db.append({"id": new_id, "title": item.title}) + return {"id": new_id, "title": item.title} diff --git a/backend/pytest.ini b/backend/pytest.ini deleted file mode 100644 index 98f49cf..0000000 --- a/backend/pytest.ini +++ /dev/null @@ -1,5 +0,0 @@ -[pytest] -asyncio_mode = auto -testpaths = tests -python_files = test_*.py -python_functions = test_* diff --git a/backend/requirements.txt b/backend/requirements.txt index 20b9e26..c964103 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,30 +1,3 @@ -# Web 框架 fastapi==0.109.2 uvicorn[standard]==0.27.1 - -# 数据库 -sqlalchemy==2.0.25 -asyncpg==0.29.0 -aiosqlite==0.19.0 - -# 认证与安全 -python-jose[cryptography]==3.3.0 -passlib[bcrypt]==1.7.4 -python-multipart==0.0.9 - -# 校验与工具 pydantic==2.6.1 -pydantic-settings==2.1.0 -email-validator==2.1.0 - -# 日志与监控 -loguru==0.7.2 - -# 测试 -pytest==8.0.0 -pytest-asyncio==0.23.4 -httpx==0.26.0 - -# 开发 -black==24.1.1 -isort==5.13.2 diff --git a/backend/scripts/seed.py b/backend/scripts/seed.py deleted file mode 100644 index e34f8c4..0000000 --- a/backend/scripts/seed.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -数据库种子数据脚本(示例) -运行: python -m scripts.seed -需先启动前将 DATABASE_URL 指向目标库。 -""" -import asyncio -import sys -from pathlib import Path - -# 将项目根目录加入 path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import async_session_factory, init_db -from app.models.user import User -from app.models.category import Category -from app.models.item import Item -from app.utils.security import get_password_hash - - -async def run(): - await init_db() - async with async_session_factory() as db: # type: AsyncSession - # 检查是否已有数据 - from sqlalchemy import select - r = await db.execute(select(User).limit(1)) - if r.scalar_one_or_none(): - print("已有用户数据,跳过 seed") - return - - admin = User( - email="admin@example.com", - username="admin", - hashed_password=get_password_hash("admin123"), - full_name="管理员", - is_active=True, - is_superuser=True, - ) - db.add(admin) - await db.flush() - - cat = Category(name="默认分类", slug="default", description="默认分类描述") - db.add(cat) - await db.flush() - - item = Item( - title="示例商品", - slug="sample-item", - description="这是一个示例商品", - price=99.99, - stock=100, - category_id=cat.id, - is_active=True, - ) - db.add(item) - await db.commit() - print("Seed 完成: admin 用户、默认分类、示例商品已创建") - - -if __name__ == "__main__": - asyncio.run(run()) diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py deleted file mode 100644 index d4839a6..0000000 --- a/backend/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Tests package diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py deleted file mode 100644 index 56b326e..0000000 --- a/backend/tests/conftest.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -Pytest fixtures:测试客户端、数据库、测试用户 -""" -import asyncio -from typing import AsyncGenerator, Generator - -import pytest -import pytest_asyncio -from httpx import ASGITransport, AsyncClient -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from app.database import Base, get_db -from app.main import app -from app.models.user import User -from app.utils.security import get_password_hash - - -# 测试用数据库 -TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" - - -@pytest.fixture(scope="session") -def event_loop() -> Generator: - loop = asyncio.get_event_loop_policy().new_event_loop() - yield loop - loop.close() - - -@pytest_asyncio.fixture -async def engine(): - eng = create_async_engine(TEST_DATABASE_URL, echo=False) - async with eng.begin() as conn: - from app.models import User, Item, Order, OrderItem, Category, AuditLog # noqa: F401 - await conn.run_sync(Base.metadata.create_all) - yield eng - await eng.dispose() - - -@pytest_asyncio.fixture -async def db_session(engine) -> AsyncGenerator[AsyncSession, None]: - factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - async with factory() as session: - yield session - - -@pytest_asyncio.fixture -async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: - async def override_get_db(): - yield db_session - - app.dependency_overrides[get_db] = override_get_db - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - ) as ac: - yield ac - app.dependency_overrides.clear() - - -@pytest_asyncio.fixture -async def test_user(db_session: AsyncSession) -> User: - user = User( - email="test@example.com", - username="testuser", - hashed_password=get_password_hash("password123"), - full_name="Test User", - is_active=True, - ) - db_session.add(user) - await db_session.commit() - await db_session.refresh(user) - return user diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py deleted file mode 100644 index 8fc5564..0000000 --- a/backend/tests/test_auth.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -认证接口测试 -""" -import pytest -from httpx import AsyncClient - -from app.models.user import User - - -@pytest.mark.asyncio -async def test_register_and_login(client: AsyncClient): - # 注册 - resp = await client.post( - "/api/v1/users", - json={ - "email": "auth@example.com", - "username": "authuser", - "password": "password123", - "full_name": "Auth User", - }, - ) - assert resp.status_code == 201 - user_data = resp.json() - assert user_data["email"] == "auth@example.com" - assert user_data["username"] == "authuser" - assert "hashed_password" not in user_data - - # 登录 - login_resp = await client.post( - "/api/v1/auth/login", - json={"username": "authuser", "password": "password123"}, - ) - assert login_resp.status_code == 200 - token_data = login_resp.json() - assert "access_token" in token_data - assert token_data.get("token_type") == "bearer" - - -@pytest.mark.asyncio -async def test_login_wrong_password(client: AsyncClient, test_user: User): - resp = await client.post( - "/api/v1/auth/login", - json={"username": test_user.username, "password": "wrong"}, - ) - assert resp.status_code == 401 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py deleted file mode 100644 index 1f01ca6..0000000 --- a/backend/tests/test_health.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -健康检查接口测试 -""" -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_health(client: AsyncClient): - response = await client.get("/api/v1/health") - assert response.status_code == 200 - data = response.json() - assert data.get("status") == "ok" - assert "app" in data - assert "timestamp" in data - - -@pytest.mark.asyncio -async def test_root(client: AsyncClient): - response = await client.get("/") - assert response.status_code == 200 - data = response.json() - assert "app" in data - assert data.get("api") == "/api/v1" diff --git a/frontend/index.html b/frontend/index.html index 6794b5e..1adf9da 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,12 +2,8 @@ - - Demo 前端 - - - + 简易待办
diff --git a/frontend/package.json b/frontend/package.json index d77e66b..a214252 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,22 +6,16 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", "preview": "vite preview" }, "dependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.22.0", - "axios": "^1.6.7" + "react-dom": "^18.2.0" }, "devDependencies": { "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.17", - "postcss": "^8.4.35", - "tailwindcss": "^3.4.1", "typescript": "^5.3.3", "vite": "^5.1.0" } diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js deleted file mode 100644 index 2e7af2b..0000000 --- a/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c174ed0..3eb60ce 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,96 +1,61 @@ -import { Routes, Route, Navigate } from 'react-router-dom' -import { useAuth } from './contexts/AuthContext' -import Layout from './components/Layout' -import Home from './pages/Home' -import Login from './pages/Login' -import Register from './pages/Register' -import Dashboard from './pages/Dashboard' -import Users from './pages/Users' -import Items from './pages/Items' -import ItemDetail from './pages/ItemDetail' -import Categories from './pages/Categories' -import Orders from './pages/Orders' -import OrderDetail from './pages/OrderDetail' -import Profile from './pages/Profile' -import NotFound from './pages/NotFound' +import { useState, useEffect } from 'react' -function ProtectedRoute({ children }: { children: React.ReactNode }) { - const { user, loading } = useAuth() - if (loading) { - return ( -
-
-
- ) +const API = '/api' + +type Item = { id: number; title: string } + +export default function App() { + const [items, setItems] = useState([]) + const [input, setInput] = useState('') + const [loading, setLoading] = useState(true) + + const load = () => { + fetch(`${API}/items`) + .then((r) => r.json()) + .then((d) => setItems(d.items || [])) + .finally(() => setLoading(false)) } - if (!user) { - return + + useEffect(() => { + load() + }, []) + + const add = (e: React.FormEvent) => { + e.preventDefault() + if (!input.trim()) return + fetch(`${API}/items`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: input.trim() }), + }) + .then((r) => r.json()) + .then(() => { + setInput('') + load() + }) } - return <>{children} -} -export default function App() { return ( - - }> - } /> - } /> - } /> - - - - } - /> - - - - } - /> - } - /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } +
+

简易待办

+
+ setInput(e.target.value)} + placeholder="输入事项" + style={{ flex: 1, padding: 8 }} /> - - } /> - + + + {loading ?

加载中...

: ( +
    + {items.map((it) => ( +
  • + {it.title} +
  • + ))} +
+ )} +
) } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts deleted file mode 100644 index 77d5aca..0000000 --- a/frontend/src/api/auth.ts +++ /dev/null @@ -1,41 +0,0 @@ -import api from './client' - -export interface LoginPayload { - username: string - password: string -} - -export interface TokenResponse { - access_token: string - refresh_token?: string - token_type: string - expires_in: number -} - -export interface UserResponse { - id: string - email: string - username: string - full_name: string | null - avatar_url: string | null - bio: string | null - is_active: boolean - is_superuser: boolean - created_at: string - updated_at: string -} - -export async function login(payload: LoginPayload): Promise { - const { data } = await api.post('/auth/login', payload) - return data -} - -export async function refreshToken(refreshToken: string): Promise { - const { data } = await api.post('/auth/refresh', { refresh_token: refreshToken }) - return data -} - -export async function getMe(): Promise { - const { data } = await api.get('/auth/me') - return data -} diff --git a/frontend/src/api/categories.ts b/frontend/src/api/categories.ts deleted file mode 100644 index d85d187..0000000 --- a/frontend/src/api/categories.ts +++ /dev/null @@ -1,66 +0,0 @@ -import api from './client' - -export interface CategoryResponse { - id: string - name: string - slug: string - description: string | null - parent_id: string | null - created_at: string - updated_at: string -} - -export interface CategoryCreatePayload { - name: string - slug: string - description?: string - parent_id?: string -} - -export interface CategoryUpdatePayload { - name?: string - slug?: string - description?: string - parent_id?: string -} - -export interface PaginatedResponse { - items: T[] - total: number - page: number - page_size: number - pages: number -} - -export async function listCategories(parentId?: string): Promise { - const { data } = await api.get('/categories', { params: parentId ? { parent_id: parentId } : {} }) - return data -} - -export async function listCategoriesPaginated(params?: { - page?: number - page_size?: number - parent_id?: string -}): Promise> { - const { data } = await api.get>('/categories/paginated', { params }) - return data -} - -export async function getCategory(id: string): Promise { - const { data } = await api.get(`/categories/${id}`) - return data -} - -export async function createCategory(payload: CategoryCreatePayload): Promise { - const { data } = await api.post('/categories', payload) - return data -} - -export async function updateCategory(id: string, payload: CategoryUpdatePayload): Promise { - const { data } = await api.patch(`/categories/${id}`, payload) - return data -} - -export async function deleteCategory(id: string): Promise { - await api.delete(`/categories/${id}`) -} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts deleted file mode 100644 index 0970049..0000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -1,32 +0,0 @@ -import axios, { AxiosError } from 'axios' - -const baseURL = import.meta.env.VITE_API_BASE_URL || '/api/v1' - -export const api = axios.create({ - baseURL, - headers: { - 'Content-Type': 'application/json', - }, -}) - -api.interceptors.request.use((config) => { - const token = localStorage.getItem('access_token') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config -}) - -api.interceptors.response.use( - (res) => res, - async (err: AxiosError) => { - if (err.response?.status === 401) { - localStorage.removeItem('access_token') - localStorage.removeItem('refresh_token') - window.dispatchEvent(new Event('auth:logout')) - } - return Promise.reject(err) - } -) - -export default api diff --git a/frontend/src/api/items.ts b/frontend/src/api/items.ts deleted file mode 100644 index 4c425dc..0000000 --- a/frontend/src/api/items.ts +++ /dev/null @@ -1,79 +0,0 @@ -import api from './client' - -export interface ItemResponse { - id: string - title: string - slug: string - description: string | null - price: string - stock: number - image_url: string | null - category_id: string | null - is_active: boolean - created_at: string - updated_at: string -} - -export interface ItemCreatePayload { - title: string - slug: string - description?: string - price: number - stock?: number - image_url?: string - category_id?: string - is_active?: boolean -} - -export interface ItemUpdatePayload { - title?: string - slug?: string - description?: string - price?: number - stock?: number - image_url?: string - category_id?: string - is_active?: boolean -} - -export interface PaginatedResponse { - items: T[] - total: number - page: number - page_size: number - pages: number -} - -export async function listItems(params?: { - page?: number - page_size?: number - category_id?: string - is_active?: boolean -}): Promise> { - const { data } = await api.get>('/items', { params }) - return data -} - -export async function getItem(id: string): Promise { - const { data } = await api.get(`/items/${id}`) - return data -} - -export async function getItemBySlug(slug: string): Promise { - const { data } = await api.get(`/items/slug/${slug}`) - return data -} - -export async function createItem(payload: ItemCreatePayload): Promise { - const { data } = await api.post('/items', payload) - return data -} - -export async function updateItem(id: string, payload: ItemUpdatePayload): Promise { - const { data } = await api.patch(`/items/${id}`, payload) - return data -} - -export async function deleteItem(id: string): Promise { - await api.delete(`/items/${id}`) -} diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts deleted file mode 100644 index f4e4f8b..0000000 --- a/frontend/src/api/orders.ts +++ /dev/null @@ -1,83 +0,0 @@ -import api from './client' - -export interface OrderItemResponse { - id: string - order_id: string - item_id: string - quantity: number - unit_price: string - subtotal: string - created_at: string -} - -export interface OrderResponse { - id: string - user_id: string - status: string - total_amount: string - shipping_address: string | null - note: string | null - order_items: OrderItemResponse[] - created_at: string - updated_at: string -} - -export interface OrderItemCreate { - item_id: string - quantity: number - unit_price: number -} - -export interface OrderCreatePayload { - items: OrderItemCreate[] - shipping_address?: string - note?: string -} - -export interface OrderUpdatePayload { - status?: string - shipping_address?: string - note?: string -} - -export interface PaginatedResponse { - items: T[] - total: number - page: number - page_size: number - pages: number -} - -export async function listMyOrders(params?: { - page?: number - page_size?: number - status?: string -}): Promise> { - const { data } = await api.get>('/orders', { params }) - return data -} - -export async function listAllOrders(params?: { - page?: number - page_size?: number - user_id?: string - status?: string -}): Promise> { - const { data } = await api.get>('/orders/admin', { params }) - return data -} - -export async function getOrder(id: string): Promise { - const { data } = await api.get(`/orders/${id}`) - return data -} - -export async function createOrder(payload: OrderCreatePayload): Promise { - const { data } = await api.post('/orders', payload) - return data -} - -export async function updateOrder(id: string, payload: OrderUpdatePayload): Promise { - const { data } = await api.patch(`/orders/${id}`, payload) - return data -} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts deleted file mode 100644 index 3ee554b..0000000 --- a/frontend/src/api/users.ts +++ /dev/null @@ -1,53 +0,0 @@ -import api from './client' -import type { UserResponse } from './auth' - -export interface UserCreatePayload { - email: string - username: string - password: string - full_name?: string - bio?: string - is_active?: boolean -} - -export interface UserUpdatePayload { - email?: string - username?: string - full_name?: string - avatar_url?: string - bio?: string - is_active?: boolean - password?: string -} - -export interface PaginatedResponse { - items: T[] - total: number - page: number - page_size: number - pages: number -} - -export async function listUsers(params?: { page?: number; page_size?: number }): Promise> { - const { data } = await api.get>('/users', { params }) - return data -} - -export async function getUser(id: string): Promise { - const { data } = await api.get(`/users/${id}`) - return data -} - -export async function createUser(payload: UserCreatePayload): Promise { - const { data } = await api.post('/users', payload) - return data -} - -export async function updateUser(id: string, payload: UserUpdatePayload): Promise { - const { data } = await api.patch(`/users/${id}`, payload) - return data -} - -export async function deleteUser(id: string): Promise { - await api.delete(`/users/${id}`) -} diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx deleted file mode 100644 index 35c70e7..0000000 --- a/frontend/src/components/Button.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react' - -type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' - -interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: Variant - loading?: boolean - children: React.ReactNode -} - -const variantClass: Record = { - primary: 'btn-primary', - secondary: 'btn-secondary', - ghost: 'btn-ghost', - danger: 'btn bg-red-600 hover:bg-red-700 focus:ring-red-500 text-white', -} - -export default function Button({ - variant = 'primary', - loading = false, - disabled, - children, - className = '', - ...props -}: ButtonProps) { - return ( - - ) -} diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx deleted file mode 100644 index aacb7fe..0000000 --- a/frontend/src/components/Card.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react' - -interface CardProps { - children: React.ReactNode - className?: string - title?: string -} - -export default function Card({ children, className = '', title }: CardProps) { - return ( -
- {title &&

{title}

} - {children} -
- ) -} diff --git a/frontend/src/components/Input.tsx b/frontend/src/components/Input.tsx deleted file mode 100644 index feab7b8..0000000 --- a/frontend/src/components/Input.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react' - -interface InputProps extends React.InputHTMLAttributes { - label?: string - error?: string -} - -export default function Input({ label, error, className = '', id, ...props }: InputProps) { - const inputId = id || (label ? label.replace(/\s/g, '-').toLowerCase() : undefined) - return ( -
- {label && ( - - )} - - {error &&

{error}

} -
- ) -} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx deleted file mode 100644 index 56878d9..0000000 --- a/frontend/src/components/Layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Outlet } from 'react-router-dom' -import Nav from './Nav' - -export default function Layout() { - return ( -
-
- ) -} diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx deleted file mode 100644 index b2dbb10..0000000 --- a/frontend/src/components/Nav.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Link, useNavigate } from 'react-router-dom' -import { useAuth } from '../contexts/AuthContext' - -export default function Nav() { - const { user, logout } = useAuth() - const navigate = useNavigate() - - const handleLogout = () => { - logout() - navigate('/') - } - - return ( - - ) -} diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx deleted file mode 100644 index 8352556..0000000 --- a/frontend/src/components/Pagination.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react' - -interface PaginationProps { - page: number - pages: number - onPageChange: (page: number) => void - total: number - pageSize: number -} - -export default function Pagination({ page, pages, onPageChange, total, pageSize }: PaginationProps) { - const start = (page - 1) * pageSize + 1 - const end = Math.min(page * pageSize, total) - - return ( -
- - 第 {start}-{end} 条,共 {total} 条 - -
- - - {page} / {pages || 1} - - -
-
- ) -} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx deleted file mode 100644 index 0b199de..0000000 --- a/frontend/src/contexts/AuthContext.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React, { createContext, useContext, useState, useEffect, useCallback } from 'react' -import type { UserResponse } from '../api/auth' -import { getMe, login as apiLogin, refreshToken } from '../api/auth' - -interface AuthState { - user: UserResponse | null - loading: boolean - error: string | null -} - -interface AuthContextValue extends AuthState { - login: (username: string, password: string) => Promise - logout: () => void - refreshUser: () => Promise -} - -const AuthContext = createContext(null) - -export function AuthProvider({ children }: { children: React.ReactNode }) { - const [state, setState] = useState({ - user: null, - loading: true, - error: null, - }) - - const loadUser = useCallback(async () => { - const token = localStorage.getItem('access_token') - const refresh = localStorage.getItem('refresh_token') - if (!token) { - if (refresh) { - try { - const res = await refreshToken(refresh) - localStorage.setItem('access_token', res.access_token) - if (res.refresh_token) localStorage.setItem('refresh_token', res.refresh_token) - const user = await getMe() - setState({ user, loading: false, error: null }) - return - } catch { - localStorage.removeItem('refresh_token') - } - } - setState({ user: null, loading: false, error: null }) - return - } - try { - const user = await getMe() - setState({ user, loading: false, error: null }) - } catch { - setState({ user: null, loading: false, error: null }) - } - }, []) - - useEffect(() => { - loadUser() - }, [loadUser]) - - useEffect(() => { - const handleLogout = () => { - setState({ user: null, loading: false, error: null }) - } - window.addEventListener('auth:logout', handleLogout) - return () => window.removeEventListener('auth:logout', handleLogout) - }, []) - - const login = useCallback(async (username: string, password: string) => { - setState((s) => ({ ...s, loading: true, error: null })) - try { - const res = await apiLogin({ username, password }) - localStorage.setItem('access_token', res.access_token) - if (res.refresh_token) localStorage.setItem('refresh_token', res.refresh_token) - const user = await getMe() - setState({ user, loading: false, error: null }) - } catch (err: unknown) { - const message = err && typeof err === 'object' && 'response' in err - ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail as string | undefined - : '登录失败' - setState({ user: null, loading: false, error: message || '登录失败' }) - throw err - } - }, []) - - const logout = useCallback(() => { - localStorage.removeItem('access_token') - localStorage.removeItem('refresh_token') - setState({ user: null, loading: false, error: null }) - }, []) - - const refreshUser = useCallback(async () => { - const token = localStorage.getItem('access_token') - if (!token) return - try { - const user = await getMe() - setState((s) => ({ ...s, user })) - } catch { - logout() - } - }, [logout]) - - const value: AuthContextValue = { - ...state, - login, - logout, - refreshUser, - } - - return {children} -} - -export function useAuth() { - const ctx = useContext(AuthContext) - if (!ctx) throw new Error('useAuth must be used within AuthProvider') - return ctx -} diff --git a/frontend/src/index.css b/frontend/src/index.css index 39df664..1cc7ee5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,45 +1,2 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - --color-bg: #0f172a; - --color-surface: #1e293b; - --color-border: #334155; - --color-text: #f1f5f9; - --color-muted: #94a3b8; -} - -body { - margin: 0; - font-family: 'DM Sans', system-ui, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - background-color: var(--color-bg); - color: var(--color-text); - min-height: 100vh; -} - -@layer components { - .btn { - @apply px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900; - } - .btn-primary { - @apply btn bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500; - } - .btn-secondary { - @apply btn bg-slate-600 text-white hover:bg-slate-500 focus:ring-slate-500; - } - .btn-ghost { - @apply btn bg-transparent hover:bg-slate-700 focus:ring-slate-500; - } - .input { - @apply w-full px-3 py-2 rounded-lg bg-slate-800 border border-slate-600 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent; - } - .card { - @apply bg-slate-800/50 rounded-xl border border-slate-700 p-6 shadow-xl; - } - .page-title { - @apply text-2xl font-bold text-slate-100 mb-6; - } -} +* { box-sizing: border-box; } +body { margin: 0; font-family: system-ui, sans-serif; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f92c451..964aeb4 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,16 +1,10 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import { BrowserRouter } from 'react-router-dom' import App from './App' -import { AuthProvider } from './contexts/AuthContext' import './index.css' ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - + , ) diff --git a/frontend/src/pages/Categories.tsx b/frontend/src/pages/Categories.tsx deleted file mode 100644 index f7cf785..0000000 --- a/frontend/src/pages/Categories.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useState, useEffect } from 'react' -import { - listCategories, - createCategory, - updateCategory, - deleteCategory, - type CategoryResponse, - type CategoryCreatePayload, - type CategoryUpdatePayload, -} from '../api/categories' -import Card from '../components/Card' -import Button from '../components/Button' -import Input from '../components/Input' - -export default function Categories() { - const [categories, setCategories] = useState([]) - const [loading, setLoading] = useState(true) - const [editingId, setEditingId] = useState(null) - const [formOpen, setFormOpen] = useState(false) - const [formName, setFormName] = useState('') - const [formSlug, setFormSlug] = useState('') - const [formDesc, setFormDesc] = useState('') - const [submitLoading, setSubmitLoading] = useState(false) - const [error, setError] = useState('') - - const load = async () => { - setLoading(true) - try { - const data = await listCategories() - setCategories(data) - } finally { - setLoading(false) - } - } - - useEffect(() => { - load() - }, []) - - const openCreate = () => { - setEditingId(null) - setFormName('') - setFormSlug('') - setFormDesc('') - setFormOpen(true) - setError('') - } - - const openEdit = (c: CategoryResponse) => { - setEditingId(c.id) - setFormName(c.name) - setFormSlug(c.slug) - setFormDesc(c.description || '') - setFormOpen(true) - setError('') - } - - const slugFromName = (name: string) => - name - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-]/g, '') - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError('') - setSubmitLoading(true) - try { - if (editingId) { - await updateCategory(editingId, { - name: formName, - slug: formSlug, - description: formDesc || undefined, - }) - } else { - await createCategory({ - name: formName, - slug: formSlug, - description: formDesc || undefined, - }) - } - setFormOpen(false) - await load() - } catch (err: unknown) { - const data = err && typeof err === 'object' && 'response' in err - ? (err as { response?: { data?: { detail?: string } } }).response?.data - : null - setError(typeof data?.detail === 'string' ? data.detail : '操作失败') - } finally { - setSubmitLoading(false) - } - } - - const handleDelete = async (id: string) => { - if (!window.confirm('确定删除该分类?')) return - try { - await deleteCategory(id) - await load() - } catch { - setError('删除失败') - } - } - - return ( -
-
-

分类管理

- -
- - {formOpen && ( - -

- {editingId ? '编辑分类' : '新建分类'} -

-
- {error &&

{error}

} - { - setFormName(e.target.value) - if (!editingId) setFormSlug(slugFromName(e.target.value)) - }} - required - /> - setFormSlug(e.target.value)} - required - /> - setFormDesc(e.target.value)} - /> -
- - -
-
-
- )} - - - {loading ? ( -
-
-
- ) : ( -
    - {categories.map((c) => ( -
  • -
    - {c.name} - /{c.slug} - {c.description && ( -

    {c.description}

    - )} -
    -
    - - -
    -
  • - ))} -
- )} - -
- ) -} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx deleted file mode 100644 index 753e296..0000000 --- a/frontend/src/pages/Dashboard.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Link } from 'react-router-dom' -import { useAuth } from '../contexts/AuthContext' -import Card from '../components/Card' - -export default function Dashboard() { - const { user } = useAuth() - - return ( -
-

控制台

-

- 欢迎,{user?.full_name || user?.username}。以下是常用入口。 -

- -
- - 用户 -

管理用户列表

- - - 商品 -

商品列表与维护

- - - 分类 -

分类管理

- - - 订单 -

我的订单

- -
- - -
-
用户名
-
{user?.username}
-
邮箱
-
{user?.email}
-
昵称
-
{user?.full_name || '—'}
-
角色
-
{user?.is_superuser ? '管理员' : '普通用户'}
-
- - 编辑个人资料 - -
-
- ) -} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx deleted file mode 100644 index 9700013..0000000 --- a/frontend/src/pages/Home.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Link } from 'react-router-dom' -import { useAuth } from '../contexts/AuthContext' - -export default function Home() { - const { user } = useAuth() - - return ( -
-
-

- 欢迎使用 Demo 前端 -

-

- 与 FastAPI 后端配套的 React 示例项目,包含用户、商品、分类与订单模块。 -

- {user ? ( -
- 进入控制台 - 浏览商品 -
- ) : ( -
- 登录 - 注册 - 浏览商品 -
- )} -
- -
-
-

用户与认证

-

- 注册、登录、JWT 刷新,个人资料与权限控制。 -

- - 前往 → - -
-
-

商品与分类

-

- 商品列表、详情、分类管理,支持分页与筛选。 -

- - 浏览商品 → - -
-
-

订单

-

- 下单、订单列表与详情,需登录后使用。 -

- - 我的订单 → - -
-
-
- ) -} diff --git a/frontend/src/pages/ItemDetail.tsx b/frontend/src/pages/ItemDetail.tsx deleted file mode 100644 index b0a15f7..0000000 --- a/frontend/src/pages/ItemDetail.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useState, useEffect } from 'react' -import { useParams, Link } from 'react-router-dom' -import { getItem, type ItemResponse } from '../api/items' -import Card from '../components/Card' -import Button from '../components/Button' -import { useAuth } from '../contexts/AuthContext' - -export default function ItemDetail() { - const { id } = useParams<{ id: string }>() - const [item, setItem] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - const { user } = useAuth() - - useEffect(() => { - if (!id) return - getItem(id) - .then(setItem) - .catch(() => setError('商品不存在或加载失败')) - .finally(() => setLoading(false)) - }, [id]) - - if (loading) { - return ( -
-
-
- ) - } - - if (error || !item) { - return ( -
-

{error || '未找到商品'}

- 返回列表 -
- ) - } - - return ( -
- - ← 返回商品列表 - -
-
- {item.image_url ? ( - {item.title} - ) : ( -
- 暂无图片 -
- )} -
-
- -

{item.title}

-

¥ {item.price}

-

库存:{item.stock}

- {item.description && ( -

{item.description}

- )} - {user && ( - - 去下单 - - )} -
-
-
-
- ) -} diff --git a/frontend/src/pages/Items.tsx b/frontend/src/pages/Items.tsx deleted file mode 100644 index 4de1045..0000000 --- a/frontend/src/pages/Items.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useState, useEffect } from 'react' -import { Link } from 'react-router-dom' -import { listItems, type ItemResponse } from '../api/items' -import Card from '../components/Card' -import Pagination from '../components/Pagination' -import { useAuth } from '../contexts/AuthContext' - -export default function Items() { - const [items, setItems] = useState([]) - const [total, setTotal] = useState(0) - const [page, setPage] = useState(1) - const [pageSize] = useState(12) - const [loading, setLoading] = useState(true) - const { user } = useAuth() - - useEffect(() => { - let cancelled = false - setLoading(true) - listItems({ page, page_size: pageSize }) - .then((res) => { - if (!cancelled) { - setItems(res.items) - setTotal(res.total) - } - }) - .finally(() => { - if (!cancelled) setLoading(false) - }) - return () => { cancelled = true } - }, [page, pageSize]) - - const pages = Math.ceil(total / pageSize) || 1 - - return ( -
-
-

商品列表

- {user && ( - 我的订单 - )} -
- - {loading ? ( -
-
-
- ) : ( - <> -
- {items.map((item) => ( - - {item.image_url ? ( - {item.title} - ) : ( -
- 暂无图片 -
- )} -

{item.title}

-

¥ {item.price}

-

库存 {item.stock}

- - ))} -
- - - )} -
- ) -} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx deleted file mode 100644 index ab07fde..0000000 --- a/frontend/src/pages/Login.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' -import { useAuth } from '../contexts/AuthContext' -import Input from '../components/Input' -import Button from '../components/Button' -import Card from '../components/Card' - -export default function Login() { - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - const { login, user } = useAuth() - const navigate = useNavigate() - - if (user) { - navigate('/dashboard', { replace: true }) - return null - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError('') - setLoading(true) - try { - await login(username, password) - navigate('/dashboard', { replace: true }) - } catch { - setError('用户名或密码错误,请重试') - } finally { - setLoading(false) - } - } - - return ( -
- -
- {error && ( -
- {error} -
- )} - setUsername(e.target.value)} - required - autoComplete="username" - /> - setPassword(e.target.value)} - required - autoComplete="current-password" - /> -
- - - 没有账号?去注册 - -
-
-
-
- ) -} diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx deleted file mode 100644 index c0da97b..0000000 --- a/frontend/src/pages/NotFound.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Link } from 'react-router-dom' - -export default function NotFound() { - return ( -
-

404

-

页面不存在

- - 返回首页 - -
- ) -} diff --git a/frontend/src/pages/OrderDetail.tsx b/frontend/src/pages/OrderDetail.tsx deleted file mode 100644 index bd4ffe2..0000000 --- a/frontend/src/pages/OrderDetail.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useState, useEffect } from 'react' -import { useParams, Link } from 'react-router-dom' -import { getOrder, updateOrder, type OrderResponse } from '../api/orders' -import Card from '../components/Card' -import Button from '../components/Button' -import { useAuth } from '../contexts/AuthContext' - -const statusMap: Record = { - pending: '待支付', - paid: '已支付', - shipped: '已发货', - completed: '已完成', - cancelled: '已取消', -} - -export default function OrderDetail() { - const { id } = useParams<{ id: string }>() - const [order, setOrder] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - const [updating, setUpdating] = useState(false) - const { user } = useAuth() - - const load = async () => { - if (!id) return - try { - const data = await getOrder(id) - setOrder(data) - } catch { - setError('订单不存在或加载失败') - } finally { - setLoading(false) - } - } - - useEffect(() => { - load() - }, [id]) - - const handleStatusChange = async (newStatus: string) => { - if (!order) return - setUpdating(true) - try { - const updated = await updateOrder(order.id, { status: newStatus }) - setOrder(updated) - } finally { - setUpdating(false) - } - } - - if (loading) { - return ( -
-
-
- ) - } - - if (error || !order) { - return ( -
-

{error || '未找到订单'}

- 返回列表 -
- ) - } - - const canChangeStatus = user?.is_superuser || order.user_id === user?.id - - return ( -
- - ← 返回订单列表 - - - -
-
状态
-
{statusMap[order.status] ?? order.status}
-
总金额
-
¥ {order.total_amount}
-
创建时间
-
{new Date(order.created_at).toLocaleString('zh-CN')}
- {order.shipping_address && ( - <> -
收货地址
-
{order.shipping_address}
- - )} - {order.note && ( - <> -
备注
-
{order.note}
- - )} -
- {canChangeStatus && ( -
- {(['pending', 'paid', 'shipped', 'completed', 'cancelled'] as const).map((s) => ( - - ))} -
- )} -
- - -
    - {order.order_items.map((oi) => ( -
  • - 商品 ID: {oi.item_id.slice(0, 8)} - × {oi.quantity} - ¥ {oi.subtotal} -
  • - ))} -
-
-
- ) -} diff --git a/frontend/src/pages/Orders.tsx b/frontend/src/pages/Orders.tsx deleted file mode 100644 index 23f259f..0000000 --- a/frontend/src/pages/Orders.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useState, useEffect } from 'react' -import { Link } from 'react-router-dom' -import { listMyOrders, listAllOrders, type OrderResponse } from '../api/orders' -import Card from '../components/Card' -import Pagination from '../components/Pagination' -import { useAuth } from '../contexts/AuthContext' - -const statusMap: Record = { - pending: '待支付', - paid: '已支付', - shipped: '已发货', - completed: '已完成', - cancelled: '已取消', -} - -export default function Orders() { - const [orders, setOrders] = useState([]) - const [total, setTotal] = useState(0) - const [page, setPage] = useState(1) - const [pageSize] = useState(10) - const [statusFilter, setStatusFilter] = useState('') - const [loading, setLoading] = useState(true) - const [useAdmin, setUseAdmin] = useState(false) - const { user } = useAuth() - - const load = async () => { - setLoading(true) - try { - const params = { page, page_size: pageSize, ...(statusFilter ? { status: statusFilter } : {}) } - const res = user?.is_superuser && useAdmin - ? await listAllOrders(params) - : await listMyOrders(params) - setOrders(res.items) - setTotal(res.total) - } finally { - setLoading(false) - } - } - - useEffect(() => { - load() - }, [page, statusFilter, useAdmin]) - - const pages = Math.ceil(total / pageSize) || 1 - - return ( -
-
-

订单列表

- {user?.is_superuser && ( - - )} -
- -
- -
- - - {loading ? ( -
-
-
- ) : orders.length === 0 ? ( -

暂无订单

- ) : ( - <> -
    - {orders.map((order) => ( -
  • -
    -
    - - 订单 #{order.id.slice(0, 8)} - - - {new Date(order.created_at).toLocaleString('zh-CN')} - -
    - - {statusMap[order.status] ?? order.status} - - - ¥ {order.total_amount} - -
    -

    - {order.order_items.length} 件商品 -

    -
  • - ))} -
- - - )} - -
- ) -} diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx deleted file mode 100644 index ec3dcc4..0000000 --- a/frontend/src/pages/Profile.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState } from 'react' -import { useAuth } from '../contexts/AuthContext' -import { updateUser } from '../api/users' -import Card from '../components/Card' -import Input from '../components/Input' -import Button from '../components/Button' - -export default function Profile() { - const { user, refreshUser } = useAuth() - const [fullName, setFullName] = useState(user?.full_name ?? '') - const [bio, setBio] = useState(user?.bio ?? '') - const [loading, setLoading] = useState(false) - const [message, setMessage] = useState<{ type: 'ok' | 'err'; text: string } | null>(null) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!user) return - setLoading(true) - setMessage(null) - try { - await updateUser(user.id, { full_name: fullName, bio: bio }) - await refreshUser() - setMessage({ type: 'ok', text: '保存成功' }) - } catch { - setMessage({ type: 'err', text: '保存失败' }) - } finally { - setLoading(false) - } - } - - if (!user) return null - - return ( -
-

个人资料

- -
-
用户名
-
{user.username}
-
邮箱
-
{user.email}
-
角色
-
{user.is_superuser ? '管理员' : '普通用户'}
-
- -
- {message && ( -

{message.text}

- )} - setFullName(e.target.value)} - /> -
- -