refactor(*): 平台架构 - 拆分后端领域模块并同步插件协议与模型能力

This commit is contained in:
ddCat
2026-09-11 14:02:49 +08:00
parent 0945115e53
commit 7ce967df0f
644 changed files with 62731 additions and 2320 deletions

View File

@@ -42,14 +42,15 @@
### 后端
- `backend/internal/handler/`:HTTP 入参、鉴权上下文、调用 service、返回统一响应;不放业务判断和数据库查询。
- `backend/internal/service/`:校验、权限、默认值、ID、时间、配额、幂等、任务编排和外部调用。画布生成调度在 `provider.go`;文本 / 图片 / 视频遗留 / 音频 / HTTP / 声明式协议分别在 `provider_text.go`、`provider_image.go`、`provider_video.go`、`provider_audio.go`、`provider_http_client.go`、`provider_protocol.go`。
- `backend/internal/service/`:稳定导入面。只再导出 `internal/app` 的类型、常量和包级函数(`aliases_*.go`)。handler/cmd 继续 import 本包。
- `backend/internal/app/`:HTTP 组合根和尚未拆出的业务实现。校验、权限、跨域编排从这里进入。画布生成调度仍主要在 `provider.go`;文本 / 图片 / 视频遗留 / 音频 / HTTP / 声明式协议分别在 `provider_text.go`、`provider_image.go`、`provider_video.go`、`provider_audio.go`、`provider_http_client.go`、`provider_protocol.go`。技能库在 `internal/skills`,提示词与风格在 `internal/prompts`,登录注册在 `internal/auth`,画布分享在 `internal/canvas`,资源引用解析在 `internal/assets`,运行时策略/限流/worker 在 `internal/platform`,错误码与通用工具在 `internal/kernel`,出站 SSRF 在 `internal/outbound`。域包不得 import `internal/service` 或 `internal/app`。
- `backend/internal/repository/`:GORM 查询和持久化;不承载业务策略。
- `backend/internal/model/`:结构、枚举和简单模型方法;不调用外部服务。
- `backend/internal/provider/`:模型供应商能力和协议实现。
- `backend/internal/database/`:数据库连接、迁移和连接池。
- `backend/cmd/`:可执行入口、迁移和启动配置;启动参数不得绕过数据目录约束。
调用链应保持为:`HTTP -> handler -> service -> repository/model -> database/resource`;需要模型上游时由 service 进入 `provider/outbound`。跨层调用必须有明确理由并补测试。
调用链应保持为:`HTTP -> handler -> service(别名)-> app(组合根)-> 业务域包 -> repository/model -> database/resource`;需要模型上游时由域包或 app 进入 `protocol` / `generation` / `outbound`。跨层调用必须有明确理由并补测试。
### Agent、插件和文档

View File

@@ -41,6 +41,7 @@
| 头像 | 昵称 | 邮箱 | 个性签名 |
| --- | --- | --- | --- |
| <img src="assets/user-ddcat.jpg" alt="ddCat" width="80"> | ddCat<br><sub>项目发起者 · 微信:ddcat0829</sub> | [ddcat666@126.com](mailto:ddcat666@126.com) | 在计算机里头没有任何黑魔法,所有的东西只不过是我现在不知道而已,总有一天我会把所有的细节、所有的内部的东西全搞明白的。 |
| <img src="assets/user-sikongyue.png" alt="爱笑的毛毛虫" width="80"> | 爱笑的毛毛虫<br><sub>用户名:sikongyue</sub> | [315515767@qq.com](mailto:315515767@qq.com) | 正在啃 main 分支,争取下次 merge 的时候变成蝴蝶 |
| <img src="assets/user-delve.jpg" alt="delve-s" width="80"> | delve-s | [3013141136@qq.com](mailto:3013141136@qq.com) | 我亦无他,惟手熟尔 |
| <img src="assets/user-CyrusAuyeung.jpg" alt="CyrusAuyeung" width="80"> | CyrusAuyeung | [cyrusauyeungho@gmail.com](mailto:cyrusauyeungho@gmail.com) | HKUST(GZ) UG |

