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
+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()
}