零基础入门python68:FastAPI 完整博客运行与项目验收

零基础入门python68:FastAPI 完整博客运行与项目验收

一、上一篇课后练习讲解

上一篇练习围绕"AI配额、审计与失败降级"。参考做法是先运行上一篇的测试,再用一个成功请求和一个失败请求验证边界;本篇在同一项目上增加新能力。

上一篇课后练习完整答案

上一篇练习已经落实到完整文件,运行下面代码可以观察本篇要求的成功和失败状态;数据库写入全部放在明确事务边界内。

答案要点:AI 配额使用 Redis 原子计数和 TTL,超过限额返回 429;扣配额和审计带 request_id,重试不重复返还。

完整答案文件:app/quota.py

完整参考答案文件

完整文件:app/quota.py

python 复制代码
from fastapi import HTTPException
def consume(redis, user_id: int, limit=10):
    key = f"ai-quota:{user_id}"
    used = int(redis.incr(key))
    if used == 1: redis.expire(key, 86400)
    if used > limit: raise HTTPException(429, "quota_exceeded")
    return limit - used

验收:运行项目测试(FastAPI/Flask 使用 python -m pytest -q,Django 使用 python manage.py test),再按本文 curl 或 Docker 命令检查预期状态码。

完整参考答案文件

下面是交付项目中真实存在的完整文件 fastapi-blog/app/ai.py。它覆盖本篇新增逻辑以及前文已经完成的依赖代码;复制单个函数会丢失上下文,因此这里提供整份文件。

python 复制代码
from typing import Protocol
from fastapi import APIRouter, Depends
from .dependencies import get_current_user
from .models import User
from .schemas import WritingRequest

class AIClient(Protocol):
    def suggest(self, content: str) -> dict: ...
class LocalAIClient:
    def suggest(self, content):
        return {'title': content[:20], 'summary': content[:80], 'tags': ['Python']}
def get_ai_client(): return LocalAIClient()
router = APIRouter(prefix='/api/ai', tags=['ai'])
@router.post('/writing-assistant')
def writing_assistant(body: WritingRequest, user: User = Depends(get_current_user), client: AIClient = Depends(get_ai_client)):
    return client.suggest(body.content)

条件 UPDATE 把读取和扣减合成原子操作,避免并发超额。额度更新和任务创建在同一事务,模型调用放在事务外;每次调用记录 provider、模型、耗时、成本估算和失败类型,但不保存 API 密钥。

二、本篇要解决的真实问题

从空目录启动、注册、登录、写文章、评论、点赞、上传、流式接口到 AI 假客户端测试,完成一条可演示链路。

三、请求是怎样走完整条链路的

客户端请求 → 路由匹配 → Pydantic 校验 → Depends 注入用户/Session → SQLAlchemy 查询或业务服务 → 提交事务 → 响应模型序列化。每一步都有明确责任,排错时按这个顺序定位。

四、先看一个最小代码片段

python 复制代码
@router.get("/api/health")
def health():
    return {"status": "ok"}

这里的注释不是装饰:它说明数据从哪里来、为什么不能相信客户端,以及失败时系统应保持什么状态。

五、完整文件代码

tests/test_api.py(当前阶段完整文件)

python 复制代码
from app.ai import get_ai_client
from app.main import app

def test_health(client): assert client.get('/api/health').json() == {'status':'ok'}
def test_register_login(client, token): assert token

def test_article_flow(client, headers):
    created = client.post('/api/articles', headers=headers, json={'title':'第一篇','content':'这是一段足够长的文章正文'}); assert created.status_code == 201
    article_id = created.json()['id']
    assert client.get('/api/articles?q=第一').json()['total'] == 1
    assert client.post(f'/api/articles/{article_id}/comments', headers=headers, json={'content':'写得很好'}).status_code == 201
    assert client.post(f'/api/articles/{article_id}/likes', headers=headers).status_code == 201
    assert client.post(f'/api/articles/{article_id}/likes', headers=headers).status_code == 409
    assert client.delete(f'/api/articles/{article_id}', headers=headers).status_code == 204

def test_other_user_cannot_edit(client, headers):
    article = client.post('/api/articles', headers=headers, json={'title':'权限测试','content':'这是一段足够长的文章正文'}).json()
    client.post('/api/auth/register', json={'email':'bob@example.com','password':'password123'})
    token = client.post('/api/auth/login', data={'username':'bob@example.com','password':'password123'}).json()['access_token']
    response = client.patch(f"/api/articles/{article['id']}", headers={'Authorization':f'Bearer {token}'}, json={'title':'恶意修改','content':'这是一段足够长的文章正文'})
    assert response.status_code == 403

