
零基础入门python53FastAPI 文章创建与响应模型一、上一篇课后练习讲解上一篇练习围绕“current_user鉴权依赖”。参考做法是先运行上一篇的测试再用一个成功请求和一个失败请求验证边界本篇在同一项目上增加新能力。上一篇课后练习完整答案上一篇练习已经落实到完整文件运行下面代码可以观察本篇要求的成功和失败状态数据库写入全部放在明确事务边界内。答案要点OAuth2PasswordBearer 提供 tokencurrent_user 解码并加载用户依赖注入让所有受保护路由共享权限边界。完整答案文件app/dependencies.py完整参考答案文件完整文件app/dependencies.pyfromfastapiimportDepends,HTTPExceptionfromfastapi.securityimportOAuth2PasswordBearerfrom.securityimportdecode_token oauth2OAuth2PasswordBearer(tokenUrl/auth/login)defcurrent_user(token:strDepends(oauth2)):userdecode_token(token)ifuserisNone:raiseHTTPException(401,invalid_token)returnuser验收运行项目测试FastAPI/Flask 使用 python -m pytest -qDjango 使用 python manage.py test再按本文 curl 或 Docker 命令检查预期状态码。完整参考答案文件下面是交付项目中真实存在的完整文件 fastapi-blog/app/dependencies.py。它覆盖本篇新增逻辑以及前文已经完成的依赖代码复制单个函数会丢失上下文因此这里提供整份文件。fromfastapiimportDepends,HTTPExceptionfromfastapi.securityimportOAuth2PasswordBearerfromjwtimportInvalidTokenErrorfromsqlalchemy.ormimportSessionfrom.databaseimportget_dbfrom.modelsimportUserfrom.securityimportdecode_token oauth2_schemeOAuth2PasswordBearer(tokenUrl/api/auth/login)defget_current_user(token:strDepends(oauth2_scheme),db:SessionDepends(get_db)):try:user_iddecode_token(token)except(InvalidTokenError,ValueError):raiseHTTPException(401,无效或过期令牌)userdb.get(User,user_id)ifnotuser:raiseHTTPException(401,用户不存在)returnuser每次请求重新查询用户是为了让禁用状态立即生效不要只相信 JWT 中的角色字段。二、本篇要解决的真实问题创建文章时作者来自 token 而不是请求体。ArticleRead 用 from_attributes 输出数据库对象避免手写重复字典。三、请求是怎样走完整条链路的客户端请求 → 路由匹配 → Pydantic 校验 → Depends 注入用户/Session → SQLAlchemy 查询或业务服务 → 提交事务 → 响应模型序列化。每一步都有明确责任排错时按这个顺序定位。四、先看一个最小代码片段router.get(/api/health)defhealth():return{status:ok}这里的注释不是装饰它说明数据从哪里来、为什么不能相信客户端以及失败时系统应保持什么状态。五、完整文件代码app/articles.py当前阶段完整文件fromfastapiimportAPIRouter,Depends,HTTPException,Queryfromsqlalchemyimportfunc,or_,selectfromsqlalchemy.excimportIntegrityErrorfromsqlalchemy.ormimportSessionfrom.databaseimportget_dbfrom.dependenciesimportget_current_userfrom.modelsimportArticle,Comment,Like,Userfrom.schemasimportArticleCreate,ArticleRead,CommentCreate routerAPIRouter(prefix/api/articles,tags[articles])router.post(,response_modelArticleRead,status_code201)defcreate_article(body:ArticleCreate,user:UserDepends(get_current_user),db:SessionDepends(get_db)):rowArticle(**body.model_dump(),author_iduser.id);db.add(row);db.commit();db.refresh(row);returnrowrouter.get()deflist_articles(page:intQuery(1,ge1),size:intQuery(10,ge1,le100),q:str|NoneNone,db:SessionDepends(get_db)):stmtselect(Article)ifq:stmtstmt.where(or_(Article.title.contains(q),Article.content.contains(q)))totaldb.scalar(select(func.count()).select_from(stmt.subquery()))rowsdb.scalars(stmt.order_by(Article.id.desc()).offset((page-1)*size).limit(size)).all()return{page:page,size:size,total:total,items:[ArticleRead.model_validate(x)forxinrows]}router.patch(/{article_id},response_modelArticleRead)defupdate_article(article_id:int,body:ArticleCreate,user:UserDepends(get_current_user),db:SessionDepends(get_db)):rowdb.get(Article,article_id)ifnotrow:raiseHTTPException(404,文章不存在)ifrow.author_id!user.id:raiseHTTPException(403,只能修改自己的文章)row.title,row.contentbody.title,body.content;db.commit();db.refresh(row);returnrowrouter.delete(/{article_id},status_code204)defdelete_article(article_id:int,user:UserDepends(get_current_user),db:SessionDepends(get_db)):rowdb.get(Article,article_id)ifnotrow:raiseHTTPException(404,文章不存在)ifrow.author_id!user.id:raiseHTTPException(403,只能删除自己的文章)db.delete(row);db.commit()router.post(/{article_id}/comments,status_code201)defcomment(article_id:int,body:CommentCreate,user:UserDepends(get_current_user),db:SessionDepends(get_db)):ifnotdb.get(Article,article_id):raiseHTTPException(404,文章不存在)rowComment(contentbody.content,article_idarticle_id,user_iduser.id)db.add(row);db.commit();db.refresh(row);return{id:row.id,content:row.content}router.post(/{article_id}/likes,status_code201)deflike(article_id:int,user:UserDepends(get_current_user),db:SessionDepends(get_db)):ifnotdb.get(Article,article_id):raiseHTTPException(404,文章不存在)db.add(Like(article_idarticle_id,user_iduser.id))try:db.commit()exceptIntegrityError:db.rollback();raiseHTTPException(409,已经点赞)return{liked:True}阅读方法先找路由函数再找它的 Depends、输入 schema、数据库操作和 response_model这五处合起来才是一个功能。app/schemas.py当前阶段完整文件fromdatetimeimportdatetimefrompydanticimportBaseModel,ConfigDict,EmailStr,Field,field_validatorclassUserCreate(BaseModel):email:EmailStr password:strField(min_length8,max_length128)classUserRead(BaseModel):model_configConfigDict(from_attributesTrue)id:intemail:strclassToken(BaseModel):access_token:strtoken_type:strbearerclassArticleCreate(BaseModel):title:strField(min_length2,max_length120)content:strField(min_length10,max_length20000)field_validator(title)classmethoddefclean_title(cls,value):returnvalue.strip()classArticleRead(ArticleCreate):model_configConfigDict(from_attributesTrue)id:intauthor_id:intcreated_at:datetimeclassCommentCreate(BaseModel):content:strField(min_length1,max_length500)classWritingRequest(BaseModel):content:strField(min_length20,max_length10000)阅读方法先找路由函数再找它的 Depends、输入 schema、数据库操作和 response_model这五处合起来才是一个功能。六、安装、启动与验收..\..\.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 何时提交、何时回滚。能用第二个用户验证资源隔离。能复现一个失败场景并说明原因。八、课后练习围绕“文章创建与响应模型”新增一个测试用例写出请求、预期响应和断言下一篇开头会给出参考实现并继续使用本项目。五、创建文章作者必须由服务端决定客户端只能提交标题和正文不能提交 author_id。作者来自 token 解出的 current_user这是后端掌握的事实。router.post(,response_modelArticleRead,status_code201)defcreate_article(body:ArticleCreate,user:UserDepends(get_current_user),db:SessionDepends(get_db)):rowArticle(titlebody.title,contentbody.content,author_iduser.id)db.add(row)db.commit()db.refresh(row)# 读取数据库生成的 id 和 created_atreturnrowdb.refresh 很容易被忽略自增 id 在提交时才生成。response_model 又会过滤未声明字段。写入失败要回滚并记录上下文不能捕获异常后返回 200。发送带 author_id 的 JSON最终数据库中的作者仍应是 token 对应用户。直接查询 select id,title,author_id from articles order by id desc limit 1保留查询结果作为本篇证据。六、当前项目中的完整文件对照阅读七、创建文章的幂等和事务网络重试可能让客户端重复创建文章。若产品要求幂等可以让客户端提供 Idempotency-Key服务端在事务中记录 key 与响应简单博客也至少应在文档中说明 POST 重试会产生两篇文章。不要用标题去重替代幂等用户可能合法发布同名文章。创建文章后再写搜索索引或缓存时先保证主事务提交成功。附属步骤失败应进入重试队列不要回滚已经对用户可见的文章。八、补充代码文件本篇结束完整模块文件本节不是代码片段而是本篇结束时该模块的完整版本。请先备份旧文件再整体替换替换后重新运行本篇命令和测试。阅读时重点看本篇新增的函数、事务边界和错误处理未涉及的代码先不要自行删减。app/articles.pyfromfastapiimportAPIRouter,Depends,HTTPException,Queryfromsqlalchemyimportfunc,or_,selectfromsqlalchemy.excimportIntegrityErrorfromsqlalchemy.ormimportSessionfrom.databaseimportget_dbfrom.dependenciesimportget_current_userfrom.modelsimportArticle,Comment,Like,Userfrom.schemasimportArticleCreate,ArticleRead,CommentCreate routerAPIRouter(prefix/api/articles,tags[articles])router.post(,response_modelArticleRead,status_code201)defcreate_article(body:ArticleCreate,user:UserDepends(get_current_user),db:SessionDepends(get_db)):rowArticle(**body.model_dump(),author_iduser.id);db.add(row);db.commit();db.refresh(row);returnrowrouter.get()deflist_articles(page:intQuery(1,ge1),size:intQuery(10,ge1,le100),q:str|NoneNone,db:SessionDepends(get_db)):stmtselect(Article)ifq:stmtstmt.where(or_(Article.title.contains(q),Article.content.contains(q)))totaldb.scalar(select(func.count()).select_from(stmt.subquery()))rowsdb.scalars(stmt.order_by(Article.id.desc()).offset((page-1)*size).limit(size)).all()return{page:page,size:size,total:total,items:[ArticleRead.model_validate(x)forxinrows]}router.patch(/{article_id},response_modelArticleRead)defupdate_article(article_id:int,body:ArticleCreate,user:UserDepends(get_current_user),db:SessionDepends(get_db)):rowdb.get(Article,article_id)ifnotrow:raiseHTTPException(404,文章不存在)ifrow.author_id!user.id:raiseHTTPException(403,只能修改自己的文章)row.title,row.contentbody.title,body.content;db.commit();db.refresh(row);returnrowrouter.delete(/{article_id},status_code204)defdelete_article(article_id:int,user:UserDepends(get_current_user),db:SessionDepends(get_db)):rowdb.get(Article,article_id)ifnotrow:raiseHTTPException(404,文章不存在)ifrow.author_id!user.id:raiseHTTPException(403,只能删除自己的文章)db.delete(row);db.commit()router.post(/{article_id}/comments,status_code201)defcomment(article_id:int,body:CommentCreate,user:UserDepends(get_current_user),db:SessionDepends(get_db)):ifnotdb.get(Article,article_id):raiseHTTPException(404,文章不存在)rowComment(contentbody.content,article_idarticle_id,user_iduser.id)db.add(row);db.commit();db.refresh(row);return{id:row.id,content:row.content}router.post(/{article_id}/likes,status_code201)deflike(article_id:int,user:UserDepends(get_current_user),db:SessionDepends(get_db)):ifnotdb.get(Article,article_id):raiseHTTPException(404,文章不存在)db.add(Like(article_idarticle_id,user_iduser.id))try:db.commit()exceptIntegrityError:db.rollback();raiseHTTPException(409,已经点赞)return{liked:True}