Skip to content
← 返回Go列表
知识专题

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,适合文件已有数据的情况:

powershell
$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 仅适用于新数据库的首条任务,已有数据须替换成响应值:

bash
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(完整业务与失败测试)
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 外键、驱动版本与平台。

继续阅读