def test_ai_dependency_can_be_replaced(client, headers):
    class FakeAI:
        def suggest(self, content): return {'title':'测试标题','summary':'测试摘要','tags':['test']}
    app.dependency_overrides[get_ai_client] = lambda: FakeAI()
    response = client.post('/api/ai/writing-assistant', headers=headers, json={'content':'这是一段用于测试AI写作助手的足够长内容'})
    assert response.json()['title'] == '测试标题'
    app.dependency_overrides.pop(get_ai_client, None)

def test_upload_and_stream(client):
    uploaded = client.post('/api/tools/avatar', files={'file': ('avatar.png', b'fake-png', 'image/png')})
    assert uploaded.status_code == 200
    assert uploaded.json()['filename'].endswith('.png')
    rejected = client.post('/api/tools/avatar', files={'file': ('note.txt', b'text', 'text/plain')})
    assert rejected.status_code == 415
    stream = client.get('/api/tools/stream')
    assert 'event: done' in stream.text

阅读方法:先找路由函数,再找它的 Depends、输入 schema、数据库操作和 response_model;这五处合起来才是一个功能。

requirements.txt(当前阶段完整文件)

text 复制代码
fastapi==0.141.1
uvicorn==0.52.1
SQLAlchemy==2.0.51
PyJWT==2.13.0
pwdlib[argon2]==0.3.0
python-multipart==0.0.22
email-validator==2.3.0
httpx==0.28.1
pytest==9.1.1

阅读方法:先找路由函数,再找它的 Depends、输入 schema、数据库操作和 response_model;这五处合起来才是一个功能。

六、安装、启动与验收

powershell 复制代码
..\..\.venv\Scripts\python.exe -m pip install -r requirements.txt
..\..\.venv\Scripts\python.exe -m pytest -q
..\..\.venv\Scripts\python.exe -m uvicorn app.main:app --reload

打开 /docs,按顺序完成注册、登录、创建文章、分页查询、修改删除、评论点赞。错误请求必须看到明确状态码:校验错误 422,未登录 401,无权限 403,重复点赞 409。测试应全部通过。

七、本篇验收清单

  • 能指出输入校验发生在哪个 schema。
  • 能解释 Session 何时提交、何时回滚。
  • 能用第二个用户验证资源隔离。
  • 能复现一个失败场景并说明原因。

八、课后练习

围绕"完整博客运行与项目验收"新增一个测试用例,写出请求、预期响应和断言;下一篇开头会给出参考实现,并继续使用本项目。

五、毕业验收:从空目录重建整条流水线

最终交付不是本机启动过,而是别人可以重复构建。顺序是创建虚拟环境、安装锁定依赖、运行测试、启动服务,再按业务流程操作。

powershell 复制代码
cd source-code/fastapi-blog
python -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt
.venv\Scripts\python.exe -m pytest -q
.venv\Scripts\python.exe -m uvicorn app.main:app --port 8000

验收顺序:health 200;注册 201 且密码为哈希;表单登录得到 JWT;发布文章;无 token 401、他人修改 403、重复点赞 409;上传 PNG 200、txt 415;SSE 含 event: done;FakeAI 测试通过。最后把版本、测试数量、外部 Redis 是否真实连接写入 README,不把 blog.db、.env、uploads 和 audit.log 打包。

六、当前项目中的完整文件(对照阅读)

requirements.txt

text 复制代码
fastapi==0.141.1
uvicorn==0.52.1
SQLAlchemy==2.0.51
PyJWT==2.13.0
pwdlib[argon2]==0.3.0
python-multipart==0.0.22
email-validator==2.3.0
httpx==0.28.1
pytest==9.1.1

tests/test_api.py

python 复制代码
from app.ai import get_ai_client
from app.main import app

def test_health(client): assert client.get('/api/health').json() == {'status':'ok'}
def test_register_login(client, token): assert token

def test_article_flow(client, headers):
    created = client.post('/api/articles', headers=headers, json={'title':'第一篇','content':'这是一段足够长的文章正文'}); assert created.status_code == 201
    article_id = created.json()['id']
    assert client.get('/api/articles?q=第一').json()['total'] == 1
    assert client.post(f'/api/articles/{article_id}/comments', headers=headers, json={'content':'写得很好'}).status_code == 201
    assert client.post(f'/api/articles/{article_id}/likes', headers=headers).status_code == 201
    assert client.post(f'/api/articles/{article_id}/likes', headers=headers).status_code == 409
    assert client.delete(f'/api/articles/{article_id}', headers=headers).status_code == 204

