(十五)重复提交与错误反馈
1. 保存之后再读取页面
表单保存成功后返回 303,Location 指向 /,浏览器随后改用 GET 读取。这个“POST → Redirect → GET”的过程常简称 PRG:刷新当前 GET 页面,不会重新发送刚才的新增表单。
为了逐项观察响应,先准备独立演示的运行环境。BookApp 是这里的书架应用,HTTPX 的 WSGITransport 直接按 WSGI 接口调用它;临时数据库让各个演示从空书架开始。
# 项目文件:web_project/src/webbook/lab.py
# 独立演示每次使用临时数据库;WSGITransport直接调用应用,便于对照具体状态码。
import tempfile
from contextlib import contextmanager
from pathlib import Path
import httpx
from .app import BookApp
from .database import initialize
@contextmanager
def lab_client():
with tempfile.TemporaryDirectory(prefix="web-book-") as folder:
database = Path(folder) / "library.sqlite3"
initialize(database)
app = BookApp(
database, origin="http://book.test", cors_origins={"http://front.test"}
)
transport = httpx.WSGITransport(app=app)
with httpx.Client(
transport=transport, base_url="http://book.test", follow_redirects=False
) as client:
yield client, app, database
def login(client, username="alice", password="learn-alice"):
token = client.get("/api/session").json()["csrf_token"]
response = client.post(
"/login", data={"username": username, "password": password, "csrf_token": token}
)
if response.status_code != 303:
raise RuntimeError("演示登录没有完成")
return client.get("/api/session").json()["csrf_token"]lab_client 复用前面 contextmanager 的用法,with 结束后关闭客户端并清理临时目录。login 则按“取令牌 → POST 登录 → 取新令牌”的顺序返回登录会话的令牌,供下面各个演示使用。
# 项目文件:demos/form_redirect.py
# POST成功返回303,随后GET读取;刷新GET不会再次发送原来的新增操作。
from webbook.lab import lab_client, login
with lab_client() as (client, _app, _database):
csrf = login(client)
response = client.post(
"/books", data={"title": "表单书籍", "tags": "网络,Python", "csrf_token": csrf}
)
print("POST", response.status_code, response.headers["location"])
print("跳转GET", client.get(response.headers["location"]).status_code)
client.get("/")
print("刷新后数量", len(client.get("/api/books").json()["items"]))
again = client.post("/books", data={"title": "表单书籍", "csrf_token": csrf})
print("再次提交", again.status_code)前面三行输出为 POST 303、跳转 GET 200、刷新后数量 1。用户再次主动提交相同表单时,数据库唯一约束让它返回 409。PRG 改变成功后的页面导航,唯一约束处理重复记录,两者分别解决不同步骤的问题。
2. 按实际失败位置给出响应
# 项目文件:demos/error_cases.py
# 每份请求只改一个位置,观察状态码怎样区分身份、格式、字段和业务冲突。
from webbook.lab import lab_client, login
with lab_client() as (client, _app, _database):
print("未登录", client.get("/api/books").status_code)
token = login(client)
headers = {"X-CSRF-Token": token}
data = {"title": "Python Web", "finished": False, "tags": []}
print("创建", client.post("/api/books", json=data, headers=headers).status_code)
print("重复", client.post("/api/books", json=data, headers=headers).status_code)
print(
"字段类型",
client.post(
"/api/books", json={**data, "finished": "false"}, headers=headers
).status_code,
)
print(
"内容类型",
client.post("/api/books", data={"title": "A"}, headers=headers).status_code,
)
print(
"JSON结构",
client.post(
"/api/books",
content="{",
headers={**headers, "Content-Type": "application/json"},
).status_code,
)
print("缺令牌", client.post("/api/books", json=data).status_code)
print("未找到", client.get("/api/books/999").status_code)
response = client.put("/api/books")
print("方法", response.status_code, response.headers["allow"])| 状态 | 本章的实际触发 | 数据库中的结果 |
|---|---|---|
| 400 | JSON 文本缺少结构、UTF-8 解码失败 | 不新增 |
| 401 | 没有登录身份,或密码不正确 | 不取得对应权限 |
| 403 | 令牌不符、来源未许可或书籍归属不符 | 不执行该写入 |
| 404 | 地址或书籍编号没有找到 | 不新增 |
| 405 | 路径不支持该请求方法 | 不执行该动作 |
| 409 | 同账号已保存同名书籍 | 保留已有一条 |
| 413 | 书籍请求体超过任务规定大小 | 不读取为书籍 |
| 415 | 入口需要 JSON,却提交表单类型 | 不进入字段校验 |
| 422 | 数据已经解析,但字段或查询不符合规则 | 显示需要修改的字段 |
| 500 | 请求中的数据库操作失败 | 回滚本次事务 |
客户端断开时,也可能已经完成了数据库提交、只是没有收到响应。重新查看列表能核对当前记录;不能仅凭“页面没有显示成功”就认定数据库一定没写入。