ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

实验一考题二 FastAPI 记账接口实验报告

实验一考题二 FastAPI 记账接口实验报告

一、实验目的:

  1. 使用 FastAPI 实现简易记账 HTTP 服务,掌握请求体、查询参数和 JSON 响应的处理方法。
  2. 完成流水新增、条件筛选、分页查询和收支汇总,理解 SQLite 持久化与事务处理。
  3. 使用 pytest 验证正常功能、非法参数与并发写入,并记录 AI 辅助编码的需求拆解、实现和验证过程。

二、实验环境:

项目实验环境
操作系统macOS
Python3.12.14
Web 框架FastAPI 0.146.0
服务程序Uvicorn 0.54.0
数据库SQLite 3.53.1
测试工具pytest 9.1.1、HTTPX 0.28.1
数据保存方式本地 SQLite 文件,金额以整数“分”存储

运行方式:安装requirements.txt中的依赖后,在工程目录执行以下命令。详细说明见同目录README.md。

python-muvicorn main:app--host127.0.0.1--port8000python-mpytest-v

三、实验内容:

1. 需求分析与验收约定

本实验只完成考题二。输入为 HTTP 请求中的 JSON 数据与查询参数,输出为 JSON 格式的流水、分页结果、收支汇总或错误信息。

接口功能关键规则
POST /records新增流水金额大于 0、最多两位小数;类型为收入或支出;分类非空;日期为 YYYY-MM-DD;成功返回 201
GET /records查询流水可组合筛选类型、分类、起止日期,支持 page 和 page_size,成功返回 200
GET /summary汇总收支按指定日期区间计算收入、支出和结余,成功返回 200
非法请求错误处理参数校验失败返回 422,errors 数组给出字段、原因和错误类型

日期区间包含开始和结束当天,省略某一端表示不限制该端。开始日期晚于结束日期时拒绝请求。分页默认第 1 页,每页 10 条,最大每页 100 条;超出数据范围返回空列表。查询结果按日期降序、编号降序排列。无数据时汇总返回 0.00。

金额字段名为amount,类型字段为type,分类字段为category,日期字段为date。为避免金额计算时的二进制浮点误差,建议输入金额字符串;服务用 Decimal 校验后转换为整数分保存,返回时统一格式化为两位小数字符串。

2. 实现过程与主要代码

先定义输入模型和校验规则,再创建数据库表,随后实现新增、筛选分页和汇总,最后运行测试与真实 HTTP 调用。下列代码为工程中完整的main.py,代码中不含注释。

