feat(backend): establish gin sqlite service skeleton
This commit is contained in:
@@ -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) 为准。
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.env.*
|
||||
bin/
|
||||
var/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
@@ -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"}`。
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <up|down|status>")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}$`,
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
-- T-201 verifies the migration lifecycle without defining later business tables.
|
||||
SELECT 1;
|
||||
|
||||
-- +goose Down
|
||||
SELECT 1;
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
+19
-23
@@ -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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
建议模块:
|
||||
|
||||
+12
-3
@@ -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。
|
||||
|
||||
+21
@@ -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 会话
|
||||
|
||||
+22
-7
@@ -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 标准入口已验证。
|
||||
|
||||
## 维护规则
|
||||
|
||||
发生以下变化时覆盖更新本文:
|
||||
|
||||
@@ -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。
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 再分别实现
|
||||
任务业务、鉴权和原子领取,不需要背负生成器演示逻辑或危险默认。
|
||||
|
||||
Reference in New Issue
Block a user