Files
ttstd-infinite-canvas/backend/cmd/server/main.go
tongtongstudio 3c25511f47
Some checks failed
Publish container images / Quality checks (push) Has been cancelled
Publish container images / Resolve build version (push) Has been cancelled
Publish container images / Build open-ai-canvas-backend (amd64) (push) Has been cancelled
Publish container images / Build open-ai-canvas-backend (arm64) (push) Has been cancelled
Publish container images / Build open-ai-canvas-web (multi) (push) Has been cancelled
Publish container images / Publish open-ai-canvas-backend manifest (push) Has been cancelled
Publish container images / Publish open-ai-canvas-web manifest (push) Has been cancelled
Publish container images / Publish release assets (push) Has been cancelled
build(ports): 本地开发改用 7380/7381 避开端口冲突
2026-09-21 14:54:20 +08:00

323 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"infinite-canvas/backend/internal/database"
"infinite-canvas/backend/internal/handler"
"infinite-canvas/backend/internal/repository"
"infinite-canvas/backend/internal/service"
"infinite-canvas/backend/internal/updaterclient"
"github.com/gin-gonic/gin"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context) error {
dataDir := env("CANVAS_BACKEND_DATA_DIR", "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return err
}
db, err := database.Open(database.Config{
Driver: env("CANVAS_DATABASE_DRIVER", "sqlite"),
DSN: os.Getenv("DATABASE_URL"),
DataDir: dataDir,
})
if err != nil {
return err
}
if err := database.ConfigurePool(db); err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
defer sqlDB.Close()
autoMigrate, err := envBool("CANVAS_AUTO_MIGRATE", true)
if err != nil {
return err
}
if autoMigrate {
err = database.MigrateSchema(db)
} else {
err = database.RequireSchemaVersion(db)
}
if err != nil {
return err
}
repo := repository.New(db)
// 本项目本地开发端口 7381,避开上游默认的 8080(见 AGENTS.md「本项目附加约定」)。
addr := env("CANVAS_BACKEND_ADDR", ":7381")
svc := service.New(repo, dataDir)
if updaterToken := strings.TrimSpace(os.Getenv("CANVAS_UPDATER_TOKEN")); updaterToken != "" {
svc.ConfigureUpdateManager(updaterclient.New(env("CANVAS_UPDATER_SOCKET", "/run/open-ai-canvas-updater/updater.sock"), updaterToken))
}
defer svc.Close()
if err := svc.ValidateRuntime(); err != nil {
return err
}
if err := svc.EnsureSystemChannelModels(); err != nil {
return err
}
if err := svc.EnsureDefaultPromptTemplates(); err != nil {
return err
}
if err := svc.EnsureBuiltinProjectWorkflowTemplate(); err != nil {
return err
}
if err := svc.EnsureBuiltinSkills(); err != nil {
return err
}
if err := svc.EnsureSkillPackages(); err != nil {
return err
}
if err := svc.EnsureBuiltinTools(); err != nil {
return err
}
if summary, err := svc.MigrateLegacyStorage(); err != nil {
log.Printf("storage migration skipped after error: %v", err)
} else if summary.Backup != "" {
log.Printf("storage migration completed: tasks=%d assets=%d projects=%d backup=%s", summary.Tasks, summary.Assets, summary.Projects, summary.Backup)
}
r := gin.New()
r.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
return fmt.Sprintf("%s - [%s] \"%s %s\" %d %s %s\n", param.ClientIP, param.TimeStamp.Format(time.RFC3339), param.Method, redactCanvasSharePath(param.Path), param.StatusCode, param.Latency, param.ErrorMessage)
}), gin.Recovery())
r.Use(handler.RequestCorrelationMiddleware())
corsMiddleware, err := cors()
if err != nil {
return err
}
r.Use(corsMiddleware)
handler.ConfigureRuntime(svc)
api := r.Group("/api")
status := newSystemStatus(db, svc)
registerSystemStatusRoutes(api, status)
handler.RegisterOAuthCallbackRoutes(r, svc)
handler.RegisterCanvasAPI(api, svc)
r.NoRoute(handler.SystemProxyNoRouteHandler(svc))
listener, err := net.Listen("tcp", addr)
if err != nil {
return err
}
workerTimeout, err := envDuration("CANVAS_SHUTDOWN_TIMEOUT", 10*time.Minute)
if err != nil {
_ = listener.Close()
return err
}
httpServer := &http.Server{Handler: r, ReadHeaderTimeout: 10 * time.Second}
svc.StartWorker()
// 启动后回填存量视频的播放副本转码(幂等,无待处理项即退出)。
go svc.BackfillPlaybackTranscodes()
status.markStarted()
serveErr := make(chan error, 1)
go func() { serveErr <- httpServer.Serve(listener) }()
log.Printf("backend listening on %s", addr)
var serveFailure error
select {
case <-ctx.Done():
case err := <-serveErr:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
serveFailure = fmt.Errorf("HTTP 服务异常退出:%w", err)
}
}
status.beginDrain()
var shutdownFailures []error
if serveFailure != nil {
shutdownFailures = append(shutdownFailures, serveFailure)
}
httpShutdownCtx, httpShutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer httpShutdownCancel()
if err := httpServer.Shutdown(httpShutdownCtx); err != nil {
_ = httpServer.Close()
shutdownFailures = append(shutdownFailures, fmt.Errorf("关闭 HTTP 服务:%w", err))
}
workerCtx, workerCancel := context.WithTimeout(context.Background(), workerTimeout)
defer workerCancel()
if err := svc.StopWorker(workerCtx); err != nil {
shutdownFailures = append(shutdownFailures, fmt.Errorf("等待后台任务退出:%w", err))
}
if err := errors.Join(shutdownFailures...); err != nil {
return err
}
log.Printf("backend stopped gracefully")
return nil
}
func redactCanvasSharePath(path string) string {
const prefix = "/api/public/canvas-shares/"
if !strings.HasPrefix(path, prefix) {
return path
}
remainder := strings.TrimPrefix(path, prefix)
if index := strings.IndexByte(remainder, '/'); index >= 0 {
return prefix + ":token" + remainder[index:]
}
return prefix + ":token"
}
func env(key string, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}
func envBool(key string, fallback bool) (bool, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf("%s 必须是 true 或 false", key)
}
return parsed, nil
}
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil || parsed <= 0 {
return 0, fmt.Errorf("%s 必须是正数时长,例如 10m", key)
}
return parsed, nil
}
const corsAllowedHeaders = "Accept, Content-Type, Authorization, X-Requested-With, X-Canvas-Scene, X-Idempotency-Key, X-Canvas-Trace-ID, X-Canvas-Upstream-URL, X-Canvas-Upstream-Format, X-Canvas-Upstream-Base-URL"
const corsAllowedMethods = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
type corsPolicy struct {
origins map[string]struct{}
allowAny bool
}
func cors() (gin.HandlerFunc, error) {
policy, err := parseCORSPolicy(os.Getenv("CANVAS_CORS_ORIGINS"))
if err != nil {
return nil, err
}
return func(c *gin.Context) {
origin := strings.TrimSpace(c.GetHeader("Origin"))
if origin != "" && !allowedOriginWithPolicy(c, origin, policy) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": http.StatusForbidden, "data": nil, "msg": "不允许的跨域来源"})
return
}
if origin != "" {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers")
}
c.Header("Access-Control-Allow-Headers", corsAllowedHeaders)
c.Header("Access-Control-Expose-Headers", "X-Request-ID, X-Canvas-Trace-ID, X-Diagnostic-Bundle-ID, X-Diagnostic-Schema-Version")
c.Header("Access-Control-Allow-Methods", corsAllowedMethods)
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}, nil
}
func allowedOrigin(c *gin.Context, origin string) bool {
policy, err := parseCORSPolicy(os.Getenv("CANVAS_CORS_ORIGINS"))
if err != nil {
return false
}
return allowedOriginWithPolicy(c, origin, policy)
}
func parseCORSPolicy(raw string) (corsPolicy, error) {
policy := corsPolicy{origins: make(map[string]struct{})}
for _, value := range strings.Split(raw, ",") {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if value == "*" {
policy.allowAny = true
continue
}
normalized, err := normalizeCORSOrigin(value)
if err != nil {
return corsPolicy{}, fmt.Errorf("CANVAS_CORS_ORIGINS contains invalid origin %q: %w", value, err)
}
policy.origins[normalized] = struct{}{}
}
return policy, nil
}
func normalizeCORSOrigin(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", fmt.Errorf("origin is empty")
}
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "", fmt.Errorf("origin must be an http or https origin")
}
if parsed.Path != "" && parsed.Path != "/" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("origin must not contain a path, query, or fragment")
}
return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host), nil
}
func allowedOriginWithPolicy(c *gin.Context, origin string, policy corsPolicy) bool {
normalizedOrigin, err := normalizeCORSOrigin(origin)
if err != nil {
return false
}
parsed, err := url.Parse(normalizedOrigin)
if err != nil {
return false
}
requestHost := c.Request.Host
if forwardedHost := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); forwardedHost != "" {
requestHost = strings.TrimSpace(strings.Split(forwardedHost, ",")[0])
}
if strings.EqualFold(parsed.Host, strings.TrimSpace(requestHost)) {
return true
}
if policy.allowAny {
return true
}
if _, ok := policy.origins[normalizedOrigin]; ok {
return true
}
if len(policy.origins) > 0 {
return false
}
host := strings.ToLower(parsed.Hostname())
return (host == "localhost" || host == "127.0.0.1" || host == "::1") && (parsed.Scheme == "http" || parsed.Scheme == "https")
}