importosimportsqlite3fromcontextlibimportclosingfromdatetimeimportdatefromdecimalimportDecimalfrompathlibimportPathfromtypingimportAnnotated,LiteralfromfastapiimportDepends,FastAPI,Queryfromfastapi.exceptionsimportRequestValidationErrorfromfastapi.responsesimportJSONResponsefrompydanticimportBaseModel,ConfigDict,Field,field_validator RecordType=Literal["收入","支出"]classRecordInput(BaseModel):model_config=ConfigDict(extra="forbid")amount:Decimal=Field(gt=0,max_digits=12,decimal_places=2)type:RecordType category:str=Field(min_length=1,max_length=50)date:date@field_validator("date",mode="before")@classmethoddefvalidate_date(cls,value):ifnotisinstance(value,str):raiseValueError("日期必须使用 YYYY-MM-DD 格式")parsed=date.fromisoformat(value)ifparsed.isoformat()!=value:raiseValueError("日期必须使用 YYYY-MM-DD 格式")returnvalue@field_validator("category")@classmethoddefvalidate_category(cls,value):value=value.strip()ifnotvalue:raiseValueError("分类不能为空")returnvaluedefdate_range(start_date:date|None=None,end_date:date|None=None,):ifstart_dateandend_dateandstart_date>end_date:raiseRequestValidationError([{"loc":["query","end_date"],"msg":"结束日期不能早于开始日期","type":"date_range",}])returnstart_date,end_datedefcreate_app(db_path=None):db_path=str(db_pathoros.getenv("ACCOUNT_DB","records.db"))Path(db_path).parent.mkdir(parents=True,exist_ok=True)defconnect():connection=sqlite3.connect(db_path,timeout=10)connection.row_factory=sqlite3.Rowreturnconnectionwithclosing(connect())asconnection:connection.execute("PRAGMA journal_mode=WAL")connection.execute(""" CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, amount_cents INTEGER NOT NULL CHECK(amount_cents > 0), type TEXT NOT NULL CHECK(type IN ('收入', '支出')), category TEXT NOT NULL, date TEXT NOT NULL ) """)connection.commit()app=FastAPI(title="简易记账接口",version="1.0.0")@app.exception_handler(RequestValidationError)asyncdefvalidation_error(request,exception):errors=[{"field":".".join(map(str,error["loc"])),"message":error["msg"],"type":error["type"]}forerrorinexception.errors()]returnJSONResponse(status_code=422,content={"errors":errors})@app.exception_handler(sqlite3.OperationalError)asyncdefdatabase_error(request,exception):returnJSONResponse(status_code=503,content={"errors":[{"field":"database","message":"数据库暂时不可用,请稍后重试","type":"database_unavailable"}]})deffilters(type=None,category=None,dates=(None,None)):clauses,values=[],[]forcolumn,operator,valuein[("type","=",type),("category","=",category),("date",">=",dates[0]),("date","<=",dates[1])]:ifvalueisnotNone:clauses.append(f"{column}{operator}?")values.append(value.isoformat()ifisinstance(value,date)elsevalue)return(" WHERE "+" AND ".join(clauses)ifclauseselse""),valuesdefmoney(cents):returnformat(Decimal(cents)/100,".2f")@app.post("/records",status_code=201)defadd_record(record:RecordInput):cents=int(record.amount*100)withclosing(connect())asconnection,connection:cursor=connection.execute("INSERT INTO records(amount_cents, type, category, date) VALUES (?, ?, ?, ?)",(cents,record.type,record.category,record.date.isoformat()))record_id=cursor.lastrowidreturn{"id":record_id,"amount":money(cents),"type":record.type,"category":record.category,"date":record.date.isoformat()}@app.get("/records")deflist_records(type:RecordType|None=None,category:Annotated[str|None,Query(min_length=1,max_length=50)]=None,page:Annotated[int,Query(ge=1)]=1,page_size:Annotated[int,Query(ge=1,le=100)]=10,dates=Depends(date_range),):where,values=filters(type,category,dates)withclosing(connect())asconnection:connection.execute("BEGIN")total=connection.execute("SELECT COUNT(*) FROM records"+where,values).fetchone()[0]rows=connection.execute("SELECT * FROM records"+where+" ORDER BY date DESC, id DESC LIMIT ? OFFSET ?",values+[page_size,(page-1)*page_size]).fetchall()connection.commit()items=[{"id":row["id"],"amount":money(row["amount_cents"]),"type":row["type"],"category":row["category"],"date":row["date"]}forrowinrows]return{"total":total,"page":page,"page_size":page_size,"items":items}@app.get("/summary")defsummary(dates=Depends(date_range)):where,values=filters(dates=dates)withclosing(connect())asconnection:row=connection.execute(""" SELECT COALESCE(SUM(CASE WHEN type='收入' THEN amount_cents ELSE 0 END), 0), COALESCE(SUM(CASE WHEN type='支出' THEN amount_cents ELSE 0 END), 0) FROM records """+where,values).fetchone()return{"income":money(row[0]),"expense":money(row[1]),"balance":money(row[0]-row[1])}returnapp app=create_app()

新增流水使用参数化 SQL,避免将用户输入直接拼接进 SQL。查询中的列名和运算符来自固定程序列表,筛选值仍使用占位符传入。列表总数和当前页数据在同一读事务中获取,避免并发写入时两次查询读到不同快照。

每次请求建立独立 SQLite 连接,写操作在事务中提交或回滚;SQLite 对写事务进行串行协调,主键由数据库分配。WAL 模式支持读写并行,连接等待锁的超时为 10 秒。如果数据库忙碌超时等操作错误发生,接口返回结构化的 503 提示。该机制适合本实验的小规模本地服务;高写入负载仍需进一步压测。

图 1 实际源代码片段及验证过程。图片由本次工程源码和执行记录排版生成,并非终端原生截图。

3. 功能调用与运行结果