BIN
assets/user-ddcat.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,9 +1,10 @@
package service
package app
import (
"encoding/json"
"errors"
"fmt"
"infinite-canvas/backend/internal/kernel"
stdlog "log"
"strings"
"time"
@@ -918,15 +919,5 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
}
func uniqueNonEmpty(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
return kernel.UniqueNonEmpty(values)
}

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"testing"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"errors"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"errors"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"os"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"io"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"
@@ -186,26 +186,24 @@ func (s *Service) collectRedisPerformance(ctx context.Context) SystemPerformance
result.CacheGroups = append(result.CacheGroups, SystemPerformanceCacheGroup{ID: "rateLimits", Label: "本地请求频控", Clearable: true})
return result
}
if s.coordinator.redis == nil {
s.coordinator.localMu.Lock()
localRateKeys := int64(len(s.coordinator.localRate))
s.coordinator.localMu.Unlock()
if !s.coordinator.HasRedis() {
localRateKeys := int64(s.coordinator.LocalRateCount())
result.CacheGroups = append(result.CacheGroups, SystemPerformanceCacheGroup{ID: "rateLimits", Label: "本地请求频控", Keys: localRateKeys, Clearable: true})
return result
}
result.Configured, result.Mode = true, "redis"
started := time.Now()
if err := s.coordinator.redis.Ping(ctx).Err(); err != nil {
if err := s.coordinator.Redis().Ping(ctx).Err(); err != nil {
result.StatusMessage = "Redis 暂时不可用"
return result
}
result.Connected = true
result.LatencyMs = max(1, time.Since(started).Milliseconds())
result.StatusMessage = "Redis 连接正常"
if size, err := s.coordinator.redis.DBSize(ctx).Result(); err == nil {
if size, err := s.coordinator.Redis().DBSize(ctx).Result(); err == nil {
result.Keys = size
}
if info, err := s.coordinator.redis.Info(ctx, "server", "clients", "memory", "stats").Result(); err == nil {
if info, err := s.coordinator.Redis().Info(ctx, "server", "clients", "memory", "stats").Result(); err == nil {
values := parseRedisInfo(info)
result.Version = values["redis_version"]
result.UptimeSeconds = redisInfoInt(values, "uptime_in_seconds")
@@ -223,10 +221,10 @@ func (s *Service) collectRedisPerformance(ctx context.Context) SystemPerformance
result.HitRate = float64(hits) / float64(hits+misses) * 100
}
}
pool := s.coordinator.redis.PoolStats()
pool := s.coordinator.Redis().PoolStats()
result.Pool = SystemPerformanceRedisPool{Hits: pool.Hits, Misses: pool.Misses, Timeouts: pool.Timeouts, TotalConnections: pool.TotalConns, IdleConnections: pool.IdleConns, StaleConnections: pool.StaleConns, PendingRequests: pool.PendingRequests}
for _, definition := range runtimeCacheDefinitions {
count, _ := scanRedisKeyCount(ctx, s.coordinator.redis, definition.pattern)
count, _ := scanRedisKeyCount(ctx, s.coordinator.Redis(), definition.pattern)
result.CacheGroups = append(result.CacheGroups, SystemPerformanceCacheGroup{ID: definition.id, Label: definition.label, Keys: count, Clearable: true})
}
return result
@@ -243,9 +241,9 @@ func (s *Service) ClearAdminRuntimeCache(ctx context.Context, actor *model.User,
defer cancel()
result := &AdminCacheClearResult{Scope: "runtime", ClearedAt: time.Now(), Groups: make([]AdminCacheClearGroupResult, 0, len(runtimeCacheDefinitions))}
if s.coordinator != nil {
if s.coordinator.redis != nil {
if s.coordinator.HasRedis() {
for _, definition := range runtimeCacheDefinitions {
deleted, err := deleteRedisKeysByPattern(ctx, s.coordinator.redis, definition.pattern)
deleted, err := deleteRedisKeysByPattern(ctx, s.coordinator.Redis(), definition.pattern)
if err != nil {
return nil, errors.New("清理 Redis 运行时缓存失败")
}
@@ -253,18 +251,15 @@ func (s *Service) ClearAdminRuntimeCache(ctx context.Context, actor *model.User,
result.DeletedKeys += deleted
}
} else {
s.coordinator.localMu.Lock()
count := len(s.coordinator.localRate)
s.coordinator.localRate = map[string]localRateEntry{}
s.coordinator.localMu.Unlock()
count := s.coordinator.ClearLocalRate()
result.DeletedKeys += int64(count)
result.Groups = append(result.Groups, AdminCacheClearGroupResult{ID: "rateLimits", DeletedKeys: int64(count)})
}
}
s.initReadCaches()
result.MemoryEntries += s.concurrencyReadCache.clearAndCount()
result.MemoryEntries += s.textReplayReadCache.clearAndCount()
result.MemoryEntries += s.routeVersionReadCache.clearAndCount()
result.MemoryEntries += s.concurrencyReadCache.ClearAndCount()
result.MemoryEntries += s.textReplayReadCache.ClearAndCount()
result.MemoryEntries += s.routeVersionReadCache.ClearAndCount()
s.routeHealthMu.Lock()
result.MemoryEntries += len(s.routeHealthBlocked)
s.routeHealthBlocked = make(map[string]time.Time)

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"
@@ -11,6 +11,7 @@ import (
"infinite-canvas/backend/internal/database"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/platform"
"infinite-canvas/backend/internal/repository"
"github.com/redis/go-redis/v9"
@@ -78,17 +79,19 @@ func TestAdminSystemPerformanceReportsSQLiteWithoutRedis(t *testing.T) {
func TestClearAdminRuntimeCacheRejectsUnknownScopeAndAuditsLocalClear(t *testing.T) {
svc, db := newSystemPerformanceTestService(t)
admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
svc.coordinator.localRate["user-1"] = localRateEntry{started: time.Now(), count: 2}
if ok, err := svc.coordinator.Allow(context.Background(), "user-1", 10, time.Hour); err != nil || !ok {
t.Fatalf("seed local rate cache: ok=%v err=%v", ok, err)
}
if _, err := svc.ClearAdminRuntimeCache(context.Background(), admin, AdminCacheClearRequest{Scope: "all"}); authStatus(err) != 400 {
t.Fatalf("unknown scope error = %#v, want 400", err)
}
if len(svc.coordinator.localRate) != 1 {
if svc.coordinator.LocalRateCount() != 1 {
t.Fatal("invalid scope changed local runtime cache")
}
svc.initReadCaches()
if _, err := svc.routeVersionReadCache.get(context.Background(), "route-version", func(context.Context) (int64, int, error) {
if _, err := svc.routeVersionReadCache.Get(context.Background(), "route-version", func(context.Context) (int64, int, error) {
return 7, 256, nil
}); err != nil {
t.Fatal(err)
@@ -102,8 +105,8 @@ func TestClearAdminRuntimeCacheRejectsUnknownScopeAndAuditsLocalClear(t *testing
if err != nil {
t.Fatal(err)
}
if result.DeletedKeys != 1 || len(svc.coordinator.localRate) != 0 {
t.Fatalf("local clear result = %#v, remaining = %d", result, len(svc.coordinator.localRate))
if result.DeletedKeys != 1 || svc.coordinator.LocalRateCount() != 0 {
t.Fatalf("local clear result = %#v, remaining = %d", result, svc.coordinator.LocalRateCount())
}
if result.MemoryEntries != 3 {
t.Fatalf("memory entries = %d, want 3", result.MemoryEntries)
@@ -123,7 +126,7 @@ func TestClearAdminRuntimeCacheRejectsUnknownScopeAndAuditsLocalClear(t *testing
func TestClearAdminRuntimeCacheUsesRedisWhitelist(t *testing.T) {
client := startSystemPerformanceRedis(t)
svc, db := newSystemPerformanceTestService(t)
svc.coordinator = &runtimeCoordinator{redis: client, instanceID: "system-performance-test", localRate: map[string]localRateEntry{}, localSlots: map[string]map[string]time.Time{}}
svc.coordinator = platform.NewCoordinatorWithRedis(client, "system-performance-test")
admin := &model.User{ID: "admin-redis", Role: model.UserRoleAdmin}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -189,7 +192,7 @@ func newSystemPerformanceTestService(t *testing.T) (*Service, *gorm.DB) {
return &Service{
repo: repository.New(db),
dataDir: dataDir,
coordinator: &runtimeCoordinator{instanceID: "local-test", localRate: map[string]localRateEntry{}, localSlots: map[string]map[string]time.Time{}},
coordinator: platform.NewCoordinatorWithRedis(nil, "local-test"),
routeCatalogTTL: 30 * time.Second,
routeCatalogMaxStale: 5 * time.Minute,
routeHealthBlocked: map[string]time.Time{},

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"bufio"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"errors"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"testing"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"testing"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/binary"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"bytes"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -0,0 +1,261 @@
package app
import (
"context"
"time"
"infinite-canvas/backend/internal/auth"
"infinite-canvas/backend/internal/model"
)
const SessionCookieName = auth.SessionCookieName
type (
EmailCodeCooldownError = auth.EmailCodeCooldownError
RegisterRequest = auth.RegisterRequest
LoginRequest = auth.LoginRequest
PublicAuthSettings = auth.PublicAuthSettings
AuthSessionResult = auth.AuthSessionResult
AuthUser = auth.AuthUser
RegistrationSettingRequest = auth.RegistrationSettingRequest
PublicRegistrationSetting = auth.PublicRegistrationSetting
PasswordResetRequest = auth.PasswordResetRequest
EmailSettingRequest = auth.EmailSettingRequest
PublicEmailSetting = auth.PublicEmailSetting
LinuxDOSettingRequest = auth.LinuxDOSettingRequest
PublicLinuxDOSetting = auth.PublicLinuxDOSetting
LinuxDOCallbackResult = auth.LinuxDOCallbackResult
LibTVSettingRequest = auth.LibTVSettingRequest
PublicLibTVSetting = auth.PublicLibTVSetting
LibTVImportRequest = auth.LibTVImportRequest
LibTVImportResult = auth.LibTVImportResult
LibTVCanvasConnection = auth.LibTVCanvasConnection
LibTVCanvasNode = auth.LibTVCanvasNode
LibTVImportIssue = auth.LibTVImportIssue
LibTVImportMetadata = auth.LibTVImportMetadata
LibTVImportWarning = auth.LibTVImportWarning
TapNowImportRequest = auth.TapNowImportRequest
TapNowImportResult = auth.TapNowImportResult
TapNowCanvasConnection = auth.TapNowCanvasConnection
TapNowCanvasNode = auth.TapNowCanvasNode
TapNowImportIssue = auth.TapNowImportIssue
TapNowImportMetadata = auth.TapNowImportMetadata
TapNowImportWarning = auth.TapNowImportWarning
)
type authHost struct {
svc *Service
}
func (h authHost) RequireAdmin(user *model.User) error {
if h.svc == nil {
return nil
}
return h.svc.RequireAdmin(user)
}
func (h authHost) EncryptSecret(value string) (string, error) {
if h.svc == nil {
return value, nil
}
return h.svc.encryptSettingSecret(value)
}
func (h authHost) DecryptSecret(value string) (string, error) {
if h.svc == nil {
return value, nil
}
return h.svc.decryptSettingSecret(value)
}
func (h authHost) SettingsEncryptionKey() ([]byte, error) {
if h.svc == nil {
return nil, nil
}
return h.svc.settingsEncryptionKey()
}
func (h authHost) BrandName() string {
if h.svc == nil {
return auth.DefaultBrandName
}
return h.svc.appearanceBrandName()
}
func (h authHost) EnsureSignupBonus(userID string) error {
if h.svc == nil {
return nil
}
return h.svc.ensureSignupBonus(userID)
}
func (h authHost) RecordActivity(userID string, event string, count int) {
if h.svc == nil {
return
}
h.svc.recordActivity(userID, event, count)
}
func (h authHost) AllowRequest(ctx context.Context, key string, limit int, window time.Duration) (bool, error) {
if h.svc == nil {
return true, nil
}
return h.svc.AllowRequest(ctx, key, limit, window)
}
func (h authHost) RequestRetryAfter(ctx context.Context, key string, window time.Duration) time.Duration {
if h.svc == nil {
return 0
}
return h.svc.RequestRetryAfter(ctx, key, window)
}
func (s *Service) authDomain() *auth.Service {
if s == nil {
return auth.New(nil, nil, nil)
}
if s.auth != nil {
return s.auth
}
return auth.New(s.repo, authHost{svc: s}, nil)
}
func (s *Service) PublicAuthSettings() (*PublicAuthSettings, error) {
return s.authDomain().PublicAuthSettings()
}
func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
return s.authDomain().Register(req)
}
func (s *Service) Login(req LoginRequest) (*AuthSessionResult, error) {
return s.authDomain().Login(req)
}
func (s *Service) Logout(cookieValue string) error {
return s.authDomain().Logout(cookieValue)
}
func (s *Service) CurrentUser(cookieValue string) (*model.User, error) {
return s.authDomain().CurrentUser(cookieValue)
}
func (s *Service) PublicAuthUser(user *model.User) (AuthUser, error) {
return s.authDomain().PublicAuthUser(user)
}
func (s *Service) AdminRegistrationSetting(actor *model.User) (*PublicRegistrationSetting, error) {
return s.authDomain().AdminRegistrationSetting(actor)
}
func (s *Service) UpdateRegistrationSetting(actor *model.User, req RegistrationSettingRequest) (*PublicRegistrationSetting, error) {
return s.authDomain().UpdateRegistrationSetting(actor, req)
}
func (s *Service) RegistrationEnabled() (bool, error) {
return s.authDomain().RegistrationEnabled()
}
func (s *Service) SendPasswordResetEmailCode(rawEmail string) error {
return s.authDomain().SendPasswordResetEmailCode(rawEmail)
}
func (s *Service) ResetPassword(req PasswordResetRequest) error {
return s.authDomain().ResetPassword(req)
}
func (s *Service) AdminEmailSetting(actor *model.User) (*PublicEmailSetting, error) {
return s.authDomain().AdminEmailSetting(actor)
}
func (s *Service) UpdateEmailSetting(actor *model.User, req EmailSettingRequest) (*PublicEmailSetting, error) {
return s.authDomain().UpdateEmailSetting(actor, req)
}
func (s *Service) EmailEnabled() (bool, error) {
return s.authDomain().EmailEnabled()
}
func (s *Service) SendRegistrationEmailCode(rawEmail string) error {
return s.authDomain().SendRegistrationEmailCode(rawEmail)
}
func (s *Service) VerifyRegistrationEmailCode(email string, rawCode string) (*model.EmailVerificationCode, error) {
return s.authDomain().VerifyRegistrationEmailCode(email, rawCode)
}
func (s *Service) AdminLinuxDOSetting(actor *model.User) (*PublicLinuxDOSetting, error) {
return s.authDomain().AdminLinuxDOSetting(actor)
}
func (s *Service) UpdateLinuxDOSetting(actor *model.User, req LinuxDOSettingRequest) (*PublicLinuxDOSetting, error) {
return s.authDomain().UpdateLinuxDOSetting(actor, req)
}
func (s *Service) LinuxDOEnabled() bool {
return s.authDomain().LinuxDOEnabled()
}
func (s *Service) BeginLinuxDOLogin(nextPath string) (string, error) {
return s.authDomain().BeginLinuxDOLogin(nextPath)
}
func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDOCallbackResult, error) {
return s.authDomain().CompleteLinuxDOLogin(stateValue, code)
}
func (s *Service) AdminLibTVSetting(actor *model.User) (*PublicLibTVSetting, error) {
return s.authDomain().AdminLibTVSetting(actor)
}
func (s *Service) UpdateLibTVSetting(actor *model.User, req LibTVSettingRequest) (*PublicLibTVSetting, error) {
return s.authDomain().UpdateLibTVSetting(actor, req)
}
func (s *Service) TestLibTV(actor *model.User, projectUUID string) error {
return s.authDomain().TestLibTV(actor, projectUUID)
}
func (s *Service) ImportLibTV(userID, canvasProjectID, projectUUID string) (*LibTVImportResult, error) {
return s.authDomain().ImportLibTV(userID, canvasProjectID, projectUUID)
}
func (s *Service) ImportTapNow(userID, canvasProjectID, shareID string) (*TapNowImportResult, error) {
return s.authDomain().ImportTapNow(userID, canvasProjectID, shareID)
}
func hashPassword(password string) (string, error) {
return auth.HashPassword(password)
}
func normalizeUsername(value string) string {
return auth.NormalizeUsername(value)
}
func normalizeEmail(value string) string {
return auth.NormalizeEmail(value)
}
func normalizeDisplayName(value string, fallback string) string {
return auth.NormalizeDisplayName(value, fallback)
}
func validateUsername(value string) error {
return auth.ValidateUsername(value)
}
func validatePassword(value string) error {
return auth.ValidatePassword(value)
}
func validateEmail(value string) error {
return auth.ValidateEmail(value)
}
func hashToken(token string) string {
return auth.HashToken(token)
}
func randomToken() string {
return auth.RandomToken()
}

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"

View File

@@ -0,0 +1,236 @@
package app
import (
"encoding/json"
"io"
"infinite-canvas/backend/internal/assets"
"infinite-canvas/backend/internal/canvas"
"infinite-canvas/backend/internal/model"
)
type (
CanvasShareRequest = canvas.CanvasShareRequest
CanvasShareStatus = canvas.CanvasShareStatus
PublicCanvasShare = canvas.PublicCanvasShare
AssetsSyncRequest = canvas.AssetsSyncRequest
CanvasProjectsSyncRequest = canvas.CanvasProjectsSyncRequest
UserDataSummary = canvas.UserDataSummary
UserDataSnapshot = canvas.UserDataSnapshot
CanvasLibrarySummary = canvas.CanvasLibrarySummary
CanvasLibraryPage = canvas.CanvasLibraryPage
)
type canvasHost struct {
svc *Service
}
func (h canvasHost) EncryptSecret(value string) (string, error) {
if h.svc == nil {
return value, nil
}
return h.svc.encryptSettingSecret(value)
}
func (h canvasHost) DecryptSecret(value string) (string, error) {
if h.svc == nil {
return value, nil
}
return h.svc.decryptSettingSecret(value)
}
func (h canvasHost) OpenResourceRange(userID string, resource *model.Resource, rangeHeader string) (*assets.ResourceStream, error) {
if h.svc == nil {
return nil, nil
}
return h.svc.openResourceRange(userID, resource, rangeHeader)
}
func (h canvasHost) PrepareResourceDelivery(userID string, resource *model.Resource, options assets.ResourceDeliveryOptions) (*assets.ResourceDelivery, error) {
if h.svc == nil {
return nil, nil
}
return h.svc.prepareResourceDelivery(userID, resource, options)
}
func (h canvasHost) WithStorageLock(fn func() error) error {
if h.svc == nil {
if fn == nil {
return nil
}
return fn()
}
h.svc.storageMu.Lock()
defer h.svc.storageMu.Unlock()
return fn()
}
func (h canvasHost) StructuredQuota(userID, kind string, creating bool, deltaBytes int64) error {
if h.svc == nil {
return nil
}
policy, err := h.svc.RuntimePolicy()
if err != nil {
return err
}
usage, err := h.svc.repo.UserStorageUsage(userID)
if err != nil {
return err
}
return validateStructuredStorageQuotaWithPolicy(usage, kind, creating, deltaBytes, policy.Resource)
}
func (h canvasHost) StructuredReplacementQuota(userID, kind string, count int, bytes int64) error {
if h.svc == nil {
return nil
}
policy, err := h.svc.RuntimePolicy()
if err != nil {
return err
}
usage, err := h.svc.repo.UserStorageUsage(userID)
if err != nil {
return err
}
return validateStructuredReplacementQuotaWithPolicy(usage, kind, count, bytes, policy.Resource)
}
func (h canvasHost) DeleteUserAssetWithResources(userID, assetID string) error {
if h.svc == nil {
return nil
}
return h.svc.deleteUserAssetWithResources(userID, assetID)
}
func (h canvasHost) RecordActivity(userID, event string, count int) {
if h.svc == nil {
return
}
h.svc.recordActivity(userID, event, count)
}
func (s *Service) canvasDomain() *canvas.Service {
if s == nil {
return canvas.New(nil, nil)
}
if s.canvas != nil {
return s.canvas
}
return canvas.New(s.repo, canvasHost{svc: s})
}
func (s *Service) CanvasShareStatus(userID string, projectID string) (CanvasShareStatus, error) {
return s.canvasDomain().CanvasShareStatus(userID, projectID)
}
func (s *Service) CreateCanvasShare(userID string, projectID string, req CanvasShareRequest) (CanvasShareStatus, error) {
return s.canvasDomain().CreateCanvasShare(userID, projectID, req)
}
func (s *Service) DeleteCanvasShare(userID string, projectID string) error {
return s.canvasDomain().DeleteCanvasShare(userID, projectID)
}
func (s *Service) PublicCanvasShare(token string) (PublicCanvasShare, error) {
return s.canvasDomain().PublicCanvasShare(token)
}
func (s *Service) OpenSharedCanvasResource(token string, resourceID string) (*model.Resource, io.ReadCloser, error) {
return s.canvasDomain().OpenSharedCanvasResource(token, resourceID)
}
func (s *Service) OpenSharedCanvasResourceRange(token string, resourceID string, rangeHeader string) (*ResourceStream, error) {
return s.canvasDomain().OpenSharedCanvasResourceRange(token, resourceID, rangeHeader)
}
func (s *Service) PrepareSharedCanvasResourceDelivery(token string, resourceID string, rangeHeader string) (*ResourceDelivery, error) {
return s.canvasDomain().PrepareSharedCanvasResourceDelivery(token, resourceID, rangeHeader)
}
func (s *Service) validateCanvasMediaAssets(userID string, raw json.RawMessage) error {
return s.canvasDomain().ValidateCanvasMediaAssets(userID, raw)
}
func (s *Service) validateAssetCanvasReferences(userID string, asset model.Asset) error {
return s.canvasDomain().ValidateAssetCanvasReferences(userID, asset)
}
func (s *Service) validateAssetReplacementCanvasReferences(userID string, replacement []model.Asset) error {
return s.canvasDomain().ValidateAssetReplacementCanvasReferences(userID, replacement)
}
func (s *Service) UserDataSnapshot(userID string) (UserDataSnapshot, error) {
return s.canvasDomain().UserDataSnapshot(userID)
}
func (s *Service) UserAssetSummaries(userID string) ([]UserDataSummary, error) {
return s.canvasDomain().UserAssetSummaries(userID)
}
func (s *Service) UserAsset(userID string, id string) (json.RawMessage, error) {
return s.canvasDomain().UserAsset(userID, id)
}
func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataSummary, error) {
return s.canvasDomain().UpsertUserAsset(userID, raw)
}
func (s *Service) DeleteUserAsset(userID string, id string) error {
return s.canvasDomain().DeleteUserAsset(userID, id)
}
func (s *Service) UserAssets(userID string) ([]json.RawMessage, error) {
return s.canvasDomain().UserAssets(userID)
}
func (s *Service) ReplaceUserAssets(userID string, req AssetsSyncRequest) ([]json.RawMessage, error) {
return s.canvasDomain().ReplaceUserAssets(userID, req)
}
func (s *Service) UserCanvasProjects(userID string) ([]json.RawMessage, error) {
return s.canvasDomain().UserCanvasProjects(userID)
}
func (s *Service) UserCanvasProjectSummaries(userID string) ([]UserDataSummary, error) {
return s.canvasDomain().UserCanvasProjectSummaries(userID)
}
func (s *Service) UserCanvasProject(userID string, id string) (json.RawMessage, error) {
return s.canvasDomain().UserCanvasProject(userID, id)
}
func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (UserDataSummary, error) {
return s.canvasDomain().UpsertUserCanvasProject(userID, raw)
}
func (s *Service) DeleteUserCanvasProject(userID string, id string) error {
return s.canvasDomain().DeleteUserCanvasProject(userID, id)
}
func (s *Service) ReplaceUserCanvasProjects(userID string, req CanvasProjectsSyncRequest) ([]json.RawMessage, error) {
return s.canvasDomain().ReplaceUserCanvasProjects(userID, req)
}
func (s *Service) UserAssetsByIDs(userID string, ids []string) ([]json.RawMessage, error) {
return s.canvasDomain().UserAssetsByIDs(userID, ids)
}
func (s *Service) UserCanvasProjectsPage(userID string, page int, pageSize int, projectID string, search string, sort string) (CanvasLibraryPage, error) {
return s.canvasDomain().UserCanvasProjectsPage(userID, page, pageSize, projectID, search, sort)
}
func clientAssetPayload(asset model.Asset) json.RawMessage {
return canvas.ClientAssetPayload(asset)
}
func validateSyncedPayload(raw json.RawMessage, label string) error {
return canvas.ValidateSyncedPayload(raw, label)
}
func containsInlineMediaDataURL(value interface{}) bool {
return canvas.ContainsInlineMediaDataURL(value)
}
func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) {
return canvas.AssetFromJSON(userID, raw)
}

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"net/url"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"errors"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"infinite-canvas/backend/internal/model"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"strings"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,12 +1,14 @@
package service
package app
import (
"encoding/json"
"testing"
"infinite-canvas/backend/internal/prompts"
)
func TestChapterAssetsPromptContract(t *testing.T) {
if !json.Valid([]byte(chapterAssetsJSONSchema)) {
if !json.Valid([]byte(prompts.ChapterAssetsJSONSchema)) {
t.Fatal("invalid chapter assets schema")
}
for _, tc := range []struct {

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"crypto/sha256"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"archive/zip"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"archive/zip"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"

View File

@@ -1,4 +1,4 @@
package service
package app
import "testing"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"errors"

View File

@@ -0,0 +1,42 @@
package app
import "infinite-canvas/backend/internal/kernel"
// 错误码和原因已迁到 kernel;此处保留兼容别名。
const (
CodeOK = kernel.CodeOK
CodeInvalidArgument = kernel.CodeInvalidArgument
CodeUnauthorized = kernel.CodeUnauthorized
CodeForbidden = kernel.CodeForbidden
CodeNotFound = kernel.CodeNotFound
CodeConflict = kernel.CodeConflict
CodeTooManyRequests = kernel.CodeTooManyRequests
CodeInternal = kernel.CodeInternal
CodeBadGateway = kernel.CodeBadGateway
CodeUnavailable = kernel.CodeUnavailable
CodeTimeout = kernel.CodeTimeout
CodeQuotaExceeded = kernel.CodeQuotaExceeded
CodeIdempotencyConflict = kernel.CodeIdempotencyConflict
CodeRateLimited = kernel.CodeRateLimited
)
type ErrorReason = kernel.ErrorReason
const (
ReasonInvalidArgument = kernel.ReasonInvalidArgument
ReasonUnauthorized = kernel.ReasonUnauthorized
ReasonForbidden = kernel.ReasonForbidden
ReasonNotFound = kernel.ReasonNotFound
ReasonConflict = kernel.ReasonConflict
ReasonFailedPrecondition = kernel.ReasonFailedPrecondition
ReasonQuotaExceeded = kernel.ReasonQuotaExceeded
ReasonRateLimited = kernel.ReasonRateLimited
ReasonUnavailable = kernel.ReasonUnavailable
ReasonTimeout = kernel.ReasonTimeout
ReasonInternal = kernel.ReasonInternal
ReasonBadGateway = kernel.ReasonBadGateway
)
func ReasonForStatus(status int) ErrorReason {
return kernel.ReasonForStatus(status)
}

View File

@@ -1,48 +1,43 @@
package service
package app
import "fmt"
import "infinite-canvas/backend/internal/kernel"
// AppError 是 service 层对外公开的结构化错误。
// Message 必须可安全展示给用户,Cause 仅用于保留内部诊断链路,不得直接写入 HTTP 响应。
type AppError struct {
Status int
Code int
Reason ErrorReason
Message string
Retryable bool
Cause error
}
func (e *AppError) Error() string {
if e == nil {
return ""
}
return e.Message
}
func (e *AppError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// AppError 是 service 层对外公开的结构化错误;实现已迁到 kernel。
type AppError = kernel.AppError
func NewAppError(status int, message string) *AppError {
return &AppError{Status: status, Code: status, Reason: ReasonForStatus(status), Message: message}
return kernel.NewAppError(status, message)
}
func WrapAppError(status int, message string, cause error) *AppError {
err := NewAppError(status, message)
err.Cause = cause
return err
return kernel.WrapAppError(status, message, cause)
}
func RateLimited(message string) *AppError {
return &AppError{Status: CodeTooManyRequests, Code: CodeRateLimited, Reason: ReasonRateLimited, Message: message, Retryable: true}
return kernel.RateLimited(message)
}
func QuotaExceeded(message string) *AppError {
return &AppError{Status: CodeForbidden, Code: CodeQuotaExceeded, Reason: ReasonQuotaExceeded, Message: message}
return kernel.QuotaExceeded(message)
}
// AuthError 保留为兼容别名。
type AuthError = AppError
func BadAuthRequest(message string) *AuthError {
return kernel.BadAuthRequest(message)
}
func NotFound(message string) *AuthError {
return kernel.NotFound(message)
}
func Unauthorized(message string) *AuthError {
return kernel.Unauthorized(message)
}
func Forbidden(message string) *AuthError {
return kernel.Forbidden(message)
}
// ModelErrorCode 定义模型相关的错误码
@@ -161,7 +156,7 @@ func GetModelErrorCode(err error) ModelErrorCode {
func FormatModelError(err error) string {
if modelErr, ok := err.(*ModelError); ok {
if len(modelErr.Details) > 0 {
return fmt.Sprintf("%s (错误码: %s)", modelErr.Message, modelErr.ErrorCode)
return kernel.StringValue(modelErr.Message) + " (错误码: " + string(modelErr.ErrorCode) + ")"
}
return modelErr.Message
}

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"errors"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"errors"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"crypto/rand"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"testing"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -0,0 +1,53 @@
package app
import (
"context"
"infinite-canvas/backend/internal/generation"
"infinite-canvas/backend/internal/protocol"
)
// 协议注册表运行时已迁到 internal/generation;此处保留未导出包装以兼容 service 内大量调用点。
// emptyProtocolRegistry 供单测显式注入「插件未安装」路径。
var emptyProtocolRegistry, _ = protocol.NewRegistry()
func withProtocolRegistry(ctx context.Context, registry *protocol.Registry) context.Context {
return generation.WithProtocolRegistry(ctx, registry)
}
func protocolRegistryFromContext(ctx context.Context) (*protocol.Registry, bool) {
return generation.ProtocolRegistryFromContext(ctx)
}
func protocolAdapterForContext(ctx context.Context, id string) (protocol.Adapter, bool) {
return generation.ProtocolAdapterForContext(ctx, id)
}
func ensureOfficialProtocolAdapter(ctx context.Context, interfaceType string) context.Context {
return generation.EnsureOfficialProtocolAdapter(ctx, interfaceType)
}
func officialDeclarativeVideoInterface(interfaceType string) (string, bool) {
return generation.OfficialDeclarativeVideoInterface(interfaceType)
}
func officialDeclarativeImageInterface(interfaceType string) (string, bool) {
return generation.OfficialDeclarativeImageInterface(interfaceType)
}
func officialDeclarativeAudioInterface(interfaceType string) (string, bool) {
return generation.OfficialDeclarativeAudioInterface(interfaceType)
}
func declarativeProtocolAdapterForContext(ctx context.Context, id string) (protocol.Adapter, bool) {
return generation.DeclarativeProtocolAdapterForContext(ctx, id)
}
func agentProtocolAdapterForContext(ctx context.Context, id string) (protocol.AgentAdapter, bool) {
return generation.AgentProtocolAdapterForContext(ctx, id)
}
func loadOfficialFallbackRegistry() *protocol.Registry {
return generation.LoadOfficialFallbackRegistry()
}

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -0,0 +1,28 @@
package app
import (
"strings"
"testing"
)
func TestExtractProviderTaskID(t *testing.T) {
id, err := extractProviderTaskID([]byte(`{"id":12,"task_id":"abc"}`))
if err != nil || id != "abc" {
t.Fatalf("extractProviderTaskID() = %q, %v", id, err)
}
_, err = extractProviderTaskID([]byte(`{"id":12}`))
if err == nil || !strings.Contains(err.Error(), "expected string") || !strings.Contains(err.Error(), "float64") {
t.Fatalf("extractProviderTaskID(numeric id) error = %v", err)
}
id, err = extractProviderTaskID([]byte(`{"data":{"task_id":"nested"}}`))
if err != nil || id != "nested" {
t.Fatalf("extractProviderTaskID(nested) = %q, %v", id, err)
}
id, err = extractProviderTaskID([]byte(`{"name":"task-from-name"}`))
if err != nil || id != "task-from-name" {
t.Fatalf("extractProviderTaskID(name) = %q, %v", id, err)
}
}

View File

@@ -1,4 +1,4 @@
package service
package app
import "infinite-canvas/backend/internal/model"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"
@@ -280,6 +280,55 @@ func capabilitySpecWithRoutePresets(spec CapabilitySpec, routes []CapabilitySpec
}
result.Options[name] = OptionConstraint{Values: values}
}
if result.ImageSize == nil || result.ImageSize.Parameter == "" || len(result.ImageSize.Presets) == 0 {
if merged := mergeCapabilityImageSize(append([]CapabilitySpec{spec}, routes...)); merged != nil {
if result.ImageSize == nil {
result.ImageSize = merged
} else {
restored := *result.ImageSize
if restored.Parameter == "" {
restored.Parameter = merged.Parameter
}
if !restored.AllowCustom {
restored.AllowCustom = merged.AllowCustom
}
if len(restored.Presets) == 0 {
restored.Presets = merged.Presets
}
result.ImageSize = &restored
}
}
}
return result
}
func mergeCapabilityImageSize(specs []CapabilitySpec) *CapabilityImageSize {
var result *CapabilityImageSize
seen := map[string]bool{}
for _, spec := range specs {
part := spec.ImageSize
if part == nil {
continue
}
if result == nil {
result = &CapabilityImageSize{Parameter: part.Parameter, AllowCustom: part.AllowCustom}
} else {
if result.Parameter != "aspect_ratio" && part.Parameter == "aspect_ratio" {
result.Parameter = "aspect_ratio"
} else if result.Parameter == "" {
result.Parameter = part.Parameter
}
result.AllowCustom = result.AllowCustom || part.AllowCustom
}
for _, preset := range part.Presets {
key := preset.Tier + ":" + preset.Ratio + ":" + preset.Size
if key == "::" || seen[key] {
continue
}
seen[key] = true
result.Presets = append(result.Presets, preset)
}
}
return result
}

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"strings"
@@ -7,6 +7,25 @@ import (
"infinite-canvas/backend/internal/model"
)
func TestCapabilitySpecWithRoutePresetsRestoresImageSize(t *testing.T) {
product := CapabilitySpec{Version: 1, Capability: "image", Options: map[string]OptionConstraint{
"size": {Values: []any{"16:9"}},
}}
route := CapabilitySpec{
Version: 1,
Capability: "image",
Options: map[string]OptionConstraint{"size": {Values: []any{"16:9"}}},
ImageSize: &CapabilityImageSize{
Parameter: "aspect_ratio",
Presets: []CapabilityImageSizePreset{{Size: "3840x2160", Tier: "4k", Ratio: "16:9", Width: 3840, Height: 2160}},
},
}
got := capabilitySpecWithRoutePresets(product, []CapabilitySpec{route})
if got.ImageSize == nil || got.ImageSize.Parameter != "aspect_ratio" || len(got.ImageSize.Presets) != 1 || got.ImageSize.Presets[0].Tier != "4k" {
t.Fatalf("restored imageSize = %#v", got.ImageSize)
}
}
func TestMatchCapabilityRoutesTextImagesOnlyToDeclaredProfiles(t *testing.T) {
plainText := CapabilitySpec{
Version: 1,

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"
@@ -416,6 +416,7 @@ func CapabilitySpecFromModelCapabilityConfig(config *ModelCapabilityConfig, capa
}
if image.Size.Parameter != "none" {
spec.Options["size"] = imageSizeOptionConstraint(image.Size)
spec.ImageSize = capabilityImageSizeFromConfig(image.Size)
}
if image.Quality.Supported {
spec.Options["quality"] = anyValues(image.Quality.Values)
@@ -492,6 +493,25 @@ func imageSizeOptionConstraint(size ImageSizeConfig) OptionConstraint {
return anyValues(values)
}
func capabilityImageSizeFromConfig(size ImageSizeConfig) *CapabilityImageSize {
if size.Parameter == "" || size.Parameter == "none" {
return nil
}
result := &CapabilityImageSize{Parameter: size.Parameter, AllowCustom: size.AllowCustom}
for _, preset := range size.Presets {
tier := strings.ToLower(strings.TrimSpace(preset.Tier))
ratio := strings.TrimSpace(preset.Ratio)
value := strings.TrimSpace(preset.Size)
if tier == "" || ratio == "" || value == "" {
continue
}
result.Presets = append(result.Presets, CapabilityImageSizePreset{
Size: value, Tier: tier, Ratio: ratio, Width: preset.Width, Height: preset.Height,
})
}
return result
}
func addInputConstraint(inputs map[string]InputConstraint, name string, min int, max int) {
if min <= 0 && max <= 0 {
return

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"fmt"
@@ -289,6 +289,35 @@ func TestNormalizeVideoCapabilityAllowsOmittedRatio(t *testing.T) {
}
}
func TestCapabilitySpecFromModelCapabilityConfigProjectsImageSizePresets(t *testing.T) {
config := &ModelCapabilityConfig{
Version: 1,
Image: &ImageCapabilityConfig{
References: ImageReferenceConfig{MaxImages: 4},
Size: ImageSizeConfig{
Parameter: "aspect_ratio",
Values: []string{"16:9"},
Default: "16:9",
Presets: []ImageSizePreset{
{Tier: "4k", Ratio: "16:9", Size: "3840x2160", Width: 3840, Height: 2160},
},
},
Quality: ImageQualityConfig{Supported: false, Default: "auto"},
MaxOutputs: 1,
},
}
spec, err := CapabilitySpecFromModelCapabilityConfig(config, "image")
if err != nil {
t.Fatal(err)
}
if spec.Options["quality"].Values != nil {
t.Fatalf("quality must stay undeclared when unsupported: %#v", spec.Options["quality"])
}
if spec.ImageSize == nil || spec.ImageSize.Parameter != "aspect_ratio" || len(spec.ImageSize.Presets) != 1 || spec.ImageSize.Presets[0].Tier != "4k" {
t.Fatalf("imageSize presets = %#v", spec.ImageSize)
}
}
func TestCapabilitySpecFromModelCapabilityConfigProjectsImageSizeOnce(t *testing.T) {
config := &ModelCapabilityConfig{
Version: 1,

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"
@@ -18,12 +18,27 @@ import (
"infinite-canvas/backend/internal/repository"
)
type CapabilityImageSizePreset struct {
Size string `json:"size"`
Tier string `json:"tier"`
Ratio string `json:"ratio"`
Width int `json:"width"`
Height int `json:"height"`
}
type CapabilityImageSize struct {
Parameter string `json:"parameter,omitempty"`
AllowCustom bool `json:"allowCustom,omitempty"`
Presets []CapabilityImageSizePreset `json:"presets,omitempty"`
}
type CapabilitySpec struct {
Version int `json:"version"`
Capability string `json:"capability"`
Operations []string `json:"operations,omitempty"`
Inputs map[string]InputConstraint `json:"inputs,omitempty"`
Options map[string]OptionConstraint `json:"options,omitempty"`
ImageSize *CapabilityImageSize `json:"imageSize,omitempty"`
}
type InputConstraint struct {
@@ -472,12 +487,12 @@ func (s *Service) invalidateRouteCatalog() {
if s.coordinator != nil {
ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout)
defer cancel()
if err := s.coordinator.bumpRouteCatalogVersion(ctx); err != nil {
if err := s.coordinator.BumpRouteCatalogVersion(ctx); err != nil {
log.Printf("logical model route catalog distributed invalidation failed: %v", err)
}
}
s.initReadCaches()
s.routeVersionReadCache.clear()
s.routeVersionReadCache.Clear()
}
func (s *Service) routeCatalogSnapshot() (*routeCatalogSnapshot, error) {
@@ -533,14 +548,14 @@ func (s *Service) currentRouteCatalogVersion() int64 {
s.routeCatalogMu.RLock()
localVersion := s.routeCatalogVersion
s.routeCatalogMu.RUnlock()
if s.coordinator == nil || s.coordinator.redis == nil {
if s.coordinator == nil || !s.coordinator.HasRedis() {
return localVersion
}
s.initReadCaches()
ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout)
defer cancel()
version, err := s.routeVersionReadCache.get(ctx, routeCatalogVersionKey, func(ctx context.Context) (int64, int, error) {
value, err := s.coordinator.routeCatalogVersion(ctx)
version, err := s.routeVersionReadCache.Get(ctx, routeCatalogVersionKey, func(ctx context.Context) (int64, int, error) {
value, err := s.coordinator.RouteCatalogVersion(ctx)
if err != nil {
log.Printf("logical model route catalog distributed version check failed: %v", err)
}
@@ -845,13 +860,13 @@ func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool {
}
s.routeHealthMu.Unlock()
if s.coordinator == nil || s.coordinator.redis == nil {
if s.coordinator == nil || !s.coordinator.HasRedis() {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
for _, key := range keys {
until, err := s.coordinator.routeBlockedUntil(ctx, key)
until, err := s.coordinator.RouteBlockedUntil(ctx, key)
if err != nil {
log.Printf("logical route distributed health check failed key=%s: %v", key, err)
return false
@@ -1234,10 +1249,10 @@ func (s *Service) blockLogicalRouteForFailure(attempt *model.RouteAttempt, taskE
// 本地状态是 Redis 不可用时的降级,也能覆盖 Redis 写入瞬间的网络抖动。
s.routeHealthBlocked[key] = until
s.routeHealthMu.Unlock()
if s.coordinator != nil && s.coordinator.redis != nil {
if s.coordinator != nil && s.coordinator.HasRedis() {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if err := s.coordinator.blockRoute(ctx, key, until); err != nil {
if err := s.coordinator.BlockRoute(ctx, key, until); err != nil {
log.Printf("logical route distributed health update failed key=%s: %v", key, err)
}
}

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"fmt"

View File

@@ -0,0 +1,81 @@
package app
import (
"net/http"
"net/url"
"time"
"infinite-canvas/backend/internal/outbound"
)
// Outbound 符号从 internal/outbound 再导出,保持 service/handler 调用面稳定。
type OutboundHeader = outbound.OutboundHeader
const (
CustomRelayHeadersHeader = outbound.CustomRelayHeadersHeader
DefaultOutboundUserAgent = outbound.DefaultOutboundUserAgent
)
func ValidateOutboundURL(rawURL string) (*url.URL, error) {
parsed, err := outbound.ValidateOutboundURL(rawURL)
return parsed, mapOutboundError(err)
}
func ValidateCustomRelayURL(rawURL string) (*url.URL, error) {
parsed, err := outbound.ValidateCustomRelayURL(rawURL)
return parsed, mapOutboundError(err)
}
func OutboundHTTPClient(timeout time.Duration) *http.Client {
return outbound.OutboundHTTPClient(timeout)
}
func ApplyDefaultOutboundHeaders(req *http.Request) {
outbound.ApplyDefaultOutboundHeaders(req)
}
func NormalizeOutboundHeaders(headers []OutboundHeader) ([]OutboundHeader, error) {
normalized, err := outbound.NormalizeOutboundHeaders(headers)
return normalized, mapOutboundError(err)
}
func ApplyOutboundHeaders(req *http.Request, headers []OutboundHeader) {
outbound.ApplyOutboundHeaders(req, headers)
}
func EncodeOutboundHeadersJSON(headers []OutboundHeader) (string, error) {
encoded, err := outbound.EncodeOutboundHeadersJSON(headers)
return encoded, mapOutboundError(err)
}
func ParseOutboundHeadersJSON(raw string) ([]OutboundHeader, error) {
decoded, err := outbound.ParseOutboundHeadersJSON(raw)
return decoded, mapOutboundError(err)
}
func DecodeRelayOutboundHeaders(encoded string) ([]OutboundHeader, error) {
decoded, err := outbound.DecodeRelayOutboundHeaders(encoded)
return decoded, mapOutboundError(err)
}
func CustomRelayHTTPClient(timeout time.Duration) *http.Client {
return outbound.CustomRelayHTTPClient(timeout)
}
func ValidateOutboundHost(host string) error {
return mapOutboundError(outbound.ValidateOutboundHost(host))
}
func AllowedPrivateUpstreamHost(host string) bool {
return outbound.AllowedPrivateUpstreamHost(host)
}
func mapOutboundError(err error) error {
if err == nil {
return nil
}
if req, ok := err.(*outbound.BadRequestError); ok {
return BadAuthRequest(req.Message)
}
return err
}

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"infinite-canvas/backend/internal/protocol"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"testing"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"context"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"archive/zip"

View File

@@ -0,0 +1,240 @@
package app
import (
"context"
"net/http"
"time"
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/platform"
)
type (
RuntimeResourcePolicy = platform.RuntimeResourcePolicy
RuntimeTaskPolicy = platform.RuntimeTaskPolicy
RuntimeRequestPolicy = platform.RuntimeRequestPolicy
RuntimePolicySetting = platform.RuntimePolicySetting
PublicRuntimePolicySetting = platform.PublicRuntimePolicySetting
PublicRuntimeLimits = platform.PublicRuntimeLimits
FeatureAvailability = platform.FeatureAvailability
PublicFeatureAvailability = platform.PublicFeatureAvailability
)
const (
FeatureShortDrama = platform.FeatureShortDrama
FeatureTaskCenter = platform.FeatureTaskCenter
FeatureCredits = platform.FeatureCredits
FeatureCustomChannels = platform.FeatureCustomChannels
FeatureFrontendModels = platform.FeatureFrontendModels
FeaturePluginCenter = platform.FeaturePluginCenter
FeatureSystemPlugins = platform.FeatureSystemPlugins
FeatureTimelineTranscription = platform.FeatureTimelineTranscription
)
type platformHost struct {
svc *Service
}
func (h platformHost) RequireAdmin(user *model.User) error {
if h.svc == nil {
return nil
}
return h.svc.RequireAdmin(user)
}
func (h platformHost) AppendAudit(actor *model.User, action, targetType, targetID, summary string, metadata any) error {
if h.svc == nil {
return nil
}
return h.svc.appendAdminAudit(actor, action, targetType, targetID, summary, metadata)
}
func (h platformHost) DesktopLocalChannelsEnabled() bool {
if h.svc == nil {
return false
}
return h.svc.DesktopLocalChannelsEnabled()
}
func (h platformHost) ChannelConcurrencyLimit(channelID string) (int, error) {
if h.svc == nil {
return 0, nil
}
channel, err := h.svc.repo.SystemChannel(channelID)
if err != nil {
return 0, err
}
return channel.ConcurrencyLimit, nil
}
func (s *Service) platformDomain() *platform.Service {
if s == nil {
return platform.New(nil, nil, nil)
}
if s.platform != nil {
return s.platform
}
// Tests and small embedded callers may construct Service literals instead
// of using New. Cache the lazily-created domain so its bounded read caches
// retain their intended lifetime and do not turn every read into a DB hit.
s.workerRuntimeMu.Lock()
defer s.workerRuntimeMu.Unlock()
if s.platform == nil {
s.platform = platform.New(s.repo, s.coordinator, platformHost{svc: s})
}
return s.platform
}
func (s *Service) RuntimePolicy() (RuntimePolicySetting, error) {
return s.platformDomain().RuntimePolicy()
}
func (s *Service) runtimeConcurrencySetting() (RuntimeTaskPolicy, error) {
return s.platformDomain().RuntimeConcurrencySetting()
}
func (s *Service) PublicRuntimeLimits() (*PublicRuntimeLimits, error) {
return s.platformDomain().PublicRuntimeLimits()
}
func (s *Service) AdminRuntimePolicySetting(actor *model.User) (*PublicRuntimePolicySetting, error) {
return s.platformDomain().AdminRuntimePolicySetting(actor)
}
func (s *Service) AdminSelfUseRuntimePolicy(actor *model.User) (*PublicRuntimePolicySetting, error) {
return s.platformDomain().AdminSelfUseRuntimePolicy(actor)
}
func (s *Service) UpdateRuntimePolicySetting(actor *model.User, value RuntimePolicySetting) (*PublicRuntimePolicySetting, error) {
return s.platformDomain().UpdateRuntimePolicySetting(actor, value)
}
func (s *Service) ResetRuntimePolicySetting(actor *model.User) (*PublicRuntimePolicySetting, error) {
return s.platformDomain().ResetRuntimePolicySetting(actor)
}
func (s *Service) FeatureAvailability() (*PublicFeatureAvailability, error) {
return s.platformDomain().FeatureAvailability()
}
func (s *Service) AdminFeatureAvailability(actor *model.User) (*PublicFeatureAvailability, error) {
return s.platformDomain().AdminFeatureAvailability(actor)
}
func (s *Service) UpdateFeatureAvailability(actor *model.User, value FeatureAvailability) (*PublicFeatureAvailability, error) {
return s.platformDomain().UpdateFeatureAvailability(actor, value)
}
func (s *Service) FeatureEnabled(feature string) (bool, error) {
return s.platformDomain().FeatureEnabled(feature)
}
func (s *Service) RequireFeature(feature string) error {
return s.platformDomain().RequireFeature(feature)
}
func (s *Service) AcquireChannelSlot(ctx context.Context, channelID string, fallbackScope string, ttl time.Duration) (func(), int, error) {
return s.platformDomain().AcquireChannelSlot(ctx, channelID, fallbackScope, ttl)
}
func (s *Service) AllowRequest(ctx context.Context, key string, limit int, window time.Duration) (bool, error) {
return s.platformDomain().AllowRequest(ctx, key, limit, window)
}
func (s *Service) RequestRetryAfter(ctx context.Context, key string, window time.Duration) time.Duration {
return s.platformDomain().RequestRetryAfter(ctx, key, window)
}
func (s *Service) AcquireCustomRelaySlot(ctx context.Context, userID string, limit int, ttl time.Duration) (func(), bool, error) {
return s.platformDomain().AcquireCustomRelaySlot(ctx, userID, limit, ttl)
}
func (s *Service) RecordChannelResult(ctx context.Context, channelID string, failed bool) error {
return s.platformDomain().RecordChannelResult(ctx, channelID, failed)
}
func (s *Service) Close() error {
if s == nil || s.coordinator == nil {
return nil
}
if !s.coordinator.HasRedis() {
return nil
}
return s.coordinator.Redis().Close()
}
func ChannelSlotFailureDetails(err error) (string, string) {
return platform.ChannelSlotFailureDetails(err)
}
func SanitizeAPICallPayload(data []byte, contentType string) string {
return platform.SanitizeAPICallPayload(data, contentType)
}
func optionalJSONString(payload map[string]any, key string) (string, error) {
return platform.OptionalJSONString(payload, key)
}
func requireJSONString(payload map[string]any, key string) (string, error) {
return platform.RequireJSONString(payload, key)
}
func firstJSONString(payload map[string]any, keys ...string) (string, error) {
return platform.FirstJSONString(payload, keys...)
}
func megabytes(value int64) int64 { return kernel.Megabytes(value) }
func gigabytes(value int64) int64 { return kernel.Gigabytes(value) }
func requestPayloadForLog(req *http.Request) string {
return platform.RequestPayloadForLog(req)
}
func (s *Service) ValidateRuntime() error {
if s == nil {
return nil
}
if s.pluginRuntimeErr != nil {
return s.pluginRuntimeErr
}
return s.runtimeErr
}
const (
featureAvailabilitySettingKey = "feature_availability"
runtimePolicySettingKey = "runtime_policy"
)
func defaultFeatureAvailability() FeatureAvailability {
return platform.DefaultFeatureAvailability()
}
func publicFeatureAvailability(setting *model.SystemSetting, value FeatureAvailability) *PublicFeatureAvailability {
return platform.ProjectFeatureAvailability(setting, value)
}
func (s *Service) withRuntimeCapabilities(result *PublicFeatureAvailability) *PublicFeatureAvailability {
if result != nil {
result.DesktopLocalChannelsEnabled = s.DesktopLocalChannelsEnabled()
}
return result
}
func selfUseRuntimePolicy() RuntimePolicySetting { return platform.SelfUseRuntimePolicy() }
func validateRuntimePolicy(value RuntimePolicySetting) error {
return platform.ValidateRuntimePolicy(value)
}
// Keep package-local test and legacy helpers source-compatible while the
// runtime policy implementation lives in internal/platform.
func defaultRuntimePolicy() RuntimePolicySetting {
return platform.DefaultRuntimePolicy()
}
const (
minChannelConcurrencyLimit = platform.MinChannelConcurrencyLimit
maxChannelConcurrencyLimit = platform.MaxChannelConcurrencyLimit
runtimeCoordinationTimeout = platform.CoordinationTimeout
routeCatalogVersionKey = platform.RouteCatalogVersionKey
)

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"testing"

View File

@@ -1,4 +1,4 @@
package service
package app
import "infinite-canvas/backend/internal/model"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"testing"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"errors"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"testing"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"testing"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"testing"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"infinite-canvas/backend/internal/model"

View File

@@ -1,4 +1,4 @@
package service
package app
import (
"fmt"

Some files were not shown because too many files have changed in this diff Show More