From ab21219a07630b705f3eb681d32b97d9ab49188b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 25 Jul 2026 22:29:56 +0800 Subject: [PATCH] feat(backend): establish gin sqlite service skeleton --- README.md | 24 ++- backend-api/.gitignore | 7 + backend-api/README.md | 35 +++ backend-api/SCAFFOLD.md | 13 ++ backend-api/cmd/api/main.go | 113 ++++++++++ backend-api/cmd/api/main_test.go | 75 +++++++ backend-api/cmd/migrate/main.go | 79 +++++++ backend-api/cmd/migrate/main_test.go | 16 ++ backend-api/go.mod | 46 ++++ backend-api/go.sum | 116 ++++++++++ backend-api/internal/config/config.go | 127 +++++++++++ backend-api/internal/config/config_test.go | 133 ++++++++++++ .../internal/platform/database/sqlite.go | 87 ++++++++ .../internal/platform/database/sqlite_test.go | 86 ++++++++ .../internal/platform/migration/runner.go | 57 +++++ .../platform/migration/runner_test.go | 69 ++++++ .../internal/transport/httpapi/router.go | 140 ++++++++++++ .../internal/transport/httpapi/router_test.go | 199 ++++++++++++++++++ .../internal/transport/httpapi/server.go | 19 ++ .../internal/transport/httpapi/server_test.go | 88 ++++++++ backend-api/migrations/00001_bootstrap.sql | 6 + backend-api/migrations/migrations.go | 8 + docs/00-ai-start-here.md | 13 +- docs/03-tech-stack.md | 44 ++-- docs/04-architecture.md | 12 ++ docs/05-coding-rules.md | 15 +- docs/api.md | 21 ++ docs/current-state.md | 29 ++- docs/tasks/T-201.md | 126 +++++++++++ init.ps1 | 46 ++++ init.sh | 39 ++++ progress.md | 8 + 32 files changed, 1854 insertions(+), 42 deletions(-) create mode 100644 backend-api/.gitignore create mode 100644 backend-api/README.md create mode 100644 backend-api/SCAFFOLD.md create mode 100644 backend-api/cmd/api/main.go create mode 100644 backend-api/cmd/api/main_test.go create mode 100644 backend-api/cmd/migrate/main.go create mode 100644 backend-api/cmd/migrate/main_test.go create mode 100644 backend-api/go.mod create mode 100644 backend-api/go.sum create mode 100644 backend-api/internal/config/config.go create mode 100644 backend-api/internal/config/config_test.go create mode 100644 backend-api/internal/platform/database/sqlite.go create mode 100644 backend-api/internal/platform/database/sqlite_test.go create mode 100644 backend-api/internal/platform/migration/runner.go create mode 100644 backend-api/internal/platform/migration/runner_test.go create mode 100644 backend-api/internal/transport/httpapi/router.go create mode 100644 backend-api/internal/transport/httpapi/router_test.go create mode 100644 backend-api/internal/transport/httpapi/server.go create mode 100644 backend-api/internal/transport/httpapi/server_test.go create mode 100644 backend-api/migrations/00001_bootstrap.sql create mode 100644 backend-api/migrations/migrations.go create mode 100644 docs/tasks/T-201.md diff --git a/README.md b/README.md index 34670fa..e44e6fa 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ MVP 不自动提交订单、不支付、不绕过验证码或平台风控。 ```text cmroubao/ ├── android-buyer/ # Roubao main 固定 commit 的 Android 基线 -├── backend-api/ # Go-Gin API、管理页面和任务数据,待创建 +├── backend-api/ # Go-Gin API、SQLite、迁移和后续管理页面 ├── docs/ # Harness Coding 项目事实和执行约束 ├── AGENTS.md # AI coding agent 权威入口 ├── init.ps1 # Windows 标准构建/真机启动入口 @@ -32,8 +32,9 @@ cmroubao/ 管理人员使用 Web 管理端,采购人员使用 Android App;二者共享同一后端、任务数据 和权限体系。验证版先使用单管理账号和设备身份,完整 RBAC 放到验证通过后。 -后端已确定采用 Go 1.23.0 + Gin 1.11.0,并用 Go Blueprint v0.10.11 生成最小 -Gin + SQLite 骨架后按领域边界二次开发。 +后端采用 Go 1.23.0 + Gin 1.11.0,以 Go Blueprint v0.10.11 为一次性骨架参考, +已收敛出 SQLite、Goose 迁移、健康检查和可关闭的 HTTP Server;业务 API 按 Phase 2 +任务逐步加入。 Android 端已核实的 Roubao 上游环境为 Kotlin 1.9.20、JDK 17、Gradle 8.2、 AGP 8.2.0、Android SDK 34,以及 Jetpack Compose Compiler 1.5.5。最低支持 @@ -54,6 +55,19 @@ Set-Location android-buyer 普通 `.\init.ps1` 或不带 `-PprobeFixturesDir` 的 Debug 构建会清除先前注入的私有 fixture,默认 APK 不携带真实订单资料。 +## 后端运行 + +```powershell +Set-Location backend-api +$env:GOTOOLCHAIN = "local" +go run ./cmd/migrate up +go run ./cmd/api +``` + +默认监听 `127.0.0.1:8080`,SQLite 位于被忽略的 `backend-api/var/`。另一个终端可 +访问 `http://127.0.0.1:8080/healthz`。可配置项和独立验证命令见 +[`backend-api/README.md`](backend-api/README.md)。 + ## 文档入口 - [AI 开发入口](docs/00-ai-start-here.md) @@ -66,5 +80,5 @@ fixture,默认 APK 不携带真实订单资料。 - [当前实现状态](docs/current-state.md) - [完整文档导航](docs/README.md) -Android Phase 0 已完成构建、真机设备就绪、workflow 测试和私有样本导入;Go 后端 -尚未建立。真实状态以 [`docs/current-state.md`](docs/current-state.md) 为准。 +Android Phase 0/1 已完成,Go 后端基础骨架已建立。真实状态以 +[`docs/current-state.md`](docs/current-state.md) 为准。 diff --git a/backend-api/.gitignore b/backend-api/.gitignore new file mode 100644 index 0000000..bf863c9 --- /dev/null +++ b/backend-api/.gitignore @@ -0,0 +1,7 @@ +.env +.env.* +bin/ +var/ +*.db +*.db-shm +*.db-wal diff --git a/backend-api/README.md b/backend-api/README.md new file mode 100644 index 0000000..dbc9754 --- /dev/null +++ b/backend-api/README.md @@ -0,0 +1,35 @@ +# cmroubao backend API + +Go 1.23.0、Gin 1.11.0 和 SQLite 构成的最小后端骨架。当前只提供健康检查和数据库 +迁移生命周期,不包含任务、鉴权或管理页面业务。 + +## 环境 + +- Go 1.23.0 +- `CGO_ENABLED=1` +- PATH 中可用的 GCC + +可选环境变量: + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `CMROUBAO_HTTP_ADDR` | `127.0.0.1:8080` | HTTP 监听地址;局域网监听必须显式配置 | +| `CMROUBAO_DATABASE_PATH` | `var/cmroubao.db` | SQLite 文件路径 | + +不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。 + +## 命令 + +```powershell +$env:GOTOOLCHAIN = "local" +go test ./... +go vet ./... +go build -o bin/cmroubao-api.exe ./cmd/api +go build -o bin/cmroubao-migrate.exe ./cmd/migrate +go run ./cmd/migrate up +go run ./cmd/migrate status +go run ./cmd/api +``` + +服务启动后,`GET /healthz` 在数据库可用时返回 `200` 和 +`{"status":"ok"}`,不可用时返回 `503` 和 `{"status":"unavailable"}`。 diff --git a/backend-api/SCAFFOLD.md b/backend-api/SCAFFOLD.md new file mode 100644 index 0000000..75f798f --- /dev/null +++ b/backend-api/SCAFFOLD.md @@ -0,0 +1,13 @@ +# Scaffold provenance + +- Generator: [Melkeydev/go-blueprint](https://github.com/Melkeydev/go-blueprint) +- Version: `v0.10.11` +- License: MIT +- Reference command: + `go-blueprint create --name backend-api --framework gin --driver sqlite --git skip` + +The generator was run once outside this repository. Its current output targets Go 1.25 and +Gin 1.12 and includes demo routes, permissive CORS, implicit `.env` loading, a package-level +database singleton, and fatal health-check behavior. Those defaults were not copied. The checked-in +code retains only the minimal command/internal package shape and is independently constrained by +this project's architecture and tests. diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go new file mode 100644 index 0000000..0d09b0f --- /dev/null +++ b/backend-api/cmd/api/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "cmroubao/backend-api/internal/config" + "cmroubao/backend-api/internal/platform/database" + "cmroubao/backend-api/internal/transport/httpapi" +) + +func main() { + if err := run(); err != nil { + log.Printf("cmroubao API stopped: %v", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load(os.LookupEnv) + if err != nil { + return err + } + + startupContext, cancelStartup := context.WithTimeout( + context.Background(), + cfg.ReadTimeout, + ) + defer cancelStartup() + db, err := database.Open(startupContext, cfg.DatabasePath) + if err != nil { + return errors.New("database startup failed") + } + defer func() { + if err := db.Close(); err != nil { + log.Print("database close failed") + } + }() + + router, err := httpapi.NewRouter(db, func(event string) { + log.Print(event) + }) + if err != nil { + return errors.New("HTTP router setup failed") + } + server := httpapi.NewServer(cfg, router) + + signalContext, stopSignals := signal.NotifyContext( + context.Background(), + os.Interrupt, + syscall.SIGTERM, + ) + defer stopSignals() + + serverErrors := make(chan error, 1) + go func() { + serverErrors <- server.ListenAndServe() + }() + + select { + case err := <-serverErrors: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return errors.New("HTTP server failed") + case <-signalContext.Done(): + return shutdownServer(server, serverErrors, cfg.ShutdownTimeout) + } +} + +func shutdownServer( + server *http.Server, + serverErrors <-chan error, + timeout time.Duration, +) error { + shutdownContext, cancelShutdown := context.WithTimeout( + context.Background(), + timeout, + ) + defer cancelShutdown() + shutdownErr := server.Shutdown(shutdownContext) + if shutdownErr != nil { + if err := server.Close(); err != nil { + return errors.New("HTTP server force close failed") + } + } + + waitTimeout := timeout + if waitTimeout > time.Second { + waitTimeout = time.Second + } + timer := time.NewTimer(waitTimeout) + defer timer.Stop() + select { + case err := <-serverErrors: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return errors.New("HTTP server failed during shutdown") + } + case <-timer.C: + _ = server.Close() + return errors.New("HTTP server did not stop") + } + if shutdownErr != nil { + return errors.New("HTTP server graceful shutdown timed out") + } + return nil +} diff --git a/backend-api/cmd/api/main_test.go b/backend-api/cmd/api/main_test.go new file mode 100644 index 0000000..9d19468 --- /dev/null +++ b/backend-api/cmd/api/main_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "errors" + "net" + "net/http" + "testing" + "time" +) + +func TestShutdownServerForceClosesAfterGracefulTimeout(t *testing.T) { + handlerStarted := make(chan struct{}) + releaseHandler := make(chan struct{}) + server := &http.Server{ + Handler: http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + close(handlerStarted) + <-releaseHandler + }), + ReadHeaderTimeout: time.Second, + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen() error = %v", err) + } + serverErrors := make(chan error, 1) + go func() { + serverErrors <- server.Serve(listener) + }() + + requestFinished := make(chan struct{}) + go func() { + defer close(requestFinished) + client := http.Client{Timeout: time.Second} + response, err := client.Get("http://" + listener.Addr().String()) + if err == nil { + _ = response.Body.Close() + } + }() + select { + case <-handlerStarted: + case <-time.After(time.Second): + t.Fatal("handler did not start") + } + + err = shutdownServer(server, serverErrors, 10*time.Millisecond) + if err == nil { + t.Fatal("shutdownServer() error = nil") + } + close(releaseHandler) + select { + case <-requestFinished: + case <-time.After(time.Second): + t.Fatal("request did not finish after force close") + } + connection, dialErr := net.DialTimeout( + "tcp", + listener.Addr().String(), + 100*time.Millisecond, + ) + if dialErr == nil { + _ = connection.Close() + t.Fatal("listener still accepted connections after force close") + } +} + +func TestShutdownServerAcceptsNormalServerClose(t *testing.T) { + serverErrors := make(chan error, 1) + serverErrors <- http.ErrServerClosed + server := &http.Server{} + + if err := shutdownServer(server, serverErrors, time.Second); err != nil && + !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("shutdownServer() error = %v", err) + } +} diff --git a/backend-api/cmd/migrate/main.go b/backend-api/cmd/migrate/main.go new file mode 100644 index 0000000..0b283b1 --- /dev/null +++ b/backend-api/cmd/migrate/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "time" + + "cmroubao/backend-api/internal/config" + "cmroubao/backend-api/internal/platform/database" + "cmroubao/backend-api/internal/platform/migration" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + log.Printf("migration command failed: %v", err) + os.Exit(1) + } +} + +func run(arguments []string) error { + if len(arguments) != 1 { + return errors.New("usage: migrate ") + } + command := arguments[0] + if command != "up" && command != "down" && command != "status" { + return errors.New("migration command must be up, down, or status") + } + + databasePath, err := config.LoadDatabasePath(os.LookupEnv) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + db, err := database.Open(ctx, databasePath) + if err != nil { + return errors.New("database startup failed") + } + defer func() { + if err := db.Close(); err != nil { + log.Print("database close failed") + } + }() + + runner, err := migration.New(db) + if err != nil { + return errors.New("migration setup failed") + } + switch command { + case "up": + count, err := runner.Up(ctx) + if err != nil { + return errors.New("migration up failed") + } + fmt.Printf("applied=%d\n", count) + case "down": + if err := runner.Down(ctx); err != nil { + return errors.New("migration down failed") + } + fmt.Println("rolled_back=1") + case "status": + statuses, err := runner.Status(ctx) + if err != nil { + return errors.New("migration status failed") + } + for _, status := range statuses { + state := "pending" + if status.Applied { + state = "applied" + } + fmt.Printf("version=%d state=%s\n", status.Version, state) + } + } + return nil +} diff --git a/backend-api/cmd/migrate/main_test.go b/backend-api/cmd/migrate/main_test.go new file mode 100644 index 0000000..cf6676a --- /dev/null +++ b/backend-api/cmd/migrate/main_test.go @@ -0,0 +1,16 @@ +package main + +import "testing" + +func TestRunRejectsMissingAndUnknownCommandsBeforeOpeningDatabase(t *testing.T) { + tests := [][]string{ + nil, + {"unknown"}, + {"up", "extra"}, + } + for _, arguments := range tests { + if err := run(arguments); err == nil { + t.Fatalf("run(%v) error = nil", arguments) + } + } +} diff --git a/backend-api/go.mod b/backend-api/go.mod new file mode 100644 index 0000000..4962a53 --- /dev/null +++ b/backend-api/go.mod @@ -0,0 +1,46 @@ +module cmroubao/backend-api + +go 1.23.0 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/mattn/go-sqlite3 v1.14.48 + github.com/pressly/goose/v3 v3.26.0 +) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect +) diff --git a/backend-api/go.sum b/backend-api/go.sum new file mode 100644 index 0000000..bcbc2d6 --- /dev/null +++ b/backend-api/go.sum @@ -0,0 +1,116 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= +github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/backend-api/internal/config/config.go b/backend-api/internal/config/config.go new file mode 100644 index 0000000..e1d8369 --- /dev/null +++ b/backend-api/internal/config/config.go @@ -0,0 +1,127 @@ +package config + +import ( + "errors" + "net" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + HTTPAddressEnvironment = "CMROUBAO_HTTP_ADDR" + DatabasePathEnvironment = "CMROUBAO_DATABASE_PATH" + + defaultHTTPAddress = "127.0.0.1:8080" + defaultDatabasePath = "var/cmroubao.db" +) + +type LookupEnvironment func(string) (string, bool) + +type Config struct { + HTTPAddress string + DatabasePath string + ReadHeaderTimeout time.Duration + ReadTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration + ShutdownTimeout time.Duration + MaxHeaderBytes int +} + +func Load(lookup LookupEnvironment) (Config, error) { + httpAddress, err := environmentValue( + lookup, + HTTPAddressEnvironment, + defaultHTTPAddress, + ) + if err != nil { + return Config{}, err + } + if err := validateHTTPAddress(httpAddress); err != nil { + return Config{}, err + } + + databasePath, err := environmentValue( + lookup, + DatabasePathEnvironment, + defaultDatabasePath, + ) + databasePath, err = validatedDatabasePath(databasePath, err) + if err != nil { + return Config{}, err + } + + return Config{ + HTTPAddress: httpAddress, + DatabasePath: filepath.Clean(databasePath), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + ShutdownTimeout: 10 * time.Second, + MaxHeaderBytes: 1 << 20, + }, nil +} + +func LoadDatabasePath(lookup LookupEnvironment) (string, error) { + databasePath, err := environmentValue( + lookup, + DatabasePathEnvironment, + defaultDatabasePath, + ) + return validatedDatabasePath(databasePath, err) +} + +func environmentValue( + lookup LookupEnvironment, + name string, + defaultValue string, +) (string, error) { + value, exists := lookup(name) + if !exists { + return defaultValue, nil + } + value = strings.TrimSpace(value) + if value == "" { + return "", errors.New(name + " must not be blank") + } + return value, nil +} + +func validateHTTPAddress(address string) error { + host, portValue, err := net.SplitHostPort(address) + if err != nil || strings.TrimSpace(host) == "" { + return errors.New(HTTPAddressEnvironment + " must include a host and port") + } + port, err := strconv.Atoi(portValue) + if err != nil || port < 1 || port > 65535 { + return errors.New(HTTPAddressEnvironment + " port must be between 1 and 65535") + } + return nil +} + +func validatedDatabasePath(path string, previousError error) (string, error) { + if previousError != nil { + return "", previousError + } + if strings.ContainsRune(path, '\x00') { + return "", errors.New( + DatabasePathEnvironment + " contains an invalid character", + ) + } + lowerPath := strings.ToLower(path) + cleanPath := filepath.Clean(path) + extension := strings.ToLower(filepath.Ext(cleanPath)) + if cleanPath == "." || + cleanPath == string(filepath.Separator) || + lowerPath == ":memory:" || + strings.HasPrefix(lowerPath, "file:") || + (extension != ".db" && extension != ".sqlite" && extension != ".sqlite3") { + return "", errors.New( + DatabasePathEnvironment + " must be a SQLite file path", + ) + } + return cleanPath, nil +} diff --git a/backend-api/internal/config/config_test.go b/backend-api/internal/config/config_test.go new file mode 100644 index 0000000..d8810e2 --- /dev/null +++ b/backend-api/internal/config/config_test.go @@ -0,0 +1,133 @@ +package config + +import ( + "path/filepath" + "testing" + "time" +) + +func TestLoadUsesSafeDefaults(t *testing.T) { + cfg, err := Load(emptyEnvironment) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.HTTPAddress != "127.0.0.1:8080" { + t.Fatalf("HTTPAddress = %q", cfg.HTTPAddress) + } + if cfg.DatabasePath != filepath.FromSlash("var/cmroubao.db") { + t.Fatalf("DatabasePath = %q", cfg.DatabasePath) + } + if cfg.ReadHeaderTimeout <= 0 || + cfg.ReadTimeout <= 0 || + cfg.WriteTimeout <= 0 || + cfg.IdleTimeout <= 0 || + cfg.ShutdownTimeout <= 0 || + cfg.MaxHeaderBytes <= 0 { + t.Fatal("server safety limits must all be positive") + } + if cfg.ShutdownTimeout > 30*time.Second { + t.Fatalf("ShutdownTimeout = %s", cfg.ShutdownTimeout) + } +} + +func TestLoadAcceptsExplicitConfiguration(t *testing.T) { + values := map[string]string{ + HTTPAddressEnvironment: "192.0.2.10:9090", + DatabasePathEnvironment: "tmp/test.db", + } + + cfg, err := Load(mapEnvironment(values)) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.HTTPAddress != values[HTTPAddressEnvironment] { + t.Fatalf("HTTPAddress = %q", cfg.HTTPAddress) + } + if cfg.DatabasePath != filepath.Clean(values[DatabasePathEnvironment]) { + t.Fatalf("DatabasePath = %q", cfg.DatabasePath) + } +} + +func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) { + tests := []struct { + name string + values map[string]string + }{ + { + name: "blank explicit address", + values: map[string]string{ + HTTPAddressEnvironment: " ", + }, + }, + { + name: "address without host", + values: map[string]string{ + HTTPAddressEnvironment: ":8080", + }, + }, + { + name: "invalid port", + values: map[string]string{ + HTTPAddressEnvironment: "127.0.0.1:70000", + }, + }, + { + name: "memory database", + values: map[string]string{ + DatabasePathEnvironment: ":memory:", + }, + }, + { + name: "database DSN", + values: map[string]string{ + DatabasePathEnvironment: "file:test.db?mode=memory", + }, + }, + { + name: "database directory", + values: map[string]string{ + DatabasePathEnvironment: "./", + }, + }, + { + name: "non SQLite extension", + values: map[string]string{ + DatabasePathEnvironment: "var/database.txt", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := Load(mapEnvironment(test.values)); err == nil { + t.Fatal("Load() error = nil") + } + }) + } +} + +func TestLoadDatabasePathIgnoresHTTPConfiguration(t *testing.T) { + path, err := LoadDatabasePath(mapEnvironment(map[string]string{ + HTTPAddressEnvironment: "invalid", + DatabasePathEnvironment: "tmp/migration.sqlite", + })) + if err != nil { + t.Fatalf("LoadDatabasePath() error = %v", err) + } + if path != filepath.Clean("tmp/migration.sqlite") { + t.Fatalf("path = %q", path) + } +} + +func emptyEnvironment(string) (string, bool) { + return "", false +} + +func mapEnvironment(values map[string]string) LookupEnvironment { + return func(name string) (string, bool) { + value, exists := values[name] + return value, exists + } +} diff --git a/backend-api/internal/platform/database/sqlite.go b/backend-api/internal/platform/database/sqlite.go new file mode 100644 index 0000000..d322c43 --- /dev/null +++ b/backend-api/internal/platform/database/sqlite.go @@ -0,0 +1,87 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "net/url" + "os" + "path/filepath" + "strings" + + _ "github.com/mattn/go-sqlite3" +) + +const busyTimeoutMilliseconds = 5000 + +type safeError struct { + message string + cause error +} + +func (e safeError) Error() string { + return e.message +} + +func (e safeError) Unwrap() error { + return e.cause +} + +func Open(ctx context.Context, path string) (*sql.DB, error) { + absolutePath, err := filepath.Abs(path) + if err != nil { + return nil, errors.New("resolve SQLite path") + } + if err := os.MkdirAll(filepath.Dir(absolutePath), 0o700); err != nil { + return nil, errors.New("create SQLite directory") + } + if info, err := os.Stat(absolutePath); err == nil && info.IsDir() { + return nil, errors.New("SQLite path is a directory") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, errors.New("inspect SQLite path") + } + + db, err := sql.Open("sqlite3", dataSourceName(absolutePath)) + if err != nil { + return nil, errors.New("initialize SQLite driver") + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, safeError{ + message: "connect to SQLite", + cause: err, + } + } + return db, nil +} + +func dataSourceName(absolutePath string) string { + slashPath := filepath.ToSlash(absolutePath) + if filepath.VolumeName(absolutePath) != "" && + !strings.HasPrefix(slashPath, "/") { + slashPath = "/" + slashPath + } + dsnURL := &url.URL{ + Scheme: "file", + Path: slashPath, + } + query := dsnURL.Query() + query.Set("_busy_timeout", "5000") + query.Set("_foreign_keys", "on") + query.Set("_journal_mode", "WAL") + query.Set("_txlock", "immediate") + dsnURL.RawQuery = query.Encode() + return dsnURL.String() +} + +func isSafeDataSourceName(value string) bool { + lowerValue := strings.ToLower(value) + return strings.HasPrefix(lowerValue, "file:") && + strings.Contains(lowerValue, "_busy_timeout=") && + strings.Contains(lowerValue, "_foreign_keys=") && + strings.Contains(lowerValue, "_journal_mode=wal") && + strings.Contains(lowerValue, "_txlock=immediate") +} diff --git a/backend-api/internal/platform/database/sqlite_test.go b/backend-api/internal/platform/database/sqlite_test.go new file mode 100644 index 0000000..80f260b --- /dev/null +++ b/backend-api/internal/platform/database/sqlite_test.go @@ -0,0 +1,86 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "testing" +) + +func TestOpenConfiguresSQLiteAndClosesCleanly(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "test.db") + db, err := Open(context.Background(), path) + if err != nil { + t.Fatalf("Open() error = %v; cause = %v", err, errors.Unwrap(err)) + } + + assertPragmaInt(t, db, "foreign_keys", 1) + assertPragmaInt(t, db, "busy_timeout", busyTimeoutMilliseconds) + assertPragmaString(t, db, "journal_mode", "wal") + + if db.Stats().MaxOpenConnections != 1 { + t.Fatalf("MaxOpenConnections = %d", db.Stats().MaxOpenConnections) + } + if err := db.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + if err := db.PingContext(context.Background()); err == nil { + t.Fatal("PingContext() after Close() error = nil") + } +} + +func TestOpenEnforcesForeignKeys(t *testing.T) { + db, err := Open( + context.Background(), + filepath.Join(t.TempDir(), "foreign-keys.db"), + ) + if err != nil { + t.Fatalf("Open() error = %v; cause = %v", err, errors.Unwrap(err)) + } + t.Cleanup(func() { _ = db.Close() }) + + if _, err := db.Exec(` + CREATE TABLE parent (id INTEGER PRIMARY KEY); + CREATE TABLE child ( + id INTEGER PRIMARY KEY, + parent_id INTEGER NOT NULL REFERENCES parent(id) + ); + `); err != nil { + t.Fatalf("create tables: %v", err) + } + if _, err := db.Exec( + "INSERT INTO child (id, parent_id) VALUES (1, 999)", + ); err == nil { + t.Fatal("foreign key violation error = nil") + } +} + +func TestDataSourceNameIncludesRequiredOptions(t *testing.T) { + dsn := dataSourceName(filepath.Join(t.TempDir(), "test.db")) + if !isSafeDataSourceName(dsn) { + t.Fatalf("unsafe DSN options") + } +} + +func assertPragmaInt(t *testing.T, db *sql.DB, name string, want int) { + t.Helper() + var got int + if err := db.QueryRow("PRAGMA " + name).Scan(&got); err != nil { + t.Fatalf("PRAGMA %s: %v", name, err) + } + if got != want { + t.Fatalf("PRAGMA %s = %d, want %d", name, got, want) + } +} + +func assertPragmaString(t *testing.T, db *sql.DB, name, want string) { + t.Helper() + var got string + if err := db.QueryRow("PRAGMA " + name).Scan(&got); err != nil { + t.Fatalf("PRAGMA %s: %v", name, err) + } + if got != want { + t.Fatalf("PRAGMA %s = %q, want %q", name, got, want) + } +} diff --git a/backend-api/internal/platform/migration/runner.go b/backend-api/internal/platform/migration/runner.go new file mode 100644 index 0000000..a3f64c8 --- /dev/null +++ b/backend-api/internal/platform/migration/runner.go @@ -0,0 +1,57 @@ +package migration + +import ( + "context" + "database/sql" + + "cmroubao/backend-api/migrations" + + "github.com/pressly/goose/v3" +) + +type Status struct { + Version int64 + Applied bool +} + +type Runner struct { + provider *goose.Provider +} + +func New(db *sql.DB) (*Runner, error) { + provider, err := goose.NewProvider( + goose.DialectSQLite3, + db, + migrations.Files, + goose.WithDisableGlobalRegistry(true), + ) + if err != nil { + return nil, err + } + return &Runner{provider: provider}, nil +} + +func (r *Runner) Up(ctx context.Context) (int, error) { + results, err := r.provider.Up(ctx) + return len(results), err +} + +func (r *Runner) Down(ctx context.Context) error { + _, err := r.provider.Down(ctx) + return err +} + +func (r *Runner) Status(ctx context.Context) ([]Status, error) { + results, err := r.provider.Status(ctx) + if err != nil { + return nil, err + } + statuses := make([]Status, 0, len(results)) + for _, result := range results { + statuses = append(statuses, Status{ + Version: result.Source.Version, + Applied: result.State == goose.StateApplied, + }) + } + return statuses, nil +} diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go new file mode 100644 index 0000000..130b09c --- /dev/null +++ b/backend-api/internal/platform/migration/runner_test.go @@ -0,0 +1,69 @@ +package migration + +import ( + "context" + "path/filepath" + "testing" + + "cmroubao/backend-api/internal/platform/database" +) + +func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { + db, err := database.Open( + context.Background(), + filepath.Join(t.TempDir(), "migration.db"), + ) + if err != nil { + t.Fatalf("database.Open() error = %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + runner, err := New(db) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + applied, err := runner.Up(context.Background()) + if err != nil { + t.Fatalf("Up() error = %v", err) + } + if applied != 1 { + t.Fatalf("Up() applied = %d, want 1", applied) + } + assertStatus(t, runner, true) + + applied, err = runner.Up(context.Background()) + if err != nil { + t.Fatalf("second Up() error = %v", err) + } + if applied != 0 { + t.Fatalf("second Up() applied = %d, want 0", applied) + } + + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("Down() error = %v", err) + } + assertStatus(t, runner, false) + + applied, err = runner.Up(context.Background()) + if err != nil { + t.Fatalf("final Up() error = %v", err) + } + if applied != 1 { + t.Fatalf("final Up() applied = %d, want 1", applied) + } +} + +func assertStatus(t *testing.T, runner *Runner, applied bool) { + t.Helper() + statuses, err := runner.Status(context.Background()) + if err != nil { + t.Fatalf("Status() error = %v", err) + } + if len(statuses) != 1 { + t.Fatalf("Status() count = %d, want 1", len(statuses)) + } + if statuses[0].Version != 1 || statuses[0].Applied != applied { + t.Fatalf("Status() = %+v", statuses[0]) + } +} diff --git a/backend-api/internal/transport/httpapi/router.go b/backend-api/internal/transport/httpapi/router.go new file mode 100644 index 0000000..05ce45a --- /dev/null +++ b/backend-api/internal/transport/httpapi/router.go @@ -0,0 +1,140 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "net/http" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" +) + +type DatabasePinger interface { + PingContext(context.Context) error +} + +type EventLogger func(string) + +func NewRouter( + database DatabasePinger, + logEvent EventLogger, +) (http.Handler, error) { + if database == nil { + return nil, errors.New("database pinger is required") + } + if logEvent == nil { + return nil, errors.New("event logger is required") + } + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(requestIDMiddleware()) + router.Use(safeRecovery(logEvent)) + router.HandleMethodNotAllowed = true + if err := router.SetTrustedProxies(nil); err != nil { + return nil, err + } + + router.GET("/healthz", healthHandler(database)) + router.NoRoute(func(ctx *gin.Context) { + ctx.JSON(http.StatusNotFound, errorResponse( + ctx, + "NOT_FOUND", + "resource not found", + )) + }) + router.NoMethod(func(ctx *gin.Context) { + ctx.JSON(http.StatusMethodNotAllowed, errorResponse( + ctx, + "METHOD_NOT_ALLOWED", + "method not allowed", + )) + }) + return router, nil +} + +func safeRecovery(logEvent EventLogger) gin.HandlerFunc { + return func(ctx *gin.Context) { + defer func() { + if recover() != nil { + logEvent("HTTP handler panic recovered") + ctx.AbortWithStatusJSON( + http.StatusInternalServerError, + errorResponse( + ctx, + "INTERNAL_ERROR", + "internal server error", + ), + ) + } + }() + ctx.Next() + } +} + +func requestIDMiddleware() gin.HandlerFunc { + return func(ctx *gin.Context) { + requestID := newRequestID() + ctx.Set(requestIDContextKey, requestID) + ctx.Header(requestIDHeader, requestID) + ctx.Next() + } +} + +func healthHandler(database DatabasePinger) gin.HandlerFunc { + return func(ctx *gin.Context) { + pingContext, cancel := context.WithTimeout(ctx.Request.Context(), time.Second) + defer cancel() + + ctx.Header("Cache-Control", "no-store") + if err := database.PingContext(pingContext); err != nil { + ctx.JSON(http.StatusServiceUnavailable, gin.H{ + "status": "unavailable", + }) + return + } + ctx.JSON(http.StatusOK, gin.H{"status": "ok"}) + } +} + +func errorResponse(ctx *gin.Context, code, message string) gin.H { + requestID, _ := ctx.Get(requestIDContextKey) + return gin.H{ + "error": gin.H{ + "code": code, + "message": message, + "retryable": false, + "details": gin.H{}, + }, + "request_id": requestID, + } +} + +func newRequestID() string { + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + now := uint64(time.Now().UnixNano()) + binary.BigEndian.PutUint64(value[:8], now) + binary.BigEndian.PutUint64(value[8:], fallbackRequestID.Add(1)) + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + return fmt.Sprintf( + "%08x-%04x-%04x-%04x-%012x", + value[0:4], + value[4:6], + value[6:8], + value[8:10], + value[10:16], + ) +} + +const ( + requestIDContextKey = "request_id" + requestIDHeader = "X-Request-ID" +) + +var fallbackRequestID atomic.Uint64 diff --git a/backend-api/internal/transport/httpapi/router_test.go b/backend-api/internal/transport/httpapi/router_test.go new file mode 100644 index 0000000..f8fb304 --- /dev/null +++ b/backend-api/internal/transport/httpapi/router_test.go @@ -0,0 +1,199 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" +) + +type fakePinger struct { + err error +} + +func (p fakePinger) PingContext(context.Context) error { + return p.err +} + +func TestHealthzReturnsStableHealthyResponse(t *testing.T) { + router, err := NewRouter(fakePinger{}, discardEvent) + if err != nil { + t.Fatalf("NewRouter() error = %v", err) + } + + response := performRequest(t, router, http.MethodGet, "/healthz") + + if response.Code != http.StatusOK { + t.Fatalf("status = %d", response.Code) + } + assertJSON(t, response, map[string]any{"status": "ok"}) + if response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("Cache-Control = %q", response.Header().Get("Cache-Control")) + } + if response.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatal("default CORS must remain disabled") + } +} + +func TestHealthzReturns503WithoutLeakingDatabaseError(t *testing.T) { + router, err := NewRouter(fakePinger{ + err: errors.New("private database path and driver details"), + }, discardEvent) + if err != nil { + t.Fatalf("NewRouter() error = %v", err) + } + + response := performRequest(t, router, http.MethodGet, "/healthz") + + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d", response.Code) + } + if strings.Contains(response.Body.String(), "private database") { + t.Fatal("health response leaked the database error") + } + assertJSON(t, response, map[string]any{"status": "unavailable"}) +} + +func TestUnknownRouteAndMethodUseStableErrors(t *testing.T) { + router, err := NewRouter(fakePinger{}, discardEvent) + if err != nil { + t.Fatalf("NewRouter() error = %v", err) + } + + notFound := performRequest(t, router, http.MethodGet, "/missing") + if notFound.Code != http.StatusNotFound { + t.Fatalf("not found status = %d", notFound.Code) + } + assertErrorCode(t, notFound, "NOT_FOUND") + + notAllowed := performRequest(t, router, http.MethodPost, "/healthz") + if notAllowed.Code != http.StatusMethodNotAllowed { + t.Fatalf("not allowed status = %d", notAllowed.Code) + } + assertErrorCode(t, notAllowed, "METHOD_NOT_ALLOWED") +} + +func TestSafeRecoveryReturnsStableErrorWithoutLoggingRequestHeaders(t *testing.T) { + var events []string + router, err := NewRouter( + panicPinger{}, + func(event string) { + events = append(events, event) + }, + ) + if err != nil { + t.Fatalf("NewRouter() error = %v", err) + } + request := httptest.NewRequest(http.MethodGet, "/healthz", nil) + request.Header.Set("Authorization", "Bearer private-token") + request.Header.Set("Cookie", "session=private-cookie") + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", response.Code) + } + assertErrorCode(t, response, "INTERNAL_ERROR") + if len(events) != 1 || events[0] != "HTTP handler panic recovered" { + t.Fatalf("events = %#v", events) + } + eventText := strings.Join(events, " ") + if strings.Contains(eventText, "private") || + strings.Contains(eventText, "Bearer") || + strings.Contains(eventText, "session") { + t.Fatalf("event log leaked request or panic content") + } +} + +func TestRouterRequiresDependencies(t *testing.T) { + if _, err := NewRouter(nil, discardEvent); err == nil { + t.Fatal("NewRouter(nil database) error = nil") + } + if _, err := NewRouter(fakePinger{}, nil); err == nil { + t.Fatal("NewRouter(nil logger) error = nil") + } +} + +func performRequest( + t *testing.T, + handler http.Handler, + method string, + path string, +) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, path, nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func assertJSON( + t *testing.T, + response *httptest.ResponseRecorder, + want map[string]any, +) { + t.Helper() + var got map[string]any + if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil { + t.Fatalf("decode JSON: %v", err) + } + if len(got) != len(want) { + t.Fatalf("JSON = %#v, want %#v", got, want) + } + for key, wantValue := range want { + if got[key] != wantValue { + t.Fatalf("JSON[%q] = %#v, want %#v", key, got[key], wantValue) + } + } +} + +func assertErrorCode( + t *testing.T, + response *httptest.ResponseRecorder, + want string, +) { + t.Helper() + var body struct { + Error struct { + Code string `json:"code"` + Retryable bool `json:"retryable"` + Details map[string]any `json:"details"` + } `json:"error"` + RequestID string `json:"request_id"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatalf("decode error JSON: %v", err) + } + if body.Error.Code != want { + t.Fatalf("error code = %q, want %q", body.Error.Code, want) + } + if body.Error.Retryable { + t.Fatal("retryable = true") + } + if body.Error.Details == nil || len(body.Error.Details) != 0 { + t.Fatalf("details = %#v", body.Error.Details) + } + if !requestIDPattern.MatchString(body.RequestID) { + t.Fatalf("request_id = %q", body.RequestID) + } + if response.Header().Get(requestIDHeader) != body.RequestID { + t.Fatalf("request ID header does not match body") + } +} + +type panicPinger struct{} + +func (panicPinger) PingContext(context.Context) error { + panic("private failure detail") +} + +func discardEvent(string) {} + +var requestIDPattern = regexp.MustCompile( + `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, +) diff --git a/backend-api/internal/transport/httpapi/server.go b/backend-api/internal/transport/httpapi/server.go new file mode 100644 index 0000000..00d707d --- /dev/null +++ b/backend-api/internal/transport/httpapi/server.go @@ -0,0 +1,19 @@ +package httpapi + +import ( + "net/http" + + "cmroubao/backend-api/internal/config" +) + +func NewServer(cfg config.Config, handler http.Handler) *http.Server { + return &http.Server{ + Addr: cfg.HTTPAddress, + Handler: handler, + ReadHeaderTimeout: cfg.ReadHeaderTimeout, + ReadTimeout: cfg.ReadTimeout, + WriteTimeout: cfg.WriteTimeout, + IdleTimeout: cfg.IdleTimeout, + MaxHeaderBytes: cfg.MaxHeaderBytes, + } +} diff --git a/backend-api/internal/transport/httpapi/server_test.go b/backend-api/internal/transport/httpapi/server_test.go new file mode 100644 index 0000000..4152215 --- /dev/null +++ b/backend-api/internal/transport/httpapi/server_test.go @@ -0,0 +1,88 @@ +package httpapi + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "testing" + "time" + + "cmroubao/backend-api/internal/config" +) + +func TestNewServerAppliesAllSafetyLimits(t *testing.T) { + cfg, err := config.Load(func(string) (string, bool) { + return "", false + }) + if err != nil { + t.Fatalf("config.Load() error = %v", err) + } + handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + + server := NewServer(cfg, handler) + + if server.Addr != cfg.HTTPAddress || server.Handler == nil { + t.Fatalf("server = %+v", server) + } + if server.ReadHeaderTimeout != cfg.ReadHeaderTimeout || + server.ReadTimeout != cfg.ReadTimeout || + server.WriteTimeout != cfg.WriteTimeout || + server.IdleTimeout != cfg.IdleTimeout || + server.MaxHeaderBytes != cfg.MaxHeaderBytes { + t.Fatal("server safety limits do not match config") + } +} + +func TestServerSupportsBoundedGracefulShutdown(t *testing.T) { + cfg, err := config.Load(func(string) (string, bool) { + return "", false + }) + if err != nil { + t.Fatalf("config.Load() error = %v", err) + } + server := NewServer( + cfg, + http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusNoContent) + }), + ) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen() error = %v", err) + } + serverErrors := make(chan error, 1) + go func() { + serverErrors <- server.Serve(listener) + }() + + client := http.Client{Timeout: time.Second} + response, err := client.Get("http://" + listener.Addr().String()) + if err != nil { + _ = listener.Close() + t.Fatalf("GET server: %v", err) + } + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + if response.StatusCode != http.StatusNoContent { + t.Fatalf("status = %d", response.StatusCode) + } + + shutdownContext, cancel := context.WithTimeout( + context.Background(), + time.Second, + ) + defer cancel() + if err := server.Shutdown(shutdownContext); err != nil { + t.Fatalf("Shutdown() error = %v", err) + } + select { + case err := <-serverErrors: + if !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("Serve() error = %v", err) + } + case <-time.After(time.Second): + t.Fatal("Serve() did not stop after Shutdown()") + } +} diff --git a/backend-api/migrations/00001_bootstrap.sql b/backend-api/migrations/00001_bootstrap.sql new file mode 100644 index 0000000..fbeb257 --- /dev/null +++ b/backend-api/migrations/00001_bootstrap.sql @@ -0,0 +1,6 @@ +-- +goose Up +-- T-201 verifies the migration lifecycle without defining later business tables. +SELECT 1; + +-- +goose Down +SELECT 1; diff --git a/backend-api/migrations/migrations.go b/backend-api/migrations/migrations.go new file mode 100644 index 0000000..c437476 --- /dev/null +++ b/backend-api/migrations/migrations.go @@ -0,0 +1,8 @@ +package migrations + +import "embed" + +// Files contains the versioned SQL migrations used by both commands and tests. +// +//go:embed *.sql +var Files embed.FS diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 845f21d..d4e90e7 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -53,7 +53,7 @@ 当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、 动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已 -验证。下一步是 T-201,生成并收敛 Go-Gin、SQLite 和迁移骨架。 +验证。T-201 后端骨架也已完成,下一步是 T-202,生成并确认 P0 Web/App 低保真原型。 严格按以下顺序推进: @@ -103,5 +103,12 @@ $env:RUN_START_COMMAND = "1" .\init.ps1 ``` -脚本执行 `test assembleDebug`;上游当前没有单元测试源,因此 `test` 的测试任务为 -`NO-SOURCE`,但编译与 APK 打包必须成功。后端建立后再追加后端测试和启动命令。 +脚本先执行 Android `test assembleDebug`,再以 `GOTOOLCHAIN=local` 执行后端 +`go test ./...`、`go vet ./...` 并构建 API 与 migration 两个入口。后端单独运行: + +```powershell +Set-Location backend-api +$env:GOTOOLCHAIN = "local" +go run ./cmd/migrate up +go run ./cmd/api +``` diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 90c7a61..9584df6 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -16,10 +16,10 @@ | 第一层任务源 | UTF-8 无 BOM 四行蝦皮订单文本 + 同订单号 JPEG | 已实现 | `task-contract` 共享 `ProbeTask/TaskSource`;CLI 输出到 `.local/`,只有显式 Debug 属性才注入 APK,默认构建会清除私有资产。 | | Android 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 | | 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 | -| 后端骨架 | Go Blueprint v0.10.11 生成的最小 Gin + SQLite 工程 | MVP 已定 | 只作为一次性脚手架输入;生成后立即重写版本约束。 | +| 后端骨架 | Go Blueprint v0.10.11 生成的最小 Gin + SQLite 工程 | 已接入并收敛 | 只作为一次性脚手架输入;演示路由、默认 CORS、`.env` 自动加载、单例和 fatal 行为均已删除。 | | 后端框架 | Gin v1.11.0 | MVP 已定 | 这是 `go.mod` 明确支持 Go 1.23.0 的最高已核实 Gin 版本。 | | 数据访问 | 标准库 `database/sql` | MVP 已定 | 领域层通过仓储接口访问,避免先引入 ORM 和代码生成复杂度。 | -| 数据迁移 | Goose v3,使用 SQL migration | MVP 已定 | 迁移可审核、可排序并支持 SQLite;版本在 T-201 建立 `go.mod` 时锁定。 | +| 数据迁移 | Goose v3.26.0,使用嵌入式 SQL migration | 已验证 | v3.26.0 是已核实仍声明 Go 1.23.0 的最高版本;v3.27.x 要求 Go 1.25。 | | 管理 Web | Gin + `html/template` + `embed` + 少量原生 JS/CSS | MVP 已定 | 不单独引入 SPA 工程,模板和静态资源随服务构建。 | | 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 | | 图片/截图 | 后端受控本地文件目录,数据库存元数据 | MVP 已定 | 禁止把二进制直接塞入日志;生产再评估对象存储。 | @@ -27,7 +27,7 @@ | App 鉴权 | 采购员登录态 + 设备绑定令牌 | 目标已定,细节待实现 | 人员身份与设备身份分离;令牌只保存哈希。 | | VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | T-103/T-104 使用严格 JSON Schema、单候选单次调用和 2048 px 图片上限;GUI-Owl/MAI-UI 动作模型不具备需求提取能力。 | | 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 | -| 后端测试 | 标准库 `testing` + `httptest` | MVP 已定 | 覆盖状态机、权限、幂等、SQLite 事务和输入校验。 | +| 后端测试 | 标准库 `testing` + `httptest` | 骨架已验证 | 当前 25 个测试覆盖配置、SQLite pragma/外键、迁移、健康检查、安全 recovery、错误合约、路由和有界关闭;后续任务再增加状态机、权限与幂等。 | | Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | Phase 1 探针已验证 | 166 次测试覆盖 runner、动态页面分类、受控证据、VLM schema、人工确认策略与隐私;OnePlus PKG110 上完成私有 fixture + 本机 mock 的需求提取和 5 候选评估 smoke。 | | 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 | @@ -87,15 +87,13 @@ go-blueprint create --name backend-api --framework gin --driver sqlite --git ski ``` Go Blueprint v0.10.11 当前生成的是 `Go 1.25.0 + Gin 1.12.0`,不能直接作为本项目 -的 `go.mod`。2026-07-25 已在临时目录将生成结果调整为以下版本,并在 -`GOTOOLCHAIN=local` 下完成 `go mod tidy` 和 `go test ./...`: +的 `go.mod`。2026-07-25 已在仓库外临时目录核实生成树,并把正式 `go.mod` 固定为: ```text go 1.23.0 github.com/gin-gonic/gin v1.11.0 -github.com/gin-contrib/cors v1.7.6 -github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.48 +github.com/pressly/goose/v3 v3.26.0 ``` Gin v1.12.0 的 `go.mod` 要求 Go 1.25.0,因此本项目禁止升级到 Gin 1.12.x,除非先 @@ -116,20 +114,18 @@ RabbitMQ、NATS 和 gRPC 为主,不符合最小 Gin + SQLite 边界。 ```text android-buyer/ # 已接入的 Roubao Kotlin Android App backend-api/ - cmd/api/ # 进程入口,只负责装配和生命周期 - internal/domain/ # 实体、状态机和确定性规则 - internal/usecase/ # 创建、领取、执行和结果归档 - internal/transport/http/ # Gin handler、中间件和页面 - internal/repository/sqlite/ # database/sql 仓储 - internal/platform/ # 配置、日志、文件和 VLM 适配器 - migrations/ # Goose SQL migration - web/templates/ # html/template - web/static/ # 少量 CSS/JS - var/ # 本地运行数据,必须忽略 + cmd/api/ # API 进程装配、信号和优雅关闭 + cmd/migrate/ # 显式 migration up/down/status + internal/config/ # 环境变量和安全默认 + internal/platform/database/ # SQLite 打开、pragma 和生命周期 + internal/platform/migration/ # Goose provider 封装 + internal/transport/httpapi/ # Gin 路由、健康检查和 HTTP Server + migrations/ # 嵌入式 Goose SQL migration + var/ # 本地运行数据,必须忽略 docs/ ``` -目录在真实骨架建立后以代码为准并同步本文。 +`domain/usecase/repository/sqlite/web` 将在对应业务任务出现真实代码时创建,不提交空包。 ## 构建与运行命令 @@ -137,13 +133,13 @@ docs/ | --- | --- | --- | | Android 标准验证 | `.\init.ps1` | 已验证 | | Android 构建 | `android-buyer\gradlew.bat assembleDebug --no-daemon` | 已验证 | -| Android 测试任务 | `android-buyer\gradlew.bat test --no-daemon` | 已验证;当前全工程 78 次测试通过 | +| Android 测试任务 | `android-buyer\gradlew.bat test --no-daemon` | 已验证;当前全工程 166 次测试通过 | | Android 安装/启动 | `$env:RUN_START_COMMAND="1"; .\init.ps1` | 已在 Android 16 真机验证 | -| 后端依赖 | `go mod download`(在 `backend-api/`) | 待 `T-201` | -| 后端测试 | `go test ./...`(在 `backend-api/`) | 待 `T-201` | -| 后端构建 | `go build -o bin/cmroubao-api.exe ./cmd/api` | 待 `T-201` | -| 后端启动 | `go run ./cmd/api` | 待 `T-201` | -| 数据迁移 | `goose -dir migrations sqlite3 ./var/cmroubao.db up` | 待 `T-201` | +| 后端依赖 | `$env:GOTOOLCHAIN="local"; go mod download`(在 `backend-api/`) | 已验证 | +| 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go vet ./...` | 已验证;25 个测试 | +| 后端构建 | `go build -o bin/cmroubao-api.exe ./cmd/api` 与 `./cmd/migrate` | 已验证 | +| 后端启动 | `go run ./cmd/api` | 已完成本机 HTTP smoke | +| 数据迁移 | `go run ./cmd/migrate up` | 已完成 `status/up/up/down/up` smoke | `gradlew installDebug` 在当前 Android 16 设备上由旧版 AGP/ddmlib 返回 `-99`, 但 SDK Platform Tools 37.0.0 的 `adb install -r -t` 成功。标准脚本因此使用 SDK diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 73293ce..8f69b3a 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -60,6 +60,18 @@ internal/platform/ # 配置、日志、文件存储和 VLM 适配器 - 在 Gin handler 中写任务状态机、SQL 或仓储业务逻辑。 - 保存拼多多密码、支付凭证或支付验证码。 +T-201 已建立的基础包只有 `config`、`platform/database`、`platform/migration` 和 +`transport/httpapi`。HTTP 进程使用非零 read-header/read/write/idle timeout、 +1 MiB header 上限和 10 秒有界关闭;默认监听 `127.0.0.1:8080`,扩大监听范围必须 +显式配置。Gin 不启用默认 Logger/CORS/Recovery,异常恢复返回稳定 JSON 且不记录 +Authorization、Cookie 或 panic 内容。 + +SQLite 默认文件为被忽略的 `backend-api/var/cmroubao.db`,连接启用 foreign keys、 +5 秒 busy timeout、WAL 和 immediate transaction,并限制单连接以匹配单进程 MVP。 +数据库由 `cmd` 显式打开和关闭,不存在包级单例。Goose SQL 通过 `embed.FS` 加载, +`cmd/migrate` 显式执行 `up/down/status`;T-201 只验证迁移链路,业务表由后续任务 +按 API 和领域模型建立。 + ### 2.2 Android App 建议模块: diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index cc75d74..b23ca7f 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -83,6 +83,14 @@ - 不向客户端返回 `password_hash`、`token_hash`、存储绝对路径或供应商密钥。 - 通用错误使用稳定 code 和可读 message;内部堆栈只进受控日志。 - 文件访问通过鉴权接口,防止路径遍历和猜测 URL。 +- Go 命令固定 `GOTOOLCHAIN=local`;`go.mod` 不得出现更高 Go 版本或未固定的 + `@latest` 依赖。 +- 后端不得自动加载 `.env`、默认开启 CORS、使用包级数据库单例,或在库、handler、 + 健康检查中调用 `log.Fatal`/`os.Exit`。 +- `http.Server` 必须配置 read-header/read/write/idle/header 上限和有界关闭; + recovery 不得把 Authorization、Cookie、panic 或请求正文写普通日志。 +- SQLite 数据文件必须位于被忽略目录,启用 foreign keys、有限 busy timeout 和 + WAL;连接由进程入口显式关闭,migration 使用固定版本和受控 SQL 文件。 ## 7. 安全与隐私 @@ -125,6 +133,7 @@ - [ ] 当前任务文件记录命令、结果、环境和未验证项。 - [ ] `current-state.md` 与真实目录、命令和 blocker 一致。 -当前 Android 标准验证命令是根目录 `.\init.ps1`;它执行 Gradle `test` 和 -`assembleDebug`。设置 `RUN_START_COMMAND=1` 时使用 SDK 内新版 ADB 安装并启动。 -后端建立后再在这里列出后端命令。 +项目标准验证命令是根目录 `.\init.ps1`;它执行 Android Gradle `test` 和 +`assembleDebug`,再以 `GOTOOLCHAIN=local` 执行后端 `go test ./...`、 +`go vet ./...`、`gofmt` 检查和两个入口构建。设置 `RUN_START_COMMAND=1` 时使用 +SDK 内新版 ADB 安装并启动 Android App。 diff --git a/docs/api.md b/docs/api.md index 7291790..b68540e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -40,6 +40,27 @@ HTTP 语义: - `429` 频率限制 - `503` 依赖暂不可用 +## 服务健康 + +### `GET /healthz` + +不需要业务身份,只检查进程和 SQLite 连接,不返回版本、路径、DSN、连接池统计或内部 +错误。响应带 `Cache-Control: no-store`,默认不返回 CORS header。 + +数据库可用: + +```json +{"status":"ok"} +``` + +返回 `200`。数据库不可用时返回 `503`: + +```json +{"status":"unavailable"} +``` + +健康检查失败不能终止进程;未知路由和不允许的方法分别使用稳定 `404`/`405` JSON。 + ## 认证 ### 管理 Web 会话 diff --git a/docs/current-state.md b/docs/current-state.md index 7f10cce..a4e2dd9 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,16 +5,19 @@ ## 当前快照 - 日期:2026-07-25 -- 阶段:Phase 1 已完成,准备开始 T-201 后端骨架 -- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104 均已纳入 Git 历史 +- 阶段:T-201 已完成,准备开始 T-202 P0 低保真原型 +- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 均已纳入 Git 历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 -- 后端:已决定使用 Go 1.23.0 + Gin 1.11.0;Go Blueprint v0.10.11 骨架尚未接入 +- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0 骨架已建立;Go Blueprint + v0.10.11 只作为一次性输入,危险默认与演示逻辑已移除 - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器 共 26 份报告、166 次测试,0 failure、0 error、0 skipped +- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 25 个测试通过; + `go vet ./...`、API/migration Windows 构建和根 `init.ps1` 均通过 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture @@ -47,18 +50,19 @@ | `docs/tasks/T-102.md` | DONE | 最多 5 个候选详情截图、证据 manifest 和结果页返回 | | `docs/tasks/T-103.md` | DONE | 私有任务需求 schema、硬约束、隐私边界和 VLM 适配器 | | `docs/tasks/T-104.md` | DONE | 动态搜索、候选严格评估、本地建议和人工确认点 | +| `docs/tasks/T-201.md` | DONE | Go-Gin、SQLite、Goose migration 和健康检查骨架 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | | `android-buyer/task-contract/` | 已有 | Android/CLI 共享 ProbeTask 与 TaskSource | | `android-buyer/tools/shopee-importer/` | 已有 | 开发机私有 fixture 导入 CLI | -| `backend-api/` | 待建 | Go-Gin、管理 Web 和数据目标目录 | -| `init.ps1` / `init.sh` | 已验证 | Android 构建入口;可选真机安装启动 | +| `backend-api/` | 已有 | Go-Gin API、SQLite、migration、健康检查和测试骨架 | +| `init.ps1` / `init.sh` | 已验证/待跨平台 | Windows 同时验证 Android/Go;Unix 入口待 Linux/WSL 复核 | ## 任务摘要 -- 已完成:T-001 至 T-004,以及 T-101 至 T-104。 +- 已完成:T-001 至 T-004、T-101 至 T-104,以及 T-201。 - 正在进行:无。 -- 下一个可领取任务:T-201 生成并收敛 Go-Gin、SQLite 和迁移骨架。 +- 下一个可领取任务:T-202 生成并确认 P0 Web/App 低保真原型。 ## 当前可运行内容 @@ -67,6 +71,11 @@ $env:RUN_START_COMMAND = "1" .\init.ps1 + +Set-Location backend-api +$env:GOTOOLCHAIN = "local" +go run ./cmd/migrate up +go run ./cmd/api ``` 2026-07-25 已在 PKG110、Android 16/API 36、拼多多 8.17.0 上完成 Debug APK @@ -85,6 +94,12 @@ $env:RUN_START_COMMAND = "1" `order_submitted` 始终为 false;日志敏感词、Base64、原始响应和 schema/prompt 命中均为 0。该结果不代表真实模型的商品匹配质量。 +同日完成 T-201 后端 smoke:本机迁移执行 `status -> up -> up -> down -> up`, +第二次 `up` 应用数为 0;API 真实进程的 `GET /healthz` 返回 `200` 和稳定 JSON, +未知路由返回 `404`,错误方法返回 `405`,无默认 CORS,stdout/stderr 均为空。 +`http.Server.Shutdown` 由集成测试验证在 1 秒预算内结束。当前 Windows 没有可用 WSL +发行版,因此 `init.sh` 未实际运行;Windows 标准入口已验证。 + ## 维护规则 发生以下变化时覆盖更新本文: diff --git a/docs/tasks/T-201.md b/docs/tasks/T-201.md new file mode 100644 index 0000000..b0b1080 --- /dev/null +++ b/docs/tasks/T-201.md @@ -0,0 +1,126 @@ +--- +id: T-201 +title: 生成并收敛 Go-Gin、SQLite 和迁移骨架 +phase: 2 +deps: + - T-104 +status: DONE +created: 2026-07-25 +context_ref: 84f2da3cf74a17c9a1909faf668f16f5fc4e5afa +work_branch: main +write_paths: + - backend-api/** + - init.ps1 + - init.sh + - README.md + - docs/00-ai-start-here.md + - docs/03-tech-stack.md + - docs/04-architecture.md + - docs/05-coding-rules.md + - docs/api.md + - docs/current-state.md + - docs/tasks/T-201.md + - progress.md +--- + +## 问题 / 背景 + +Phase 1 已证明 Android 能从私有任务完成结构化需求提取、拼多多有界搜索、最多 5 个 +候选评估并停在人工确认点。Phase 2 需要统一后端承载后续的管理 Web、手动领取、租约 +和结果回传,但仓库当前没有 Go module、HTTP 服务、SQLite 生命周期或迁移入口。 + +## 关联需求与交互 + +- 功能:F-001、F-002、F-003、F-007 的共享后端基础,不在本任务实现业务接口。 +- 用户故事:US-001、US-002、US-003、US-007 的前置基础设施。 +- 交互:不适用;T-202 才生成并确认 P0 Web/App 原型。 +- 架构/API:`docs/03-tech-stack.md` 后端版本与骨架选择、`docs/04-architecture.md` + Backend API 分层、`docs/api.md` 通用 HTTP 约定。 + +## 方案 + +1. 固定 Go Blueprint v0.10.11,在仓库外临时目录生成最小 Gin + SQLite 项目作为 + 一次性参考,不直接保留演示业务和生成器默认配置。 +2. 建立 `cmd/api` 和 `cmd/migrate` 两个入口;应用入口只负责装配、信号处理和优雅 + 关闭,迁移入口显式执行版本化 SQL。 +3. 使用 Go 1.23.0、Gin 1.11.0、`database/sql`、`mattn/go-sqlite3` 和 + Goose v3.26.0;所有验证设置 `GOTOOLCHAIN=local`。 +4. 配置、SQLite、迁移和 Gin transport 分包;关闭默认 CORS,不读取或提交 `.env`, + 默认只监听本机回环地址,健康检查失败不得终止进程或泄露内部错误。 +5. SQLite 创建父目录、启用 foreign keys、busy timeout 和 WAL,限制单进程写连接, + 并由调用方显式关闭数据库。 +6. 单元/集成测试覆盖配置边界、健康检查成功/失败、SQLite pragma、迁移 up/down 和 + HTTP 路由;根初始化脚本纳入后端测试与构建。 + +## 验收要点 + +- [x] `go.mod` 精确声明 Go 1.23.0、Gin 1.11.0 和固定依赖版本。 +- [x] `GOTOOLCHAIN=local go test ./...` 和 Windows 后端构建通过。 +- [x] migration 在临时 SQLite 上可 up/down,运行数据目录被 Git 忽略。 +- [x] `/healthz` 正常返回稳定 JSON;数据库不可用时返回 503 且进程不退出。 +- [x] HTTP Server 具有读头、读、写、空闲和优雅关闭 timeout。 +- [x] 没有包级数据库单例、隐式 `.env`、默认 CORS、演示业务或明文凭证。 +- [x] 根 `init.ps1`/`init.sh` 同时验证 Android 和后端。 +- [x] 启动 smoke、迁移、测试、构建和敏感信息检查均有可复现证据。 + +## 边界 + +- 不实现任务创建、列表、鉴权、claim、租约、事件或资产 API。 +- 不实现管理 Web 页面或 Android HTTP TaskSource。 +- 不引入 ORM、SPA、Redis、消息队列、WebSocket、Docker 或自动下单逻辑。 +- 不提交 SQLite 数据文件、`.env`、密钥、私有任务或生成器临时目录。 + +## 执行记录 + +### 2026-07-25:任务开始 + +- 基于 T-104 提交 `84f2da3` 开始。 +- 本机已核实 Go 1.23.0、CGO 开启、GCC 可用,且 Go Blueprint 可执行文件存在。 +- 先完成骨架生成审计和依赖版本固定,再写入正式代码。 + +### 2026-07-25:骨架审计与实现 + +- 本机 Go Blueprint 精确版本为 v0.10.11;在仓库外临时目录执行文档生成命令, + 文件已完整生成,但 CLI 在非交互 PowerShell 恢复终端时返回错误码。 +- 生成树当前解析为 Go 1.25.0、Gin 1.12.0,并含默认 CORS、`.env` autoload、 + 包级数据库单例、`log.Fatal` 健康检查和 Hello World;正式实现未复制这些行为。 +- 正式 `go.mod` 固定 Go 1.23.0、Gin 1.11.0、go-sqlite3 1.14.48 和 Goose 3.26.0; + v3.27.x 因要求 Go 1.25 未采用。 +- 建立 `cmd/api`、`cmd/migrate`、`internal/config`、SQLite、migration 和 + `transport/httpapi`;没有提前创建空的 domain/usecase/repository/web 包。 +- SQLite 强制 foreign keys、5 秒 busy timeout、WAL、immediate transaction 和 + 单连接;Gin 默认只监听回环地址,无 CORS/默认 logger,请求异常只记录通用事件。 +- 公共错误固定 `request_id`、`retryable=false` 和空 `details`;优雅关闭超时后强制 + 关闭 listener 并有界等待,避免数据库关闭后继续接受请求。 + +### 2026-07-25:自动化验证 + +- 执行 `$env:GOTOOLCHAIN="local"; go test -count=1 ./...`:25 个测试通过,0 失败; + 配置、SQLite、迁移、健康 200/503、安全 recovery、错误合约、404/405、正常关闭和 + 超时强制关闭均有覆盖。 +- 执行 `go test -race -count=1 ./...`、`go vet ./...`、`gofmt -l`、API/migration + Windows 构建,全部通过;二进制使用本地 Go 1.23.0 工具链。 +- 根目录 `$env:RUN_START_COMMAND="0"; .\init.ps1` 成功完成 Android + `test assembleDebug`、后端 test/vet/gofmt 和双入口构建。 +- 默认 Debug APK 的 `assets/probe-fixtures/` 条目为 0;`bin/`、`var/`、数据库、 + WAL/SHM、日志和 `.env` 均被 Git 忽略。 + +### 2026-07-25:迁移与 HTTP smoke + +- 独立临时 SQLite 执行 `status -> up -> up -> down -> up -> status`: + 状态按 pending/applied 切换,第二次 `up` 返回 `applied=0`。 +- API Windows 进程使用动态回环端口启动;`GET /healthz` 返回 + `{"status":"ok"}`,Cache-Control 为 no-store,未知路由为 404,POST health 为 + 405,公共错误合约完整,未返回 CORS header,stdout/stderr 字节数均为 0。 +- smoke 结束后精确检查 API 测试进程数量为 0。健康 503 和不终止行为由 Fake DB + 集成测试验证;不需要破坏真实运行时数据库来制造故障。 + +### 未验证项 + +- 当前 Windows 的 `bash.exe` 指向未配置发行版的 WSL,`init.sh` 未实际运行;脚本 + 已与 Windows 入口同步 Go/CGO/test/vet/gofmt/build 逻辑,需在 Linux/WSL 复核。 +- `/healthz` 当前只表示进程与数据库连接可用,不检查 migration 是否最新;增加 + readiness/migration 门禁应在首个业务表落地前完成。 +- 默认数据库路径相对启动工作目录;本地命令必须从 `backend-api/` 运行,部署时应 + 显式配置绝对 `CMROUBAO_DATABASE_PATH`。 +- 本任务没有业务表、鉴权、管理页面或任务 API;分别属于 T-202 至 T-205。 diff --git a/init.ps1 b/init.ps1 index 4e4fe89..0c62b09 100644 --- a/init.ps1 +++ b/init.ps1 @@ -12,6 +12,7 @@ $sdkRoot = if ($env:ANDROID_SDK_ROOT) { } $gradle = Join-Path $PSScriptRoot "android-buyer\gradlew.bat" +$backendRoot = Join-Path $PSScriptRoot "backend-api" $adb = Join-Path $sdkRoot "platform-tools\adb.exe" $apk = Join-Path $PSScriptRoot "android-buyer\app\build\outputs\apk\debug\app-debug.apk" @@ -31,9 +32,26 @@ $javaVersion = (& java -version 2>&1) -join "`n" if ($javaVersion -notmatch 'version "17(?:\.|")') { throw "需要 JDK 17,当前 java -version 输出: $javaVersion" } +if (-not (Test-Path -LiteralPath (Join-Path $backendRoot "go.mod"))) { + throw "缺少 Go 后端工程: $backendRoot" +} +if (-not (Get-Command go -ErrorAction SilentlyContinue)) { + throw "缺少 Go 1.23.0,go 不在 PATH 中。" +} +$goVersion = (& go version) -join "`n" +if ($goVersion -notmatch '\bgo1\.23\.0\b') { + throw "需要 Go 1.23.0,当前 go version 输出: $goVersion" +} +if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) { + throw "SQLite CGO 构建需要 GCC,gcc 不在 PATH 中。" +} $env:ANDROID_HOME = $sdkRoot $env:ANDROID_SDK_ROOT = $sdkRoot +$env:GOTOOLCHAIN = "local" +if ((& go env CGO_ENABLED) -ne "1") { + throw "SQLite 构建需要 CGO_ENABLED=1。" +} Write-Host "==> 验证 Android 工程" Push-Location (Join-Path $PSScriptRoot "android-buyer") @@ -48,6 +66,34 @@ try { Write-Host "==> Debug APK: $apk" +Write-Host "==> 验证 Go 后端" +Push-Location $backendRoot +try { + & go test ./... + if ($LASTEXITCODE -ne 0) { + throw "Go 后端测试失败,退出码: $LASTEXITCODE" + } + & go vet ./... + if ($LASTEXITCODE -ne 0) { + throw "Go 后端 vet 失败,退出码: $LASTEXITCODE" + } + $unformatted = @(& gofmt -l cmd internal migrations) + if ($LASTEXITCODE -ne 0 -or $unformatted.Count -ne 0) { + throw "Go 后端存在未格式化文件: $($unformatted -join ', ')" + } + New-Item -ItemType Directory -Force "bin" | Out-Null + & go build -o "bin/cmroubao-api.exe" ./cmd/api + if ($LASTEXITCODE -ne 0) { + throw "Go API 构建失败,退出码: $LASTEXITCODE" + } + & go build -o "bin/cmroubao-migrate.exe" ./cmd/migrate + if ($LASTEXITCODE -ne 0) { + throw "Go migration 构建失败,退出码: $LASTEXITCODE" + } +} finally { + Pop-Location +} + if ($env:RUN_START_COMMAND -eq "1") { if (-not (Test-Path -LiteralPath $adb)) { throw "缺少 SDK Platform Tools: $adb" diff --git a/init.sh b/init.sh index 876bda2..3c775f0 100755 --- a/init.sh +++ b/init.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ANDROID_DIR="$ROOT_DIR/android-buyer" +BACKEND_DIR="$ROOT_DIR/backend-api" SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Android/Sdk}}" GRADLE="$ANDROID_DIR/gradlew" ADB="$SDK_ROOT/platform-tools/adb" @@ -30,9 +31,31 @@ if [[ "$JAVA_VERSION" != *'version "17.'* ]]; then echo "ERROR: JDK 17 is required; found: $JAVA_VERSION" >&2 exit 2 fi +if [[ ! -f "$BACKEND_DIR/go.mod" ]]; then + echo "ERROR: Go backend project is missing: $BACKEND_DIR" >&2 + exit 2 +fi +if ! command -v go >/dev/null 2>&1; then + echo "ERROR: Go 1.23.0 is missing from PATH." >&2 + exit 2 +fi +GO_VERSION="$(go version)" +if [[ ! "$GO_VERSION" =~ ^go\ version\ go1\.23\.0[[:space:]] ]]; then + echo "ERROR: Go 1.23.0 is required; found: $GO_VERSION" >&2 + exit 2 +fi +if ! command -v gcc >/dev/null 2>&1; then + echo "ERROR: GCC is required for the SQLite CGO build." >&2 + exit 2 +fi export ANDROID_HOME="$SDK_ROOT" export ANDROID_SDK_ROOT="$SDK_ROOT" +export GOTOOLCHAIN=local +if [[ "$(go env CGO_ENABLED)" != "1" ]]; then + echo "ERROR: CGO_ENABLED=1 is required for SQLite." >&2 + exit 2 +fi echo "==> Verifying Android project" ( @@ -42,6 +65,22 @@ echo "==> Verifying Android project" echo "==> Debug APK: $APK" +echo "==> Verifying Go backend" +( + cd "$BACKEND_DIR" + go test ./... + go vet ./... + UNFORMATTED="$(gofmt -l cmd internal migrations)" + if [[ -n "$UNFORMATTED" ]]; then + echo "ERROR: Go files need gofmt:" >&2 + echo "$UNFORMATTED" >&2 + exit 2 + fi + mkdir -p bin + go build -o bin/cmroubao-api ./cmd/api + go build -o bin/cmroubao-migrate ./cmd/migrate +) + if [[ "${RUN_START_COMMAND:-0}" == "1" ]]; then if [[ ! -x "$ADB" ]]; then echo "ERROR: Android Platform Tools are missing: $ADB" >&2 diff --git a/progress.md b/progress.md index 8b1ddbf..fa053de 100644 --- a/progress.md +++ b/progress.md @@ -102,3 +102,11 @@ 串行评估最多 5 个候选,由本地规则生成建议并停在人工确认点。 - 影响:Phase 1 完成;模型、自动化和人员操作均不能提交订单或支付。下一步 T-201 建立 Go-Gin、SQLite 和迁移骨架,真实模型供应商与质量验证仍作为外部 blocker。 + +## 2026-07-25 Go 后端可运行基线 + +- 类型:阶段切换 +- 内容:完成 T-201;Go Blueprint v0.10.11 作为一次性参考,正式后端固定 Go 1.23.0、 + Gin 1.11.0、SQLite 和 Goose 3.26.0,并建立健康检查、migration 与有界生命周期。 +- 影响:Phase 2 后端基础可复现;T-202 可先确认 P0 原型,T-203 至 T-205 再分别实现 + 任务业务、鉴权和原子领取,不需要背负生成器演示逻辑或危险默认。