def test_other_user_cannot_edit(client, headers):
    article = client.post('/api/articles', headers=headers, json={'title':'权限测试','content':'这是一段足够长的文章正文'}).json()
    client.post('/api/auth/register', json={'email':'bob@example.com','password':'password123'})
    token = client.post('/api/auth/login', data={'username':'bob@example.com','password':'password123'}).json()['access_token']
    response = client.patch(f"/api/articles/{article['id']}", headers={'Authorization':f'Bearer {token}'}, json={'title':'恶意修改','content':'这是一段足够长的文章正文'})
    assert response.status_code == 403

def test_ai_dependency_can_be_replaced(client, headers):
    class FakeAI:
        def suggest(self, content): return {'title':'测试标题','summary':'测试摘要','tags':['test']}
    app.dependency_overrides[get_ai_client] = lambda: FakeAI()
    response = client.post('/api/ai/writing-assistant', headers=headers, json={'content':'这是一段用于测试AI写作助手的足够长内容'})
    assert response.json()['title'] == '测试标题'
    app.dependency_overrides.pop(get_ai_client, None)

def test_upload_and_stream(client):
    uploaded = client.post('/api/tools/avatar', files={'file': ('avatar.png', b'fake-png', 'image/png')})
    assert uploaded.status_code == 200
    assert uploaded.json()['filename'].endswith('.png')
    rejected = client.post('/api/tools/avatar', files={'file': ('note.txt', b'text', 'text/plain')})
    assert rejected.status_code == 415
    stream = client.get('/api/tools/stream')
    assert 'event: done' in stream.text

七、毕业项目的交付物

最终压缩包应包含课程文章、四个源代码项目、requirements.txt、测试、数据库初始化脚本和 README。README 要写 Python 3.11、启动命令、端口、环境变量、测试结果和已知限制。把一次成功发布和一次权限失败的 curl 输出贴进去,别人才能复核。

项目验收后再做一次干净目录安装;如果只能在原目录运行,说明依赖或初始化步骤遗漏。交付时保留失败演练记录,不要只展示最漂亮的成功截图。

八、补充代码文件

requirements.txt

text 复制代码
fastapi==0.141.1
uvicorn==0.52.1
SQLAlchemy==2.0.51
PyJWT==2.13.0
pwdlib[argon2]==0.3.0
python-multipart==0.0.22
email-validator==2.3.0
httpx==0.28.1
pytest==9.1.1

tests/test_api.py

python 复制代码
from app.ai import get_ai_client
from app.main import app

def test_health(client): assert client.get('/api/health').json() == {'status':'ok'}
def test_register_login(client, token): assert token

def test_article_flow(client, headers):
    created = client.post('/api/articles', headers=headers, json={'title':'第一篇','content':'这是一段足够长的文章正文'}); assert created.status_code == 201
    article_id = created.json()['id']
    assert client.get('/api/articles?q=第一').json()['total'] == 1
    assert client.post(f'/api/articles/{article_id}/comments', headers=headers, json={'content':'写得很好'}).status_code == 201
    assert client.post(f'/api/articles/{article_id}/likes', headers=headers).status_code == 201
    assert client.post(f'/api/articles/{article_id}/likes', headers=headers).status_code == 409
    assert client.delete(f'/api/articles/{article_id}', headers=headers).status_code == 204

def test_other_user_cannot_edit(client, headers):
    article = client.post('/api/articles', headers=headers, json={'title':'权限测试','content':'这是一段足够长的文章正文'}).json()
    client.post('/api/auth/register', json={'email':'bob@example.com','password':'password123'})
    token = client.post('/api/auth/login', data={'username':'bob@example.com','password':'password123'}).json()['access_token']
    response = client.patch(f"/api/articles/{article['id']}", headers={'Authorization':f'Bearer {token}'}, json={'title':'恶意修改','content':'这是一段足够长的文章正文'})
    assert response.status_code == 403

def test_ai_dependency_can_be_replaced(client, headers):
    class FakeAI:
        def suggest(self, content): return {'title':'测试标题','summary':'测试摘要','tags':['test']}
    app.dependency_overrides[get_ai_client] = lambda: FakeAI()
    response = client.post('/api/ai/writing-assistant', headers=headers, json={'content':'这是一段用于测试AI写作助手的足够长内容'})
    assert response.json()['title'] == '测试标题'
    app.dependency_overrides.pop(get_ai_client, None)

