feat: initialize independent Sense product skeleton (#21)

This commit is contained in:
QiuSW
2026-08-12 16:49:17 +08:00
parent be2c1e3c81
commit d2169990d3
33 changed files with 9140 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
package sense
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"yovision.local/sense/internal/platform"
)
type moduleRegistrar func(*platform.App) error
var moduleRegistrars []moduleRegistrar
// registerModule is intentionally package-local. Feature integration files in
// this directory append one registrar without changing the shared bootstrap.
func registerModule(registrar moduleRegistrar) {
moduleRegistrars = append(moduleRegistrars, registrar)
}
func Run() error {
cfg, err := platform.LoadConfig()
if err != nil {
return err
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel()}))
database, err := platform.OpenDatabase(context.Background(), cfg)
if err != nil {
return err
}
if database != nil {
defer database.Close()
}
app := platform.NewApp(cfg, database, logger)
for _, registrar := range moduleRegistrars {
if err := registrar(app); err != nil {
return fmt.Errorf("register Sense module: %w", err)
}
}
if err := app.ApplyMigrations(context.Background()); err != nil {
return err
}
server := &http.Server{
Addr: cfg.HTTPAddress,
Handler: app.Handler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() {
logger.Info("Sense server started", "address", cfg.HTTPAddress, "database_mode", cfg.DatabaseMode)
errCh <- server.ListenAndServe()
}()
stopCh := make(chan os.Signal, 1)
signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM)
select {
case <-stopCh:
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return server.Shutdown(ctx)
case serveErr := <-errCh:
if serveErr == http.ErrServerClosed {
return nil
}
return serveErr
}
}
+5
View File
@@ -0,0 +1,5 @@
package config
// Package config exists as the stable public configuration boundary for Sense.
// Runtime parsing is implemented in internal/platform so feature packages do
// not read process environment variables directly.
+13
View File
@@ -0,0 +1,13 @@
module yovision.local/sense
go 1.26.5
require github.com/jackc/pgx/v5 v5.8.0
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
+26
View File
@@ -0,0 +1,26 @@
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
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=
+103
View File
@@ -0,0 +1,103 @@
package platform
import (
"database/sql"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type App struct {
config Config
database *sql.DB
logger *slog.Logger
mux *http.ServeMux
migrations []Migration
}
func NewApp(cfg Config, database *sql.DB, logger *slog.Logger) *App {
app := &App{config: cfg, database: database, logger: logger, mux: http.NewServeMux()}
app.registerPlatformRoutes()
return app
}
func (a *App) Config() Config { return a.config }
func (a *App) Database() *sql.DB { return a.database }
func (a *App) Logger() *slog.Logger { return a.logger }
func (a *App) Handle(pattern string, handler http.Handler) {
a.mux.Handle(pattern, handler)
}
func (a *App) HandleFunc(pattern string, handler http.HandlerFunc) {
a.mux.HandleFunc(pattern, handler)
}
func (a *App) Handler() http.Handler {
return requestSecurityHeaders(a.mux)
}
func (a *App) registerPlatformRoutes() {
a.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok", "product": "sense"})
})
a.mux.HandleFunc("GET /readyz", a.ready)
a.mux.HandleFunc("GET /api/v1/system/info", func(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"product": "Sense",
"database": a.config.DatabaseMode,
"modules": []string{},
})
})
// Deliberately do not register upstream demo/code-generation routes.
if a.config.StaticDir != "" {
a.registerStaticFiles(a.config.StaticDir)
}
}
func (a *App) ready(w http.ResponseWriter, r *http.Request) {
if a.database == nil {
WriteJSON(w, http.StatusOK, map[string]string{"status": "ready", "database": "memory"})
return
}
ctx, cancel := timeLimitedContext(r, 2*time.Second)
defer cancel()
if err := a.database.PingContext(ctx); err != nil {
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready", "database": "unavailable"})
return
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "ready", "database": "postgres"})
}
func (a *App) registerStaticFiles(directory string) {
absolute, err := filepath.Abs(directory)
if err != nil {
a.logger.Warn("invalid UI static directory", "error", err)
return
}
fileSystem := os.DirFS(absolute)
a.mux.Handle("GET /assets/", http.StripPrefix("/", http.FileServer(http.FS(fileSystem))))
a.mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(r.URL.Path)), "/")
if path == "." || path == "" {
path = "index.html"
}
if _, err := fs.Stat(fileSystem, path); err != nil {
path = "index.html"
}
http.ServeFileFS(w, r, fileSystem, path)
})
}
func requestSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
@@ -0,0 +1,31 @@
package platform
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
)
func TestPlatformRoutesAndDisabledDemo(t *testing.T) {
app := NewApp(Config{DatabaseMode: DatabaseModeMemory}, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
for _, test := range []struct {
path string
want int
}{
{path: "/healthz", want: http.StatusOK},
{path: "/readyz", want: http.StatusOK},
{path: "/api/v1/system/info", want: http.StatusOK},
{path: "/api/v1/demo", want: http.StatusNotFound},
{path: "/api/v1/system/tools", want: http.StatusNotFound},
} {
req := httptest.NewRequest(http.MethodGet, test.path, nil)
res := httptest.NewRecorder()
app.Handler().ServeHTTP(res, req)
if res.Code != test.want {
t.Errorf("GET %s status = %d, want %d", test.path, res.Code, test.want)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
package platform
import (
"fmt"
"log/slog"
"os"
"strings"
)
const (
DatabaseModePostgres = "postgres"
DatabaseModeMemory = "memory"
)
type Config struct {
HTTPAddress string
DatabaseMode string
DatabaseURL string
StaticDir string
LogLevelName string
}
func LoadConfig() (Config, error) {
cfg := Config{
HTTPAddress: valueOrDefault("SENSE_HTTP_ADDRESS", "127.0.0.1:18080"),
DatabaseMode: strings.ToLower(valueOrDefault("SENSE_DATABASE_MODE", DatabaseModePostgres)),
DatabaseURL: strings.TrimSpace(os.Getenv("SENSE_DATABASE_URL")),
StaticDir: strings.TrimSpace(os.Getenv("SENSE_UI_STATIC_DIR")),
LogLevelName: strings.ToLower(valueOrDefault("SENSE_LOG_LEVEL", "info")),
}
switch cfg.DatabaseMode {
case DatabaseModePostgres:
if cfg.DatabaseURL == "" {
return Config{}, fmt.Errorf("SENSE_DATABASE_URL is required when SENSE_DATABASE_MODE=postgres")
}
case DatabaseModeMemory:
// Explicitly limited to local smoke tests and automated tests.
default:
return Config{}, fmt.Errorf("unsupported SENSE_DATABASE_MODE %q", cfg.DatabaseMode)
}
return cfg, nil
}
func (c Config) LogLevel() slog.Level {
switch c.LogLevelName {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func valueOrDefault(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
@@ -0,0 +1,23 @@
package platform
import "testing"
func TestLoadConfigRequiresPostgresURL(t *testing.T) {
t.Setenv("SENSE_DATABASE_MODE", "postgres")
t.Setenv("SENSE_DATABASE_URL", "")
if _, err := LoadConfig(); err == nil {
t.Fatal("expected missing PostgreSQL URL to fail")
}
}
func TestLoadConfigAllowsExplicitMemoryMode(t *testing.T) {
t.Setenv("SENSE_DATABASE_MODE", "memory")
t.Setenv("SENSE_DATABASE_URL", "")
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
if cfg.DatabaseMode != DatabaseModeMemory {
t.Fatalf("DatabaseMode = %q", cfg.DatabaseMode)
}
}
+11
View File
@@ -0,0 +1,11 @@
package platform
import (
"context"
"net/http"
"time"
)
func timeLimitedContext(r *http.Request, timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(r.Context(), timeout)
}
@@ -0,0 +1,31 @@
package platform
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func OpenDatabase(ctx context.Context, cfg Config) (*sql.DB, error) {
if cfg.DatabaseMode == DatabaseModeMemory {
return nil, nil
}
database, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("open Sense PostgreSQL: %w", err)
}
database.SetMaxOpenConns(20)
database.SetMaxIdleConns(5)
database.SetConnMaxLifetime(30 * time.Minute)
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := database.PingContext(pingCtx); err != nil {
database.Close()
return nil, fmt.Errorf("connect Sense PostgreSQL: %w", err)
}
return database, nil
}
+39
View File
@@ -0,0 +1,39 @@
package platform
import (
"encoding/json"
"errors"
"net/http"
)
type APIError struct {
Status int
Code string
Message string
}
func (e *APIError) Error() string { return e.Message }
func WriteJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func WriteError(w http.ResponseWriter, err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
WriteJSON(w, apiErr.Status, map[string]any{"error": map[string]string{"code": apiErr.Code, "message": apiErr.Message}})
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "internal_error", "message": "服务暂时不可用"}})
}
func DecodeJSON(r *http.Request, target any) error {
decoder := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return &APIError{Status: http.StatusBadRequest, Code: "invalid_request", Message: "请求内容格式不正确"}
}
return nil
}
@@ -0,0 +1,63 @@
package platform
import (
"context"
"database/sql"
"fmt"
"sort"
)
type Migration struct {
Version int64
Name string
SQL string
}
func (a *App) RegisterMigration(migration Migration) {
a.migrations = append(a.migrations, migration)
}
func (a *App) ApplyMigrations(ctx context.Context) error {
if a.database == nil {
return nil
}
if _, err := a.database.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS sense_schema_migrations (
version BIGINT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
return fmt.Errorf("create Sense migration table: %w", err)
}
sort.Slice(a.migrations, func(i, j int) bool { return a.migrations[i].Version < a.migrations[j].Version })
for _, migration := range a.migrations {
if err := applyMigration(ctx, a.database, migration); err != nil {
return err
}
}
return nil
}
func applyMigration(ctx context.Context, database *sql.DB, migration Migration) error {
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %d: %w", migration.Version, err)
}
defer tx.Rollback()
var exists bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM sense_schema_migrations WHERE version = $1)`, migration.Version).Scan(&exists); err != nil {
return fmt.Errorf("check migration %d: %w", migration.Version, err)
}
if exists {
return tx.Commit()
}
if _, err := tx.ExecContext(ctx, migration.SQL); err != nil {
return fmt.Errorf("apply migration %d (%s): %w", migration.Version, migration.Name, err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense_schema_migrations(version, name) VALUES($1, $2)`, migration.Version, migration.Name); err != nil {
return fmt.Errorf("record migration %d: %w", migration.Version, err)
}
return tx.Commit()
}
+13
View File
@@ -0,0 +1,13 @@
package main
import (
"log"
sensecmd "yovision.local/sense/cmd/sense"
)
func main() {
if err := sensecmd.Run(); err != nil {
log.Fatal(err)
}
}