Files
cmroubao/backend-api/cmd/api/main.go
T

417 lines
8.8 KiB
Go

package main
import (
"context"
"database/sql"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"cmroubao/backend-api/internal/config"
"cmroubao/backend-api/internal/platform/assetstore"
"cmroubao/backend-api/internal/platform/database"
"cmroubao/backend-api/internal/platform/migration"
"cmroubao/backend-api/internal/platform/ocrapi"
"cmroubao/backend-api/internal/platform/password"
"cmroubao/backend-api/internal/platform/shunyunbao"
repository "cmroubao/backend-api/internal/repository/sqlite"
"cmroubao/backend-api/internal/transport/authcommon"
"cmroubao/backend-api/internal/transport/httpapi"
"cmroubao/backend-api/internal/transport/webui"
"cmroubao/backend-api/internal/usecase"
"github.com/gin-gonic/gin"
)
func main() {
if err := run(); err != nil {
log.Printf("cmroubao API stopped: %v", err)
os.Exit(1)
}
}
func run() error {
cfg, err := loadConfig(os.LookupEnv, config.DefaultERPEnvironmentFile)
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 := buildRouter(startupContext, cfg, db)
if err != nil {
if errors.Is(err, errPendingMigrations) {
return err
}
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() {
if cfg.TLSCertificate != "" {
serverErrors <- server.ListenAndServeTLS(
cfg.TLSCertificate,
cfg.TLSPrivateKey,
)
return
}
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 loadConfig(
lookup config.LookupEnvironment,
environmentFile string,
) (config.Config, error) {
effectiveLookup, err := config.WithERPEnvironmentFile(
environmentFile,
lookup,
)
if err != nil {
return config.Config{}, err
}
return config.Load(effectiveLookup)
}
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
}
type migrationStatusReader interface {
Status(context.Context) ([]migration.Status, error)
}
var errPendingMigrations = errors.New(
"database migrations are pending; run migrate up",
)
func buildRouter(
ctx context.Context,
cfg config.Config,
db *sql.DB,
) (http.Handler, error) {
runner, err := migration.New(db)
if err != nil {
return nil, err
}
if err := requireCurrentMigrations(ctx, runner); err != nil {
return nil, err
}
store, err := repository.New(db)
if err != nil {
return nil, err
}
files, err := assetstore.New(cfg.AssetDirectory)
if err != nil {
return nil, err
}
clock := usecase.SystemClock{}
ids := usecase.UUIDGenerator{}
assets, err := usecase.NewAssetService(store, files, clock, ids)
if err != nil {
return nil, err
}
tasks, err := usecase.NewTaskService(store, clock, ids)
if err != nil {
return nil, err
}
lifecycle, err := usecase.NewLifecycleService(
store,
clock,
ids,
cfg.ClaimLease,
cfg.RunningLease,
cfg.ReadinessTTL,
)
if err != nil {
return nil, err
}
results, err := usecase.NewExecutionResultService(
store,
files,
clock,
ids,
)
if err != nil {
return nil, err
}
authorizations, err := usecase.NewOrderAuthorizationService(
store,
clock,
ids,
)
if err != nil {
return nil, err
}
commands, err := usecase.NewDeviceOrderCommandService(
store,
clock,
ids,
)
if err != nil {
return nil, err
}
dryRuns, err := usecase.NewOrderDryRunService(store, clock, ids)
if err != nil {
return nil, err
}
submissions, err := usecase.NewOrderSubmissionService(
store,
clock,
ids,
)
if err != nil {
return nil, err
}
ocr, err := ocrapi.NewClient(cfg.OCRAPIURL, 0)
if err != nil {
return nil, err
}
var erpDiagnosticLogger shunyunbao.DiagnosticLogger
if cfg.ERPDebugLog {
log.Print("ERP diagnostic logging enabled; response summaries are redacted")
erpDiagnosticLogger = func(event string) {
log.Print(event)
}
}
erpSession, err := shunyunbao.NewSessionManager(shunyunbao.SessionConfig{
BaseURL: cfg.ShunyunbaoURL,
Username: cfg.ShunyunbaoUsername,
Password: cfg.ShunyunbaoPassword,
Timeout: cfg.ShunyunbaoTimeout,
CaptchaRecognizer: ocr,
DiagnosticLogger: erpDiagnosticLogger,
})
if err != nil {
return nil, err
}
freightImages, err := usecase.NewFreightImageService(
store,
erpSession,
files,
clock,
)
if err != nil {
return nil, err
}
freight, err := usecase.NewFreightService(
store,
erpSession,
clock,
ids,
90*time.Second,
usecase.WithFreightImageCache(freightImages),
)
if err != nil {
return nil, err
}
if _, err := freight.RecoverInterrupted(ctx); err != nil {
return nil, err
}
procurement, err := usecase.NewProcurementService(store, clock, ids)
if err != nil {
return nil, err
}
passwords, err := password.NewBcrypt(12)
if err != nil {
return nil, err
}
registerDeviceRoutes, err := httpapi.NewDeviceRouteRegistrar(
httpapi.DeviceServices{
Lifecycle: lifecycle,
Assets: assets,
Results: results,
Commands: commands,
DryRuns: dryRuns,
Submissions: submissions,
},
)
if err != nil {
return nil, err
}
auth, err := usecase.NewAuthService(
store,
passwords,
clock,
ids,
usecase.CryptoTokenGenerator{},
)
if err != nil {
return nil, err
}
webService, err := webui.NewUsecaseAdapter(
tasks,
assets,
authorizations,
freight,
)
if err != nil {
return nil, err
}
webService.SetProcurement(procurement)
renderer, err := webui.NewRenderer()
if err != nil {
return nil, err
}
webHandler, err := webui.NewHandler(webService, renderer, func(event string) {
log.Print(event)
})
if err != nil {
return nil, err
}
authWebService, err := webui.NewAuthUsecaseAdapter(auth)
if err != nil {
return nil, err
}
loginLimiter, err := authcommon.NewAttemptLimiter(
10,
5*time.Minute,
4096,
)
if err != nil {
return nil, err
}
authWebHandler, err := webui.NewAuthHandler(
authWebService,
renderer,
loginLimiter,
)
if err != nil {
return nil, err
}
registerPublicAuth, err := httpapi.NewPublicAuthRegistrar(
auth,
loginLimiter,
)
if err != nil {
return nil, err
}
registerAdminRoutes, err := httpapi.NewAdminRouteRegistrar(
httpapi.AdminServices{
Assets: assets,
Tasks: tasks,
Results: results,
Authorizations: authorizations,
Freight: freight,
FreightImages: freightImages,
Procurement: procurement,
},
webHandler,
)
if err != nil {
return nil, err
}
registerProtectedRoutes := func(routes gin.IRoutes) error {
if err := registerAdminRoutes(routes); err != nil {
return err
}
authWebHandler.RegisterProtected(routes)
return nil
}
return httpapi.NewRouter(httpapi.RouterDependencies{
Database: db,
RegisterPublicRoutes: func(routes gin.IRoutes) error {
webHandler.RegisterStatic(routes)
authWebHandler.RegisterPublic(routes)
return registerPublicAuth(routes)
},
RegisterAdminRoutes: registerProtectedRoutes,
RegisterDeviceRoutes: registerDeviceRoutes,
AdminSessions: auth,
DeviceAccess: auth,
LogEvent: func(event string) {
log.Print(event)
},
})
}
func requireCurrentMigrations(
ctx context.Context,
reader migrationStatusReader,
) error {
statuses, err := reader.Status(ctx)
if err != nil {
return errors.New("database migration status failed")
}
if len(statuses) == 0 {
return errors.New("database has no known migrations")
}
for _, status := range statuses {
if !status.Applied {
return errPendingMigrations
}
}
return nil
}