Go:Gin 与 SQLite 任务学习记录 API · 完整业务流程与重启至练习与自检
前置:HTTP 衔接。本章把任务、学习心得和状态变化连起来:创建任务 → 写心得 → 开始 → 完成 → 查询复盘。项目是本机单用户教学服务,与知识库网站无关;不包含登录、多人授权或公网部署。
教学组合 Go 1.27.2、Gin 1.12.0、modernc.org/sqlite 1.60.1。Windows amd64、Ubuntu 26.04.1 amd64/WSL2 的测试、构建、实际启动和重启持久化属于历史验证;本轮仅核对正文与源码,修改后未执行示例;macOS arm64 仅保留平台说明,本项目不执行平台运行验证。纯 Go SQLite 驱动降低本地 C 工具链要求,不代表所有平台已测试。
完整业务流程与重启
服务保持运行,在新终端操作。PowerShell 直接从创建响应取得 id,适合文件已有数据的情况:
$base = 'http://127.0.0.1:8080'
$task = Invoke-RestMethod "$base/tasks" -Method Post -ContentType 'application/json; charset=utf-8' -Body '{"title":"学习切片","category":"Go"}'
$taskId = $task.id
Invoke-RestMethod "$base/tasks/$taskId/records" -Method Post -ContentType 'application/json; charset=utf-8' -Body '{"content":"append 可能重新分配底层数组"}'
Invoke-RestMethod "$base/tasks/$taskId/status" -Method Put -ContentType 'application/json' -Body '{"status":"doing"}'
Invoke-RestMethod "$base/tasks/$taskId/status" -Method Put -ContentType 'application/json' -Body '{"status":"done"}'
Invoke-RestMethod "$base/tasks?category=Go&status=done&page=1&page_size=20"
Invoke-RestMethod "$base/tasks/$taskId/changes"Bash/Zsh 先创建并从输出读取 id;以下 task_id=1 仅适用于新数据库的首条任务,已有数据须替换成响应值:
curl -i -X POST http://127.0.0.1:8080/tasks -H 'Content-Type: application/json' -d '{"title":"学习切片","category":"Go"}'
task_id=1
curl -i -X POST "http://127.0.0.1:8080/tasks/$task_id/records" -H 'Content-Type: application/json' -d '{"content":"append 可能重新分配底层数组"}'
curl -i -X PUT "http://127.0.0.1:8080/tasks/$task_id/status" -H 'Content-Type: application/json' -d '{"status":"doing"}'
curl -i -X PUT "http://127.0.0.1:8080/tasks/$task_id/status" -H 'Content-Type: application/json' -d '{"status":"done"}'
curl -i 'http://127.0.0.1:8080/tasks?category=Go&status=done&page=1&page_size=20'
curl -i "http://127.0.0.1:8080/tasks/$task_id/changes"预期创建和心得分别为 201,状态修改 200,最终 done,changes 有 todo→doing 与 doing→done 两条。重复提交 done 不增加记录。Ctrl+C 停服务,在相同目录和相同 LEARNING_DB 下再 go run .,GET /tasks/{id}、/records、/changes 应仍保留任务、1 条心得和 2 条变更。
构建:Windows go build -o learning-api.exe .,随后 ./learning-api.exe;Bash/Zsh go build -o learning-api .,随后 ./learning-api。二进制与数据库是不同文件,更新程序不应删除数据库。此处只验证本机运行,不宣称完成 Linux systemd、反向代理或公网部署。
测试与排错
Gin 负责路由、中间件和 JSON 响应;字段规则、状态机、SQL、事务仍由应用定义。按 router → decode/taskID → handler → database/sql 路径阅读代码。main.go 保持一个小应用结构,避免初学者先穿过多层抽象才能找到业务。
main_test.go(完整业务与失败测试)
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
func request(t *testing.T, h http.Handler, method, path, body string, status int) map[string]any {
t.Helper()
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != status {
t.Fatalf("%s %s: got %d want %d: %s", method, path, w.Code, status, w.Body.String())
}
var result map[string]any
if status != 204 {
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
}
return result
}
func TestWorkflowAndPersistence(t *testing.T) {
file := filepath.Join(t.TempDir(), "learning.db")
app, err := openApp(file)
if err != nil {
t.Fatal(err)
}
defer app.db.Close()
h := app.router()
created := request(t, h, "POST", "/tasks", `{"title":"学习切片","category":"Go"}`, 201)
if created["status"] != "todo" || created["title"] != "学习切片" {
t.Fatal(created)
}
request(t, h, "POST", "/tasks/1/records", `{"content":"append 可能重新分配数组"}`, 201)
request(t, h, "PUT", "/tasks/1/status", `{"status":"doing"}`, 200)
request(t, h, "PUT", "/tasks/1/status", `{"status":"doing"}`, 200)
request(t, h, "PUT", "/tasks/1/status", `{"status":"done"}`, 200)
changes := request(t, h, "GET", "/tasks/1/changes", "", 200)["items"].([]any)
if len(changes) != 2 {
t.Fatal(changes)
}
list := request(t, h, "GET", "/tasks?category=Go&status=done&q=切片", "", 200)
if list["total"] != float64(1) || len(list["items"].([]any)) != 1 {
t.Fatal(list)
}
request(t, h, "PATCH", "/tasks/1", `{"title":"复习切片"}`, 200)
if err := app.db.Close(); err != nil {
t.Fatal(err)
}
reopened, err := openApp(file)
if err != nil {
t.Fatal(err)
}
defer reopened.db.Close()
h = reopened.router()
got := request(t, h, "GET", "/tasks/1", "", 200)
if got["title"] != "复习切片" || got["status"] != "done" {
t.Fatal(got)
}
if len(request(t, h, "GET", "/tasks/1/records", "", 200)["items"].([]any)) != 1 {
t.Fatal("record lost")
}
request(t, h, "DELETE", "/tasks/1", "", 204)
request(t, h, "GET", "/tasks/1", "", 404)
for _, table := range []string{"records", "changes"} {
var count int
if err := reopened.db.QueryRow("SELECT count(*) FROM " + table).Scan(&count); err != nil || count != 0 {
t.Fatalf("cascade %s: %d %v", table, count, err)
}
}
}
func TestValidationAndPagination(t *testing.T) {
app, err := openApp(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer app.db.Close()
h := app.router()
for _, body := range []string{`{}`, `null`, `{"title":" "}`, `{"title":"ok","unknown":1}`, `{"title":1}`, `{"title":"ok"} {}`, `{"title":"` + strings.Repeat("字", 121) + `"}`} {
request(t, h, "POST", "/tasks", body, 400)
}
for _, path := range []string{"/tasks?page=0", "/tasks?page=-1", "/tasks?page=abc", "/tasks?page_size=101", "/tasks?status=bad"} {
request(t, h, "GET", path, "", 400)
}
request(t, h, "POST", "/tasks", `{"title":"first"}`, 201)
request(t, h, "POST", "/tasks", `{"title":"second"}`, 201)
list := request(t, h, "GET", "/tasks?page_size=1", "", 200)
if list["items"].([]any)[0].(map[string]any)["title"] != "second" || list["total"] != float64(2) {
t.Fatal(list)
}
if len(request(t, h, "GET", "/tasks?page=9", "", 200)["items"].([]any)) != 0 {
t.Fatal("out of range")
}
for _, body := range []string{`{}`, `null`, `{"title":null}`, `{"category":null}`, `{"status":"done"}`} {
request(t, h, "PATCH", "/tasks/1", body, 400)
}
request(t, h, "PUT", "/tasks/1/status", `{"status":"done"}`, 409)
request(t, h, "PUT", "/tasks/1/status", `{"status":"bad"}`, 400)
request(t, h, "POST", "/tasks/99/records", `{"content":"test"}`, 404)
request(t, h, "POST", "/tasks/1/records", `{"content":" "}`, 400)
request(t, h, "GET", "/tasks/not-an-id", "", 400)
request(t, h, "GET", "/unknown", "", 404)
request(t, h, "POST", "/tasks/1", "", 405)
}
func TestStatusRollback(t *testing.T) {
app, err := openApp(filepath.Join(t.TempDir(), "rollback.db"))
if err != nil {
t.Fatal(err)
}
defer app.db.Close()
h := app.router()
request(t, h, "POST", "/tasks", `{"title":"事务练习"}`, 201)
_, err = app.db.Exec(`CREATE TRIGGER reject_change BEFORE INSERT ON changes BEGIN SELECT RAISE(ABORT, 'exercise failure'); END`)
if err != nil {
t.Fatal(err)
}
request(t, h, "PUT", "/tasks/1/status", `{"status":"doing"}`, 500)
got := request(t, h, "GET", "/tasks/1", "", 200)
if got["status"] != "todo" {
t.Fatal("half committed", got)
}
if len(request(t, h, "GET", "/tasks/1/changes", "", 200)["items"].([]any)) != 0 {
t.Fatal("unexpected log")
}
}
func TestForeignKeysAfterConnectionReplacement(t *testing.T) {
app, err := openApp(filepath.Join(t.TempDir(), "replace.db"))
if err != nil {
t.Fatal(err)
}
defer app.db.Close()
app.db.SetMaxIdleConns(0)
h := app.router()
request(t, h, "POST", "/tasks", `{"title":"连接替换"}`, 201)
request(t, h, "POST", "/tasks/1/records", `{"content":"保留外键约束"}`, 201)
request(t, h, "DELETE", "/tasks/1", "", 204)
var count int
if err := app.db.QueryRow("SELECT count(*) FROM records").Scan(&count); err != nil || count != 0 {
t.Fatalf("orphan records: %d, %v", count, err)
}
}运行 go test -v ./...、go vet ./...。测试使用独立临时数据库,覆盖正常流程、非法分页与字段、事务回滚、重开数据库、删除级联和连接替换。httptest 不等于服务进程测试,所以前面必须再做实际启动与重启检查。
| 现象 | 排查与处理 |
|---|---|
| address already in use | 检查自己的旧服务是否占用 8080,停止旧实例后重试 |
| 重启后任务不见 | 查看日志中的 database 路径,检查工作目录和 LEARNING_DB 是否变化 |
| database is locked | 检查是否有其他写入者或长事务;busy_timeout 只提供等待,不消除竞争 |
| FOREIGN KEY constraint failed | 检查 task_id 是否存在、插入顺序是否正确,不要靠关闭外键“修复” |
| 返回 500 | 用对应请求时间查看服务日志,检查数据目录权限与磁盘状态 |
练习与自检
新建任务直接请求 done,预期 409;确认任务仍是 todo 且 changes 为空。完成正常转换后 DELETE,预期 204,之后 GET 任务为 404。用 PATCH 只修改 category,确认 title 没被零值覆盖。解释为什么“更新状态后再单独插日志”不能保证一致性。
参考解法
调用 /status 前先明确状态机;错误转换必须在写入前拒绝。测试以同一临时数据库检查 tasks 与关联表;失败事务不能留下半次更新。PATCH 使用可选字段区分“未提供”和“提供空值”,不是直接把解码结构体的所有零值写回。不能通过禁用外键、忽略 Commit 错误或吞掉日志写入错误让测试变绿。
MySQL 接入、Gin 内部机制、ORM、性能优化、复杂架构是后续专题,不是本项目运行前置。核对依据:Gin 中文测试、Gin 英文测试、Go SQL 事务、SQLite 外键、驱动版本与平台。
继续阅读
- 开发实践
- 开发实践
- 开发实践