feat: 改造为开箱即用模板 — MySQL/MongoDB(Beanie) 一键切换 + AGENTS.md 开发规范

- src/ 按域组织骨架,示例域 items 双后端实现(SQLAlchemy2.0 async / Beanie 2.x)
- DB_BACKEND 环境变量切换,router 只依赖 Repo Protocol
- alembic 迁移(含初始 items 迁移)、uv 依赖管理、ruff、pytest 异步测试
- Dockerfile + docker-compose(app/mysql/mongo)
- AGENTS.md 重写为本模板开发规范;原最佳实践文档归档 docs/
- 验证:mongodb/mysql 双后端 pytest 全绿 + uvicorn 真实冒烟通过
This commit is contained in:
2026-08-21 21:23:26 +08:00
parent 5e00aa6095
commit 981bee5a46
34 changed files with 3579 additions and 1256 deletions

30
tests/conftest.py Normal file
View File

@@ -0,0 +1,30 @@
"""测试基座httpx ASGITransport 进程内测试,不起真实服务。
默认跑 mongodb 后端(需要本地 27017 有 mongo
跑 mysql 后端DB_BACKEND=mysql MYSQL_DSN=... uv run pytest
"""
import os
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
# 测试用独立库,避免污染开发数据
os.environ.setdefault("MONGO_DB", "app_test")
if os.environ.get("DB_BACKEND") == "mysql":
os.environ["MYSQL_DSN"] = os.environ.get(
"MYSQL_DSN", "mysql+aiomysql://root:root@127.0.0.1:3306/app_test?charset=utf8mb4"
)
from src.main import create_app
@pytest_asyncio.fixture
async def client():
app = create_app()
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
# 手动触发 lifespanASGITransport 不自动跑 lifespan
async with app.router.lifespan_context(app):
yield ac

33
tests/test_items.py Normal file
View File

@@ -0,0 +1,33 @@
async def test_health(client):
resp = await client.get("/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
async def test_items_crud(client):
# create
resp = await client.post("/api/v1/items", json={"name": "demo", "description": "d"})
assert resp.status_code == 201
item = resp.json()
assert item["name"] == "demo"
item_id = item["id"]
# get
resp = await client.get(f"/api/v1/items/{item_id}")
assert resp.status_code == 200
# list
resp = await client.get("/api/v1/items")
assert resp.status_code == 200
assert resp.json()["total"] >= 1
# update
resp = await client.patch(f"/api/v1/items/{item_id}", json={"name": "demo2"})
assert resp.status_code == 200
assert resp.json()["name"] == "demo2"
# delete
resp = await client.delete(f"/api/v1/items/{item_id}")
assert resp.status_code == 204
resp = await client.get(f"/api/v1/items/{item_id}")
assert resp.status_code == 404