From b3199c5e7eb28cfabceb33e59f29880403f1f129 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Thu, 6 Aug 2026 16:36:51 +0800 Subject: [PATCH] =?UTF-8?q?refactor(*):=20=E4=BB=A3=E7=A0=81=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E6=B2=BB=E7=90=86=20-=20=E6=94=B6=E6=95=9B=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=AE=A2=E6=88=B7=E7=AB=AF=E3=80=81=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=81=8C=E8=B4=A3=E4=B8=8E=E5=BC=82=E6=AD=A5=E8=AE=A1=E8=B4=B9?= =?UTF-8?q?=E5=B7=A1=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/quality.yml | 48 + .gitignore | 4 + README.md | 2 +- backend/internal/handler/routes.go | 1 + backend/internal/model/models.go | 709 -------- backend/internal/model/models_channel.go | 94 + backend/internal/model/models_finance.go | 116 ++ backend/internal/model/models_identity.go | 57 + backend/internal/model/models_platform.go | 77 + backend/internal/model/models_project.go | 279 +++ backend/internal/model/models_task.go | 105 ++ backend/internal/repository/billing_review.go | 68 + backend/internal/service/billing_review.go | 44 + backend/internal/service/provider.go | 416 ++--- .../service/provider_request_types.go | 85 + .../service/provider_video_options.go | 253 +++ backend/internal/service/service.go | 108 +- .../service/service_workflow_results.go | 65 + web/package.json | 2 + web/src/components/audio-settings-panel.tsx | 10 +- .../canvas/canvas-emotion-workspace.tsx | 234 ++- .../canvas/canvas-node-prompt-panel.tsx | 183 +- .../canvas/canvas-preset-picker.tsx | 112 +- .../canvas/canvas-project-sidebar.tsx | 200 ++- web/src/components/model-picker.tsx | 143 +- .../ui/aceternity/floating-dock.tsx | 39 +- .../ui/aceternity/full-screen-loader.tsx | 9 +- .../pages/canvas/canvas-project-feedback.tsx | 89 +- web/src/pages/canvas/canvas-refresh-shell.tsx | 38 + web/src/pages/canvas/project.tsx | 1539 +++++++++-------- web/src/services/api/announcements.ts | 12 +- web/src/services/api/auth.ts | 17 +- web/src/services/api/canvas-share.ts | 12 +- web/src/services/api/projects.ts | 16 +- web/src/services/api/request.ts | 25 + web/src/services/api/resources.ts | 14 +- web/src/services/api/skills.ts | 16 +- web/src/services/api/task-center.ts | 36 +- web/src/services/api/user-data.ts | 16 +- web/src/services/api/wallet.ts | 17 +- web/src/styles/globals.css | 10 + web/test/canvas-drawing-engine.test.ts | 2 +- 42 files changed, 3051 insertions(+), 2271 deletions(-) create mode 100644 .github/workflows/quality.yml create mode 100644 backend/internal/model/models_channel.go create mode 100644 backend/internal/model/models_finance.go create mode 100644 backend/internal/model/models_identity.go create mode 100644 backend/internal/model/models_platform.go create mode 100644 backend/internal/model/models_project.go create mode 100644 backend/internal/model/models_task.go create mode 100644 backend/internal/repository/billing_review.go create mode 100644 backend/internal/service/billing_review.go create mode 100644 backend/internal/service/provider_request_types.go create mode 100644 backend/internal/service/provider_video_options.go create mode 100644 backend/internal/service/service_workflow_results.go create mode 100644 web/src/pages/canvas/canvas-refresh-shell.tsx diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 000000000..01bece97c --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,48 @@ +name: Quality checks + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + backend: + name: Backend checks + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache: true + - name: Check formatting + run: test -z "$(gofmt -l .)" + - name: Run Go tests + run: go test ./... + + web: + name: Web checks + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Check formatting + run: bun run format:check + - name: Typecheck + run: bun run typecheck + - name: Run tests + run: bun run test diff --git a/.gitignore b/.gitignore index b824d1466..73af2608a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ dist/ *.sqlite3 *.bak *.tmp +/*.drawio.bkp +/prompt-preferences-*-before.png +/prompt*-before.png +assets/prototypes/.*.drawio.bkp .settings-key .DS_Store .idea/ diff --git a/README.md b/README.md index 22756e085..13ccc3425 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@
diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index 417344da8..5e8ad75f6 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -334,6 +334,7 @@ func RegisterSessionRoutes(r *gin.RouterGroup, svc *service.Service) { r.GET("/sessions/:id", querySession) r.POST("/files", uploadFile) r.GET("/sessions/:id/results", downloadResults) + // 兼容旧客户端的废弃路由;新调用统一使用上方 REST 风格路径。 r.POST("/create_session", createSession) r.GET("/query_session/:id", querySession) r.POST("/upload_file", uploadFile) diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index ed47eab1b..3aade4cc1 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -2,8 +2,6 @@ package model import ( "time" - - "gorm.io/gorm" ) type TaskStatus string @@ -149,710 +147,3 @@ const ( WorkflowStepStatusFailed WorkflowStepStatus = "failed" WorkflowStepStatusSkipped WorkflowStepStatus = "skipped" ) - -type User struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Username string `json:"username" gorm:"uniqueIndex;size:80"` - Email string `json:"email,omitempty" gorm:"size:160"` - DisplayName string `json:"displayName" gorm:"size:80"` - Role UserRole `json:"role" gorm:"index;size:24"` - Status UserStatus `json:"status" gorm:"index;size:24"` - PasswordHash string `json:"-"` - LastLoginAt *time.Time `json:"lastLoginAt"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type AuthSession struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - TokenHash string `json:"-"` - ExpiresAt time.Time `json:"expiresAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type UserIdentity struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - Provider string `json:"provider" gorm:"size:32;uniqueIndex:idx_user_identity_provider_subject,priority:1"` - Subject string `json:"subject" gorm:"size:160;uniqueIndex:idx_user_identity_provider_subject,priority:2"` - ProviderUsername string `json:"providerUsername" gorm:"size:160"` - AvatarURL string `json:"avatarUrl"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type OAuthState struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Provider string `json:"provider" gorm:"index;size:32"` - StateHash string `json:"-" gorm:"uniqueIndex;size:64"` - CodeVerifier string `json:"-" gorm:"size:160"` - NextPath string `json:"nextPath"` - ExpiresAt time.Time `json:"expiresAt" gorm:"index"` - UsedAt *time.Time `json:"usedAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt"` -} - -type EmailVerificationCode struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Email string `json:"email" gorm:"index;size:160"` - CodeHash string `json:"-" gorm:"size:64"` - Purpose string `json:"purpose" gorm:"index;size:32"` - ExpiresAt time.Time `json:"expiresAt" gorm:"index"` - UsedAt *time.Time `json:"usedAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt" gorm:"index"` -} - -type ModelChannel struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - Scope ChannelScope `json:"scope" gorm:"index;size:24"` - Enabled bool `json:"enabled" gorm:"index"` - Name string `json:"name" gorm:"size:80"` - BaseURL string `json:"baseUrl"` - APIKey string `json:"-"` - SecretKey string `json:"-"` - APIFormat string `json:"apiFormat" gorm:"size:24"` - ConcurrencyLimit int `json:"concurrencyLimit"` - ModelsJSON string `json:"modelsJson" gorm:"type:text"` - HeadersJSON string `json:"-" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` -} - -type ChannelModel struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"` - ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"` - DisplayName string `json:"displayName" gorm:"size:160"` - Capability string `json:"capability" gorm:"size:32;index"` - Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"` - BillingMode string `json:"billingMode" gorm:"size:32"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` - InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` - OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` - CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` - PriceConfigured bool `json:"priceConfigured" gorm:"index"` - Enabled bool `json:"enabled" gorm:"index"` - PriceVersion int64 `json:"priceVersion"` - CapabilityConfigJSON string `json:"-" gorm:"type:text"` - CapabilityVersion int64 `json:"capabilityVersion"` - CapabilityConfig map[string]any `json:"capabilityConfig,omitempty" gorm:"-"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` -} - -type ApiCallLog struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_api_logs_user_created,priority:1"` - UserDisplayName string `json:"userDisplayName,omitempty" gorm:"-"` - UserAccount string `json:"userAccount,omitempty" gorm:"-"` - ChannelID string `json:"channelId" gorm:"index;size:36;index:idx_api_logs_channel_created,priority:1"` - ChannelName string `json:"channelName" gorm:"-"` - TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` - TaskStatus TaskStatus `json:"taskStatus,omitempty" gorm:"-"` - BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` - Source string `json:"source" gorm:"index;size:64"` - Capability string `json:"capability" gorm:"index;size:32"` - Operation string `json:"operation" gorm:"size:64"` - RequestKind string `json:"requestKind" gorm:"index;size:24"` - Billable bool `json:"billable" gorm:"index"` - APIFormat string `json:"apiFormat" gorm:"size:24"` - Method string `json:"method" gorm:"size:16"` - Path string `json:"path"` - Model string `json:"model" gorm:"size:120;index:idx_api_logs_model_created,priority:1"` - Status ApiCallStatus `json:"status" gorm:"index;size:24;index:idx_api_logs_status_created,priority:1"` - StatusCode int `json:"statusCode"` - DurationMs int64 `json:"durationMs"` - PollCount int `json:"pollCount"` - ProviderStatus string `json:"providerStatus,omitempty" gorm:"size:32"` - InputTokens int64 `json:"inputTokens"` - OutputTokens int64 `json:"outputTokens"` - CachedTokens int64 `json:"cachedTokens"` - UsageAvailable bool `json:"usageAvailable"` - MediaCount int `json:"mediaCount"` - MediaPreviewURL string `json:"mediaPreviewUrl,omitempty" gorm:"-"` - MediaPreviewKind string `json:"mediaPreviewKind,omitempty" gorm:"-"` - VideoSeconds int `json:"videoSeconds"` - ProviderRequestID string `json:"providerRequestId" gorm:"size:160"` - EstimatedCostMicros int64 `json:"estimatedCostMicros"` - CostAvailable bool `json:"costAvailable"` - Currency string `json:"currency" gorm:"size:12"` - ErrorCode string `json:"errorCode,omitempty" gorm:"index;size:80"` - Error string `json:"error"` - ConcurrencyLimit int `json:"concurrencyLimit"` - UpstreamURL string `json:"upstreamUrl"` - RequestContentType string `json:"requestContentType,omitempty" gorm:"size:160"` - RequestBody string `json:"requestBody,omitempty" gorm:"type:text"` - ResponseBody string `json:"responseBody,omitempty" gorm:"type:text"` - StartedAt time.Time `json:"startedAt"` - CreatedAt time.Time `json:"createdAt" gorm:"index;index:idx_api_logs_user_created,priority:2;index:idx_api_logs_channel_created,priority:2;index:idx_api_logs_model_created,priority:2;index:idx_api_logs_status_created,priority:2"` -} - -type CreditAccount struct { - UserID string `json:"userId" gorm:"primaryKey;size:36"` - AvailableMicrocredits int64 `json:"availableMicrocredits"` - ReservedMicrocredits int64 `json:"reservedMicrocredits"` - Version int64 `json:"version"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type CreditLedgerEntry struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"size:36;index;index:idx_credit_ledger_user_created,priority:1"` - Type CreditLedgerType `json:"type" gorm:"size:32;index"` - AmountMicrocredits int64 `json:"amountMicrocredits"` - AvailableDeltaMicrocredits int64 `json:"availableDeltaMicrocredits"` - ReservedDeltaMicrocredits int64 `json:"reservedDeltaMicrocredits"` - AvailableAfterMicrocredits int64 `json:"availableAfterMicrocredits"` - ReservedAfterMicrocredits int64 `json:"reservedAfterMicrocredits"` - BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` - RedeemCodeID string `json:"redeemCodeId,omitempty" gorm:"index;size:36"` - ActorUserID string `json:"actorUserId,omitempty" gorm:"index;size:36"` - Model string `json:"model,omitempty" gorm:"size:120;index"` - ChannelID string `json:"channelId,omitempty" gorm:"size:36;index"` - Scene string `json:"scene,omitempty" gorm:"size:80;index"` - Note string `json:"note,omitempty" gorm:"size:500"` - ReferenceKey *string `json:"referenceKey,omitempty" gorm:"size:180;uniqueIndex"` - CreatedAt time.Time `json:"createdAt" gorm:"index:idx_credit_ledger_user_created,priority:2"` -} - -type BillingOrder struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_billing_user_idempotency,priority:1"` - IdempotencyKey string `json:"idempotencyKey" gorm:"size:160;uniqueIndex:idx_billing_user_idempotency,priority:2"` - TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` - ChannelID string `json:"channelId" gorm:"index;size:36"` - ChannelModelID string `json:"channelModelId" gorm:"index;size:36"` - Model string `json:"model" gorm:"index;size:120"` - Capability string `json:"capability" gorm:"index;size:32"` - Scene string `json:"scene" gorm:"index;size:80"` - BillingMode string `json:"billingMode" gorm:"size:32"` - PriceVersion int64 `json:"priceVersion"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` - MultiplierBasisPoints int64 `json:"multiplierBasisPoints"` - Quantity int64 `json:"quantity"` - AmountMicrocredits int64 `json:"amountMicrocredits"` - ReservedAmountMicrocredits int64 `json:"reservedAmountMicrocredits"` - ActualAmountMicrocredits int64 `json:"actualAmountMicrocredits"` - RefundedAmountMicrocredits int64 `json:"refundedAmountMicrocredits"` - InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` - OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` - CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` - InputTokens int64 `json:"inputTokens"` - OutputTokens int64 `json:"outputTokens"` - CachedTokens int64 `json:"cachedTokens"` - UsageAvailable bool `json:"usageAvailable"` - Status BillingStatus `json:"status" gorm:"index;size:24"` - ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` - Error string `json:"error,omitempty" gorm:"size:1000"` - ResolvedBy string `json:"resolvedBy,omitempty" gorm:"index;size:36"` - ResolutionNote string `json:"resolutionNote,omitempty" gorm:"size:500"` - StartedAt *time.Time `json:"startedAt"` - SettledAt *time.Time `json:"settledAt"` - RefundedAt *time.Time `json:"refundedAt"` - CreatedAt time.Time `json:"createdAt" gorm:"index"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type RedeemBatch struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - AmountMicrocredits int64 `json:"amountMicrocredits"` - Count int `json:"count"` - Note string `json:"note" gorm:"size:500"` - CreatedBy string `json:"createdBy" gorm:"index;size:36"` - CodesCipher string `json:"-" gorm:"type:text"` - ExpiresAt *time.Time `json:"expiresAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt" gorm:"index"` - AvailableCount int64 `json:"availableCount" gorm:"->;-:migration"` - RedeemedCount int64 `json:"redeemedCount" gorm:"->;-:migration"` - DisabledCount int64 `json:"disabledCount" gorm:"->;-:migration"` - ExpiredCount int64 `json:"expiredCount" gorm:"->;-:migration"` -} - -type RedeemCode struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - BatchID string `json:"batchId" gorm:"index;size:36;index:idx_redeem_codes_batch_status,priority:1;index:idx_redeem_codes_batch_created,priority:1"` - CodeHash string `json:"-" gorm:"uniqueIndex;size:64"` - CodeSuffix string `json:"codeSuffix" gorm:"size:4"` - AmountMicrocredits int64 `json:"amountMicrocredits"` - Status RedeemCodeStatus `json:"status" gorm:"index;size:24;index:idx_redeem_codes_batch_status,priority:2"` - RedeemedBy string `json:"redeemedBy,omitempty" gorm:"index;size:36"` - RedeemedAt *time.Time `json:"redeemedAt"` - RedeemedIP string `json:"redeemedIp,omitempty" gorm:"size:64"` - ExpiresAt *time.Time `json:"expiresAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt" gorm:"index:idx_redeem_codes_batch_created,priority:2"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type ModelPricing struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ChannelID string `json:"channelId" gorm:"size:36;uniqueIndex:idx_model_pricing_scope,priority:1"` - Model string `json:"model" gorm:"size:120;uniqueIndex:idx_model_pricing_scope,priority:2"` - Capability string `json:"capability" gorm:"size:32;uniqueIndex:idx_model_pricing_scope,priority:3"` - Currency string `json:"currency" gorm:"size:12"` - InputPerMillionMicros int64 `json:"inputPerMillionMicros"` - OutputPerMillionMicros int64 `json:"outputPerMillionMicros"` - CachedPerMillionMicros int64 `json:"cachedPerMillionMicros"` - PerRequestMicros int64 `json:"perRequestMicros"` - PerMediaMicros int64 `json:"perMediaMicros"` - PerVideoSecondMicros int64 `json:"perVideoSecondMicros"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type UserDailyActivity struct { - ID string `json:"id" gorm:"primaryKey;size:64"` - Day time.Time `json:"day" gorm:"type:date;uniqueIndex:idx_user_daily_activity_day_user,priority:1;index"` - UserID string `json:"userId" gorm:"size:36;uniqueIndex:idx_user_daily_activity_day_user,priority:2;index"` - FirstActiveAt *time.Time `json:"firstActiveAt"` - LastActiveAt *time.Time `json:"lastActiveAt"` - LoginCount int `json:"loginCount"` - TaskCount int `json:"taskCount"` - AgentMessageCount int `json:"agentMessageCount"` - CanvasActive bool `json:"canvasActive"` - AssetCount int `json:"assetCount"` - ResourceCount int `json:"resourceCount"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type SystemSetting struct { - Key string `json:"key" gorm:"primaryKey;size:80"` - ValueJSON string `json:"valueJson" gorm:"type:text"` - UpdatedBy string `json:"updatedBy" gorm:"index;size:36"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type UserOSSSetting struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_user_oss_settings_user_created,priority:1"` - Enabled bool `json:"enabled" gorm:"index"` - ValueJSON string `json:"-" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt" gorm:"index:idx_user_oss_settings_user_created,priority:2"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type UserDailyUploadUsage struct { - ID string `json:"id" gorm:"primaryKey;size:64"` - UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_user_daily_upload_day,priority:1"` - Day string `json:"day" gorm:"size:10;index;uniqueIndex:idx_user_daily_upload_day,priority:2"` - Bytes int64 `json:"bytes"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Skill struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - OwnerID string `json:"ownerId" gorm:"index;size:36"` - AuthorName string `json:"authorName" gorm:"size:120;index"` - AuthorAvatarURL string `json:"authorAvatarUrl" gorm:"size:1000"` - Name string `json:"name" gorm:"size:80;index"` - Description string `json:"description" gorm:"size:500"` - Instruction string `json:"instruction" gorm:"type:text"` - Status int `json:"status" gorm:"index"` - Source int `json:"source" gorm:"index"` - Tag string `json:"tag" gorm:"size:32;index"` - SortWeight int `json:"sortWeight" gorm:"index"` - IsPrivate bool `json:"isPrivate" gorm:"index"` - MarkdownURL string `json:"markdownUrl" gorm:"size:500"` - ShowcaseMediaJSON string `json:"-" gorm:"type:text"` - ExtraInfo string `json:"extraInfo" gorm:"type:text"` - InitialLikeCount int64 `json:"initialLikeCount"` - InitialAddedCount int64 `json:"initialAddedCount"` - CreatedAt time.Time `json:"createdAt" gorm:"index"` - UpdatedAt time.Time `json:"updatedAt" gorm:"index"` -} - -type UserSkillState struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_user_skill_state_user_skill,priority:1"` - SkillID string `json:"skillId" gorm:"size:36;index;uniqueIndex:idx_user_skill_state_user_skill,priority:2"` - Added bool `json:"added" gorm:"index"` - Liked bool `json:"liked" gorm:"index"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Resource struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_resources_user_created,priority:1"` - Kind string `json:"kind" gorm:"index;size:24"` - Status ResourceStatus `json:"status" gorm:"index;size:24"` - Provider string `json:"provider" gorm:"size:24"` - Endpoint string `json:"endpoint"` - Bucket string `json:"bucket" gorm:"size:160"` - // 用户 OSS 每次修改都会生成新版本,资源固定引用创建时的版本,避免历史资源因换密钥失效。 - StorageSettingID string `json:"-" gorm:"index;size:36"` - ObjectKey string `json:"objectKey" gorm:"index"` - PublicURL string `json:"publicUrl"` - MimeType string `json:"mimeType" gorm:"size:120"` - Size int64 `json:"size"` - Width int `json:"width"` - Height int `json:"height"` - DurationMs int64 `json:"durationMs"` - ETag string `json:"etag" gorm:"size:160"` - Error string `json:"error"` - CreatedAt time.Time `json:"createdAt" gorm:"index:idx_resources_user_created,priority:2"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Asset struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_assets_user_updated,priority:1"` - Kind string `json:"kind" gorm:"index;size:24"` - Category AssetCategory `json:"category" gorm:"index;size:32"` - Status AssetVersionStatus `json:"status" gorm:"index;size:24"` - PrimaryVersionID string `json:"primaryVersionId,omitempty" gorm:"index;size:36"` - Title string `json:"title" gorm:"size:240"` - PayloadJSON string `json:"payloadJson" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_assets_user_updated,priority:2"` -} - -type ProjectAssetLink struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1"` - AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:2"` - CreatedAt time.Time `json:"createdAt"` -} - -type ProjectAssetCandidate struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36"` - UnitID string `json:"unitId,omitempty" gorm:"index;size:36"` - ShotID string `json:"shotId,omitempty" gorm:"index;size:36"` - Name string `json:"name" gorm:"size:240"` - Category AssetCategory `json:"category" gorm:"index;size:32"` - Status string `json:"status" gorm:"index;size:32"` - DetailsJSON string `json:"detailsJson" gorm:"type:text"` - ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:36"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type AssetVersion struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_asset_versions_number,priority:1"` - Version int `json:"version" gorm:"uniqueIndex:idx_asset_versions_number,priority:2"` - Status AssetVersionStatus `json:"status" gorm:"index;size:24"` - DefinitionJSON string `json:"definitionJson" gorm:"type:text"` - Prompt string `json:"prompt" gorm:"type:text"` - Note string `json:"note" gorm:"size:500"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type AssetRepresentation struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - TaskID string `json:"taskId,omitempty" gorm:"index;size:36;uniqueIndex:idx_asset_representations_task_role,priority:1"` - AssetVersionID string `json:"assetVersionId" gorm:"index;size:36;uniqueIndex:idx_asset_representations_version_role,priority:1"` - ResourceID string `json:"resourceId,omitempty" gorm:"index;size:36"` - MediaType string `json:"mediaType" gorm:"index;size:24"` - Role string `json:"role" gorm:"index;size:32;uniqueIndex:idx_asset_representations_task_role,priority:2;uniqueIndex:idx_asset_representations_version_role,priority:2"` - MetadataJSON string `json:"metadataJson" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` -} - -// VoiceProfile 是可复用的声音身份;试听音频只是表现资源,不等同于声音本身。 -type VoiceProfile struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_voice_profiles_user_provider_key,priority:1"` - Name string `json:"name" gorm:"size:160"` - Provider string `json:"provider" gorm:"size:48;uniqueIndex:idx_voice_profiles_user_provider_key,priority:2"` - VoiceKey string `json:"voiceKey" gorm:"size:160;uniqueIndex:idx_voice_profiles_user_provider_key,priority:3"` - Language string `json:"language" gorm:"size:80"` - Timbre string `json:"timbre" gorm:"size:240"` - SampleResourceID string `json:"sampleResourceId,omitempty" gorm:"index;size:36"` - CompatibleModelsJSON string `json:"compatibleModelsJson" gorm:"type:text"` - Status string `json:"status" gorm:"index;size:24"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type CharacterVoiceBinding struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - AssetVersionID string `json:"assetVersionId" gorm:"uniqueIndex;size:36"` - VoiceProfileID string `json:"voiceProfileId" gorm:"index;size:36"` - Instructions string `json:"instructions" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// Project 是短剧领域聚合根;CanvasProject 仍代表可独立创作的画布文档。 -type Project struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_projects_user_name,priority:1"` - Name string `json:"name" gorm:"size:240;uniqueIndex:idx_projects_user_name,priority:2"` - Type string `json:"type" gorm:"size:32;index"` - AspectRatio string `json:"aspectRatio" gorm:"size:16"` - SourceType string `json:"sourceType" gorm:"size:32"` - Description string `json:"description" gorm:"type:text"` - StylePresetID string `json:"stylePresetId" gorm:"size:64"` - Status ProjectStatus `json:"status" gorm:"index;size:24"` - Revision int64 `json:"revision"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt" gorm:"index"` -} - -type ProjectUnit struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36"` - ParentID string `json:"parentId,omitempty" gorm:"index;size:36"` - Kind ProjectUnitKind `json:"kind" gorm:"index;size:24"` - Title string `json:"title" gorm:"size:240"` - SourceText string `json:"sourceText" gorm:"type:text"` - Status ProjectUnitStatus `json:"status" gorm:"index;size:24"` - Position int `json:"position"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type CanvasUnitLink struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:1"` - CanvasID string `json:"canvasId" gorm:"index;size:80;uniqueIndex:idx_canvas_unit_links_unique,priority:2"` - UnitID string `json:"unitId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:3"` - Role string `json:"role" gorm:"size:32"` - CreatedAt time.Time `json:"createdAt"` -} - -type Shot struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36"` - UnitID string `json:"unitId" gorm:"index;size:36"` - Title string `json:"title" gorm:"size:240"` - Description string `json:"description" gorm:"type:text"` - Position int `json:"position"` - DurationMs int64 `json:"durationMs"` - Status string `json:"status" gorm:"index;size:24"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type ShotAssetReference struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ShotID string `json:"shotId" gorm:"index;size:36;uniqueIndex:idx_shot_asset_reference_unique,priority:1"` - AssetVersionID string `json:"assetVersionId" gorm:"index;size:36;uniqueIndex:idx_shot_asset_reference_unique,priority:2"` - Role string `json:"role" gorm:"index;size:32;uniqueIndex:idx_shot_asset_reference_unique,priority:3"` - Status string `json:"status" gorm:"index;size:24"` - CreatedAt time.Time `json:"createdAt"` -} - -type WorkflowTemplateVersion struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - TemplateKey string `json:"templateKey" gorm:"size:80;uniqueIndex:idx_workflow_template_version,priority:1"` - Name string `json:"name" gorm:"size:160"` - Version int `json:"version" gorm:"uniqueIndex:idx_workflow_template_version,priority:2"` - DefinitionJSON string `json:"definitionJson" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` -} - -type WorkflowInstance struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:1"` - UnitID string `json:"unitId,omitempty" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:2"` - TemplateVersionID string `json:"templateVersionId" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:3"` - Scope string `json:"scope" gorm:"index;size:24"` - Status WorkflowStatus `json:"status" gorm:"index;size:24"` - Revision int64 `json:"revision"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type WorkflowStepInstance struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - WorkflowInstanceID string `json:"workflowInstanceId" gorm:"index;size:36;uniqueIndex:idx_workflow_steps_instance_key,priority:1"` - StepKey string `json:"stepKey" gorm:"size:80;uniqueIndex:idx_workflow_steps_instance_key,priority:2"` - Name string `json:"name" gorm:"size:160"` - Position int `json:"position"` - Status WorkflowStepStatus `json:"status" gorm:"index;size:24"` - InputJSON string `json:"inputJson" gorm:"type:text"` - OutputJSON string `json:"outputJson" gorm:"type:text"` - Error string `json:"error" gorm:"type:text"` - StartedAt *time.Time `json:"startedAt"` - CompletedAt *time.Time `json:"completedAt"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type WorkflowStepTask struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - WorkflowStepID string `json:"workflowStepId" gorm:"index;size:36;uniqueIndex:idx_workflow_step_tasks_unique,priority:1"` - TaskID string `json:"taskId" gorm:"index;size:36;uniqueIndex:idx_workflow_step_tasks_unique,priority:2"` - CreatedAt time.Time `json:"createdAt"` -} - -type CanvasProject struct { - ID string `json:"id" gorm:"primaryKey;size:80"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_canvas_projects_user_updated,priority:1"` - ProjectID string `json:"projectId,omitempty" gorm:"index;size:36"` - Title string `json:"title" gorm:"size:240"` - PayloadJSON string `json:"payloadJson" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_canvas_projects_user_updated,priority:2"` -} - -type CanvasShare struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_canvas_share_owner_project,priority:1"` - ProjectID string `json:"projectId" gorm:"index;size:80;uniqueIndex:idx_canvas_share_owner_project,priority:2"` - TokenHash string `json:"-" gorm:"uniqueIndex;size:64"` - TokenCipher string `json:"-" gorm:"type:text"` - Enabled bool `json:"enabled" gorm:"index"` - ExpiresAt *time.Time `json:"expiresAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type PromptTemplate struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Operation string `json:"operation" gorm:"size:64;index;uniqueIndex:idx_prompt_template_operation_version,priority:1"` - Name string `json:"name" gorm:"size:120"` - Version int `json:"version" gorm:"uniqueIndex:idx_prompt_template_operation_version,priority:2"` - Content string `json:"content" gorm:"type:text"` - OutputType string `json:"outputType" gorm:"size:24"` - Enabled bool `json:"enabled" gorm:"index;index:idx_prompt_template_active,priority:2"` - CreatedBy string `json:"createdBy" gorm:"index;size:36"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// UserPromptCustomization 只保存用户的创作策略层,动态上下文和输出契约始终由服务端编译器注入。 -type UserPromptCustomization struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_user_prompt_operation,priority:1"` - Operation string `json:"operation" gorm:"size:64;index;uniqueIndex:idx_user_prompt_operation,priority:2"` - Mode string `json:"mode" gorm:"size:24"` - Content string `json:"content" gorm:"type:text"` - BaseTemplateID string `json:"baseTemplateId" gorm:"size:36;index"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Announcement struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Title string `json:"title" gorm:"size:120"` - Content string `json:"content" gorm:"type:text"` - Level AnnouncementLevel `json:"level" gorm:"index;size:24"` - Status AnnouncementStatus `json:"status" gorm:"index;size:24;index:idx_announcements_status_published,priority:1"` - CreatedBy string `json:"createdBy" gorm:"index;size:36"` - PublishedAt time.Time `json:"publishedAt" gorm:"index:idx_announcements_status_published,priority:2"` - ClosedAt *time.Time `json:"closedAt"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type UserAnnouncementRead struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_user_announcement_read,priority:1"` - AnnouncementID string `json:"announcementId" gorm:"index;size:36;uniqueIndex:idx_user_announcement_read,priority:2"` - ReadAt time.Time `json:"readAt"` -} - -type Task struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_tasks_user_created,priority:1"` - SessionID string `json:"sessionId" gorm:"index;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:80"` - Type string `json:"type" gorm:"index;size:64"` - Status TaskStatus `json:"status" gorm:"index;size:24;index:idx_tasks_status_created,priority:1;index:idx_tasks_claim,priority:1;index:idx_tasks_provider_cancel,priority:1"` - Stage string `json:"stage" gorm:"size:80"` - Progress int `json:"progress"` - Prompt string `json:"prompt"` - Operation string `json:"operation" gorm:"size:64"` - Provider string `json:"provider" gorm:"size:64"` - Model string `json:"model" gorm:"size:120"` - BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` - ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` - ProviderCancelStatus ProviderCancelStatus `json:"providerCancelStatus,omitempty" gorm:"index;size:24;index:idx_tasks_provider_cancel,priority:2"` - ProviderCancelError string `json:"providerCancelError,omitempty" gorm:"type:text"` - ProviderCancelAttempts int `json:"providerCancelAttempts,omitempty"` - ProviderCancelRequestedAt *time.Time `json:"providerCancelRequestedAt,omitempty"` - ProviderCancelledAt *time.Time `json:"providerCancelledAt,omitempty"` - ProviderCancelNextCheckAt *time.Time `json:"providerCancelNextCheckAt,omitempty" gorm:"index:idx_tasks_provider_cancel,priority:3"` - PollStage string `json:"pollStage,omitempty" gorm:"size:32"` - NextPollAt *time.Time `json:"nextPollAt,omitempty" gorm:"index"` - LeaseOwner string `json:"-" gorm:"index;size:120"` - LeaseExpiresAt *time.Time `json:"-" gorm:"index;index:idx_tasks_claim,priority:2"` - InputJSON string `json:"inputJson" gorm:"type:text"` - ResultJSON string `json:"resultJson" gorm:"type:text"` - TextDraft string `json:"textDraft,omitempty" gorm:"type:text"` - Error string `json:"error"` - Attempts int `json:"attempts"` - StartedAt *time.Time `json:"startedAt"` - CompletedAt *time.Time `json:"completedAt"` - CreatedAt time.Time `json:"createdAt" gorm:"index:idx_tasks_user_created,priority:2;index:idx_tasks_status_created,priority:2;index:idx_tasks_claim,priority:3"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// TaskTextDelta 只保存可回放窗口内的文本增量;最终正文和失败草稿分别归并到 Task.ResultJSON 与 Task.TextDraft。 -type TaskTextDelta struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_task_text_deltas_user_created,priority:1"` - TaskID string `json:"taskId" gorm:"index;size:36;uniqueIndex:idx_task_text_deltas_sequence,priority:1"` - Sequence int64 `json:"sequence" gorm:"uniqueIndex:idx_task_text_deltas_sequence,priority:2"` - Content string `json:"content" gorm:"type:text"` - ByteCount int64 `json:"byteCount"` - CreatedAt time.Time `json:"createdAt" gorm:"index:idx_task_text_deltas_user_created,priority:2"` - ExpiresAt time.Time `json:"expiresAt" gorm:"index"` -} - -type Session struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:80"` - Status SessionStatus `json:"status" gorm:"index;size:24"` - Prompt string `json:"prompt"` - CanvasSnapshotJSON string `json:"canvasSnapshotJson" gorm:"type:text"` - CanvasOpsJSON string `json:"canvasOpsJson" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Message struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - SessionID string `json:"sessionId" gorm:"index;size:36"` - Role string `json:"role" gorm:"size:24"` - Content string `json:"content"` - Payload string `json:"payload" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` -} - -type TaskLog struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - TaskID string `json:"taskId" gorm:"index;size:36"` - Level string `json:"level" gorm:"size:24"` - Message string `json:"message"` - Payload string `json:"payload" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` -} - -type SessionFile struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - SessionID string `json:"sessionId" gorm:"index;size:36"` - FileName string `json:"fileName"` - MimeType string `json:"mimeType"` - Path string `json:"-"` - Size int64 `json:"size"` - CreatedAt time.Time `json:"createdAt"` -} - -type Result struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - TaskID string `json:"taskId" gorm:"index;size:36"` - SessionID string `json:"sessionId" gorm:"index;size:36"` - Kind string `json:"kind" gorm:"size:64"` - URL string `json:"url"` - Payload string `json:"payload" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` -} diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go new file mode 100644 index 000000000..ad5f71507 --- /dev/null +++ b/backend/internal/model/models_channel.go @@ -0,0 +1,94 @@ +package model + +import ( + "gorm.io/gorm" + "time" +) + +type ModelChannel struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + Scope ChannelScope `json:"scope" gorm:"index;size:24"` + Enabled bool `json:"enabled" gorm:"index"` + Name string `json:"name" gorm:"size:80"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"-"` + SecretKey string `json:"-"` + APIFormat string `json:"apiFormat" gorm:"size:24"` + ConcurrencyLimit int `json:"concurrencyLimit"` + ModelsJSON string `json:"modelsJson" gorm:"type:text"` + HeadersJSON string `json:"-" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +type ChannelModel struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"` + ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"` + DisplayName string `json:"displayName" gorm:"size:160"` + Capability string `json:"capability" gorm:"size:32;index"` + Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"` + BillingMode string `json:"billingMode" gorm:"size:32"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + PriceConfigured bool `json:"priceConfigured" gorm:"index"` + Enabled bool `json:"enabled" gorm:"index"` + PriceVersion int64 `json:"priceVersion"` + CapabilityConfigJSON string `json:"-" gorm:"type:text"` + CapabilityVersion int64 `json:"capabilityVersion"` + CapabilityConfig map[string]any `json:"capabilityConfig,omitempty" gorm:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +type ApiCallLog struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_api_logs_user_created,priority:1"` + UserDisplayName string `json:"userDisplayName,omitempty" gorm:"-"` + UserAccount string `json:"userAccount,omitempty" gorm:"-"` + ChannelID string `json:"channelId" gorm:"index;size:36;index:idx_api_logs_channel_created,priority:1"` + ChannelName string `json:"channelName" gorm:"-"` + TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` + TaskStatus TaskStatus `json:"taskStatus,omitempty" gorm:"-"` + BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` + Source string `json:"source" gorm:"index;size:64"` + Capability string `json:"capability" gorm:"index;size:32"` + Operation string `json:"operation" gorm:"size:64"` + RequestKind string `json:"requestKind" gorm:"index;size:24"` + Billable bool `json:"billable" gorm:"index"` + APIFormat string `json:"apiFormat" gorm:"size:24"` + Method string `json:"method" gorm:"size:16"` + Path string `json:"path"` + Model string `json:"model" gorm:"size:120;index:idx_api_logs_model_created,priority:1"` + Status ApiCallStatus `json:"status" gorm:"index;size:24;index:idx_api_logs_status_created,priority:1"` + StatusCode int `json:"statusCode"` + DurationMs int64 `json:"durationMs"` + PollCount int `json:"pollCount"` + ProviderStatus string `json:"providerStatus,omitempty" gorm:"size:32"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + CachedTokens int64 `json:"cachedTokens"` + UsageAvailable bool `json:"usageAvailable"` + MediaCount int `json:"mediaCount"` + MediaPreviewURL string `json:"mediaPreviewUrl,omitempty" gorm:"-"` + MediaPreviewKind string `json:"mediaPreviewKind,omitempty" gorm:"-"` + VideoSeconds int `json:"videoSeconds"` + ProviderRequestID string `json:"providerRequestId" gorm:"size:160"` + EstimatedCostMicros int64 `json:"estimatedCostMicros"` + CostAvailable bool `json:"costAvailable"` + Currency string `json:"currency" gorm:"size:12"` + ErrorCode string `json:"errorCode,omitempty" gorm:"index;size:80"` + Error string `json:"error"` + ConcurrencyLimit int `json:"concurrencyLimit"` + UpstreamURL string `json:"upstreamUrl"` + RequestContentType string `json:"requestContentType,omitempty" gorm:"size:160"` + RequestBody string `json:"requestBody,omitempty" gorm:"type:text"` + ResponseBody string `json:"responseBody,omitempty" gorm:"type:text"` + StartedAt time.Time `json:"startedAt"` + CreatedAt time.Time `json:"createdAt" gorm:"index;index:idx_api_logs_user_created,priority:2;index:idx_api_logs_channel_created,priority:2;index:idx_api_logs_model_created,priority:2;index:idx_api_logs_status_created,priority:2"` +} diff --git a/backend/internal/model/models_finance.go b/backend/internal/model/models_finance.go new file mode 100644 index 000000000..54db099c6 --- /dev/null +++ b/backend/internal/model/models_finance.go @@ -0,0 +1,116 @@ +package model + +import "time" + +type CreditAccount struct { + UserID string `json:"userId" gorm:"primaryKey;size:36"` + AvailableMicrocredits int64 `json:"availableMicrocredits"` + ReservedMicrocredits int64 `json:"reservedMicrocredits"` + Version int64 `json:"version"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CreditLedgerEntry struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"size:36;index;index:idx_credit_ledger_user_created,priority:1"` + Type CreditLedgerType `json:"type" gorm:"size:32;index"` + AmountMicrocredits int64 `json:"amountMicrocredits"` + AvailableDeltaMicrocredits int64 `json:"availableDeltaMicrocredits"` + ReservedDeltaMicrocredits int64 `json:"reservedDeltaMicrocredits"` + AvailableAfterMicrocredits int64 `json:"availableAfterMicrocredits"` + ReservedAfterMicrocredits int64 `json:"reservedAfterMicrocredits"` + BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` + RedeemCodeID string `json:"redeemCodeId,omitempty" gorm:"index;size:36"` + ActorUserID string `json:"actorUserId,omitempty" gorm:"index;size:36"` + Model string `json:"model,omitempty" gorm:"size:120;index"` + ChannelID string `json:"channelId,omitempty" gorm:"size:36;index"` + Scene string `json:"scene,omitempty" gorm:"size:80;index"` + Note string `json:"note,omitempty" gorm:"size:500"` + ReferenceKey *string `json:"referenceKey,omitempty" gorm:"size:180;uniqueIndex"` + CreatedAt time.Time `json:"createdAt" gorm:"index:idx_credit_ledger_user_created,priority:2"` +} + +type BillingOrder struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_billing_user_idempotency,priority:1"` + IdempotencyKey string `json:"idempotencyKey" gorm:"size:160;uniqueIndex:idx_billing_user_idempotency,priority:2"` + TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` + ChannelID string `json:"channelId" gorm:"index;size:36"` + ChannelModelID string `json:"channelModelId" gorm:"index;size:36"` + Model string `json:"model" gorm:"index;size:120"` + Capability string `json:"capability" gorm:"index;size:32"` + Scene string `json:"scene" gorm:"index;size:80"` + BillingMode string `json:"billingMode" gorm:"size:32"` + PriceVersion int64 `json:"priceVersion"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + MultiplierBasisPoints int64 `json:"multiplierBasisPoints"` + Quantity int64 `json:"quantity"` + AmountMicrocredits int64 `json:"amountMicrocredits"` + ReservedAmountMicrocredits int64 `json:"reservedAmountMicrocredits"` + ActualAmountMicrocredits int64 `json:"actualAmountMicrocredits"` + RefundedAmountMicrocredits int64 `json:"refundedAmountMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + CachedTokens int64 `json:"cachedTokens"` + UsageAvailable bool `json:"usageAvailable"` + Status BillingStatus `json:"status" gorm:"index;size:24"` + ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` + Error string `json:"error,omitempty" gorm:"size:1000"` + ResolvedBy string `json:"resolvedBy,omitempty" gorm:"index;size:36"` + ResolutionNote string `json:"resolutionNote,omitempty" gorm:"size:500"` + StartedAt *time.Time `json:"startedAt"` + SettledAt *time.Time `json:"settledAt"` + RefundedAt *time.Time `json:"refundedAt"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type RedeemBatch struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + AmountMicrocredits int64 `json:"amountMicrocredits"` + Count int `json:"count"` + Note string `json:"note" gorm:"size:500"` + CreatedBy string `json:"createdBy" gorm:"index;size:36"` + CodesCipher string `json:"-" gorm:"type:text"` + ExpiresAt *time.Time `json:"expiresAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + AvailableCount int64 `json:"availableCount" gorm:"->;-:migration"` + RedeemedCount int64 `json:"redeemedCount" gorm:"->;-:migration"` + DisabledCount int64 `json:"disabledCount" gorm:"->;-:migration"` + ExpiredCount int64 `json:"expiredCount" gorm:"->;-:migration"` +} + +type RedeemCode struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + BatchID string `json:"batchId" gorm:"index;size:36;index:idx_redeem_codes_batch_status,priority:1;index:idx_redeem_codes_batch_created,priority:1"` + CodeHash string `json:"-" gorm:"uniqueIndex;size:64"` + CodeSuffix string `json:"codeSuffix" gorm:"size:4"` + AmountMicrocredits int64 `json:"amountMicrocredits"` + Status RedeemCodeStatus `json:"status" gorm:"index;size:24;index:idx_redeem_codes_batch_status,priority:2"` + RedeemedBy string `json:"redeemedBy,omitempty" gorm:"index;size:36"` + RedeemedAt *time.Time `json:"redeemedAt"` + RedeemedIP string `json:"redeemedIp,omitempty" gorm:"size:64"` + ExpiresAt *time.Time `json:"expiresAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt" gorm:"index:idx_redeem_codes_batch_created,priority:2"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ModelPricing struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ChannelID string `json:"channelId" gorm:"size:36;uniqueIndex:idx_model_pricing_scope,priority:1"` + Model string `json:"model" gorm:"size:120;uniqueIndex:idx_model_pricing_scope,priority:2"` + Capability string `json:"capability" gorm:"size:32;uniqueIndex:idx_model_pricing_scope,priority:3"` + Currency string `json:"currency" gorm:"size:12"` + InputPerMillionMicros int64 `json:"inputPerMillionMicros"` + OutputPerMillionMicros int64 `json:"outputPerMillionMicros"` + CachedPerMillionMicros int64 `json:"cachedPerMillionMicros"` + PerRequestMicros int64 `json:"perRequestMicros"` + PerMediaMicros int64 `json:"perMediaMicros"` + PerVideoSecondMicros int64 `json:"perVideoSecondMicros"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/model/models_identity.go b/backend/internal/model/models_identity.go new file mode 100644 index 000000000..249f80695 --- /dev/null +++ b/backend/internal/model/models_identity.go @@ -0,0 +1,57 @@ +package model + +import "time" + +type User struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + Username string `json:"username" gorm:"uniqueIndex;size:80"` + Email string `json:"email,omitempty" gorm:"size:160"` + DisplayName string `json:"displayName" gorm:"size:80"` + Role UserRole `json:"role" gorm:"index;size:24"` + Status UserStatus `json:"status" gorm:"index;size:24"` + PasswordHash string `json:"-"` + LastLoginAt *time.Time `json:"lastLoginAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type AuthSession struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + TokenHash string `json:"-"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserIdentity struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + Provider string `json:"provider" gorm:"size:32;uniqueIndex:idx_user_identity_provider_subject,priority:1"` + Subject string `json:"subject" gorm:"size:160;uniqueIndex:idx_user_identity_provider_subject,priority:2"` + ProviderUsername string `json:"providerUsername" gorm:"size:160"` + AvatarURL string `json:"avatarUrl"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type OAuthState struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + Provider string `json:"provider" gorm:"index;size:32"` + StateHash string `json:"-" gorm:"uniqueIndex;size:64"` + CodeVerifier string `json:"-" gorm:"size:160"` + NextPath string `json:"nextPath"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index"` + UsedAt *time.Time `json:"usedAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` +} + +type EmailVerificationCode struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + Email string `json:"email" gorm:"index;size:160"` + CodeHash string `json:"-" gorm:"size:64"` + Purpose string `json:"purpose" gorm:"index;size:32"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index"` + UsedAt *time.Time `json:"usedAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` +} diff --git a/backend/internal/model/models_platform.go b/backend/internal/model/models_platform.go new file mode 100644 index 000000000..858b83090 --- /dev/null +++ b/backend/internal/model/models_platform.go @@ -0,0 +1,77 @@ +package model + +import "time" + +type UserDailyActivity struct { + ID string `json:"id" gorm:"primaryKey;size:64"` + Day time.Time `json:"day" gorm:"type:date;uniqueIndex:idx_user_daily_activity_day_user,priority:1;index"` + UserID string `json:"userId" gorm:"size:36;uniqueIndex:idx_user_daily_activity_day_user,priority:2;index"` + FirstActiveAt *time.Time `json:"firstActiveAt"` + LastActiveAt *time.Time `json:"lastActiveAt"` + LoginCount int `json:"loginCount"` + TaskCount int `json:"taskCount"` + AgentMessageCount int `json:"agentMessageCount"` + CanvasActive bool `json:"canvasActive"` + AssetCount int `json:"assetCount"` + ResourceCount int `json:"resourceCount"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type SystemSetting struct { + Key string `json:"key" gorm:"primaryKey;size:80"` + ValueJSON string `json:"valueJson" gorm:"type:text"` + UpdatedBy string `json:"updatedBy" gorm:"index;size:36"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserOSSSetting struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_user_oss_settings_user_created,priority:1"` + Enabled bool `json:"enabled" gorm:"index"` + ValueJSON string `json:"-" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt" gorm:"index:idx_user_oss_settings_user_created,priority:2"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserDailyUploadUsage struct { + ID string `json:"id" gorm:"primaryKey;size:64"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_user_daily_upload_day,priority:1"` + Day string `json:"day" gorm:"size:10;index;uniqueIndex:idx_user_daily_upload_day,priority:2"` + Bytes int64 `json:"bytes"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Skill struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + OwnerID string `json:"ownerId" gorm:"index;size:36"` + AuthorName string `json:"authorName" gorm:"size:120;index"` + AuthorAvatarURL string `json:"authorAvatarUrl" gorm:"size:1000"` + Name string `json:"name" gorm:"size:80;index"` + Description string `json:"description" gorm:"size:500"` + Instruction string `json:"instruction" gorm:"type:text"` + Status int `json:"status" gorm:"index"` + Source int `json:"source" gorm:"index"` + Tag string `json:"tag" gorm:"size:32;index"` + SortWeight int `json:"sortWeight" gorm:"index"` + IsPrivate bool `json:"isPrivate" gorm:"index"` + MarkdownURL string `json:"markdownUrl" gorm:"size:500"` + ShowcaseMediaJSON string `json:"-" gorm:"type:text"` + ExtraInfo string `json:"extraInfo" gorm:"type:text"` + InitialLikeCount int64 `json:"initialLikeCount"` + InitialAddedCount int64 `json:"initialAddedCount"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index"` +} + +type UserSkillState struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_user_skill_state_user_skill,priority:1"` + SkillID string `json:"skillId" gorm:"size:36;index;uniqueIndex:idx_user_skill_state_user_skill,priority:2"` + Added bool `json:"added" gorm:"index"` + Liked bool `json:"liked" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go new file mode 100644 index 000000000..7a5818edd --- /dev/null +++ b/backend/internal/model/models_project.go @@ -0,0 +1,279 @@ +package model + +import "time" + +type Resource struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_resources_user_created,priority:1"` + Kind string `json:"kind" gorm:"index;size:24"` + Status ResourceStatus `json:"status" gorm:"index;size:24"` + Provider string `json:"provider" gorm:"size:24"` + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket" gorm:"size:160"` + // 用户 OSS 每次修改都会生成新版本,资源固定引用创建时的版本,避免历史资源因换密钥失效。 + StorageSettingID string `json:"-" gorm:"index;size:36"` + ObjectKey string `json:"objectKey" gorm:"index"` + PublicURL string `json:"publicUrl"` + MimeType string `json:"mimeType" gorm:"size:120"` + Size int64 `json:"size"` + Width int `json:"width"` + Height int `json:"height"` + DurationMs int64 `json:"durationMs"` + ETag string `json:"etag" gorm:"size:160"` + Error string `json:"error"` + CreatedAt time.Time `json:"createdAt" gorm:"index:idx_resources_user_created,priority:2"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Asset struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_assets_user_updated,priority:1"` + Kind string `json:"kind" gorm:"index;size:24"` + Category AssetCategory `json:"category" gorm:"index;size:32"` + Status AssetVersionStatus `json:"status" gorm:"index;size:24"` + PrimaryVersionID string `json:"primaryVersionId,omitempty" gorm:"index;size:36"` + Title string `json:"title" gorm:"size:240"` + PayloadJSON string `json:"payloadJson" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_assets_user_updated,priority:2"` +} + +type ProjectAssetLink struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1"` + AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:2"` + CreatedAt time.Time `json:"createdAt"` +} + +type ProjectAssetCandidate struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36"` + UnitID string `json:"unitId,omitempty" gorm:"index;size:36"` + ShotID string `json:"shotId,omitempty" gorm:"index;size:36"` + Name string `json:"name" gorm:"size:240"` + Category AssetCategory `json:"category" gorm:"index;size:32"` + Status string `json:"status" gorm:"index;size:32"` + DetailsJSON string `json:"detailsJson" gorm:"type:text"` + ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:36"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type AssetVersion struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_asset_versions_number,priority:1"` + Version int `json:"version" gorm:"uniqueIndex:idx_asset_versions_number,priority:2"` + Status AssetVersionStatus `json:"status" gorm:"index;size:24"` + DefinitionJSON string `json:"definitionJson" gorm:"type:text"` + Prompt string `json:"prompt" gorm:"type:text"` + Note string `json:"note" gorm:"size:500"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type AssetRepresentation struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + TaskID string `json:"taskId,omitempty" gorm:"index;size:36;uniqueIndex:idx_asset_representations_task_role,priority:1"` + AssetVersionID string `json:"assetVersionId" gorm:"index;size:36;uniqueIndex:idx_asset_representations_version_role,priority:1"` + ResourceID string `json:"resourceId,omitempty" gorm:"index;size:36"` + MediaType string `json:"mediaType" gorm:"index;size:24"` + Role string `json:"role" gorm:"index;size:32;uniqueIndex:idx_asset_representations_task_role,priority:2;uniqueIndex:idx_asset_representations_version_role,priority:2"` + MetadataJSON string `json:"metadataJson" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` +} + +// VoiceProfile 是可复用的声音身份;试听音频只是表现资源,不等同于声音本身。 +type VoiceProfile struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_voice_profiles_user_provider_key,priority:1"` + Name string `json:"name" gorm:"size:160"` + Provider string `json:"provider" gorm:"size:48;uniqueIndex:idx_voice_profiles_user_provider_key,priority:2"` + VoiceKey string `json:"voiceKey" gorm:"size:160;uniqueIndex:idx_voice_profiles_user_provider_key,priority:3"` + Language string `json:"language" gorm:"size:80"` + Timbre string `json:"timbre" gorm:"size:240"` + SampleResourceID string `json:"sampleResourceId,omitempty" gorm:"index;size:36"` + CompatibleModelsJSON string `json:"compatibleModelsJson" gorm:"type:text"` + Status string `json:"status" gorm:"index;size:24"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CharacterVoiceBinding struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + AssetVersionID string `json:"assetVersionId" gorm:"uniqueIndex;size:36"` + VoiceProfileID string `json:"voiceProfileId" gorm:"index;size:36"` + Instructions string `json:"instructions" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Project 是短剧领域聚合根;CanvasProject 仍代表可独立创作的画布文档。 +type Project struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_projects_user_name,priority:1"` + Name string `json:"name" gorm:"size:240;uniqueIndex:idx_projects_user_name,priority:2"` + Type string `json:"type" gorm:"size:32;index"` + AspectRatio string `json:"aspectRatio" gorm:"size:16"` + SourceType string `json:"sourceType" gorm:"size:32"` + Description string `json:"description" gorm:"type:text"` + StylePresetID string `json:"stylePresetId" gorm:"size:64"` + Status ProjectStatus `json:"status" gorm:"index;size:24"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index"` +} + +type ProjectUnit struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36"` + ParentID string `json:"parentId,omitempty" gorm:"index;size:36"` + Kind ProjectUnitKind `json:"kind" gorm:"index;size:24"` + Title string `json:"title" gorm:"size:240"` + SourceText string `json:"sourceText" gorm:"type:text"` + Status ProjectUnitStatus `json:"status" gorm:"index;size:24"` + Position int `json:"position"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CanvasUnitLink struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:1"` + CanvasID string `json:"canvasId" gorm:"index;size:80;uniqueIndex:idx_canvas_unit_links_unique,priority:2"` + UnitID string `json:"unitId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:3"` + Role string `json:"role" gorm:"size:32"` + CreatedAt time.Time `json:"createdAt"` +} + +type Shot struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36"` + UnitID string `json:"unitId" gorm:"index;size:36"` + Title string `json:"title" gorm:"size:240"` + Description string `json:"description" gorm:"type:text"` + Position int `json:"position"` + DurationMs int64 `json:"durationMs"` + Status string `json:"status" gorm:"index;size:24"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ShotAssetReference struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ShotID string `json:"shotId" gorm:"index;size:36;uniqueIndex:idx_shot_asset_reference_unique,priority:1"` + AssetVersionID string `json:"assetVersionId" gorm:"index;size:36;uniqueIndex:idx_shot_asset_reference_unique,priority:2"` + Role string `json:"role" gorm:"index;size:32;uniqueIndex:idx_shot_asset_reference_unique,priority:3"` + Status string `json:"status" gorm:"index;size:24"` + CreatedAt time.Time `json:"createdAt"` +} + +type WorkflowTemplateVersion struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + TemplateKey string `json:"templateKey" gorm:"size:80;uniqueIndex:idx_workflow_template_version,priority:1"` + Name string `json:"name" gorm:"size:160"` + Version int `json:"version" gorm:"uniqueIndex:idx_workflow_template_version,priority:2"` + DefinitionJSON string `json:"definitionJson" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` +} + +type WorkflowInstance struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:1"` + UnitID string `json:"unitId,omitempty" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:2"` + TemplateVersionID string `json:"templateVersionId" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:3"` + Scope string `json:"scope" gorm:"index;size:24"` + Status WorkflowStatus `json:"status" gorm:"index;size:24"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type WorkflowStepInstance struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + WorkflowInstanceID string `json:"workflowInstanceId" gorm:"index;size:36;uniqueIndex:idx_workflow_steps_instance_key,priority:1"` + StepKey string `json:"stepKey" gorm:"size:80;uniqueIndex:idx_workflow_steps_instance_key,priority:2"` + Name string `json:"name" gorm:"size:160"` + Position int `json:"position"` + Status WorkflowStepStatus `json:"status" gorm:"index;size:24"` + InputJSON string `json:"inputJson" gorm:"type:text"` + OutputJSON string `json:"outputJson" gorm:"type:text"` + Error string `json:"error" gorm:"type:text"` + StartedAt *time.Time `json:"startedAt"` + CompletedAt *time.Time `json:"completedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type WorkflowStepTask struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + WorkflowStepID string `json:"workflowStepId" gorm:"index;size:36;uniqueIndex:idx_workflow_step_tasks_unique,priority:1"` + TaskID string `json:"taskId" gorm:"index;size:36;uniqueIndex:idx_workflow_step_tasks_unique,priority:2"` + CreatedAt time.Time `json:"createdAt"` +} + +type CanvasProject struct { + ID string `json:"id" gorm:"primaryKey;size:80"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_canvas_projects_user_updated,priority:1"` + ProjectID string `json:"projectId,omitempty" gorm:"index;size:36"` + Title string `json:"title" gorm:"size:240"` + PayloadJSON string `json:"payloadJson" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_canvas_projects_user_updated,priority:2"` +} + +type CanvasShare struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_canvas_share_owner_project,priority:1"` + ProjectID string `json:"projectId" gorm:"index;size:80;uniqueIndex:idx_canvas_share_owner_project,priority:2"` + TokenHash string `json:"-" gorm:"uniqueIndex;size:64"` + TokenCipher string `json:"-" gorm:"type:text"` + Enabled bool `json:"enabled" gorm:"index"` + ExpiresAt *time.Time `json:"expiresAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type PromptTemplate struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + Operation string `json:"operation" gorm:"size:64;index;uniqueIndex:idx_prompt_template_operation_version,priority:1"` + Name string `json:"name" gorm:"size:120"` + Version int `json:"version" gorm:"uniqueIndex:idx_prompt_template_operation_version,priority:2"` + Content string `json:"content" gorm:"type:text"` + OutputType string `json:"outputType" gorm:"size:24"` + Enabled bool `json:"enabled" gorm:"index;index:idx_prompt_template_active,priority:2"` + CreatedBy string `json:"createdBy" gorm:"index;size:36"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// UserPromptCustomization 只保存用户的创作策略层,动态上下文和输出契约始终由服务端编译器注入。 +type UserPromptCustomization struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_user_prompt_operation,priority:1"` + Operation string `json:"operation" gorm:"size:64;index;uniqueIndex:idx_user_prompt_operation,priority:2"` + Mode string `json:"mode" gorm:"size:24"` + Content string `json:"content" gorm:"type:text"` + BaseTemplateID string `json:"baseTemplateId" gorm:"size:36;index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Announcement struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + Title string `json:"title" gorm:"size:120"` + Content string `json:"content" gorm:"type:text"` + Level AnnouncementLevel `json:"level" gorm:"index;size:24"` + Status AnnouncementStatus `json:"status" gorm:"index;size:24;index:idx_announcements_status_published,priority:1"` + CreatedBy string `json:"createdBy" gorm:"index;size:36"` + PublishedAt time.Time `json:"publishedAt" gorm:"index:idx_announcements_status_published,priority:2"` + ClosedAt *time.Time `json:"closedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserAnnouncementRead struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_user_announcement_read,priority:1"` + AnnouncementID string `json:"announcementId" gorm:"index;size:36;uniqueIndex:idx_user_announcement_read,priority:2"` + ReadAt time.Time `json:"readAt"` +} diff --git a/backend/internal/model/models_task.go b/backend/internal/model/models_task.go new file mode 100644 index 000000000..22d5d9522 --- /dev/null +++ b/backend/internal/model/models_task.go @@ -0,0 +1,105 @@ +package model + +import "time" + +type Task struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_tasks_user_created,priority:1"` + SessionID string `json:"sessionId" gorm:"index;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:80"` + Type string `json:"type" gorm:"index;size:64"` + Status TaskStatus `json:"status" gorm:"index;size:24;index:idx_tasks_status_created,priority:1;index:idx_tasks_claim,priority:1;index:idx_tasks_provider_cancel,priority:1"` + Stage string `json:"stage" gorm:"size:80"` + Progress int `json:"progress"` + Prompt string `json:"prompt"` + Operation string `json:"operation" gorm:"size:64"` + Provider string `json:"provider" gorm:"size:64"` + Model string `json:"model" gorm:"size:120"` + BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` + ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` + ProviderCancelStatus ProviderCancelStatus `json:"providerCancelStatus,omitempty" gorm:"index;size:24;index:idx_tasks_provider_cancel,priority:2"` + ProviderCancelError string `json:"providerCancelError,omitempty" gorm:"type:text"` + ProviderCancelAttempts int `json:"providerCancelAttempts,omitempty"` + ProviderCancelRequestedAt *time.Time `json:"providerCancelRequestedAt,omitempty"` + ProviderCancelledAt *time.Time `json:"providerCancelledAt,omitempty"` + ProviderCancelNextCheckAt *time.Time `json:"providerCancelNextCheckAt,omitempty" gorm:"index:idx_tasks_provider_cancel,priority:3"` + PollStage string `json:"pollStage,omitempty" gorm:"size:32"` + NextPollAt *time.Time `json:"nextPollAt,omitempty" gorm:"index"` + LeaseOwner string `json:"-" gorm:"index;size:120"` + LeaseExpiresAt *time.Time `json:"-" gorm:"index;index:idx_tasks_claim,priority:2"` + InputJSON string `json:"inputJson" gorm:"type:text"` + ResultJSON string `json:"resultJson" gorm:"type:text"` + TextDraft string `json:"textDraft,omitempty" gorm:"type:text"` + Error string `json:"error"` + Attempts int `json:"attempts"` + StartedAt *time.Time `json:"startedAt"` + CompletedAt *time.Time `json:"completedAt"` + CreatedAt time.Time `json:"createdAt" gorm:"index:idx_tasks_user_created,priority:2;index:idx_tasks_status_created,priority:2;index:idx_tasks_claim,priority:3"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// TaskTextDelta 只保存可回放窗口内的文本增量;最终正文和失败草稿分别归并到 Task.ResultJSON 与 Task.TextDraft。 +type TaskTextDelta struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_task_text_deltas_user_created,priority:1"` + TaskID string `json:"taskId" gorm:"index;size:36;uniqueIndex:idx_task_text_deltas_sequence,priority:1"` + Sequence int64 `json:"sequence" gorm:"uniqueIndex:idx_task_text_deltas_sequence,priority:2"` + Content string `json:"content" gorm:"type:text"` + ByteCount int64 `json:"byteCount"` + CreatedAt time.Time `json:"createdAt" gorm:"index:idx_task_text_deltas_user_created,priority:2"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index"` +} + +type Session struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:80"` + Status SessionStatus `json:"status" gorm:"index;size:24"` + Prompt string `json:"prompt"` + CanvasSnapshotJSON string `json:"canvasSnapshotJson" gorm:"type:text"` + CanvasOpsJSON string `json:"canvasOpsJson" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Message struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + SessionID string `json:"sessionId" gorm:"index;size:36"` + Role string `json:"role" gorm:"size:24"` + Content string `json:"content"` + Payload string `json:"payload" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` +} + +type TaskLog struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + TaskID string `json:"taskId" gorm:"index;size:36"` + Level string `json:"level" gorm:"size:24"` + Message string `json:"message"` + Payload string `json:"payload" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` +} + +type SessionFile struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + SessionID string `json:"sessionId" gorm:"index;size:36"` + FileName string `json:"fileName"` + MimeType string `json:"mimeType"` + Path string `json:"-"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"createdAt"` +} + +type Result struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + TaskID string `json:"taskId" gorm:"index;size:36"` + SessionID string `json:"sessionId" gorm:"index;size:36"` + Kind string `json:"kind" gorm:"size:64"` + URL string `json:"url"` + Payload string `json:"payload" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/backend/internal/repository/billing_review.go b/backend/internal/repository/billing_review.go new file mode 100644 index 000000000..14887107c --- /dev/null +++ b/backend/internal/repository/billing_review.go @@ -0,0 +1,68 @@ +package repository + +import ( + "time" + + "infinite-canvas/backend/internal/model" +) + +// BillingReviewStats 记录需要人工关注的长期未闭合订单数量。 +// 巡检只读数据库,不改变订单状态或积分余额。 +type BillingReviewStats struct { + Reserved int64 `json:"reserved"` + Running int64 `json:"running"` + Uncertain int64 `json:"uncertain"` + Oldest *time.Time `json:"oldest,omitempty"` +} + +func (s BillingReviewStats) Total() int64 { + return s.Reserved + s.Running + s.Uncertain +} + +// StaleBillingReviewStats 查询超过 age 未更新、仍处于未闭合状态的订单。 +func (r *Repository) StaleBillingReviewStats(now time.Time, age time.Duration) (BillingReviewStats, error) { + cutoff := now.Add(-age) + stats := BillingReviewStats{} + var rows []struct { + Status model.BillingStatus + Count int64 + } + if err := r.db.Model(&model.BillingOrder{}). + Select("status, count(*) AS count"). + Where("status IN ? AND updated_at < ?", []model.BillingStatus{ + model.BillingStatusReserved, + model.BillingStatusRunning, + model.BillingStatusUncertain, + }, cutoff). + Group("status").Scan(&rows).Error; err != nil { + return stats, err + } + for _, row := range rows { + switch row.Status { + case model.BillingStatusReserved: + stats.Reserved = row.Count + case model.BillingStatusRunning: + stats.Running = row.Count + case model.BillingStatusUncertain: + stats.Uncertain = row.Count + } + } + if stats.Total() == 0 { + return stats, nil + } + var oldest struct { + CreatedAt time.Time + } + if err := r.db.Model(&model.BillingOrder{}). + Select("created_at"). + Where("status IN ? AND updated_at < ?", []model.BillingStatus{ + model.BillingStatusReserved, + model.BillingStatusRunning, + model.BillingStatusUncertain, + }, cutoff). + Order("created_at asc").Limit(1).Scan(&oldest).Error; err != nil { + return stats, err + } + stats.Oldest = &oldest.CreatedAt + return stats, nil +} diff --git a/backend/internal/service/billing_review.go b/backend/internal/service/billing_review.go new file mode 100644 index 000000000..81d994007 --- /dev/null +++ b/backend/internal/service/billing_review.go @@ -0,0 +1,44 @@ +package service + +import ( + "log" + "time" + + "infinite-canvas/backend/internal/repository" +) + +const billingReviewStaleAfter = 15 * time.Minute + +// AuditBillingReview 执行只读账单巡检,提醒运维及时进入人工核对链路。 +// 资金状态必须由人工依据上游证据结算或退款,巡检不能代替资金动作。 +func (s *Service) AuditBillingReview() error { + stats, err := s.repo.StaleBillingReviewStats(time.Now(), billingReviewStaleAfter) + if err != nil { + return err + } + if stats.Total() == 0 { + return nil + } + oldest := "" + if stats.Oldest != nil { + oldest = stats.Oldest.UTC().Format(time.RFC3339) + } + log.Printf("billing review audit stale_orders=%d reserved=%d running=%d uncertain=%d oldest=%s", stats.Total(), stats.Reserved, stats.Running, stats.Uncertain, oldest) + return nil +} + +func (s *Service) startBillingReviewAudit() { + audit := func() { + if err := s.AuditBillingReview(); err != nil { + log.Printf("billing review audit failed: %v", err) + } + } + audit() + go func() { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for range ticker.C { + audit() + } + }() +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 84d1766fa..b308e63f6 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -1022,7 +1022,13 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string] id := resumedProviderRequestID(ctx) var created map[string]interface{} if id == "" && (input.Config.InterfaceType == "xai-video" || isGrokVideoConfig(input.Config)) { - requestBody, err := grokVideoBody(input) + var requestBody interface{} + var err error + if input.Config.InterfaceType == "xai-video" { + requestBody, err = xaiVideoRequestBody(input) + } else { + requestBody, err = grokVideoBody(input) + } if err != nil { return nil, err } @@ -1109,21 +1115,21 @@ func runGeminiVeoVideoTask(ctx context.Context, input canvasGenerationInput) (ma } id := resumedProviderRequestID(ctx) if id == "" { - instance := map[string]interface{}{"prompt": strings.TrimSpace(input.Prompt)} + instance := geminiVeoInstance{Prompt: strings.TrimSpace(input.Prompt)} if len(input.ReferenceImages) == 1 { raw, mimeType, err := mediaBytes(input.ReferenceImages[0]) if err != nil { return nil, fmt.Errorf("读取 Gemini Veo 起始图失败:%w", err) } - instance["image"] = map[string]interface{}{"bytesBase64Encoded": base64.StdEncoding.EncodeToString(raw), "mimeType": mimeType} + instance.Image = &geminiVeoImage{BytesBase64Encoded: base64.StdEncoding.EncodeToString(raw), MIMEType: mimeType} } - body := map[string]interface{}{ - "instances": []interface{}{instance}, - "parameters": map[string]interface{}{ - "aspectRatio": normalizeNewAPIChannel2Ratio(input.Config.Size, strings.ToLower(input.Config.Model)), - "durationSeconds": normalizeSeedanceVideosDuration(input.Config.VideoSeconds), - "resolution": normalizeNewAPIChannel2Resolution(input.Config.VQuality, strings.ToLower(input.Config.Model)), - "sampleCount": 1, + body := geminiVeoRequest{ + Instances: []geminiVeoInstance{instance}, + Parameters: geminiVeoParameters{ + AspectRatio: normalizeNewAPIChannel2Ratio(input.Config.Size, strings.ToLower(input.Config.Model)), + DurationSeconds: normalizeSeedanceVideosDuration(input.Config.VideoSeconds), + Resolution: normalizeNewAPIChannel2Resolution(input.Config.VQuality, strings.ToLower(input.Config.Model)), + SampleCount: 1, }, } var created map[string]interface{} @@ -1273,7 +1279,7 @@ func runNewAPIChannel2VideoTask(ctx context.Context, input canvasGenerationInput id := resumedProviderRequestID(ctx) var created map[string]interface{} if id == "" { - body, err := newAPIChannel2VideoBody(input) + body, err := newAPIChannel2VideoRequestBody(input) if err != nil { return nil, err } @@ -1393,9 +1399,9 @@ func logNewAPIChannel2QueryRetry(ctx context.Context, providerTaskID string, ret _ = metadata.Service.log(metadata.UserID, metadata.TaskID, "warn", "上游任务查询失败,1 分钟后重试", payload) } -func newAPIChannel2VideoBody(input canvasGenerationInput) (map[string]interface{}, error) { +func newAPIChannel2VideoRequestBody(input canvasGenerationInput) (newAPIVideoRequest, error) { if len(input.ReferenceImages) > 9 || len(input.ReferenceVideos) > 3 || len(input.ReferenceAudios) > 3 { - return nil, errors.New("NewAPI Video Generations 最多支持 9 张参考图、3 个参考视频和 3 个参考音频") + return newAPIVideoRequest{}, errors.New("NewAPI Video Generations 最多支持 9 张参考图、3 个参考视频和 3 个参考音频") } modelName := strings.ToLower(strings.TrimSpace(input.Config.Model)) requiresSingleImage := modelName == "grok-video-1.5" || modelName == "grok-video-1.5-1080p" @@ -1405,19 +1411,19 @@ func newAPIChannel2VideoBody(input canvasGenerationInput) (map[string]interface{ for _, image := range input.ReferenceImages { url, err := videoGenerationsMediaURL(image) if err != nil { - return nil, err + return newAPIVideoRequest{}, err } images = append(images, url) } } if requiresSingleImage { if len(images) != 1 { - return nil, fmt.Errorf("NewAPI Video Generations 的 %s 必须且只能提供 1 张参考图(当前 %d 张)", input.Config.Model, len(images)) + return newAPIVideoRequest{}, fmt.Errorf("NewAPI Video Generations 的 %s 必须且只能提供 1 张参考图(当前 %d 张)", input.Config.Model, len(images)) } } frameImages, err := videoFrameImageURLs(input, images) if err != nil { - return nil, err + return newAPIVideoRequest{}, err } if len(frameImages) > 0 { images = frameImages @@ -1429,44 +1435,54 @@ func newAPIChannel2VideoBody(input canvasGenerationInput) (map[string]interface{ } ratio := normalizeNewAPIChannel2Ratio(input.Config.Size, modelName) resolution := normalizeNewAPIChannel2Resolution(input.Config.VQuality, modelName) - body := map[string]interface{}{ - "model": input.Config.Model, - "prompt": strings.TrimSpace(input.Prompt), - "seconds": strconv.Itoa(seconds), - "aspect_ratio": ratio, - "resolution": resolution, + body := newAPIVideoRequest{ + Model: input.Config.Model, + Prompt: strings.TrimSpace(input.Prompt), + Seconds: strconv.Itoa(seconds), + AspectRatio: ratio, + Resolution: resolution, } if videoCapabilitySupportsAudio(input) { - body["generate_audio"] = parseBool(input.Config.VideoGenerateAudio, true) + value := parseBool(input.Config.VideoGenerateAudio, true) + body.GenerateAudio = &value } if len(images) > 0 { - body["image_urls"] = images + body.ImageURLs = images } videoURLs := make([]string, 0, len(input.ReferenceVideos)) for _, video := range input.ReferenceVideos { url, err := videoGenerationsMediaURL(video) if err != nil { - return nil, err + return newAPIVideoRequest{}, err } videoURLs = append(videoURLs, url) } if len(videoURLs) > 0 { - body["video_urls"] = videoURLs + body.VideoURLs = videoURLs } audioURLs := make([]string, 0, len(input.ReferenceAudios)) for _, audio := range input.ReferenceAudios { url, err := videoGenerationsMediaURL(audio) if err != nil { - return nil, err + return newAPIVideoRequest{}, err } audioURLs = append(audioURLs, url) } if len(audioURLs) > 0 { - body["audio_urls"] = audioURLs + body.AudioURLs = audioURLs } return body, nil } +// 兼容现有单元测试和旧调用方;实际请求路径使用上面的类型化 DTO。 +func newAPIChannel2VideoBody(input canvasGenerationInput) (map[string]interface{}, error) { + body, err := newAPIChannel2VideoRequestBody(input) + if err != nil { + return nil, err + } + return requestAsMap(body) +} + func videoGenerationsMediaURL(media providerMedia) (string, error) { value := strings.TrimSpace(firstNonEmpty(media.URL, media.DataURL)) if isPublicMediaURL(value) || strings.HasPrefix(value, "data:") { @@ -1680,7 +1696,11 @@ func validateGenerationInterface(mode string, interfaceType string) error { func grokVideoBody(input canvasGenerationInput) (map[string]interface{}, error) { if input.Config.InterfaceType == "xai-video" { - return xaiVideoBody(input) + body, err := xaiVideoRequestBody(input) + if err != nil { + return nil, err + } + return requestAsMap(body) } seconds := defaultString(input.Config.VideoSeconds, "6") @@ -1713,33 +1733,42 @@ func grokVideoBody(input canvasGenerationInput) (map[string]interface{}, error) } // xAI 生成接口与 legacy /videos 使用不同字段,保持独立可避免兼容字段触发上游 422。 -func xaiVideoBody(input canvasGenerationInput) (map[string]interface{}, error) { - body := map[string]interface{}{ - "model": input.Config.Model, - "prompt": strings.TrimSpace(input.Prompt), - "duration": normalizeXAIVideoDuration(input.Config.VideoSeconds), - "aspect_ratio": normalizeXAIVideoAspectRatio(input.Config.Size), - "resolution": normalizeXAIVideoResolution(input.Config.VQuality), +func xaiVideoRequestBody(input canvasGenerationInput) (xaiVideoRequest, error) { + body := xaiVideoRequest{ + Model: input.Config.Model, + Prompt: strings.TrimSpace(input.Prompt), + Duration: normalizeXAIVideoDuration(input.Config.VideoSeconds), + AspectRatio: normalizeXAIVideoAspectRatio(input.Config.Size), + Resolution: normalizeXAIVideoResolution(input.Config.VQuality), } if !shouldSendNewAPIVideoImages(input) || len(input.ReferenceImages) == 0 { return body, nil } if len(input.ReferenceImages) > 1 { - return nil, fmt.Errorf("xAI 图生视频只支持 1 张起始图,当前连接了 %d 张", len(input.ReferenceImages)) + return xaiVideoRequest{}, fmt.Errorf("xAI 图生视频只支持 1 张起始图,当前连接了 %d 张", len(input.ReferenceImages)) } imageURL, err := openAIImageInputURL(input.ReferenceImages[0]) + if err != nil { + return xaiVideoRequest{}, err + } + body.Image = &xaiVideoImage{URL: imageURL} + return body, nil +} + +// 兼容旧的 map 断言调用;xAI 实际请求使用类型化 DTO。 +func xaiVideoBody(input canvasGenerationInput) (map[string]interface{}, error) { + body, err := xaiVideoRequestBody(input) if err != nil { return nil, err } - body["image"] = map[string]interface{}{"url": imageURL} - return body, nil + return requestAsMap(body) } func runSeedanceVideosTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { id := resumedProviderRequestID(ctx) var created map[string]interface{} if id == "" { - body, err := seedanceVideosBody(input) + body, err := seedanceVideosRequestBody(input) if err != nil { return nil, err } @@ -1807,18 +1836,20 @@ func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationIn } } } - body := map[string]interface{}{ - "model": input.Config.Model, - "content": content, - "ratio": normalizeSeedanceRatio(input.Config.Size), - "resolution": normalizeSeedanceResolution(input.Config.VQuality, input.Config.Model), - "duration": normalizeSeedanceDuration(input.Config.VideoSeconds), + body := seedanceAgentPlanRequest{ + Model: input.Config.Model, + Content: content, + Ratio: normalizeSeedanceRatio(input.Config.Size), + Resolution: normalizeSeedanceResolution(input.Config.VQuality, input.Config.Model), + Duration: normalizeSeedanceDuration(input.Config.VideoSeconds), } if videoCapabilitySupportsAudio(input) { - body["generate_audio"] = parseBool(input.Config.VideoGenerateAudio, true) + value := parseBool(input.Config.VideoGenerateAudio, true) + body.GenerateAudio = &value } if videoCapabilitySupportsWatermark(input) { - body["watermark"] = parseBool(input.Config.VideoWatermark, false) + value := parseBool(input.Config.VideoWatermark, false) + body.Watermark = &value } if err := postJSON(ctx, input.Config, "/contents/generations/tasks", body, &created); err != nil { return nil, err @@ -2364,64 +2395,74 @@ func newAPIVideoPromptText(input canvasGenerationInput) string { return strings.TrimSpace(input.Prompt) } -func seedanceVideosBody(input canvasGenerationInput) (map[string]interface{}, error) { +func seedanceVideosRequestBody(input canvasGenerationInput) (seedanceVideosRequest, error) { if (len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0) && len(input.ReferenceImages) == 0 { - return nil, errors.New("Seedance 参考视频或参考音频需要同时连接至少 1 张主参考图") + return seedanceVideosRequest{}, errors.New("Seedance 参考视频或参考音频需要同时连接至少 1 张主参考图") } - body := map[string]interface{}{ - "model": input.Config.Model, - "prompt": seedanceVideosPromptText(input), - "aspect_ratio": normalizeSeedanceVideosRatio(input.Config.Size), - "duration": normalizeSeedanceVideosDuration(input.Config.VideoSeconds), + body := seedanceVideosRequest{ + Model: input.Config.Model, + Prompt: seedanceVideosPromptText(input), + AspectRatio: normalizeSeedanceVideosRatio(input.Config.Size), + Duration: normalizeSeedanceVideosDuration(input.Config.VideoSeconds), } if videoCapabilitySupportsAudio(input) { - body["generate_audio"] = parseBool(input.Config.VideoGenerateAudio, true) + value := parseBool(input.Config.VideoGenerateAudio, true) + body.GenerateAudio = &value } imageURLs := make([]string, 0, len(input.ReferenceImages)) for _, image := range input.ReferenceImages { url, err := openAIImageInputURL(image) if err != nil { - return nil, err + return seedanceVideosRequest{}, err } imageURLs = append(imageURLs, url) } frameImageURLs, err := videoFrameImageURLs(input, imageURLs) if err != nil { - return nil, err + return seedanceVideosRequest{}, err } if len(frameImageURLs) > 0 { - body["image_urls"] = frameImageURLs + body.ImageURLs = frameImageURLs } else if len(imageURLs) > 0 { - body["image_url"] = imageURLs[0] + body.ImageURL = imageURLs[0] if len(imageURLs) > 1 { - body["reference_image_urls"] = imageURLs[1:] + body.ReferenceImageURLs = imageURLs[1:] } } videoURLs := make([]string, 0, len(input.ReferenceVideos)) for _, video := range input.ReferenceVideos { url, err := seedanceVideosMediaURL(video) if err != nil { - return nil, err + return seedanceVideosRequest{}, err } videoURLs = append(videoURLs, url) } if len(videoURLs) > 0 { - body["reference_videos"] = videoURLs + body.ReferenceVideos = videoURLs } audioURLs := make([]string, 0, len(input.ReferenceAudios)) for _, audio := range input.ReferenceAudios { url, err := seedanceVideosMediaURL(audio) if err != nil { - return nil, err + return seedanceVideosRequest{}, err } audioURLs = append(audioURLs, url) } if len(audioURLs) > 0 { - body["reference_audios"] = audioURLs + body.ReferenceAudios = audioURLs } return body, nil } +// 兼容旧的 map 断言调用;实际请求路径使用类型化 Seedance DTO。 +func seedanceVideosBody(input canvasGenerationInput) (map[string]interface{}, error) { + body, err := seedanceVideosRequestBody(input) + if err != nil { + return nil, err + } + return requestAsMap(body) +} + func seedancePromptText(input canvasGenerationInput) string { return strings.TrimSpace(input.Prompt) } @@ -2526,248 +2567,3 @@ func seedanceErrorMessage(state map[string]interface{}) string { } return "" } - -func isPublicMediaURL(value string) bool { - lower := strings.ToLower(value) - return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") -} - -func isSeedanceVideoConfig(config providerConfig) bool { - model := strings.ToLower(config.Model) - return strings.Contains(model, "seedance") || strings.Contains(model, "doubao-seedance") || isArkPlanVideoConfig(config) -} - -func isGrokVideoConfig(config providerConfig) bool { - return strings.Contains(strings.ToLower(strings.TrimSpace(config.Model)), "grok") -} - -func isArkPlanVideoConfig(config providerConfig) bool { - return strings.Contains(strings.ToLower(config.BaseURL), "/api/plan/v3") -} - -func normalizeImageQuality(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "1k": - return "low" - case "2k": - return "medium" - case "4k": - return "high" - default: - return value - } -} - -func normalizePixelSize(value string) string { - value = strings.TrimSpace(value) - if value == "" || value == "auto" { - return "" - } - // 画布按比例保存常用预设;图片接口只接受像素尺寸,必须在请求边界完成转换。 - switch value { - case "1:1": - return "1024x1024" - case "3:2": - return "1536x1024" - case "2:3": - return "1024x1536" - case "4:3": - return "1360x1024" - case "3:4": - return "1024x1360" - case "16:9": - return "1824x1024" - case "9:16": - return "1024x1824" - case "21:9": - return "2352x1008" - } - if strings.Contains(value, "x") { - return value - } - return "" -} - -func normalizeVideoSize(value string) string { - value = strings.TrimSpace(value) - if value == "" || value == "auto" { - return "" - } - if strings.Contains(value, "x") { - return value - } - if value == "9:16" || value == "2:3" || value == "3:4" { - return "720x1280" - } - return "1280x720" -} - -func normalizeVideoResolution(value string) string { - value = strings.TrimSpace(value) - if value == "" || value == "auto" || value == "medium" || value == "high" { - return "720p" - } - if value == "low" { - return "480p" - } - if strings.EqualFold(value, "4k") { - return "2160p" - } - if strings.HasSuffix(value, "p") { - return value - } - return value + "p" -} - -func normalizeXAIVideoDuration(value string) int { - duration, err := strconv.Atoi(strings.TrimSpace(value)) - if err != nil || duration <= 0 { - return 6 - } - return duration -} - -func normalizeXAIVideoResolution(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "480", "480p", "low": - return "480p" - case "1080", "1080p": - return "1080p" - case "2160", "2160p", "4k": - return "2160p" - default: - return "720p" - } -} - -func normalizeXAIVideoAspectRatio(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - allowed := map[string]bool{ - "1:1": true, "16:9": true, "9:16": true, "4:3": true, - "3:4": true, "3:2": true, "2:3": true, - } - if allowed[value] { - return value - } - parts := strings.Split(value, "x") - if len(parts) != 2 { - return "16:9" - } - width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0])) - height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1])) - if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { - return "16:9" - } - ratio := float64(width) / float64(height) - candidates := []struct { - name string - ratio float64 - }{ - {name: "1:1", ratio: 1}, - {name: "16:9", ratio: 16.0 / 9}, - {name: "9:16", ratio: 9.0 / 16}, - {name: "4:3", ratio: 4.0 / 3}, - {name: "3:4", ratio: 3.0 / 4}, - {name: "3:2", ratio: 3.0 / 2}, - {name: "2:3", ratio: 2.0 / 3}, - } - bestName := "16:9" - bestDifference := 2.0 - for _, candidate := range candidates { - difference := ratio - candidate.ratio - if difference < 0 { - difference = -difference - } - if difference < bestDifference { - bestName = candidate.name - bestDifference = difference - } - } - return bestName -} - -func normalizeSeedanceDuration(value string) int { - if strings.TrimSpace(value) == "-1" { - return -1 - } - seconds, err := strconv.Atoi(strings.TrimSpace(value)) - if err != nil || seconds <= 0 { - return 5 - } - return seconds -} - -func normalizeSeedanceVideosDuration(value string) int { - return normalizeSeedanceDuration(value) -} - -func normalizeSeedanceRatio(value string) string { - value = strings.TrimSpace(value) - if value == "" || value == "auto" || value == "adaptive" { - return "adaptive" - } - switch value { - case "16:9", "9:16", "1:1", "4:3", "3:4", "21:9": - return value - default: - return "adaptive" - } -} - -func normalizeSeedanceVideosRatio(value string) string { - ratio := normalizeSeedanceRatio(value) - if ratio == "adaptive" { - return "16:9" - } - return ratio -} - -func normalizeSeedanceResolution(value string, model string) string { - resolution := strings.TrimSuffix(strings.TrimSpace(value), "p") - if strings.EqualFold(resolution, "4k") { - resolution = "2160" - } - switch resolution { - case "480", "720", "1080", "2160": - default: - if value == "low" { - resolution = "480" - } else { - resolution = "720" - } - } - if strings.Contains(strings.ToLower(model), "fast") && (resolution == "1080" || resolution == "2160") { - resolution = "720" - } - return resolution + "p" -} - -func parseBool(value string, fallback bool) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case "true": - return true - case "false": - return false - default: - return fallback - } -} - -func parseFloat(value string, fallback float64) float64 { - number, err := strconv.ParseFloat(strings.TrimSpace(value), 64) - if err != nil || number == 0 { - return fallback - } - return number -} - -func sleepContext(ctx context.Context, duration time.Duration) error { - timer := time.NewTimer(duration) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} diff --git a/backend/internal/service/provider_request_types.go b/backend/internal/service/provider_request_types.go new file mode 100644 index 000000000..50a955a85 --- /dev/null +++ b/backend/internal/service/provider_request_types.go @@ -0,0 +1,85 @@ +package service + +import "encoding/json" + +func requestAsMap(value interface{}) (map[string]interface{}, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + result := make(map[string]interface{}) + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} + +type newAPIVideoRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Seconds string `json:"seconds"` + AspectRatio string `json:"aspect_ratio"` + Resolution string `json:"resolution"` + GenerateAudio *bool `json:"generate_audio,omitempty"` + ImageURLs []string `json:"image_urls,omitempty"` + VideoURLs []string `json:"video_urls,omitempty"` + AudioURLs []string `json:"audio_urls,omitempty"` +} + +type seedanceVideosRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + AspectRatio string `json:"aspect_ratio"` + Duration int `json:"duration"` + GenerateAudio *bool `json:"generate_audio,omitempty"` + ImageURL string `json:"image_url,omitempty"` + ReferenceImageURLs []string `json:"reference_image_urls,omitempty"` + ImageURLs []string `json:"image_urls,omitempty"` + ReferenceVideos []string `json:"reference_videos,omitempty"` + ReferenceAudios []string `json:"reference_audios,omitempty"` +} + +type xaiVideoRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Duration int `json:"duration"` + AspectRatio string `json:"aspect_ratio"` + Resolution string `json:"resolution"` + Image *xaiVideoImage `json:"image,omitempty"` +} + +type xaiVideoImage struct { + URL string `json:"url"` +} + +type geminiVeoRequest struct { + Instances []geminiVeoInstance `json:"instances"` + Parameters geminiVeoParameters `json:"parameters"` +} + +type geminiVeoInstance struct { + Prompt string `json:"prompt"` + Image *geminiVeoImage `json:"image,omitempty"` +} + +type geminiVeoImage struct { + BytesBase64Encoded string `json:"bytesBase64Encoded"` + MIMEType string `json:"mimeType"` +} + +type geminiVeoParameters struct { + AspectRatio string `json:"aspectRatio"` + DurationSeconds int `json:"durationSeconds"` + Resolution string `json:"resolution"` + SampleCount int `json:"sampleCount"` +} + +type seedanceAgentPlanRequest struct { + Model string `json:"model"` + Content []map[string]interface{} `json:"content"` + Ratio string `json:"ratio"` + Resolution string `json:"resolution"` + Duration int `json:"duration"` + GenerateAudio *bool `json:"generate_audio,omitempty"` + Watermark *bool `json:"watermark,omitempty"` +} diff --git a/backend/internal/service/provider_video_options.go b/backend/internal/service/provider_video_options.go new file mode 100644 index 000000000..7b5c27588 --- /dev/null +++ b/backend/internal/service/provider_video_options.go @@ -0,0 +1,253 @@ +package service + +import ( + "context" + "strconv" + "strings" + "time" +) + +func isPublicMediaURL(value string) bool { + lower := strings.ToLower(value) + return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") +} + +func isSeedanceVideoConfig(config providerConfig) bool { + model := strings.ToLower(config.Model) + return strings.Contains(model, "seedance") || strings.Contains(model, "doubao-seedance") || isArkPlanVideoConfig(config) +} + +func isGrokVideoConfig(config providerConfig) bool { + return strings.Contains(strings.ToLower(strings.TrimSpace(config.Model)), "grok") +} + +func isArkPlanVideoConfig(config providerConfig) bool { + return strings.Contains(strings.ToLower(config.BaseURL), "/api/plan/v3") +} + +func normalizeImageQuality(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1k": + return "low" + case "2k": + return "medium" + case "4k": + return "high" + default: + return value + } +} + +func normalizePixelSize(value string) string { + value = strings.TrimSpace(value) + if value == "" || value == "auto" { + return "" + } + // 画布按比例保存常用预设;图片接口只接受像素尺寸,必须在请求边界完成转换。 + switch value { + case "1:1": + return "1024x1024" + case "3:2": + return "1536x1024" + case "2:3": + return "1024x1536" + case "4:3": + return "1360x1024" + case "3:4": + return "1024x1360" + case "16:9": + return "1824x1024" + case "9:16": + return "1024x1824" + case "21:9": + return "2352x1008" + } + if strings.Contains(value, "x") { + return value + } + return "" +} + +func normalizeVideoSize(value string) string { + value = strings.TrimSpace(value) + if value == "" || value == "auto" { + return "" + } + if strings.Contains(value, "x") { + return value + } + if value == "9:16" || value == "2:3" || value == "3:4" { + return "720x1280" + } + return "1280x720" +} + +func normalizeVideoResolution(value string) string { + value = strings.TrimSpace(value) + if value == "" || value == "auto" || value == "medium" || value == "high" { + return "720p" + } + if value == "low" { + return "480p" + } + if strings.EqualFold(value, "4k") { + return "2160p" + } + if strings.HasSuffix(value, "p") { + return value + } + return value + "p" +} + +func normalizeXAIVideoDuration(value string) int { + duration, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || duration <= 0 { + return 6 + } + return duration +} + +func normalizeXAIVideoResolution(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "480", "480p", "low": + return "480p" + case "1080", "1080p": + return "1080p" + case "2160", "2160p", "4k": + return "2160p" + default: + return "720p" + } +} + +func normalizeXAIVideoAspectRatio(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + allowed := map[string]bool{ + "1:1": true, "16:9": true, "9:16": true, "4:3": true, + "3:4": true, "3:2": true, "2:3": true, + } + if allowed[value] { + return value + } + parts := strings.Split(value, "x") + if len(parts) != 2 { + return "16:9" + } + width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0])) + height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1])) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return "16:9" + } + ratio := float64(width) / float64(height) + candidates := []struct { + name string + ratio float64 + }{ + {name: "1:1", ratio: 1}, + {name: "16:9", ratio: 16.0 / 9}, + {name: "9:16", ratio: 9.0 / 16}, + {name: "4:3", ratio: 4.0 / 3}, + {name: "3:4", ratio: 3.0 / 4}, + {name: "3:2", ratio: 3.0 / 2}, + {name: "2:3", ratio: 2.0 / 3}, + } + bestName := "16:9" + bestDifference := 2.0 + for _, candidate := range candidates { + difference := ratio - candidate.ratio + if difference < 0 { + difference = -difference + } + if difference < bestDifference { + bestName = candidate.name + bestDifference = difference + } + } + return bestName +} + +func normalizeSeedanceDuration(value string) int { + if strings.TrimSpace(value) == "-1" { + return -1 + } + seconds, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || seconds <= 0 { + return 5 + } + return seconds +} + +func normalizeSeedanceVideosDuration(value string) int { + return normalizeSeedanceDuration(value) +} + +func normalizeSeedanceRatio(value string) string { + value = strings.TrimSpace(value) + if value == "" || value == "auto" || value == "adaptive" { + return "adaptive" + } + switch value { + case "16:9", "9:16", "1:1", "4:3", "3:4", "21:9": + return value + default: + return "adaptive" + } +} + +func normalizeSeedanceVideosRatio(value string) string { + ratio := normalizeSeedanceRatio(value) + if ratio == "adaptive" { + return "16:9" + } + return ratio +} + +func normalizeSeedanceResolution(value string, model string) string { + resolution := strings.TrimSuffix(strings.TrimSpace(value), "p") + if strings.EqualFold(resolution, "4k") { + resolution = "2160" + } + switch resolution { + case "480", "720", "1080", "2160": + default: + if value == "low" { + resolution = "480" + } else { + resolution = "720" + } + } + if strings.Contains(strings.ToLower(model), "fast") && (resolution == "1080" || resolution == "2160") { + resolution = "720" + } + return resolution + "p" +} + +func parseBool(value string, fallback bool) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true": + return true + case "false": + return false + default: + return fallback + } +} + +func parseFloat(value string, fallback float64) float64 { + number, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || number == 0 { + return fallback + } + return number +} + +func sleepContext(ctx context.Context, duration time.Duration) error { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 46775e709..0c89b64c6 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -164,20 +164,20 @@ type agentStoryboardPlan struct { } type agentStoryboardShot struct { - Title string `json:"title"` - Description string `json:"description"` - Duration int `json:"durationSeconds"` - Dialogue string `json:"dialogue"` - ShotSize string `json:"shotSize"` - Emotion string `json:"emotion"` - Lighting string `json:"lightingAndAtmosphere"` - AudioEffects string `json:"audioEffects"` - VisualPrompt string `json:"visualPrompt"` - VideoPrompt string `json:"videoPrompt"` - Camera string `json:"camera"` - Motion string `json:"motion"` - TimeBeats string `json:"timeBeats"` - Negative string `json:"negativePrompt"` + Title string `json:"title"` + Description string `json:"description"` + Duration int `json:"durationSeconds"` + Dialogue string `json:"dialogue"` + ShotSize string `json:"shotSize"` + Emotion string `json:"emotion"` + Lighting string `json:"lightingAndAtmosphere"` + AudioEffects string `json:"audioEffects"` + VisualPrompt string `json:"visualPrompt"` + VideoPrompt string `json:"videoPrompt"` + Camera string `json:"camera"` + Motion string `json:"motion"` + TimeBeats string `json:"timeBeats"` + Negative string `json:"negativePrompt"` AssetTags []string `json:"assetTags"` CharacterIDs []string `json:"characterIds"` Intent string `json:"narrativeIntent"` @@ -196,6 +196,7 @@ func New(repo *repository.Repository, dataDir string) *Service { func (s *Service) StartWorker() { s.startTextReplayCleanup() s.startProviderCancellationReconciliation() + s.startBillingReviewAudit() go func() { slots := make(chan struct{}, maxChannelConcurrencyLimit) dispatch := func() { @@ -1763,10 +1764,10 @@ func shotDescription(shot agentStoryboardShot) string { func storyboardImagePromptValues(projectStyle string, styleGuide string, shot agentStoryboardShot) map[string]string { negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、风格突变和塑料材质") return map[string]string{ - "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), - "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 360), + "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), + "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 360), "表演起始状态": compactPromptText(shot.Performance, 180), - "负面要求": compactPromptText(negative, 140), + "负面要求": compactPromptText(negative, 140), } } @@ -1787,15 +1788,15 @@ func storyboardVideoPromptValues(projectStyle string, styleGuide string, shot ag timeBeats := defaultString(strings.TrimSpace(shot.TimeBeats), fmt.Sprintf("0-%d秒:%s", shot.Duration, strings.TrimSpace(shot.Description))) negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、闪烁、风格突变和动作僵硬") values := map[string]string{ - "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), - "镜头意图": compactPromptText(shot.Intent+";观众视点:"+shot.ViewerPOV+";情绪:"+shot.Emotion, 150), - "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 280), + "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), + "镜头意图": compactPromptText(shot.Intent+";观众视点:"+shot.ViewerPOV+";情绪:"+shot.Emotion, 150), + "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 280), "表演与调度": compactPromptText(shot.Performance, 180), - "摄影机": compactPromptText(strings.TrimSpace(shot.ShotSize)+";"+camera+";主运镜:"+motion, 220), - "时间节拍": compactPromptText(timeBeats, 240), + "摄影机": compactPromptText(strings.TrimSpace(shot.ShotSize)+";"+camera+";主运镜:"+motion, 220), + "时间节拍": compactPromptText(timeBeats, 240), "运动与结尾": compactPromptText(shot.VideoPrompt+";连续性结尾:"+shot.ContinuityOut, 240), - "声音": compactPromptText(strings.TrimSpace(shot.Dialogue)+";音效:"+strings.TrimSpace(shot.AudioEffects), 160), - "负面要求": compactPromptText(negative, 160), + "声音": compactPromptText(strings.TrimSpace(shot.Dialogue)+";音效:"+strings.TrimSpace(shot.AudioEffects), 160), + "负面要求": compactPromptText(negative, 160), } if len(shot.MustHave) > 0 { priority := "必须完成:" + strings.Join(shot.MustHave, ";") @@ -1945,65 +1946,6 @@ func (s *Service) markSessionFailed(task model.Task, message string) error { } return s.repo.Create(&model.Message{ID: newID(), UserID: task.UserID, SessionID: task.SessionID, Role: "assistant", Content: defaultString(message, "会话任务失败。")}) } - -func buildAgentResult(task model.Task) (map[string]any, []map[string]any) { - title := strings.TrimSpace(task.Prompt) - if len([]rune(title)) > 28 { - title = string([]rune(title)[:28]) + "..." - } - result := map[string]any{ - "taskId": task.ID, - "operation": task.Operation, - "provider": defaultString(task.Provider, "internal-agent"), - "model": defaultString(task.Model, "workflow-router"), - "plan": []map[string]any{ - {"kind": "script", "title": "创意脚本", "content": task.Prompt}, - {"kind": "scene", "title": "主场景", "content": "根据用户输入拆解为可生成的视频场景。"}, - {"kind": "shot", "title": "镜头 1", "content": "建立画面、主体、风格和运镜。"}, - {"kind": "final", "title": "成片", "content": "等待视频生成 Provider 回填成片结果。"}, - }, - } - ops := []map[string]any{ - nodeOp("script-"+task.ID, "text", "剧本 · "+title, 0, 0, "script", task.Prompt), - nodeOp("scene-"+task.ID, "text", "场景 · 主场景", 380, 0, "scene", "主场景设定、角色关系、视觉风格。"), - nodeOpWithMetadata("shot-"+task.ID, "video", "分镜 · 镜头 1", 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": "text_to_video"}), - nodeOp("final-"+task.ID, "video", "成片 · 待生成", 1140, 0, "final", ""), - connectOp("script-"+task.ID, "scene-"+task.ID), - connectOp("scene-"+task.ID, "shot-"+task.ID), - connectOp("shot-"+task.ID, "final-"+task.ID), - } - return result, ops -} - -func buildVideoWorkflowResult(task model.Task) (map[string]any, []map[string]any) { - title := strings.TrimSpace(task.Prompt) - if len([]rune(title)) > 28 { - title = string([]rune(title)[:28]) + "..." - } - operation := defaultString(task.Operation, strings.TrimPrefix(task.Type, "video_")) - result := map[string]any{ - "taskId": task.ID, - "operation": operation, - "provider": defaultString(task.Provider, "internal-agent"), - "model": defaultString(task.Model, "workflow-router"), - "plan": []map[string]any{ - {"kind": "reference_set", "title": "参考素材组", "content": "收集原视频、参考图、参考音频和版本样片。"}, - {"kind": "shot", "title": "编辑镜头", "content": task.Prompt}, - {"kind": "final", "title": "结果版本", "content": "等待 provider 生成或人工确认后回填版本结果。"}, - }, - } - ops := []map[string]any{ - nodeOp("video-brief-"+task.ID, "text", "编辑需求 · "+title, 0, 0, "script", task.Prompt), - nodeOpWithMetadata("video-ref-"+task.ID, "text", "参考素材组", 380, 0, map[string]any{"workflowKind": "reference_set", "status": "idle", "content": "原片、参考图、参考音频、风格板或历史版本。", "videoEditOperation": operation}), - nodeOpWithMetadata("video-shot-"+task.ID, "video", "视频任务 · "+operation, 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": operation}), - nodeOpWithMetadata("video-result-"+task.ID, "video", "结果版本 · 待回填", 1140, 0, map[string]any{"workflowKind": "final", "status": "idle", "videoEditOperation": operation, "versionLabel": "v1"}), - connectOp("video-brief-"+task.ID, "video-ref-"+task.ID), - connectOp("video-ref-"+task.ID, "video-shot-"+task.ID), - connectOp("video-shot-"+task.ID, "video-result-"+task.ID), - } - return result, ops -} - func nodeOp(id string, nodeType string, title string, x int, y int, workflowKind string, content string) map[string]any { return nodeOpWithMetadata(id, nodeType, title, x, y, map[string]any{"content": content, "workflowKind": workflowKind, "status": "idle"}) } diff --git a/backend/internal/service/service_workflow_results.go b/backend/internal/service/service_workflow_results.go new file mode 100644 index 000000000..fb9f4c9ba --- /dev/null +++ b/backend/internal/service/service_workflow_results.go @@ -0,0 +1,65 @@ +package service + +import ( + "strings" + + "infinite-canvas/backend/internal/model" +) + +func buildAgentResult(task model.Task) (map[string]any, []map[string]any) { + title := strings.TrimSpace(task.Prompt) + if len([]rune(title)) > 28 { + title = string([]rune(title)[:28]) + "..." + } + result := map[string]any{ + "taskId": task.ID, + "operation": task.Operation, + "provider": defaultString(task.Provider, "internal-agent"), + "model": defaultString(task.Model, "workflow-router"), + "plan": []map[string]any{ + {"kind": "script", "title": "创意脚本", "content": task.Prompt}, + {"kind": "scene", "title": "主场景", "content": "根据用户输入拆解为可生成的视频场景。"}, + {"kind": "shot", "title": "镜头 1", "content": "建立画面、主体、风格和运镜。"}, + {"kind": "final", "title": "成片", "content": "等待视频生成 Provider 回填成片结果。"}, + }, + } + ops := []map[string]any{ + nodeOp("script-"+task.ID, "text", "剧本 · "+title, 0, 0, "script", task.Prompt), + nodeOp("scene-"+task.ID, "text", "场景 · 主场景", 380, 0, "scene", "主场景设定、角色关系、视觉风格。"), + nodeOpWithMetadata("shot-"+task.ID, "video", "分镜 · 镜头 1", 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": "text_to_video"}), + nodeOp("final-"+task.ID, "video", "成片 · 待生成", 1140, 0, "final", ""), + connectOp("script-"+task.ID, "scene-"+task.ID), + connectOp("scene-"+task.ID, "shot-"+task.ID), + connectOp("shot-"+task.ID, "final-"+task.ID), + } + return result, ops +} + +func buildVideoWorkflowResult(task model.Task) (map[string]any, []map[string]any) { + title := strings.TrimSpace(task.Prompt) + if len([]rune(title)) > 28 { + title = string([]rune(title)[:28]) + "..." + } + operation := defaultString(task.Operation, strings.TrimPrefix(task.Type, "video_")) + result := map[string]any{ + "taskId": task.ID, + "operation": operation, + "provider": defaultString(task.Provider, "internal-agent"), + "model": defaultString(task.Model, "workflow-router"), + "plan": []map[string]any{ + {"kind": "reference_set", "title": "参考素材组", "content": "收集原视频、参考图、参考音频和版本样片。"}, + {"kind": "shot", "title": "编辑镜头", "content": task.Prompt}, + {"kind": "final", "title": "结果版本", "content": "等待 provider 生成或人工确认后回填版本结果。"}, + }, + } + ops := []map[string]any{ + nodeOp("video-brief-"+task.ID, "text", "编辑需求 · "+title, 0, 0, "script", task.Prompt), + nodeOpWithMetadata("video-ref-"+task.ID, "text", "参考素材组", 380, 0, map[string]any{"workflowKind": "reference_set", "status": "idle", "content": "原片、参考图、参考音频、风格板或历史版本。", "videoEditOperation": operation}), + nodeOpWithMetadata("video-shot-"+task.ID, "video", "视频任务 · "+operation, 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": operation}), + nodeOpWithMetadata("video-result-"+task.ID, "video", "结果版本 · 待回填", 1140, 0, map[string]any{"workflowKind": "final", "status": "idle", "videoEditOperation": operation, "versionLabel": "v1"}), + connectOp("video-brief-"+task.ID, "video-ref-"+task.ID), + connectOp("video-ref-"+task.ID, "video-shot-"+task.ID), + connectOp("video-shot-"+task.ID, "video-result-"+task.ID), + } + return result, ops +} diff --git a/web/package.json b/web/package.json index a2c2664d8..cd9dc5834 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,8 @@ "scripts": { "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "test": "bun test test/agent-prompt-cache.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-generation-layout.test.ts test/canvas-leafer-viewport.test.ts test/client-id.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/components/audio-settings-panel.tsx b/web/src/components/audio-settings-panel.tsx index d88e62626..22ee09864 100644 --- a/web/src/components/audio-settings-panel.tsx +++ b/web/src/components/audio-settings-panel.tsx @@ -17,7 +17,7 @@ type AudioSettingsPanelProps = { className?: string; }; -export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[320px] space-y-4 rounded-2xl px-1 py-0.5" }: AudioSettingsPanelProps) { +export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[var(--panel-width-compact)] space-y-4 rounded-2xl px-1 py-0.5" }: AudioSettingsPanelProps) { const voice = normalizeAudioVoiceValue(config.audioVoice); const format = normalizeAudioFormatValue(config.audioFormat); const speed = normalizeAudioSpeedValue(config.audioSpeed); @@ -82,7 +82,13 @@ export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) { return ( -