启动 Uvicorn 后,使用 HTTP 客户端向本地服务依次新增以下三条流水,再调用筛选和汇总接口。记录图片中的响应来自实际 HTTP 调用。

日期类型分类金额(元)
2026-10-01收入工资5000.00
2026-10-02支出餐饮35.50
2026-10-03支出交通12.80

查询GET /records?type=支出&page=1&page_size=1,应得到符合条件的总记录数 2,当前页仅返回日期较新的交通支出 12.80 元。

查询GET /summary?start_date=2026-10-01&end_date=2026-10-03,实际返回:

{"income":"5000.00","expense":"48.30","balance":"4951.70"}

人工核对:支出为 35.50 + 12.80 = 48.30 元,结余为 5000.00 − 48.30 = 4951.70 元,与接口结果一致。

图 2 新增、筛选分页及区间汇总的实际 HTTP 响应展示。

4. 测试用例与异常处理

测试文件test_main.py使用临时数据库,覆盖正常调用和多种边界情况。

测试项目验证内容预期结果
正常新增完整合法流水201,返回编号及流水字段
金额边界0、负数、超过两位小数、NaN、超过位数上限422,不写入数据
字段异常非法类型、空白分类、缺少金额、多余字段422,结构化错误
日期异常不存在的日期、非约定请求体日期格式422
筛选分页类型、分类和日期组合筛选,翻页及超出末页总数、排序和当前页结果正确
汇总日期边界内的收入、支出、结余与预期金额相等,区间外记录被排除
空数据空列表及空区间汇总空列表与 0.00
查询异常第 0 页、每页超过 100 条、反向日期区间、非法日期422
并发写入10 个线程并发提交 100 笔 0.01 元收入全部 201,100 个不同编号,总收入 1.00 元
持久化重建应用并读取同一数据库已写入数据仍存在

实际执行结果为24 passed,1 warning。警告来自测试客户端对 HTTPX 的兼容性弃用提示,不影响本次测试通过。并发用例同时检查成功状态码、记录总数、编号唯一性和汇总金额。该结果验证了本次测试范围,不能据此推断任意并发规模均无风险。

另外向正在运行的 HTTP 服务提交金额为 0 的请求,实际返回 422,响应含errors数组与body.amount字段。

图 3 pytest 实际输出与非法金额请求的实际响应展示。原始记录保存在pytest-result.txt和evidence.json中。

5. AI 协作与代码审查记录

本次协作中的用户要求是完成考题二、生成四部分 Markdown 报告、提供无注释主要代码及过程和结果图片。AI 将任务拆解为需求约定、接口实现、测试、真实 HTTP 验证和报告整理。

阶段本次处理内容验证或保留记录
需求澄清明确字段名、类型取值、金额精度、日期边界及分页约束本报告第 1 节
编码新建 main.py 与 test_main.py完整源文件及 changes.diff
验证安装依赖,执行 pytest;启动服务并调用接口pytest-result.txt、evidence.json、结果记录图片
复核检查参数化 SQL、整数金额、事务、连接关闭及分页快照主要代码及对应测试

changes.diff记录本次新增源码的差异,不虚构多轮修改历史。完整对话保留在当前聊天中,本节仅是关键过程摘要。

风险审查发现:当前接口未实现登录认证和账户隔离,只适合本地实验;数据库写入受 SQLite 单写者机制限制;源码未记录完整请求体,异常响应也不暴露数据库路径和 SQL 细节。运行前应根据使用场景确定数据库备份、访问控制与部署方式。代码由 AI 辅助生成,提交前仍需由提交者阅读并确认。

四、心得体会:

本实验表明,在交给 AI 编写代码之前,先明确金额精度、日期边界、分页规则和错误格式,可以减少实现过程中反复调整。接口能够启动只是第一步,还需要通过独立的预期值检查结果是否正确。

金额处理和并发写入是本次实验的重点。使用整数分存储后,汇总结果可以精确核对;使用独立连接、事务与数据库主键后,可以减少共享连接和手动分配编号引起的问题。测试既要检查正常返回,也要检查非法数据没有进入数据库。

AI 适合辅助搭建接口、补充测试和整理文档,但业务规则与交付内容仍需要逐项核对。后续可以在现有功能上增加用户认证、账户隔离和数据库备份,并在更高并发下继续验证服务行为。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进