feat(backend): establish gin sqlite service skeleton

This commit is contained in:
QiuSW
2026-07-25 22:29:56 +08:00
parent 84f2da3cf7
commit ab21219a07
32 changed files with 1854 additions and 42 deletions
+19 -5
View File
@@ -22,7 +22,7 @@ MVP 不自动提交订单、不支付、不绕过验证码或平台风控。
```text ```text
cmroubao/ cmroubao/
├── android-buyer/ # Roubao main 固定 commit 的 Android 基线 ├── android-buyer/ # Roubao main 固定 commit 的 Android 基线
├── backend-api/ # Go-Gin API、管理页面和任务数据,待创建 ├── backend-api/ # Go-Gin API、SQLite、迁移和后续管理页面
├── docs/ # Harness Coding 项目事实和执行约束 ├── docs/ # Harness Coding 项目事实和执行约束
├── AGENTS.md # AI coding agent 权威入口 ├── AGENTS.md # AI coding agent 权威入口
├── init.ps1 # Windows 标准构建/真机启动入口 ├── init.ps1 # Windows 标准构建/真机启动入口
@@ -32,8 +32,9 @@ cmroubao/
管理人员使用 Web 管理端,采购人员使用 Android App;二者共享同一后端、任务数据 管理人员使用 Web 管理端,采购人员使用 Android App;二者共享同一后端、任务数据
和权限体系。验证版先使用单管理账号和设备身份,完整 RBAC 放到验证通过后。 和权限体系。验证版先使用单管理账号和设备身份,完整 RBAC 放到验证通过后。
后端已确定采用 Go 1.23.0 + Gin 1.11.0,并用 Go Blueprint v0.10.11 生成最小 后端采用 Go 1.23.0 + Gin 1.11.0,以 Go Blueprint v0.10.11 为一次性骨架参考,
Gin + SQLite 骨架后按领域边界二次开发。 已收敛出 SQLite、Goose 迁移、健康检查和可关闭的 HTTP Server;业务 API 按 Phase 2
任务逐步加入。
Android 端已核实的 Roubao 上游环境为 Kotlin 1.9.20、JDK 17、Gradle 8.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。最低支持 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 构建会清除先前注入的私有 普通 `.\init.ps1` 或不带 `-PprobeFixturesDir` 的 Debug 构建会清除先前注入的私有
fixture,默认 APK 不携带真实订单资料。 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) - [AI 开发入口](docs/00-ai-start-here.md)
@@ -66,5 +80,5 @@ fixture,默认 APK 不携带真实订单资料。
- [当前实现状态](docs/current-state.md) - [当前实现状态](docs/current-state.md)
- [完整文档导航](docs/README.md) - [完整文档导航](docs/README.md)
Android Phase 0 已完成构建、真机设备就绪、workflow 测试和私有样本导入;Go 后端 Android Phase 0/1 已完成,Go 后端基础骨架已建立。真实状态以
尚未建立。真实状态以 [`docs/current-state.md`](docs/current-state.md) 为准。 [`docs/current-state.md`](docs/current-state.md) 为准。
+7
View File
@@ -0,0 +1,7 @@
.env
.env.*
bin/
var/
*.db
*.db-shm
*.db-wal
+35
View File
@@ -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"}`。
+13
View File
@@ -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.
+113
View File
@@ -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
}
+75
View File
@@ -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)
}
}
+79
View File
@@ -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
}
+16
View File
@@ -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)
}
}
}
+46
View File
@@ -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
)
+116
View File
@@ -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=
+127
View File
@@ -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
}
+133
View File
@@ -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;
+8
View File
@@ -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
+10 -3
View File
@@ -53,7 +53,7 @@
当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、 当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、
动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已 动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已
验证。下一步是 T-201,生成并收敛 Go-Gin、SQLite 和迁移骨架。 验证。T-201 后端骨架也已完成,下一步是 T-202,生成并确认 P0 Web/App 低保真原型。
严格按以下顺序推进: 严格按以下顺序推进:
@@ -103,5 +103,12 @@ $env:RUN_START_COMMAND = "1"
.\init.ps1 .\init.ps1
``` ```
脚本执行 `test assembleDebug`;上游当前没有单元测试源,因此 `test` 的测试任务为 脚本先执行 Android `test assembleDebug`,再以 `GOTOOLCHAIN=local` 执行后端
`NO-SOURCE`,但编译与 APK 打包必须成功。后端建立后再追加后端测试和启动命令。 `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
View File
@@ -16,10 +16,10 @@
| 第一层任务源 | UTF-8 无 BOM 四行蝦皮订单文本 + 同订单号 JPEG | 已实现 | `task-contract` 共享 `ProbeTask/TaskSource`;CLI 输出到 `.local/`,只有显式 Debug 属性才注入 APK,默认构建会清除私有资产。 | | 第一层任务源 | UTF-8 无 BOM 四行蝦皮订单文本 + 同订单号 JPEG | 已实现 | `task-contract` 共享 `ProbeTask/TaskSource`;CLI 输出到 `.local/`,只有显式 Debug 属性才注入 APK,默认构建会清除私有资产。 |
| Android 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 | | Android 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 |
| 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 | | 后端语言 | 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 版本。 | | 后端框架 | Gin v1.11.0 | MVP 已定 | 这是 `go.mod` 明确支持 Go 1.23.0 的最高已核实 Gin 版本。 |
| 数据访问 | 标准库 `database/sql` | MVP 已定 | 领域层通过仓储接口访问,避免先引入 ORM 和代码生成复杂度。 | | 数据访问 | 标准库 `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 工程,模板和静态资源随服务构建。 | | 管理 Web | Gin + `html/template` + `embed` + 少量原生 JS/CSS | MVP 已定 | 不单独引入 SPA 工程,模板和静态资源随服务构建。 |
| 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 | | 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 |
| 图片/截图 | 后端受控本地文件目录,数据库存元数据 | MVP 已定 | 禁止把二进制直接塞入日志;生产再评估对象存储。 | | 图片/截图 | 后端受控本地文件目录,数据库存元数据 | MVP 已定 | 禁止把二进制直接塞入日志;生产再评估对象存储。 |
@@ -27,7 +27,7 @@
| App 鉴权 | 采购员登录态 + 设备绑定令牌 | 目标已定,细节待实现 | 人员身份与设备身份分离;令牌只保存哈希。 | | App 鉴权 | 采购员登录态 + 设备绑定令牌 | 目标已定,细节待实现 | 人员身份与设备身份分离;令牌只保存哈希。 |
| VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | T-103/T-104 使用严格 JSON Schema、单候选单次调用和 2048 px 图片上限;GUI-Owl/MAI-UI 动作模型不具备需求提取能力。 | | VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | T-103/T-104 使用严格 JSON Schema、单候选单次调用和 2048 px 图片上限;GUI-Owl/MAI-UI 动作模型不具备需求提取能力。 |
| 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 | | 通知 | 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。 | | 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 或受控测试网络访问。 | | 部署 | 单机局域网 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 Blueprint v0.10.11 当前生成的是 `Go 1.25.0 + Gin 1.12.0`,不能直接作为本项目
的 `go.mod`。2026-07-25 已在临时目录将生成结果调整为以下版本,并在 的 `go.mod`。2026-07-25 已在仓库外临时目录核实生成树,并把正式 `go.mod` 固定为:
`GOTOOLCHAIN=local` 下完成 `go mod tidy` 和 `go test ./...`:
```text ```text
go 1.23.0 go 1.23.0
github.com/gin-gonic/gin v1.11.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/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,除非先 Gin v1.12.0 的 `go.mod` 要求 Go 1.25.0,因此本项目禁止升级到 Gin 1.12.x,除非先
@@ -116,20 +114,18 @@ RabbitMQ、NATS 和 gRPC 为主,不符合最小 Gin + SQLite 边界。
```text ```text
android-buyer/ # 已接入的 Roubao Kotlin Android App android-buyer/ # 已接入的 Roubao Kotlin Android App
backend-api/ backend-api/
cmd/api/ # 进程入口,只负责装配和生命周期 cmd/api/ # API 进程装配、信号和优雅关闭
internal/domain/ # 实体、状态机和确定性规则 cmd/migrate/ # 显式 migration up/down/status
internal/usecase/ # 创建、领取、执行和结果归档 internal/config/ # 环境变量和安全默认
internal/transport/http/ # Gin handler、中间件和页面 internal/platform/database/ # SQLite 打开、pragma 和生命周期
internal/repository/sqlite/ # database/sql 仓储 internal/platform/migration/ # Goose provider 封装
internal/platform/ # 配置、日志、文件和 VLM 适配器 internal/transport/httpapi/ # Gin 路由、健康检查和 HTTP Server
migrations/ # Goose SQL migration migrations/ # 嵌入式 Goose SQL migration
web/templates/ # html/template
web/static/ # 少量 CSS/JS
var/ # 本地运行数据,必须忽略 var/ # 本地运行数据,必须忽略
docs/ docs/
``` ```
目录在真实骨架建立后以代码为准并同步本文。 `domain/usecase/repository/sqlite/web` 将在对应业务任务出现真实代码时创建,不提交空包。
## 构建与运行命令 ## 构建与运行命令
@@ -137,13 +133,13 @@ docs/
| --- | --- | --- | | --- | --- | --- |
| Android 标准验证 | `.\init.ps1` | 已验证 | | Android 标准验证 | `.\init.ps1` | 已验证 |
| Android 构建 | `android-buyer\gradlew.bat assembleDebug --no-daemon` | 已验证 | | 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 真机验证 | | Android 安装/启动 | `$env:RUN_START_COMMAND="1"; .\init.ps1` | 已在 Android 16 真机验证 |
| 后端依赖 | `go mod download`(在 `backend-api/`) | 待 `T-201` | | 后端依赖 | `$env:GOTOOLCHAIN="local"; go mod download`(在 `backend-api/`) | 已验证 |
| 后端测试 | `go test ./...`(在 `backend-api/`) | 待 `T-201` | | 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go vet ./...` | 已验证;25 个测试 |
| 后端构建 | `go build -o bin/cmroubao-api.exe ./cmd/api` | 待 `T-201` | | 后端构建 | `go build -o bin/cmroubao-api.exe ./cmd/api` 与 `./cmd/migrate` | 已验证 |
| 后端启动 | `go run ./cmd/api` | 待 `T-201` | | 后端启动 | `go run ./cmd/api` | 已完成本机 HTTP smoke |
| 数据迁移 | `goose -dir migrations sqlite3 ./var/cmroubao.db up` | 待 `T-201` | | 数据迁移 | `go run ./cmd/migrate up` | 已完成 `status/up/up/down/up` smoke |
`gradlew installDebug` 在当前 Android 16 设备上由旧版 AGP/ddmlib 返回 `-99`, `gradlew installDebug` 在当前 Android 16 设备上由旧版 AGP/ddmlib 返回 `-99`,
但 SDK Platform Tools 37.0.0 的 `adb install -r -t` 成功。标准脚本因此使用 SDK 但 SDK Platform Tools 37.0.0 的 `adb install -r -t` 成功。标准脚本因此使用 SDK
+12
View File
@@ -60,6 +60,18 @@ internal/platform/ # 配置、日志、文件存储和 VLM 适配器
- 在 Gin handler 中写任务状态机、SQL 或仓储业务逻辑。 - 在 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 ### 2.2 Android App
建议模块: 建议模块:
+12 -3
View File
@@ -83,6 +83,14 @@
- 不向客户端返回 `password_hash`、`token_hash`、存储绝对路径或供应商密钥。 - 不向客户端返回 `password_hash`、`token_hash`、存储绝对路径或供应商密钥。
- 通用错误使用稳定 code 和可读 message;内部堆栈只进受控日志。 - 通用错误使用稳定 code 和可读 message;内部堆栈只进受控日志。
- 文件访问通过鉴权接口,防止路径遍历和猜测 URL。 - 文件访问通过鉴权接口,防止路径遍历和猜测 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. 安全与隐私 ## 7. 安全与隐私
@@ -125,6 +133,7 @@
- [ ] 当前任务文件记录命令、结果、环境和未验证项。 - [ ] 当前任务文件记录命令、结果、环境和未验证项。
- [ ] `current-state.md` 与真实目录、命令和 blocker 一致。 - [ ] `current-state.md` 与真实目录、命令和 blocker 一致。
当前 Android 标准验证命令是根目录 `.\init.ps1`;它执行 Gradle `test` 和 项目标准验证命令是根目录 `.\init.ps1`;它执行 Android Gradle `test` 和
`assembleDebug`。设置 `RUN_START_COMMAND=1` 时使用 SDK 内新版 ADB 安装并启动。 `assembleDebug`,再以 `GOTOOLCHAIN=local` 执行后端 `go test ./...`、
后端建立后再在这里列出后端命令。 `go vet ./...`、`gofmt` 检查和两个入口构建。设置 `RUN_START_COMMAND=1` 时使用
SDK 内新版 ADB 安装并启动 Android App。
+21
View File
@@ -40,6 +40,27 @@ HTTP 语义:
- `429` 频率限制 - `429` 频率限制
- `503` 依赖暂不可用 - `503` 依赖暂不可用
## 服务健康
### `GET /healthz`
不需要业务身份,只检查进程和 SQLite 连接,不返回版本、路径、DSN、连接池统计或内部
错误。响应带 `Cache-Control: no-store`,默认不返回 CORS header。
数据库可用:
```json
{"status":"ok"}
```
返回 `200`。数据库不可用时返回 `503`:
```json
{"status":"unavailable"}
```
健康检查失败不能终止进程;未知路由和不允许的方法分别使用稳定 `404`/`405` JSON。
## 认证 ## 认证
### 管理 Web 会话 ### 管理 Web 会话
+22 -7
View File
@@ -5,16 +5,19 @@
## 当前快照 ## 当前快照
- 日期:2026-07-25 - 日期:2026-07-25
- 阶段:Phase 1 已完成,准备开始 T-201 后端骨架 - 阶段:T-201 已完成,准备开始 T-202 P0 低保真原型
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104 均已纳入 Git 历史 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 均已纳入 Git 历史
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 - 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、 - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器 - 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器
共 26 份报告、166 次测试,0 failure、0 error、0 skipped 共 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、安全阻塞、 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
@@ -47,18 +50,19 @@
| `docs/tasks/T-102.md` | DONE | 最多 5 个候选详情截图、证据 manifest 和结果页返回 | | `docs/tasks/T-102.md` | DONE | 最多 5 个候选详情截图、证据 manifest 和结果页返回 |
| `docs/tasks/T-103.md` | DONE | 私有任务需求 schema、硬约束、隐私边界和 VLM 适配器 | | `docs/tasks/T-103.md` | DONE | 私有任务需求 schema、硬约束、隐私边界和 VLM 适配器 |
| `docs/tasks/T-104.md` | DONE | 动态搜索、候选严格评估、本地建议和人工确认点 | | `docs/tasks/T-104.md` | DONE | 动态搜索、候选严格评估、本地建议和人工确认点 |
| `docs/tasks/T-201.md` | DONE | Go-Gin、SQLite、Goose migration 和健康检查骨架 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
| `android-buyer/task-contract/` | 已有 | Android/CLI 共享 ProbeTask 与 TaskSource | | `android-buyer/task-contract/` | 已有 | Android/CLI 共享 ProbeTask 与 TaskSource |
| `android-buyer/tools/shopee-importer/` | 已有 | 开发机私有 fixture 导入 CLI | | `android-buyer/tools/shopee-importer/` | 已有 | 开发机私有 fixture 导入 CLI |
| `backend-api/` | 待建 | Go-Gin、管理 Web 和数据目标目录 | | `backend-api/` | 已有 | Go-Gin API、SQLite、migration、健康检查和测试骨架 |
| `init.ps1` / `init.sh` | 已验证 | Android 构建入口;可选真机安装启动 | | `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" $env:RUN_START_COMMAND = "1"
.\init.ps1 .\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 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 `order_submitted` 始终为 false;日志敏感词、Base64、原始响应和 schema/prompt
命中均为 0。该结果不代表真实模型的商品匹配质量。 命中均为 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 标准入口已验证。
## 维护规则 ## 维护规则
发生以下变化时覆盖更新本文: 发生以下变化时覆盖更新本文:
+126
View File
@@ -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。
+46
View File
@@ -12,6 +12,7 @@ $sdkRoot = if ($env:ANDROID_SDK_ROOT) {
} }
$gradle = Join-Path $PSScriptRoot "android-buyer\gradlew.bat" $gradle = Join-Path $PSScriptRoot "android-buyer\gradlew.bat"
$backendRoot = Join-Path $PSScriptRoot "backend-api"
$adb = Join-Path $sdkRoot "platform-tools\adb.exe" $adb = Join-Path $sdkRoot "platform-tools\adb.exe"
$apk = Join-Path $PSScriptRoot "android-buyer\app\build\outputs\apk\debug\app-debug.apk" $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(?:\.|")') { if ($javaVersion -notmatch 'version "17(?:\.|")') {
throw "需要 JDK 17,当前 java -version 输出: $javaVersion" 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_HOME = $sdkRoot
$env:ANDROID_SDK_ROOT = $sdkRoot $env:ANDROID_SDK_ROOT = $sdkRoot
$env:GOTOOLCHAIN = "local"
if ((& go env CGO_ENABLED) -ne "1") {
throw "SQLite 构建需要 CGO_ENABLED=1。"
}
Write-Host "==> 验证 Android 工程" Write-Host "==> 验证 Android 工程"
Push-Location (Join-Path $PSScriptRoot "android-buyer") Push-Location (Join-Path $PSScriptRoot "android-buyer")
@@ -48,6 +66,34 @@ try {
Write-Host "==> Debug APK: $apk" 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 ($env:RUN_START_COMMAND -eq "1") {
if (-not (Test-Path -LiteralPath $adb)) { if (-not (Test-Path -LiteralPath $adb)) {
throw "缺少 SDK Platform Tools: $adb" throw "缺少 SDK Platform Tools: $adb"
+39
View File
@@ -4,6 +4,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ANDROID_DIR="$ROOT_DIR/android-buyer" ANDROID_DIR="$ROOT_DIR/android-buyer"
BACKEND_DIR="$ROOT_DIR/backend-api"
SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Android/Sdk}}" SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Android/Sdk}}"
GRADLE="$ANDROID_DIR/gradlew" GRADLE="$ANDROID_DIR/gradlew"
ADB="$SDK_ROOT/platform-tools/adb" 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 echo "ERROR: JDK 17 is required; found: $JAVA_VERSION" >&2
exit 2 exit 2
fi 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_HOME="$SDK_ROOT"
export ANDROID_SDK_ROOT="$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" echo "==> Verifying Android project"
( (
@@ -42,6 +65,22 @@ echo "==> Verifying Android project"
echo "==> Debug APK: $APK" 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 [[ "${RUN_START_COMMAND:-0}" == "1" ]]; then
if [[ ! -x "$ADB" ]]; then if [[ ! -x "$ADB" ]]; then
echo "ERROR: Android Platform Tools are missing: $ADB" >&2 echo "ERROR: Android Platform Tools are missing: $ADB" >&2
+8
View File
@@ -102,3 +102,11 @@
串行评估最多 5 个候选,由本地规则生成建议并停在人工确认点。 串行评估最多 5 个候选,由本地规则生成建议并停在人工确认点。
- 影响:Phase 1 完成;模型、自动化和人员操作均不能提交订单或支付。下一步 T-201 - 影响:Phase 1 完成;模型、自动化和人员操作均不能提交订单或支付。下一步 T-201
建立 Go-Gin、SQLite 和迁移骨架,真实模型供应商与质量验证仍作为外部 blocker。 建立 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 再分别实现
任务业务、鉴权和原子领取,不需要背负生成器演示逻辑或危险默认。