feat: initialize independent Sense product skeleton (#21)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user