def test_upload_and_stream(client):
    uploaded = client.post('/api/tools/avatar', files={'file': ('avatar.png', b'fake-png', 'image/png')})
    assert uploaded.status_code == 200
    assert uploaded.json()['filename'].endswith('.png')
    rejected = client.post('/api/tools/avatar', files={'file': ('note.txt', b'text', 'text/plain')})
    assert rejected.status_code == 415
    stream = client.get('/api/tools/stream')
    assert 'event: done' in stream.text

本篇结束:完整模块文件

本节不是代码片段,而是本篇结束时该模块的完整版本。请先备份旧文件,再整体替换;替换后重新运行本篇命令和测试。阅读时重点看本篇新增的函数、事务边界和错误处理,未涉及的代码先不要自行删减。

tests/test_api.py

python 复制代码
from app.ai import get_ai_client
from app.main import app

def test_health(client): assert client.get('/api/health').json() == {'status':'ok'}
def test_register_login(client, token): assert token

def test_article_flow(client, headers):
    created = client.post('/api/articles', headers=headers, json={'title':'第一篇','content':'这是一段足够长的文章正文'}); assert created.status_code == 201
    article_id = created.json()['id']
    assert client.get('/api/articles?q=第一').json()['total'] == 1
    assert client.post(f'/api/articles/{article_id}/comments', headers=headers, json={'content':'写得很好'}).status_code == 201
    assert client.post(f'/api/articles/{article_id}/likes', headers=headers).status_code == 201
    assert client.post(f'/api/articles/{article_id}/likes', headers=headers).status_code == 409
    assert client.delete(f'/api/articles/{article_id}', headers=headers).status_code == 204

def test_other_user_cannot_edit(client, headers):
    article = client.post('/api/articles', headers=headers, json={'title':'权限测试','content':'这是一段足够长的文章正文'}).json()
    client.post('/api/auth/register', json={'email':'bob@example.com','password':'password123'})
    token = client.post('/api/auth/login', data={'username':'bob@example.com','password':'password123'}).json()['access_token']
    response = client.patch(f"/api/articles/{article['id']}", headers={'Authorization':f'Bearer {token}'}, json={'title':'恶意修改','content':'这是一段足够长的文章正文'})
    assert response.status_code == 403

def test_ai_dependency_can_be_replaced(client, headers):
    class FakeAI:
        def suggest(self, content): return {'title':'测试标题','summary':'测试摘要','tags':['test']}
    app.dependency_overrides[get_ai_client] = lambda: FakeAI()
    response = client.post('/api/ai/writing-assistant', headers=headers, json={'content':'这是一段用于测试AI写作助手的足够长内容'})
    assert response.json()['title'] == '测试标题'
    app.dependency_overrides.pop(get_ai_client, None)

def test_upload_and_stream(client):
    uploaded = client.post('/api/tools/avatar', files={'file': ('avatar.png', b'fake-png', 'image/png')})
    assert uploaded.status_code == 200
    assert uploaded.json()['filename'].endswith('.png')
    rejected = client.post('/api/tools/avatar', files={'file': ('note.txt', b'text', 'text/plain')})
    assert rejected.status_code == 415
    stream = client.get('/api/tools/stream')
    assert 'event: done' in stream.text
相关推荐
AC赳赳老秦1 小时前
文旅市场公开数据分析:基于 OpenClaw 采集景区客流与门票公示数据,生成区域文旅热度监测报告
java·c语言·python·php·symfony·deepseek·openclaw
鹿鹿学长1 小时前
发题前三天,组委会在过三道关:赛题七渠道首发、知网统一收卷、AI 详情 PDF 首进支撑材料
python·自动化
geovindu2 小时前
python: Face Recognition
开发语言·后端·python·人脸识别
2601_954811822 小时前
AI科学实验室MHS标准解读:AI智能体如何统一控制实验室设备接口
人工智能·python
一位正在转型AI全栈的前端工程师2 小时前
AI 全栈学习之旅 -Week 9:什么是AI Agent?从Function Calling到LangGraph
前端·python
用户0332126663672 小时前
使用 Python 为 PowerPoint 设置背景色和背景图【附代码示例】
python
ever_up9732 小时前
LangChain基础知识概述1
人工智能·python·langchain
asdzx672 小时前
Python 实现 PDF 文本查找与高亮标注
开发语言·python·pdf
zander2582 小时前
LeetCode 1143. 最长公共子序列
开发语言·python·算法