本机教学实现 · Python 3.12 标准库 + SQLite + 原生 JavaScript · 最近核对:2026-10。不需要模型 API、Node 包或数据库服务;不是公网部署模板。
在输入框写下“BERT”,点保存,再刷新。还在吗?如果只把文字加进页面,刷新就没了;写进浏览器的 localStorage,可以跨刷新保留,但换个浏览器就看不到;这次我们让 Python 接收记录,存进 SQLite。
这不是说每个清单都需要后端。单人、单浏览器的功能,用 localStorage 往往更省事。这里加 API,是为了看清前端、服务端和数据库怎么配合。
先运行,不急着读完代码
从仓库根目录运行,数据库文件放在仓库外:
python3 practice/web-lab/code/server.py --db /tmp/agi-reading-list.sqlite3
打开 http://127.0.0.1:8787/?lang=zh;右上角可以切英文。不要双击 HTML 文件,也不要把它塞进本站 _site/ 后期待 API 自动出现。Windows 可以把 --db 换成自己可写的临时目录路径。
试这几步:添加“BERT”→ 改成“在读”→ 刷新 → Ctrl+C 关闭 Python → 用同一个数据库路径重启。记录应该仍在。/tmp 只是练习位置,可能被系统清理,不是长期备份;保留练习结果时选仓库外的持久目录。
server.py 是服务端,app.js 管请求和页面状态,index.html 与 style.css 管结构和样式。服务端只允许读取这三个固定静态文件,不会把整个仓库变成文件服务器。
点一次保存,到底发生了什么?
- 浏览器POST /api/notes发出 JSON:{"title":"BERT"}。此时还不能显示“已保存”。
- Python检查输入标题必须是 1–120 个字符;不接受多余字段、错误类型或过大的请求体。
- SQLite写入并提交数据库分配 ID,保存标题、状态与创建时间。
- 浏览器收到 201,再更新列表使用服务端返回的记录,不自己猜一个 ID。
打开开发者工具的 Network 面板,选中 /api/notes,看请求方法、请求体、状态码和返回值。页面底部也有简短的请求记录。想单独检查 API,可以用:
curl -i http://127.0.0.1:8787/api/notes \
-H 'Content-Type: application/json' \
-d '{"title":"BERT"}'
curl http://127.0.0.1:8787/api/notes
第一条会添加记录。不要因为想多看一次结果就重复发送 POST——这个最小版本没有创建去重,同样标题会生成不同 ID。查询结果用 GET。
数据和接口,约定得小一点
数据库只有 notes 一张表。id 是稳定标识,title 是标题,status 只接受 to_read / reading / done,created_at 是服务端写入的 UTC 时间。标题不要求唯一:两条同名记录仍可分别修改。
| 请求 | 做什么 | 成功返回 |
|---|---|---|
GET /api/notes |
读最近添加的 100 条,按 ID 倒序 | 200,{"items": [...]} |
POST /api/notes |
用 {"title":"BERT"} 创建 |
201,新记录 |
PATCH /api/notes/1 |
用 {"status":"done"} 设置状态 |
200,更新后的记录 |
GET /api/health |
确认请求处理器能响应 | 200,不证明数据库可写 |
PATCH 在这里是设置状态,不是“切换到下一状态”。同一个 done 请求执行两次,结果仍是已读;POST 创建两次却会多一条。可以先从这点理解幂等性(idempotency):重试是否改变了原本想要的结果,取决于具体操作。
客户端的 required 和 maxlength 只改善填写体验。直接用 curl 就能绕过它们,所以 Python 还要校验。SQL 用 ? 绑定参数,不拼接用户输入;页面用 textContent 显示标题,不把标题当成 HTML。两者解决的是不同问题:一个避免输入被当作 SQL,另一个避免它被当作页面代码。SQLite 参数绑定
失败时,比“保存失败”多想一步
| 可以自己制造的情况 | 应该看到什么 | 为什么 |
|---|---|---|
| 用 curl 发空标题 | 400,数据库不增加记录 |
前端不是唯一入口 |
把状态发成 finished |
400,原状态保留 |
后端限制可接受的状态 |
| 更新不存在的 ID | 404 |
不假装更新成功 |
| 服务停止后点刷新 | 页面提示无法确认结果 | 连接失败,不等于清单为空 |
| 数据库暂时无法读写 | 503,不回传内部文件路径 |
故障不是业务上的“没有记录” |
fetch() 收到 HTTP 400/500 时,不会仅因为这个状态码就自动抛异常,所以代码检查 response.ok。网络断开、响应解析失败或超过 8 秒时,页面不会声称“肯定没保存”。服务端可能已经提交,只是响应没到:先刷新确认,再决定是否重试。Fetch 的错误处理
按钮在请求中会暂时禁用,防止本页连续点击,但这不是跨页面、跨设备的去重保证。要做得更可靠,可以给创建操作加请求 ID,在数据库里加唯一约束,让相同 ID 的重试返回同一条记录。不要只在内存里记一下就叫“可靠去重”。
测试放到 CI,而不是只靠手点
python3 -m unittest discover -s site/tests -p 'test_web_lab.py' -v
测试为每个用例创建临时数据库,直接调用 WSGI 应用,不占端口:检查新增、改状态、重新创建应用后的持久化、非法输入、方法和路径限制。WSGI 是 Python Web 服务器与应用之间的接口;这里使用标准库实现,方便看清请求,而不是推荐它承担公网流量。WSGI 文档
这些测试会被本站已有的 unittest discover -s site/tests 收进去,无需再新建一套部署工作流。它们不等于真实网络或浏览器测试:页面布局、请求中的提示、语言切换和服务中断后的体验,还要在浏览器检查。
为什么先这样选?
| 当前选择 | 省了什么 | 什么时候换 |
|---|---|---|
| 原生 HTML / JS | 不先配置打包器和组件框架 | 复杂界面、共享状态变多时,再比较 React 等方案 |
| 标准库 WSGI | 看得见路由、JSON 和 HTTP 返回 | 业务路由变多时,用 FastAPI / Flask 等减少重复;生产服务器另选 |
| SQLite 文件 | 不需要另外运行数据库服务 | 多实例共享、高写并发等需求出现时,评估 PostgreSQL 等方案 |
| 同源提供页面和 API | 不先处理跨域与 cookie 配置 | 分开部署时再设计 CORS、认证与环境地址 |
本例刻意没有删除接口、分页、账户和数据库迁移。100 条限制是展示范围,不是数据库最多能存 100 条;生产分页不能只加一个“更多”按钮而不约定排序和游标。
不要直接开放公网。 服务器绑定 127.0.0.1,检查 Host / Origin,不提供跨域访问;这些只是在本地减少误用,不是用户认证。真正多人使用还需要身份与记录权限、HTTPS、CSRF 等请求防护、限流、备份恢复、迁移和运行监控。尤其别把端口转发出去,就当成已经完成部署。