From a79b54ada544c9603e1ea481ce2c0278562c1b2c Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:05:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(*):=20=E5=B9=B3=E5=8F=B0=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E6=80=A7=20-=20=E4=BF=AE=E5=A4=8D=E4=BC=9A=E8=AF=9D=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E3=80=81=E8=BF=90=E8=A1=8C=E6=97=B6=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E4=B8=8E=E9=83=A8=E7=BD=B2=E9=95=9C=E5=83=8F=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 4 + README.md | 5 +- backend/internal/app/admin.go | 17 +- .../app/read_cache_integration_test.go | 6 +- backend/internal/handler/auth.go | 7 +- backend/internal/hostupdate/manager.go | 27 +- backend/internal/hostupdate/manager_ops.go | 67 +++- backend/internal/platform/host.go | 18 +- backend/internal/platform/runtime_policy.go | 37 ++- backend/internal/repository/finance.go | 14 + backend/internal/repository/repository.go | 11 + docker-compose.deploy.yml | 6 +- docs/content/docs/backend/http-api.mdx | 14 + docs/content/docs/backend/system-update.mdx | 21 +- docs/content/docs/progress/pending-test.mdx | 10 + .../canvas-final-performance-architecture.md | 288 ++++++++++++++++++ scripts/install-host-updater.sh | 5 + scripts/install-server-image.sh | 54 +++- web/src/components/ai/ai-message-markdown.tsx | 5 +- .../components/auth/auth-session-hydrator.tsx | 35 ++- .../canvas/canvas-cloud-agent-chat-ui.tsx | 70 ++++- web/src/hooks/use-workspace-logout.ts | 2 +- web/src/lib/localforage-storage.ts | 4 +- web/src/lib/user-scope.ts | 16 + web/src/lib/user-session.ts | 139 ++++++--- web/src/main.tsx | 3 +- web/src/services/api/auth.ts | 2 - .../diagnostics/client-diagnostics.ts | 4 +- .../services/diagnostics/diagnostics-api.ts | 1 + web/src/stores/use-asset-store.ts | 2 +- web/src/vite-env.d.ts | 6 + web/vite.config.ts | 4 + 32 files changed, 788 insertions(+), 116 deletions(-) create mode 100644 docs/design/canvas-final-performance-architecture.md diff --git a/Dockerfile b/Dockerfile index 2cb0f9a99..33572ef49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,12 @@ COPY assets /app/assets COPY web ./ ARG VITE_TLDRAW_LICENSE_KEY ARG BUILD_VERSION +ARG BUILD_COMMIT=unknown +ARG BUILD_TIME=unknown ENV VITE_TLDRAW_LICENSE_KEY=${VITE_TLDRAW_LICENSE_KEY} ENV CANVAS_BUILD_VERSION=${BUILD_VERSION} +ENV CANVAS_BUILD_COMMIT=${BUILD_COMMIT} +ENV CANVAS_BUILD_TIME=${BUILD_TIME} # 生产镜像只构建云端工作台前端;Agent Runtime 在后端 Worker 中运行。 RUN bun --bun ./node_modules/vite/bin/vite.js build diff --git a/README.md b/README.md index 39ef4043e..349b9d340 100644 --- a/README.md +++ b/README.md @@ -151,10 +151,11 @@ sudo docker compose --env-file .env \ 不需要源码时,可使用镜像部署脚本: ```bash -curl -fsSL https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/main/scripts/install-server-image.sh | sudo bash +curl -fsSL https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/main/scripts/install-server-image.sh \ + | sudo env CANVAS_IMAGE_TAG=v1.5.7.1 bash ``` -生产环境请在 `/opt/open-ai-canvas/.env` 中将 `CANVAS_IMAGE_TAG` 固定为具体 Release,不要使用 `latest`。更新流程、数据库迁移、备份和回退说明见[系统更新文档](docs/content/docs/backend/system-update.mdx)。 +脚本会先按指定 Release 拉取镜像,再解析并写入 `CANVAS_BACKEND_IMAGE`、`CANVAS_WEB_IMAGE` 的 GHCR digest;生产 Compose 不接受缺失 digest 的默认镜像或 `latest`。更新流程、数据库迁移、备份和回退说明见[系统更新文档](docs/content/docs/backend/system-update.mdx)。 ## 安全边界 diff --git a/backend/internal/app/admin.go b/backend/internal/app/admin.go index 58690cf44..8f9f3bb8e 100644 --- a/backend/internal/app/admin.go +++ b/backend/internal/app/admin.go @@ -188,6 +188,18 @@ func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error if err != nil { return nil, err } + channelIDs := make([]string, 0, len(channels)) + for _, channel := range channels { + channelIDs = append(channelIDs, channel.ID) + } + channelModels, err := s.repo.ChannelModelReferences(channelIDs) + if err != nil { + return nil, err + } + modelsByChannel := make(map[string][]model.ChannelModel, len(channels)) + for _, item := range channelModels { + modelsByChannel[item.ChannelID] = append(modelsByChannel[item.ChannelID], item) + } result := &AdminReferenceData{ Users: make([]AdminUserReference, 0, len(users)), Channels: make([]AdminChannelReference, 0, len(channels)), @@ -196,10 +208,7 @@ func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error result.Users = append(result.Users, AdminUserReference{ID: user.ID, Username: user.Username, DisplayName: user.DisplayName}) } for _, channel := range channels { - items, itemErr := s.repo.ChannelModels(channel.ID, true) - if itemErr != nil { - return nil, itemErr - } + items := modelsByChannel[channel.ID] models := make([]string, 0, len(items)) displayNames := make([]string, 0, len(items)) for _, item := range items { diff --git a/backend/internal/app/read_cache_integration_test.go b/backend/internal/app/read_cache_integration_test.go index d669c3f20..4d08298e7 100644 --- a/backend/internal/app/read_cache_integration_test.go +++ b/backend/internal/app/read_cache_integration_test.go @@ -73,7 +73,7 @@ func TestCachedTextReplayIsolatesUsersCursorsAndCopies(t *testing.T) { } } -func TestRuntimeConcurrencyCacheDoesNotCacheAuthoritativePolicy(t *testing.T) { +func TestRuntimeConcurrencyCacheCachesAuthoritativePolicy(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatal(err) @@ -101,8 +101,8 @@ func TestRuntimeConcurrencyCacheDoesNotCacheAuthoritativePolicy(t *testing.T) { t.Fatal(err) } } - if queries.Load() != before+2 { - t.Fatal("authoritative policy incorrectly cached") + if queries.Load() != before { + t.Fatalf("authoritative policy cache missed: queries=%d before=%d", queries.Load(), before) } policy := defaultRuntimePolicy() policy.Task.WorkerConcurrency = 7 diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 2db5195ab..5dec5df49 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -164,11 +164,6 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - logicalModels, logicalModelsErr := svc.PublicLogicalModels(nil) - if logicalModelsErr != nil { - failService(c, logicalModelsErr) - return - } limits, err := svc.PublicRuntimeLimits() if err != nil { failService(c, err) @@ -184,7 +179,7 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - ok(c, gin.H{"user": publicUser, "logicalModels": logicalModels, "runtimeLimits": limits, "drawingEngine": drawingEngine, "features": features}) + ok(c, gin.H{"user": publicUser, "runtimeLimits": limits, "drawingEngine": drawingEngine, "features": features}) }) r.GET("/channels/system", func(c *gin.Context) { actor, err := currentUser(c, svc) diff --git a/backend/internal/hostupdate/manager.go b/backend/internal/hostupdate/manager.go index ec8bf8d17..01a6f92bd 100644 --- a/backend/internal/hostupdate/manager.go +++ b/backend/internal/hostupdate/manager.go @@ -264,13 +264,19 @@ func (m *Manager) runUpdate(fromVersion, targetVersion string) { m.failWithoutRollback(PhaseFailed, fmt.Errorf("保存旧 Compose 配置:%w", err)) return } + if err := replaceFile(m.envPath(), m.previousEnvPath(), 0o600); err != nil { + m.failWithoutRollback(PhaseFailed, fmt.Errorf("保存旧部署环境:%w", err)) + return + } m.setPhase(PhasePulling, "拉取目标版本镜像") - if err := m.compose(nextCompose, targetVersion, m.config.StepTimeout, nil, "pull", "backend", "web"); err != nil { + targetImages := immutableImageRefs(m.config.Repository, targetVersion) + if err := m.composeWithImages(nextCompose, targetVersion, targetImages, m.config.StepTimeout, nil, "pull", "backend", "web"); err != nil { m.failWithoutRollback(PhaseFailed, err) return } - if err := m.verifyImages(targetVersion); err != nil { + targetImages, err = m.verifyImages(targetVersion) + if err != nil { m.failWithoutRollback(PhaseFailed, err) return } @@ -282,13 +288,13 @@ func (m *Manager) runUpdate(fromVersion, targetVersion string) { } m.setPhase(PhaseMigrating, "执行目标版本数据库迁移") - if err := m.compose(nextCompose, targetVersion, m.config.StepTimeout, nil, "run", "--rm", "migrate"); err != nil { + if err := m.composeWithImages(nextCompose, targetVersion, targetImages, m.config.StepTimeout, nil, "run", "--rm", "migrate"); err != nil { m.failWithRollback(err, fromVersion, backup) return } m.setPhase(PhaseSwitching, "切换 Compose 配置与镜像版本") - if err := setEnvValue(m.envPath(), "CANVAS_IMAGE_TAG", strings.TrimPrefix(targetVersion, "v")); err != nil { + if err := setDeploymentImages(m.envPath(), targetVersion, targetImages); err != nil { m.failWithRollback(err, fromVersion, backup) return } @@ -297,7 +303,7 @@ func (m *Manager) runUpdate(fromVersion, targetVersion string) { m.failWithRollback(err, fromVersion, backup) return } - if err := m.compose(m.composePath(), targetVersion, m.config.StepTimeout, nil, "up", "-d", "--remove-orphans", "--wait", "--wait-timeout", "600"); err != nil { + if err := m.composeWithImages(m.composePath(), targetVersion, targetImages, m.config.StepTimeout, nil, "up", "-d", "--remove-orphans", "--wait", "--wait-timeout", "600"); err != nil { m.failWithRollback(err, fromVersion, backup) return } @@ -368,10 +374,12 @@ func (m *Manager) runRollback(targetVersion string, backup Backup, automatic boo failures = append(failures, fmt.Errorf("恢复旧 Compose 配置:%w", err)) } } - if err := m.restoreDatabase(backup); err != nil { - failures = append(failures, err) + if _, err := os.Stat(m.previousEnvPath()); err == nil { + if err := replaceFile(m.previousEnvPath(), m.envPath(), 0o600); err != nil { + failures = append(failures, fmt.Errorf("恢复旧部署环境:%w", err)) + } } - if err := setEnvValue(m.envPath(), "CANVAS_IMAGE_TAG", strings.TrimPrefix(targetVersion, "v")); err != nil { + if err := m.restoreDatabase(backup); err != nil { failures = append(failures, err) } started := true @@ -513,6 +521,9 @@ func (m *Manager) envPath() string { return filepath.Join(m.config.InstallDir, m func (m *Manager) previousComposePath() string { return filepath.Join(m.config.StateDir, "previous-compose.yml") } +func (m *Manager) previousEnvPath() string { + return filepath.Join(m.config.StateDir, "previous.env") +} func randomID() string { buffer := make([]byte, 12) diff --git a/backend/internal/hostupdate/manager_ops.go b/backend/internal/hostupdate/manager_ops.go index 38d8958b5..4f87c2d2b 100644 --- a/backend/internal/hostupdate/manager_ops.go +++ b/backend/internal/hostupdate/manager_ops.go @@ -133,7 +133,7 @@ func (m *Manager) preflight(composePath, targetVersion string) error { if err := checkBackupDiskSpace(m.config.BackupDir); err != nil { return err } - if err := m.compose(composePath, targetVersion, 2*time.Minute, nil, "config", "--quiet"); err != nil { + if err := m.composeWithImages(composePath, targetVersion, immutableImageRefs(m.config.Repository, targetVersion), 2*time.Minute, nil, "config", "--quiet"); err != nil { return fmt.Errorf("目标 Compose 校验失败:%w", err) } current, err := m.currentVersion() @@ -243,7 +243,25 @@ func (m *Manager) restartSelf() { _ = m.runner.Run(ctx, "systemctl", []string{"restart", m.config.ServiceName}, nil, io.Discard, io.Discard) } +type deploymentImages struct { + backend string + web string +} + +func immutableImageRefs(repository, version string) deploymentImages { + owner := strings.SplitN(repository, "/", 2)[0] + tag := strings.TrimPrefix(version, "v") + return deploymentImages{ + backend: "ghcr.io/" + owner + "/open-ai-canvas-backend:" + tag, + web: "ghcr.io/" + owner + "/open-ai-canvas-web:" + tag, + } +} + func (m *Manager) compose(composePath, imageTag string, timeout time.Duration, stdout io.Writer, arguments ...string) error { + return m.composeWithImages(composePath, imageTag, deploymentImages{}, timeout, stdout, arguments...) +} + +func (m *Manager) composeWithImages(composePath, imageTag string, images deploymentImages, timeout time.Duration, stdout io.Writer, arguments ...string) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() args := []string{"compose", "--env-file", m.envPath(), "-f", composePath} @@ -252,7 +270,11 @@ func (m *Manager) compose(composePath, imageTag string, timeout time.Duration, s if stdout == nil { stdout = io.Discard } - err := m.runner.Run(ctx, "docker", args, []string{"CANVAS_IMAGE_TAG=" + strings.TrimPrefix(imageTag, "v")}, stdout, &stderr) + environment := []string{"CANVAS_IMAGE_TAG=" + strings.TrimPrefix(imageTag, "v")} + if images.backend != "" { + environment = append(environment, "CANVAS_BACKEND_IMAGE="+images.backend, "CANVAS_WEB_IMAGE="+images.web) + } + err := m.runner.Run(ctx, "docker", args, environment, stdout, &stderr) if err != nil { message := strings.TrimSpace(stderr.String()) if len(message) > 1000 { @@ -266,22 +288,47 @@ func (m *Manager) compose(composePath, imageTag string, timeout time.Duration, s return nil } -func (m *Manager) verifyImages(targetVersion string) error { - owner := strings.SplitN(m.config.Repository, "/", 2)[0] - for _, image := range []string{"ghcr.io/" + owner + "/open-ai-canvas-backend:", "ghcr.io/" + owner + "/open-ai-canvas-web:"} { +func (m *Manager) verifyImages(targetVersion string) (deploymentImages, error) { + images := immutableImageRefs(m.config.Repository, targetVersion) + refs := []string{images.backend, images.web} + digests := make([]string, 0, len(refs)) + for _, image := range refs { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) var output bytes.Buffer var stderr bytes.Buffer - err := m.runner.Run(ctx, "docker", []string{"image", "inspect", image + strings.TrimPrefix(targetVersion, "v"), "--format", "{{json .RepoDigests}}"}, nil, &output, &stderr) + err := m.runner.Run(ctx, "docker", []string{"image", "inspect", image, "--format", "{{json .RepoDigests}}"}, nil, &output, &stderr) cancel() if err != nil { - return fmt.Errorf("校验目标镜像摘要失败:%s", strings.TrimSpace(stderr.String())) + return deploymentImages{}, fmt.Errorf("校验目标镜像摘要失败:%s", strings.TrimSpace(stderr.String())) } - if !strings.Contains(output.String(), "@sha256:") { - return fmt.Errorf("目标镜像 %s 未包含仓库摘要", image+targetVersion) + var repositoryDigests []string + if err := json.Unmarshal(output.Bytes(), &repositoryDigests); err != nil { + return deploymentImages{}, fmt.Errorf("解析目标镜像摘要失败:%w", err) } + repository := strings.Split(image, ":")[0] + var digest string + for _, candidate := range repositoryDigests { + if strings.HasPrefix(candidate, repository+"@sha256:") { + digest = candidate + break + } + } + if digest == "" { + return deploymentImages{}, fmt.Errorf("目标镜像 %s 未包含仓库摘要", image) + } + digests = append(digests, digest) } - return nil + return deploymentImages{backend: digests[0], web: digests[1]}, nil +} + +func setDeploymentImages(path, version string, images deploymentImages) error { + if err := setEnvValue(path, "CANVAS_IMAGE_TAG", strings.TrimPrefix(version, "v")); err != nil { + return err + } + if err := setEnvValue(path, "CANVAS_BACKEND_IMAGE", images.backend); err != nil { + return err + } + return setEnvValue(path, "CANVAS_WEB_IMAGE", images.web) } func (m *Manager) createBackup(version string) (Backup, error) { diff --git a/backend/internal/platform/host.go b/backend/internal/platform/host.go index a89901530..63b93c0b1 100644 --- a/backend/internal/platform/host.go +++ b/backend/internal/platform/host.go @@ -28,10 +28,11 @@ func (nopHost) ChannelConcurrencyLimit(string) (int, error) { } type Service struct { - repo *repository.Repository - host Host - Coordinator *Coordinator - concurrencyCache *BoundedReadCache[string, RuntimeTaskPolicy] + repo *repository.Repository + host Host + Coordinator *Coordinator + concurrencyCache *BoundedReadCache[string, RuntimeTaskPolicy] + runtimePolicyCache *BoundedReadCache[string, RuntimePolicySetting] } func New(repo *repository.Repository, coordinator *Coordinator, host Host) *Service { @@ -39,10 +40,11 @@ func New(repo *repository.Repository, coordinator *Coordinator, host Host) *Serv host = nopHost{} } return &Service{ - repo: repo, - host: host, - Coordinator: coordinator, - concurrencyCache: NewBoundedReadCache[string, RuntimeTaskPolicy](1, 1024, 1, 2*time.Second), + repo: repo, + host: host, + Coordinator: coordinator, + concurrencyCache: NewBoundedReadCache[string, RuntimeTaskPolicy](1, 1024, 1, 2*time.Second), + runtimePolicyCache: NewBoundedReadCache[string, RuntimePolicySetting](1, 8192, 1, 2*time.Second), } } diff --git a/backend/internal/platform/runtime_policy.go b/backend/internal/platform/runtime_policy.go index 85322987a..e838adb9a 100644 --- a/backend/internal/platform/runtime_policy.go +++ b/backend/internal/platform/runtime_policy.go @@ -10,8 +10,6 @@ import ( "time" "infinite-canvas/backend/internal/model" - - "gorm.io/gorm" ) const runtimePolicySettingKey = "runtime_policy" @@ -216,8 +214,21 @@ func selfUseRuntimePolicy() RuntimePolicySetting { func SelfUseRuntimePolicy() RuntimePolicySetting { return selfUseRuntimePolicy() } func (s *Service) RuntimePolicy() (RuntimePolicySetting, error) { - _, value, err := s.readRuntimePolicy() - return value, err + if s.runtimePolicyCache == nil { + _, value, err := s.readRuntimePolicy() + return value, err + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return s.runtimePolicyCache.Get(ctx, runtimePolicySettingKey, func(ctx context.Context) (RuntimePolicySetting, int, error) { + reader := &Service{ + repo: s.repo.WithContext(ctx), + host: s.host, + Coordinator: s.Coordinator, + } + _, value, err := reader.readRuntimePolicy() + return value, 2048, err + }) } func (s *Service) RuntimeConcurrencySetting() (RuntimeTaskPolicy, error) { @@ -225,7 +236,7 @@ func (s *Service) RuntimeConcurrencySetting() (RuntimeTaskPolicy, error) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() return s.concurrencyCache.Get(ctx, runtimePolicySettingKey, func(ctx context.Context) (RuntimeTaskPolicy, int, error) { - reader := &Service{repo: s.repo.WithContext(ctx), host: s.host, Coordinator: s.Coordinator, concurrencyCache: s.concurrencyCache} + reader := &Service{repo: s.repo.WithContext(ctx), host: s.host, Coordinator: s.Coordinator, concurrencyCache: s.concurrencyCache, runtimePolicyCache: s.runtimePolicyCache} policy, err := reader.RuntimePolicy() return policy.Task, 256, err }) @@ -284,6 +295,9 @@ func (s *Service) UpdateRuntimePolicySetting(actor *model.User, value RuntimePol } s.concurrencyCache.Clear() + if s.runtimePolicyCache != nil { + s.runtimePolicyCache.Clear() + } if err := s.appendAdminAudit(actor, "runtime_policy.update", "system_setting", runtimePolicySettingKey, "更新资源与请求策略", map[string]any{"before": before, "after": value}); err != nil { return nil, err } @@ -303,6 +317,9 @@ func (s *Service) ResetRuntimePolicySetting(actor *model.User) (*PublicRuntimePo } s.concurrencyCache.Clear() + if s.runtimePolicyCache != nil { + s.runtimePolicyCache.Clear() + } after := DefaultRuntimePolicy() if err := s.appendAdminAudit(actor, "runtime_policy.reset", "system_setting", runtimePolicySettingKey, "重置资源与请求策略", map[string]any{"before": before, "after": after}); err != nil { return nil, err @@ -311,14 +328,14 @@ func (s *Service) ResetRuntimePolicySetting(actor *model.User) (*PublicRuntimePo } func (s *Service) readRuntimePolicy() (*model.SystemSetting, RuntimePolicySetting, error) { - setting, err := s.repo.SystemSetting(runtimePolicySettingKey) - if errors.Is(err, gorm.ErrRecordNotFound) { - value := DefaultRuntimePolicy() - return nil, value, validateRuntimePolicy(value) - } + setting, err := s.repo.SystemSettingOptional(runtimePolicySettingKey) if err != nil { return nil, RuntimePolicySetting{}, err } + if setting == nil { + value := DefaultRuntimePolicy() + return nil, value, validateRuntimePolicy(value) + } value := DefaultRuntimePolicy() if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil { return nil, RuntimePolicySetting{}, errors.New("资源与请求策略配置格式无效") diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 3fea41fef..f2150b49b 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -86,6 +86,20 @@ func (r *Repository) ChannelModels(channelID string, includeDisabled bool) ([]mo return items, r.attachChannelModelPriceTiers(pointers) } +func (r *Repository) ChannelModelReferences(channelIDs []string) ([]model.ChannelModel, error) { + if len(channelIDs) == 0 { + return []model.ChannelModel{}, nil + } + var items []model.ChannelModel + if err := r.db.Select("id", "channel_id", "model_key", "display_name", "sort_order", "enabled"). + Where("channel_id IN ?", channelIDs). + Order("channel_id asc, sort_order asc, created_at asc, id asc"). + Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + func (r *Repository) CreateDuplicatedSystemChannel(channel *model.ModelChannel, channelModels []model.ChannelModel, priceTiers []model.ChannelModelPriceTier) error { return r.db.Transaction(func(tx *gorm.DB) error { if err := tx.Create(channel).Error; err != nil { diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 7d7d952ff..f743c96ab 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -835,6 +835,17 @@ func (r *Repository) SystemSetting(key string) (*model.SystemSetting, error) { return &setting, nil } +func (r *Repository) SystemSettingOptional(key string) (*model.SystemSetting, error) { + var setting model.SystemSetting + if err := r.db.Where("key = ?", key).Limit(1).Find(&setting).Error; err != nil { + return nil, err + } + if setting.Key == "" { + return nil, nil + } + return &setting, nil +} + func (r *Repository) SaveSystemSetting(setting *model.SystemSetting) error { return r.db.Save(setting).Error } diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index c86f5f95f..35c8dbb2a 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -27,7 +27,7 @@ services: restart: unless-stopped migrate: - image: ghcr.io/ddcat-ai/open-ai-canvas-backend:${CANVAS_IMAGE_TAG:-latest} + image: ${CANVAS_BACKEND_IMAGE:?请先配置 CANVAS_BACKEND_IMAGE(必须包含镜像 digest)} command: ["migrate-schema", "up"] environment: CANVAS_BACKEND_DATA_DIR: /data @@ -38,7 +38,7 @@ services: condition: service_healthy backend: - image: ghcr.io/ddcat-ai/open-ai-canvas-backend:${CANVAS_IMAGE_TAG:-latest} + image: ${CANVAS_BACKEND_IMAGE:?请先配置 CANVAS_BACKEND_IMAGE(必须包含镜像 digest)} environment: CANVAS_BACKEND_ADDR: ":8080" CANVAS_BACKEND_DATA_DIR: /data @@ -71,7 +71,7 @@ services: stop_grace_period: ${CANVAS_SHUTDOWN_TIMEOUT:-10m} web: - image: ghcr.io/ddcat-ai/open-ai-canvas-web:${CANVAS_IMAGE_TAG:-latest} + image: ${CANVAS_WEB_IMAGE:?请先配置 CANVAS_WEB_IMAGE(必须包含镜像 digest)} ports: - "${CANVAS_HTTP_PORT:-3000}:3000" volumes: diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 0a0fb5b0f..64ee01e4d 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -25,6 +25,20 @@ description: 信封、错误码与 OpenAPI 当前自动签名能力:公开 CDN、七牛私有下载签名和对象存储源站签名。阿里云/腾讯云私有 CDN 签名尚未自动支持,不能仅填写 CDN 域名就宣称已命中 CDN。真实 CORS、Range、CDN 命中率及账单收益必须在实际云环境通过浏览器网络记录和服务端流量观测验收。 +## 画布资源完整性与 409 + +画布写入在保存历史快照前校验当前文档中的资源引用;引用须属于当前用户且处于 `ready`。引用收集器递归遍历受支持的资源字段,因此 `nodes[节点 ID].metadata.videoPreview.storageKey` 等嵌套派生封面也会参与检查。历史快照保存会一并校验新版本引用;若资源在保存期间缺失或状态变化,事务回滚,画布 revision 不递增。 + +HTTP 409 不能一概解释为 revision 冲突:普通版本冲突使用 `reason: conflict`;资源完整性失败使用 `reason: canvas_history_resources_missing`,并在 `details` 中返回 `resourceIds` 和 `missingResources`(含资源 ID、JSON 路径、节点 ID、引用类型及 `source`)。客户端应按 `reason` 分流,不解析 `msg` 猜测原因。显式资源修复写入仅允许历史 preimage 存在缺失资源,不允许新画布仍引用缺失资源,也不绕过 revision CAS。 + +运维扫描/修复仅处理当前画布中可安全清除的失效 `videoPreview`;不删除主媒体、不扫描并改写所有历史快照,也不替代备份。使用目标数据库对应的新版本二进制,先在维护窗口备份并执行只读扫描: + +```sh +CANVAS_BACKEND_DATA_DIR=/明确的数据目录 ./migrate-schema repair-canvas-resources +``` + +复核 JSON 报告后,只有 `repairable` 项可由明确授权的操作员再次运行 `repair-canvas-resources --apply`。命令要求显式配置 `CANVAS_BACKEND_DATA_DIR` 或 `DATABASE_URL`,逐画布按版本 CAS 保存并保留原始历史;有未解决主媒体/素材绑定问题时以非零退出,不会自动恢复或重传。发生并发编辑时停止并重新扫描,不能用强制覆盖代替恢复有效历史或重新上传。 + ## 生成成功后的重试保存 `POST /tasks/:id/recover-media` 无需请求体。当前用户只能恢复自己有保存恢复记录的失败任务;重入队仍返回原任务 ID,不请求重新生成、不新建计费订单。已排队、运行或完成时幂等返回当前任务。取消任务、已退款订单、任务/账单归属不一致、项目已删除或归档时拒绝恢复。 diff --git a/docs/content/docs/backend/system-update.mdx b/docs/content/docs/backend/system-update.mdx index a6d3906d8..3889ccf03 100644 --- a/docs/content/docs/backend/system-update.mdx +++ b/docs/content/docs/backend/system-update.mdx @@ -19,7 +19,7 @@ description: Host Updater、Release 检查、数据库备份迁移、健康验 4. 拉取目标 backend/web 镜像并要求存在仓库 `sha256` 摘要。任何失败都在停服前中止。 5. 停止 web/backend,等待 Backend 的 SIGTERM drain 完成;PostgreSQL 和 Redis 保持运行。 6. 用目标 backend 镜像独立执行 `migrate-schema up`。整批迁移在事务和 PostgreSQL advisory lock 内完成,迁移名称与 checksum 必须匹配。 -7. 原子更新 `CANVAS_IMAGE_TAG` 和 Compose 文件,启动目标版本。 +7. 解析目标 backend/web 镜像的仓库 digest,原子更新 `CANVAS_IMAGE_TAG`、`CANVAS_BACKEND_IMAGE`、`CANVAS_WEB_IMAGE` 和 Compose 文件,启动目标版本;运行容器始终使用 `image@sha256:...`,不使用 `latest`。 8. 连续检查 `/api/health/ready` 的数据库、运行时、schema 和 build version,并经过稳定窗口后才标记成功。 更新状态写入 `/var/lib/open-ai-canvas-updater/state.json`。Backend 或浏览器在切换期间断线不会终止宿主机操作,恢复后可继续读取日志。 @@ -30,20 +30,27 @@ description: Host Updater、Release 检查、数据库备份迁移、健康验 1. 停止目标 web/backend; 2. 恢复更新前保存的 Compose 文件; -3. 再次校验备份 ZIP 和 SHA-256; -4. 使用 `pg_restore --clean --if-exists` 恢复 PostgreSQL; -5. 恢复旧 `CANVAS_IMAGE_TAG`,启动旧服务并重新进行稳定窗口验证。 +3. 恢复更新前保存的 `.env`,确保 backend/web 镜像仍绑定到原 digest; +4. 再次校验备份 ZIP 和 SHA-256; +5. 使用 `pg_restore --clean --if-exists` 恢复 PostgreSQL; +6. 启动旧服务并重新进行稳定窗口验证。 回退任一步失败时状态变为 `manual_intervention`,不伪造成功。管理后台仅在存在旧版本和已校验备份时开放人工回退按钮。 ## 安装与升级约束 -GHCR 镜像部署必须把 `CANVAS_IMAGE_TAG` 固定到具体 Release,不可使用 `latest`。安装更新器: +GHCR 镜像部署必须把 `CANVAS_IMAGE_TAG` 固定到具体 Release,并把 `CANVAS_BACKEND_IMAGE`、`CANVAS_WEB_IMAGE` 固定到对应 GHCR digest,不可使用 `latest` 或仅使用可变 tag。首次安装时显式指定 Release: + +```bash +curl -fsSL https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/main/scripts/install-server-image.sh \ + | sudo env CANVAS_IMAGE_TAG=v1.5.7.1 bash +``` + +已有部署只安装更新器时,先确认 `.env` 已包含两个 `@sha256:...` 镜像引用,再执行: ```bash cd /opt/open-ai-canvas curl -fsSL https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/main/scripts/install-host-updater.sh | sudo bash -sudo docker compose --env-file .env -f docker-compose.deploy.yml up -d --force-recreate backend web --wait ``` 脚本会下载与当前 Release 匹配的 Linux amd64/arm64 二进制和 `SHA256SUMS`,生成随机 `CANVAS_UPDATER_TOKEN`,创建 systemd 服务、Socket 目录、状态目录和 `backups/`。Token 只保存在服务器 `.env` 与 root-only 更新器环境文件中。 @@ -59,7 +66,7 @@ sudo docker compose --env-file .env -f docker-compose.deploy.yml up -d --force-r 3. 前端 Bun 构建阶段固定到 `BUILDPLATFORM`,静态资源只生成一次,再放入 AMD64/ARM64 nginx 镜像。Bun 与 `web/package.json`、质量检查保持 `1.3.9` 一致。 4. 先推送 `sha-<完整提交>-amd64/arm64` 后端镜像及 `sha-<完整提交>-multi` 前端镜像,以摘要 artifact 传递结果。两种后端架构和前端全部成功后,才从摘要合并发布 manifest,不用可变标签作为合并源。 5. 同一 SHA 的 main/tag 发布串行运行;后到的一轮仍经过质量门禁,然后复用已有 SHA 镜像,不重复编译。只有明确的镜像不存在可以转入构建,鉴权、限流、网络和异常摘要必须报错。 -6. 发布保留完整 SHA 和短 SHA 标签;只有版本 tag 推送写入具体版本标签。`main` 只有在仍是远端最新提交时才更新 `latest`,旧提交的慢任务不能覆盖新版本。正式部署仍应固定版本标签,不依赖 `latest`。 +6. 发布保留完整 SHA 和短 SHA 标签;只有版本 tag 推送写入具体版本标签。`main` 只有在仍是远端最新提交时才更新 `latest`,旧提交的慢任务不能覆盖新版本。正式部署由安装器和更新器解析并落盘 digest,不依赖 `latest`。 Go 检查显式使用 `backend/go.sum` 作为缓存依赖键,并上传 `go-test-timings`(JSONL),可按 `Test` / `Elapsed` 定位慢测试。Docker 的后端模块下载保留在普通镜像层,编译步骤可以直接读取;支付插件只按实际 import 下载依赖,不重复预取完整后端模块图。版本参数下移到编译入口。GHA 构建缓存按镜像和架构隔离,避免并行任务共用同一 scope。`go-build` cache mount 只用于同一个 builder 内的复用,当前没有额外的跨 Runner mount 导出方案;不能把它当作持久编译缓存。冷缓存首轮和缓存上传仍可能较慢,需要比较连续两轮的实际耗时。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 83332121e..fdbb3d140 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,16 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 画布资源断链、前端发布兼容与画布热点 + +- 已实现:统一递归识别持久资源字段(含嵌套 `videoPreview`);保存前和历史快照保存时校验当前资源归属/ready 状态,资源缺失返回带路径和 ID 的结构化 409。显式修复保留坏版本历史但要求新版本完整且 revision CAS 成功。后台 `repair-canvas-resources` 默认只读,仅可清除确认缺失的派生视频预览;主媒体问题报告为 unresolved。 +- 已实现:哈希 chunk 采用长缓存,缺失资源不缓存;HTML 显式 `no-cache, must-revalidate`。静态容器发布保留当前与上一发布 chunk,并在保留窗口内清理更旧资源;Vite preload 失败只自动恢复一次,之后交由路由错误页,用户手动重试仍可用。已有数据卷不会凭空恢复首次部署前已删的 chunk。 +- 已实现:同步显式 repair/force 请求不会被正在运行的普通同步吞掉;素材元数据请求按账号 scope/generation 隔离迟到响应。画布内容 hash 对 viewport/revision-only 副本复用;视口更新走轻量路径;loading 节点共享画布级 ticker,页面隐藏时暂停该 ticker。 +- [ ] 先备份隔离/测试数据库,验证 `repair-canvas-resources` dry-run、`--apply`、重复运行幂等、并发 revision 冲突、只清派生 preview、不改主媒体;检查 unresolved 后人工恢复历史或重传。未扫描或改写生产数据库。 +- [ ] 使用发布测试卷连续发布三版以上:旧 tab 延迟触发旧 chunk 后仍可加载;验证 HTML 与哈希资源 Cache-Control、旧 chunk 404 不被 SPA fallback 改写,以及浏览器一次性恢复不循环。当前没有 CDN/Ingress 线上响应证据,Firefox MIME 具体响应类型未确认。 +- [ ] Firefox/Chrome/Safari 实际页面走关键 lazy route;检查离线草稿、登录切换和错误重试行为。尚未做真实浏览器/CDN 验收。 +- [ ] 使用 React Profiler 与长时间运行的大画布记录任务更新、节点数/可见数、主线程长任务和页面隐藏行为。当前只做结构性降热点,不推断整画布必然全量重渲染或已达到某个帧率。 + ## 原任务 ID 媒体保存恢复 - [x] 故障注入:下载 503 后服务实例重建继续原任务、OSS 上传失败保留暂存文件及资源 ID、素材登记异常使任务成功事务回滚,登记恢复无需再次下载。 diff --git a/docs/design/canvas-final-performance-architecture.md b/docs/design/canvas-final-performance-architecture.md new file mode 100644 index 000000000..82d3c8d8c --- /dev/null +++ b/docs/design/canvas-final-performance-architecture.md @@ -0,0 +1,288 @@ +# 画布性能与媒体分发最终改造方案 + +状态:基于当前 `main`(`7bf655e9`)和现有 OSS/CDN 重构重新整理。本文是当前仓库的最终目标和一次性改造边界,不把已经存在的能力重复当成待建设,也不把关键问题推到二期、三期。 + +## 1. 最终结论 + +当前仓库已经完成了画布优化的第一代基础设施: + +- 资源访问已经收敛到 `assets.ResolveAccess` 和 `POST /api/resources/access`。 +- 画布已经有 `canvas-live-viewport` 的合成层预览、Leafer underlay/overlay 图形层、空间索引、节点拖拽预览和选择预览。 +- 图片、视频和音频的多数展示路径已经使用 `storageKey` 解析访问地址。 +- 云资源 `/file` 路径已经能够重定向到 CDN/源站,平台不必转发云端媒体正文。 + +因此,最终改造方向不是重写成 WebGL,也不是复制 LibTV 的组件,而是把当前已经存在的能力合并成一个闭环: + +```text +稳定资源身份 + -> 统一访问描述 + -> poster-first 媒体状态机 + -> 展示与字节读取分离 + -> 交互预览与持久化提交分离 + -> 可见节点 / 轻量节点 / 完整节点三层 LOD + -> 同一帧节点、连接、对齐线更新 + -> 以设备帧预算驱动调度和验收 +``` + +用户手势帧永远不承担以下工作:整视频 Blob 下载、视频首帧编码上传、批量 React 复杂节点挂载、全量连线重算、逐 pointermove 持久化和签名地址同步写入画布。 + +## 2. 外部方案需要修正的地方 + +外部方案仍然正确的核心原则是视频默认图片化、旧画面不清空、交互期间不做重活、屏内节点不能被屏外预算截断,以及节点与连线使用同一帧预览。 + +以下内容不能直接照搬: + +1. **OSS snapshot 尚未成为当前仓库的统一首帧供应链。** 当前 `web/src/services/canvas-video-preview.ts` 仍在缺少持久 poster 时使用浏览器截帧并后台上传;这只能是历史资源的降级路径,不能继续作为新资源的正常路径。 +2. **当前没有完整的 `SelfVirtualizingNode` 和渐进挂载队列。** `use-canvas-render-model.ts` 主要负责空间查询和 DOM 节点挂载窗口,固定使用 280/420/720 节点预算;这不是完整 LOD,也不能宣称支持任意万级画布。 +3. **Leafer 目前主要承载连接、选框、对齐线和草稿线。** 节点正文仍是 DOM。`canvas-live-viewport.ts` 已解决交互期合成变换,但不能替代节点 LOD 和挂载调度。 +4. **小地图仍然为所有节点创建 DOM 和标签。** 只有图片预览数量限制为 24,节点和标签没有虚拟化;节点规模增大时,小地图本身会变成主线程负担。 +5. **当前播放后整文件缓存会制造重复流量。** `resource-blob-cache.ts` 曾由播放器播放后延迟 4 秒拉取完整 Blob;播放 Range 和完整下载可能并存,且缓存预算最高可达 2 GiB。这不属于普通展示行为。 +6. **`provider-input` 的浏览器边界与文档不一致。** 当前登录用户 `/resources/access` 直接以 `allowProvider=true` 处理该用途,前端可以申请长 TTL 模型输入地址。最终合同必须把模型输入迁移到服务端受控读取,浏览器公开 batch 只接受展示、复制、下载和浏览器字节处理。 +7. **固定数字不是性能承诺。** 0.23/0.27、256px、30+4、280/420/720、5000 条连接、DPR 上限 3 都只能是初始参数。最终参数必须由真实设备和帧预算测量得到。 + +## 3. 最终分层 + +### 3.1 持久数据层 + +画布 JSON 只保存稳定身份和业务元数据: + +```ts +type CanvasMediaRef = { + kind: "image" | "video" | "audio"; + resourceId?: string; + storageKey?: string; // resource: + originalUrl?: string; // 外部资源历史兼容字段 + previewContent?: string; // 已经持久化的静态预览 + videoPreview?: { + storageKey?: string; + content?: string; + width?: number; + height?: number; + mimeType?: string; + }; + naturalWidth?: number; + naturalHeight?: number; + mimeType?: string; +}; +``` + +临时签名 URL、`blob:` URL、当前播放器实例、缓存 lease 和访问描述不得写入画布 JSON、任务结果、localStorage 或日志。 + +上传、生成、导入三条路径必须在创建节点时统一写入同一 `resource:`。只有外部直链未转存时才保留外部 URL,并明确标记为外部资源。 + +### 3.2 访问描述层 + +`assets.ResolveAccess` 是唯一分发策略入口。浏览器端的 `media-access` 只负责: + +- 按 scope、资源、用途、变体合并请求。 +- 使用服务端返回的 `expiresAt`、`refreshAt` 和 `revision`。 +- 账号切换时取消在途请求并清理旧描述。 +- 只把描述交给 ``、`