feat(canvas): 创作助手 - 合并创作对话、能力发现与画布接续

This commit is contained in:
ddCat
2026-09-09 22:46:45 +08:00
97 changed files with 8434 additions and 326 deletions

View File

@@ -0,0 +1,33 @@
package database
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
"testing"
)
func TestCreationMigrationKeepsLegacyTasksNullableAndEnforcesSubmissionUnique(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = migrateSchemaV10(db); err != nil {
t.Fatal(err)
}
for _, id := range []string{"manual-one", "manual-two"} {
if err = db.Create(&model.Task{ID: id}).Error; err != nil {
t.Fatal(err)
}
}
sid := "submission"
if err = db.Create(&model.Task{ID: "creation-one", CreationSubmissionID: &sid}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&model.Task{ID: "creation-two", CreationSubmissionID: &sid}).Error; err == nil {
t.Fatal("duplicate execution admitted")
}
if !db.Migrator().HasTable(&model.CreationRun{}) || !db.Migrator().HasTable(&model.CreationSubmission{}) {
t.Fatal("creation tables missing")
}
}

View File

@@ -10,7 +10,7 @@ import (
"gorm.io/gorm"
)
const CurrentSchemaVersion int64 = 9
const CurrentSchemaVersion int64 = 10
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
@@ -20,6 +20,7 @@ const paymentTopupChecksum = "sha256:payment-topup-v5-20260902"
const resourcePlaybackChecksum = "sha256:resource-playback-v6-20260902"
const assetLibraryFoldersChecksum = "sha256:asset-library-folders-v6-20260902"
const logicalModelActiveCodeChecksum = "sha256:logical-model-active-code-v8-20260905"
const creationRuntimeChecksum = "sha256:creation-runtime-v10-20260909"
const postgresSchemaMigrationLockID int64 = 73123910420260830
@@ -55,6 +56,7 @@ var schemaMigrations = []migration{
{version: 7, name: "asset_library_folders", checksum: assetLibraryFoldersChecksum, apply: migrateSchemaV7},
{version: 8, name: "logical_model_active_code", checksum: logicalModelActiveCodeChecksum, apply: migrateSchemaV8},
{version: 9, name: "channel_presentation", checksum: "sha256:channel-presentation-v9-20260908", apply: migrateChannelPresentation},
{version: 10, name: "creation_runtime", checksum: creationRuntimeChecksum, apply: migrateSchemaV10},
}
func migrateChannelPresentation(tx *gorm.DB) error {
@@ -213,6 +215,14 @@ func migrateSchemaV8(tx *gorm.DB) error {
return nil
}
// migrateSchemaV10 只增加创作运行时表和任务幂等关联;旧任务的空 submission ID 必须继续合法。
func migrateSchemaV10(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.CreationRun{}, &model.CreationSubmission{}, &model.Task{}); err != nil {
return fmt.Errorf("创建创作运行时结构:%w", err)
}
return nil
}
func MigrateSchema(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
if tx.Dialector.Name() == "postgres" {

View File

@@ -87,6 +87,8 @@ func Models() []any {
&model.Announcement{},
&model.UserAnnouncementRead{},
&model.Task{},
&model.CreationRun{},
&model.CreationSubmission{},
&model.TaskTextDelta{},
&model.Session{},
&model.Message{},

View File

@@ -22,6 +22,7 @@ func RegisterCanvasAPI(api *gin.RouterGroup, svc *service.Service) {
RegisterAdminSystemPerformanceRoutes(api, svc)
RegisterAnnouncementRoutes(api, svc)
RegisterFinanceRoutes(api, svc)
RegisterCreationRoutes(api, svc)
RegisterPaymentRoutes(api, svc)
RegisterLibTVRoutes(api, svc)
RegisterTapNowRoutes(api, svc)

View File

@@ -0,0 +1,87 @@
package handler
import (
"github.com/gin-gonic/gin"
"infinite-canvas/backend/internal/service"
"net/http"
)
func RegisterCreationRoutes(r *gin.RouterGroup, svc *service.Service) {
read := func(action string) gin.HandlerFunc {
return func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
var value any
switch action {
case "list":
value, err = svc.ListCreationRuns(user.ID)
case "get":
value, err = svc.GetCreationRun(user.ID, c.Param("id"))
case "snapshot":
value, err = svc.CreationCanvasSnapshot(user.ID, c.Param("id"))
}
if err != nil {
failService(c, err)
return
}
ok(c, value)
}
}
write := func(action string) gin.HandlerFunc {
return func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20)
var req service.CreationRequest
if err = c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
var value any
id := c.Param("id")
switch action {
case "create":
value, err = svc.CreateCreationRun(user.ID, req)
case "prepare":
value, err = svc.PrepareCreationSubmission(user.ID, id, req)
case "approve":
value, err = svc.ApproveCreationSubmissions(user.ID, id, req)
case "refresh-quote":
value, err = svc.RefreshCreationSubmission(user.ID, id, req)
case "execute":
value, err = svc.ExecuteCreationSubmission(user.ID, id, req)
case "canvas":
value, err = svc.CreateRunCanvas(user.ID, id, req)
case "commit":
value, err = svc.CommitCreationCanvas(user.ID, id, req)
default:
value, err = svc.ChangeCreationRun(user.ID, id, action, req)
}
if err != nil {
failService(c, err)
return
}
ok(c, value)
}
}
r.GET("/creation-runs", read("list"))
r.POST("/creation-runs", write("create"))
r.GET("/creation-runs/:id", read("get"))
r.PATCH("/creation-runs/:id", write("save"))
for _, action := range []string{"claim", "heartbeat", "release", "proposal-approve", "proposal-invalidate"} {
r.POST("/creation-runs/:id/"+action, write(action))
}
r.POST("/creation-runs/:id/submissions/prepare", write("prepare"))
r.POST("/creation-runs/:id/submissions/approve", write("approve"))
r.POST("/creation-runs/:id/submissions/refresh", write("refresh-quote"))
r.POST("/creation-runs/:id/execute", write("execute"))
r.POST("/creation-runs/:id/canvas", write("canvas"))
r.GET("/creation-runs/:id/canvas-snapshot", read("snapshot"))
r.POST("/creation-runs/:id/canvas-commit", write("commit"))
}

View File

@@ -0,0 +1,44 @@
package model
import "time"
// CreationRun keeps execution authority separate from untrusted conversation state.
type CreationRun struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_creation_client,priority:1"`
ClientKey string `json:"-" gorm:"size:120;uniqueIndex:idx_creation_client,priority:2"`
CreateHash string `json:"-" gorm:"size:64"`
CanvasID string `json:"canvasId,omitempty" gorm:"size:80"`
Revision int64 `json:"revision"`
ExecutionEpoch int64 `json:"executionEpoch"`
ExecutionOwner string `json:"executionOwner" gorm:"size:120"`
LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"`
Status string `json:"status" gorm:"size:32"`
StateJSON string `json:"-" gorm:"type:text"`
ApprovedProposalVersion int64 `json:"approvedProposalVersion,omitempty"`
ApprovedProposalHash string `json:"approvedProposalHash,omitempty" gorm:"size:64"`
ApprovedOperationsJSON string `json:"-" gorm:"type:text"`
ApprovedCanvasJSON string `json:"-" gorm:"type:text"`
ApprovedAt *time.Time `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CreationSubmission struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
UserID string `json:"-" gorm:"size:36;index"`
RunID string `json:"runId" gorm:"size:36;uniqueIndex:idx_creation_item,priority:1"`
ItemKey string `json:"itemKey" gorm:"size:160;uniqueIndex:idx_creation_item,priority:2"`
ProposalVersion int64 `json:"-"`
ProposalHash string `json:"-" gorm:"size:64"`
RequestJSON string `json:"-" gorm:"type:text"`
RequestHash string `json:"requestHash" gorm:"size:64"`
QuoteJSON string `json:"-" gorm:"type:text"`
PriceSignature string `json:"-" gorm:"type:text"`
ExpiresAt time.Time `json:"-"`
ApprovedAt *time.Time `json:"approvedAt,omitempty"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
TaskID *string `json:"taskId,omitempty" gorm:"size:36;uniqueIndex"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
}

View File

@@ -3,6 +3,7 @@ package model
import "time"
type Task struct {
CreationSubmissionID *string `json:"creationSubmissionId,omitempty" gorm:"size:36;uniqueIndex"`
ID string `json:"id" gorm:"primaryKey;size:36"`
UserID string `json:"userId" gorm:"index;size:36;index:idx_tasks_user_created,priority:1;index:idx_tasks_user_project_created,priority:1"`
TraceID string `json:"-" gorm:"index;size:96"`

View File

@@ -0,0 +1,168 @@
package repository
import (
"encoding/json"
"errors"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"infinite-canvas/backend/internal/model"
)
var ErrCreationConflict = errors.New("creation state changed; reload before continuing")
func (r *Repository) CreationStorageUsage(userID string) (int64, int64, error) {
var usage struct {
Count int64
Bytes int64
}
query := `SELECT (SELECT COUNT(*) FROM creation_runs WHERE user_id = ?) AS count,
(SELECT COALESCE(SUM(length(CAST(COALESCE(state_json,'') AS BLOB))+length(CAST(COALESCE(approved_operations_json,'') AS BLOB))+length(CAST(COALESCE(approved_canvas_json,'') AS BLOB))),0) FROM creation_runs WHERE user_id = ?)
+ (SELECT COALESCE(SUM(length(CAST(COALESCE(request_json,'') AS BLOB))+length(CAST(COALESCE(quote_json,'') AS BLOB))+length(CAST(COALESCE(price_signature,'') AS BLOB))),0) FROM creation_submissions WHERE user_id = ?) AS bytes`
if r.Dialect() == "postgres" {
query = strings.ReplaceAll(query, "length(CAST(COALESCE(", "octet_length(COALESCE(")
query = strings.ReplaceAll(query, ",'') AS BLOB))", ",''))")
}
err := r.db.Raw(query, userID, userID, userID).Scan(&usage).Error
return usage.Count, usage.Bytes, err
}
func (r *Repository) CreationRun(userID, id string) (*model.CreationRun, error) {
var run model.CreationRun
err := r.db.First(&run, "id = ? AND user_id = ?", id, userID).Error
return &run, err
}
func (r *Repository) CreationRunByClientKey(userID, key string) (*model.CreationRun, error) {
var run model.CreationRun
err := r.db.First(&run, "user_id = ? AND client_key = ?", userID, key).Error
return &run, err
}
func (r *Repository) CreationRuns(userID string) ([]model.CreationRun, error) {
items := []model.CreationRun{}
err := r.db.Where("user_id = ?", userID).Order("updated_at DESC").Limit(100).Find(&items).Error
return items, err
}
func (r *Repository) CreateCreationRun(run *model.CreationRun) error {
var old model.CreationRun
if err := r.db.First(&old, "user_id = ? AND client_key = ?", run.UserID, run.ClientKey).Error; err == nil {
if old.CreateHash != run.CreateHash {
return ErrCreationConflict
}
*run = old
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := r.db.Create(run).Error; err != nil {
if e := r.db.First(&old, "user_id = ? AND client_key = ?", run.UserID, run.ClientKey).Error; e == nil && old.CreateHash == run.CreateHash {
*run = old
return nil
}
return err
}
return nil
}
// A conditional write obtains the run row's write lock in both SQLite and PostgreSQL.
// All submission consumption and task reservation use the returned transaction repository.
func (r *Repository) MutateCreationRun(userID, id string, fn func(*model.CreationRun, *Repository) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.CreationRun{}).Where("id = ? AND user_id = ?", id, userID).UpdateColumn("revision", gorm.Expr("revision"))
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
scoped := New(tx)
run, err := scoped.CreationRun(userID, id)
if err != nil {
return err
}
if err := fn(run, scoped); err != nil {
return err
}
return tx.Save(run).Error
})
}
func (r *Repository) CreationSubmissions(userID, runID string) ([]model.CreationSubmission, error) {
items := []model.CreationSubmission{}
err := r.db.Where("user_id = ? AND run_id = ?", userID, runID).Order("created_at").Find(&items).Error
return items, err
}
func (r *Repository) CreationSubmission(userID, runID, id string) (*model.CreationSubmission, error) {
var item model.CreationSubmission
err := r.db.First(&item, "id = ? AND user_id = ? AND run_id = ?", id, userID, runID).Error
return &item, err
}
func (r *Repository) SaveCreationSubmission(item *model.CreationSubmission) error {
return r.db.Save(item).Error
}
func (r *Repository) RevokeCreationSubmissions(runID string) error {
return r.db.Model(&model.CreationSubmission{}).Where("run_id = ? AND task_id IS NULL AND revoked_at IS NULL", runID).Update("revoked_at", time.Now()).Error
}
func (r *Repository) CreateCreationCanvas(canvas *model.CanvasProject) error {
return r.db.Create(canvas).Error
}
func (r *Repository) CompareSaveCreationCanvas(canvas *model.CanvasProject, previous string) error {
result := r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ? AND payload_json = ?", canvas.ID, canvas.UserID, previous).Updates(map[string]any{"payload_json": canvas.PayloadJSON, "title": canvas.Title, "updated_at": time.Now()})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrCreationConflict
}
return nil
}
// Capture server-side configuration versions, never channel credentials.
func (r *Repository) CreationPriceSignature(task *model.Task, channelID, modelKey string) (string, error) {
db := r.db.Clauses(clause.Locking{Strength: "SHARE"}).Session(&gorm.Session{})
values := map[string]any{}
var cm model.ChannelModel
query := db.Where("channel_id = ? AND model_key = ?", channelID, modelKey)
if task.ChannelModelID != "" {
query = db.Where("id = ?", task.ChannelModelID)
}
if err := query.First(&cm).Error; err != nil {
return "", err
}
values["channelModel"] = cm
values["capabilityConfig"] = cm.CapabilityConfigJSON
var channel struct {
ID string
Enabled bool
UpdatedAt time.Time
}
if err := db.Model(&model.ModelChannel{}).Select("id, enabled, updated_at").First(&channel, "id = ?", cm.ChannelID).Error; err != nil {
return "", err
}
values["channel"] = channel
var tiers []model.ChannelModelPriceTier
if err := db.Where("channel_model_id = ?", cm.ID).Order("id").Find(&tiers).Error; err != nil {
return "", err
}
values["tiers"] = tiers
if task.LogicalModelID != "" {
var logical model.LogicalModel
if err := db.First(&logical, "id = ?", task.LogicalModelID).Error; err != nil {
return "", err
}
values["logical"] = logical
var route model.LogicalModelRoute
if err := db.First(&route, "id = ?", task.RouteID).Error; err != nil {
return "", err
}
values["route"] = route
}
var settings []model.SystemSetting
if err := db.Where("key IN ?", []string{"credit_policy", "feature_availability"}).Order("key").Find(&settings).Error; err != nil {
return "", err
}
values["pricingSettings"] = settings
b, err := json.Marshal(values)
return string(b), err
}

View File

@@ -118,6 +118,20 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s
for _, session := range sessions {
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "会话", ID: session.ID, Title: session.Prompt, PrimaryJSON: session.CanvasSnapshotJSON, SecondaryJSON: session.CanvasOpsJSON})
}
var runs []model.CreationRun
if err := r.db.Where("user_id = ?", userID).Find(&runs).Error; err != nil {
return snapshot, err
}
for _, run := range runs {
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "创作会话", ID: run.ID, Title: "智能创作", PrimaryJSON: run.StateJSON, SecondaryJSON: run.ApprovedOperationsJSON})
}
var submissions []model.CreationSubmission
if err := r.db.Where("user_id = ? AND revoked_at IS NULL", userID).Find(&submissions).Error; err != nil {
return snapshot, err
}
for _, submission := range submissions {
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "创作执行项", ID: submission.ID, Title: submission.ItemKey, PrimaryJSON: submission.RequestJSON})
}
var messages []model.Message
if err := r.db.Select("id", "content", "payload").Where("user_id = ?", userID).Find(&messages).Error; err != nil {

View File

@@ -0,0 +1,903 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
type CreationGuard struct {
ExecutionEpoch int64 `json:"executionEpoch"`
Owner string `json:"owner"`
}
type CreationRequest struct {
CreationGuard
ClientKey string `json:"clientKey"`
CanvasID string `json:"canvasId"`
Revision int64 `json:"revision"`
ExpectedEpoch int64 `json:"expectedEpoch"`
State map[string]any `json:"state"`
Status string `json:"status"`
ProposalVersion int64 `json:"proposalVersion"`
Proposal json.RawMessage `json:"proposal"`
Ops []CreationCanvasOp `json:"ops"`
ItemKey string `json:"itemKey"`
Request CreateTaskRequest `json:"request"`
SubmissionIDs []string `json:"submissionIds"`
SubmissionID string `json:"submissionId"`
ExpectedSnapshotHash string `json:"expectedSnapshotHash"`
Document json.RawMessage `json:"document"`
}
type CreationRunOutput struct {
model.CreationRun
State map[string]any `json:"state"`
}
type CreationQuote struct {
Model string `json:"model"`
BillingMode string `json:"billingMode"`
Quantity int64 `json:"quantity"`
AmountMicrocredits int64 `json:"amountMicrocredits"`
Estimated bool `json:"estimated"`
ExpiresAt time.Time `json:"expiresAt"`
QuoteHash string `json:"quoteHash"`
Options map[string]any `json:"options,omitempty"`
}
type CreationSubmissionOutput struct {
model.CreationSubmission
Quote CreationQuote `json:"quote"`
}
type CreationDetail struct {
Run CreationRunOutput `json:"run"`
Submissions []CreationSubmissionOutput `json:"submissions"`
}
type creationTaskPreparation struct{ Order *model.BillingOrder }
func creationConflict(message string) error {
return &AppError{Status: 409, Code: 409, Message: message}
}
func creationError(err error) error {
if errors.Is(err, repository.ErrInsufficientCredits) {
return BadAuthRequest("积分不足,请充值后重试")
}
if errors.Is(err, repository.ErrActiveTaskLimit) {
return BadAuthRequest("同时排队或运行的任务已达到上限")
}
if errors.Is(err, repository.ErrLogicalModelUnavailable) {
return creationConflict("模型已更新或停用,请重新准备报价")
}
if errors.Is(err, repository.ErrCreationConflict) {
return creationConflict("创作状态已变化,请重新读取后继续")
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return &AppError{Status: 404, Code: 404, Message: "创作记录不存在或无权访问"}
}
return err
}
func creationHash(value any) string {
b, _ := json.Marshal(value)
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func creationRunOutput(run model.CreationRun) CreationRunOutput {
state := map[string]any{}
_ = json.Unmarshal([]byte(run.StateJSON), &state)
return CreationRunOutput{run, state}
}
func creationSubmissionOutput(item model.CreationSubmission) CreationSubmissionOutput {
var quote CreationQuote
_ = json.Unmarshal([]byte(item.QuoteJSON), &quote)
return CreationSubmissionOutput{item, quote}
}
func validateCreationGuard(run *model.CreationRun, guard CreationGuard) error {
if guard.Owner == "" || run.ExecutionOwner != guard.Owner || run.ExecutionEpoch != guard.ExecutionEpoch || run.LeaseExpiresAt == nil || !run.LeaseExpiresAt.After(time.Now()) {
return creationConflict("执行控制权已过期,请在当前页面重新接管")
}
return nil
}
func validateCreationJSON(value any) error {
b, err := json.Marshal(value)
if err != nil || len(b) > 1<<20 {
return BadAuthRequest("创作内容超过限制或格式无效")
}
var decoded any
_ = json.Unmarshal(b, &decoded)
var visit func(any) bool
visit = func(v any) bool {
switch item := v.(type) {
case map[string]any:
for key, child := range item {
switch strings.ToLower(key) {
case "apikey", "secretkey", "authorization", "cookie", "headers", "baseurl", "token", "accesstoken", "refresh_token":
if child != nil && child != "" {
return false
}
}
if !visit(child) {
return false
}
}
case []any:
for _, child := range item {
if !visit(child) {
return false
}
}
case string:
if strings.HasPrefix(item, "data:") || strings.Contains(item, "X-Amz-Signature=") || strings.Contains(item, "X-Tos-Signature=") {
return false
}
}
return true
}
if !visit(decoded) {
return BadAuthRequest("创作记录不能包含密钥、内嵌媒体或临时签名链接")
}
return nil
}
func (s *Service) CreateCreationRun(userID string, req CreationRequest) (*CreationDetail, error) {
if strings.TrimSpace(userID) == "" || strings.TrimSpace(req.ClientKey) == "" || len(req.ClientKey) > 120 {
return nil, BadAuthRequest("缺少稳定会话键")
}
if err := validateCreationJSON(req.State); err != nil {
return nil, err
}
if req.CanvasID != "" {
if _, err := s.repo.CanvasProjectForUser(userID, req.CanvasID); err != nil {
return nil, creationError(err)
}
}
b, _ := json.Marshal(req.State)
run := model.CreationRun{ID: newID(), UserID: userID, ClientKey: req.ClientKey, CreateHash: creationHash([]any{req.CanvasID, req.State}), CanvasID: req.CanvasID, Revision: 1, Status: "idle", StateJSON: string(b)}
if old, e := s.repo.CreationRunByClientKey(userID, req.ClientKey); e == nil {
if old.CreateHash != run.CreateHash {
return nil, creationConflict("同一会话键的内容不同")
}
return s.GetCreationRun(userID, old.ID)
} else if !errors.Is(e, gorm.ErrRecordNotFound) {
return nil, e
}
s.storageMu.Lock()
defer s.storageMu.Unlock()
if err := s.validateCreationStorage(s.repo, userID, true, int64(len(b))); err != nil {
return nil, err
}
if err := s.repo.CreateCreationRun(&run); err != nil {
return nil, creationError(err)
}
return s.GetCreationRun(userID, run.ID)
}
func (s *Service) GetCreationRun(userID, id string) (*CreationDetail, error) {
run, err := s.repo.CreationRun(userID, id)
if err != nil {
return nil, creationError(err)
}
items, err := s.repo.CreationSubmissions(userID, id)
if err != nil {
return nil, err
}
out := &CreationDetail{Run: creationRunOutput(*run), Submissions: []CreationSubmissionOutput{}}
for _, item := range items {
out.Submissions = append(out.Submissions, creationSubmissionOutput(item))
}
return out, nil
}
func (s *Service) ListCreationRuns(userID string) (map[string]any, error) {
items, err := s.repo.CreationRuns(userID)
out := []CreationRunOutput{}
for _, run := range items {
out = append(out, creationRunOutput(run))
}
return map[string]any{"runs": out}, err
}
func (s *Service) ChangeCreationRun(userID, id, action string, req CreationRequest) (any, error) {
if action == "save" || action == "proposal-approve" || action == "proposal-invalidate" {
s.storageMu.Lock()
defer s.storageMu.Unlock()
}
var out any
err := s.repo.MutateCreationRun(userID, id, func(run *model.CreationRun, repo *repository.Repository) error {
previousBytes := len(run.StateJSON) + len(run.ApprovedOperationsJSON) + len(run.ApprovedCanvasJSON)
now := time.Now()
if action == "claim" {
if req.Owner == "" || len(req.Owner) > 120 || req.ExpectedEpoch != run.ExecutionEpoch {
return creationConflict("执行代次已变化")
}
run.ExecutionEpoch++
run.ExecutionOwner = req.Owner
until := now.Add(45 * time.Second)
run.LeaseExpiresAt = &until
} else {
if err := validateCreationGuard(run, req.CreationGuard); err != nil {
return err
}
switch action {
case "heartbeat":
until := now.Add(45 * time.Second)
run.LeaseExpiresAt = &until
out = map[string]any{"leaseExpiresAt": until}
return nil
case "release":
run.LeaseExpiresAt = nil
out = map[string]any{"released": true}
return nil
case "save":
if req.Revision != run.Revision {
return repository.ErrCreationConflict
}
if err := validateCreationJSON(req.State); err != nil {
return err
}
if !strings.Contains("|idle|running|waiting_answer|waiting_proposal|waiting_canvas|waiting_payment|waiting_task|paused|completed|cancelled|", "|"+req.Status+"|") || req.Status == "" {
return BadAuthRequest("创作状态无效")
}
if run.Status == "cancelled" && req.Status != "cancelled" {
return creationConflict("已取消会话不能继续执行")
}
b, _ := json.Marshal(req.State)
run.StateJSON = string(b)
run.Status = req.Status
case "proposal-approve":
if req.Revision != run.Revision || req.ProposalVersion <= 0 {
return repository.ErrCreationConflict
}
if err := validateCreationOps(req.Ops); err != nil {
return err
}
if err := validateCreationJSON(req.Proposal); err != nil {
return err
}
for _, op := range req.Ops {
if key := stringValue(op.Metadata["storageKey"]); key != "" {
if !strings.HasPrefix(key, "resource:") {
return BadAuthRequest("已有素材必须来自当前账号资源库")
}
resource, e := repo.ResourceForUser(userID, strings.TrimPrefix(key, "resource:"))
if e != nil {
return e
}
if resource.Status != model.ResourceStatusReady {
return BadAuthRequest("已有素材尚未就绪")
}
}
}
hash := creationHash([]any{req.Proposal, req.Ops})
if req.ProposalVersion <= run.ApprovedProposalVersion {
if req.ProposalVersion == run.ApprovedProposalVersion && hash == run.ApprovedProposalHash {
out = creationRunOutput(*run)
return nil
}
return repository.ErrCreationConflict
}
if err := repo.RevokeCreationSubmissions(id); err != nil {
return err
}
b, _ := json.Marshal(req.Ops)
run.ApprovedOperationsJSON = string(b)
run.ApprovedProposalVersion = req.ProposalVersion
run.ApprovedProposalHash = hash
run.ApprovedAt = &now
run.ApprovedCanvasJSON = ""
if run.CanvasID != "" {
canvas, e := repo.CanvasProjectForUser(userID, run.CanvasID)
if e != nil {
return e
}
baseline, e := creationApprovalBaseline(canvas.PayloadJSON, req.Ops)
if e != nil {
return e
}
run.ApprovedCanvasJSON = baseline
}
case "proposal-invalidate":
if req.Revision != run.Revision {
return repository.ErrCreationConflict
}
if err := repo.RevokeCreationSubmissions(id); err != nil {
return err
}
run.ApprovedAt = nil
run.ApprovedProposalHash = ""
run.ApprovedOperationsJSON = ""
run.ApprovedCanvasJSON = ""
default:
return BadAuthRequest("未知创作操作")
}
}
run.Revision++
if action == "save" || action == "proposal-approve" {
delta := int64(len(run.StateJSON) + len(run.ApprovedOperationsJSON) + len(run.ApprovedCanvasJSON) - previousBytes)
if e := s.validateCreationStorage(repo, userID, false, delta); e != nil {
return e
}
}
out = creationRunOutput(*run)
return nil
})
return out, creationError(err)
}
func (s *Service) prepareCreationTask(userID string, req CreateTaskRequest) (*model.Task, *model.BillingOrder, string, error) {
config, _ := req.Input["config"].(map[string]any)
if req.LogicalModelID == "" && strings.TrimSpace(stringValue(config["channelId"])) == "" {
return nil, nil, "", BadAuthRequest("智能创作目前仅支持后端受管模型,请在原入口使用其他渠道")
}
if taskInputUsesWorkflowProvider(req.Input) || isTextReplayTaskRequest(req.Input) {
return nil, nil, "", BadAuthRequest("智能创作不支持本机、工作流或文本回放任务")
}
// System selectors are the only authority; discard frontend sentinel credentials.
safeConfig := map[string]any{}
for _, key := range []string{"channelId", "channelModelKey", "model", "priceTierId", "apiFormat", "interfaceType", "size", "quality", "transparentBackground", "count", "videoSeconds", "vquality", "videoGenerateAudio", "videoWatermark", "videoArkPrivateAssetUpload", "systemPrompt"} {
if value, ok := config[key]; ok {
safeConfig[key] = value
}
}
config = safeConfig
req.Input["config"] = safeConfig
if err := validateCreationJSON(req); err != nil {
return nil, nil, "", err
}
if req.Type != "canvas_text" && req.Type != "text" && req.Type != "agent_storyboard_rows" && req.Type != "canvas_image" && req.Type != "canvas_video" {
return nil, nil, "", BadAuthRequest("智能创作任务类型不受支持")
}
expectedMode := map[string]string{"text": "text", "canvas_text": "text", "agent_storyboard_rows": "text", "canvas_image": "image", "canvas_video": "video"}[req.Type]
if stringValue(req.Input["mode"]) != expectedMode || strings.TrimSpace(stringValue(req.Input["prompt"])) != strings.TrimSpace(req.Prompt) {
return nil, nil, "", BadAuthRequest("任务类型、模式和实际提示词必须一致")
}
if req.SessionID != "" {
return nil, nil, "", BadAuthRequest("智能创作不能写入旧会话")
}
if expectedMode != "text" && req.Input["agentRequests"] != nil {
return nil, nil, "", BadAuthRequest("媒体任务不允许携带独立模型协议请求")
}
if count := stringValue(config["count"]); count != "" && count != "1" {
return nil, nil, "", BadAuthRequest("每个获批执行项只能生成一个产物")
}
if req.Input["mask"] != nil {
return nil, nil, "", BadAuthRequest("本期智能创作暂不支持蒙版任务")
}
if expectedMode != "text" {
canvas, e := s.repo.CanvasProjectForUser(userID, req.ProjectID)
if e != nil {
return nil, nil, "", creationError(e)
}
doc, e := creationDocument(canvas.PayloadJSON)
if e != nil {
return nil, nil, "", e
}
nodes, e := creationObjects(doc["nodes"])
if e != nil {
return nil, nil, "", e
}
if refs, ok := req.Input["referenceImages"].([]any); ok {
for _, raw := range refs {
ref, _ := raw.(map[string]any)
node := nodes[stringValue(ref["id"])]
meta, _ := node["metadata"].(map[string]any)
if node == nil || node["type"] != "image" || meta["status"] != "success" || stringValue(ref["storageKey"]) != stringValue(meta["storageKey"]) {
return nil, nil, "", creationConflict("参考素材已变化或尚未就绪")
}
}
}
}
for _, name := range []string{"referenceImages", "referenceVideos", "referenceAudios"} {
if list, ok := req.Input[name].([]any); ok {
for _, raw := range list {
media, ok := raw.(map[string]any)
if !ok {
return nil, nil, "", BadAuthRequest("素材引用格式无效")
}
key := stringValue(media["storageKey"])
if !strings.HasPrefix(key, "resource:") {
return nil, nil, "", BadAuthRequest("请先将参考素材保存到当前账号资源库")
}
resource, err := s.repo.ResourceForUser(userID, strings.TrimPrefix(key, "resource:"))
if err != nil {
return nil, nil, "", creationError(err)
}
if resource.Status != "ready" {
return nil, nil, "", BadAuthRequest("参考素材尚未就绪")
}
delete(media, "url")
delete(media, "dataUrl")
media["bytes"] = resource.Size
media["durationMs"] = resource.DurationMs
}
}
}
prepared := &creationTaskPreparation{}
req.creationPrepare = prepared
task, err := s.CreateTask(userID, req)
if err != nil {
return nil, nil, "", err
}
var input map[string]any
_ = json.Unmarshal([]byte(task.InputJSON), &input)
resolved, _ := input["config"].(map[string]any)
for _, key := range []string{"size", "videoSeconds", "vquality", "quality", "count"} {
if requested := stringValue(config[key]); requested != "" && !strings.EqualFold(requested, stringValue(resolved[key])) {
return nil, nil, "", creationConflict("模型解析后的生成规格与请求不同,请调整方案后重新报价")
}
}
channel, channelErr := s.repo.SystemChannel(stringValue(resolved["channelId"]))
if channelErr != nil {
return nil, nil, "", channelErr
}
if channel.AllowLocalChannel {
return nil, nil, "", BadAuthRequest("智能创作暂不支持本机受管渠道")
}
if expectedMode == "text" {
var typed canvasGenerationInput
if err = json.Unmarshal([]byte(task.InputJSON), &typed); err != nil {
return nil, nil, "", err
}
if len(typed.ReferenceImages) > 0 {
cm, e := s.repo.ChannelModelByKey(channel.ID, stringValue(resolved["model"]))
if e != nil {
return nil, nil, "", e
}
profile, e := DecodeModelCapabilityConfig(cm.CapabilityConfigJSON)
if e != nil || profile == nil || profile.Text == nil || profile.Text.References.MaxImages < len(typed.ReferenceImages) {
return nil, nil, "", BadAuthRequest("当前文本模型未配置足够的图片理解能力")
}
}
if err = validateAgentResourcePlaceholders(typed); err != nil {
return nil, nil, "", err
}
}
sig, err := s.repo.CreationPriceSignature(task, stringValue(resolved["channelId"]), stringValue(resolved["model"]))
return task, prepared.Order, sig, err
}
func creationQuoteFor(task *model.Task, order *model.BillingOrder, signature string, expires time.Time) CreationQuote {
quote := CreationQuote{Model: task.Model, BillingMode: "free", Quantity: 1, ExpiresAt: expires}
if order != nil {
quote.BillingMode = order.BillingMode
quote.Quantity = order.Quantity
quote.AmountMicrocredits = order.AmountMicrocredits
quote.Estimated = order.BillingMode == "token"
}
var input map[string]any
_ = json.Unmarshal([]byte(task.InputJSON), &input)
config, _ := input["config"].(map[string]any)
quote.Options = map[string]any{}
for _, key := range []string{"size", "videoSeconds", "vquality", "quality", "maxTokens"} {
if value, ok := config[key]; ok {
quote.Options[key] = value
}
}
quote.QuoteHash = creationHash([]any{signature, quote.BillingMode, quote.Quantity, quote.AmountMicrocredits, quote.Options})
return quote
}
func validateCreationSubmissionScope(run *model.CreationRun, version int64, req CreateTaskRequest) error {
if run.Status == "paused" || run.Status == "cancelled" || run.Status == "completed" {
return creationConflict("请先恢复创作任务")
}
if req.Type == "canvas_text" || req.Type == "text" || req.Type == "agent_storyboard_rows" {
if req.ProjectID != "" && req.ProjectID != run.CanvasID {
return creationConflict("规划任务的画布关联不匹配")
}
return nil
}
if run.CanvasID == "" || req.ProjectID != run.CanvasID {
return creationConflict("媒体任务必须提交到当前创作画布")
}
if run.ApprovedAt == nil || version != run.ApprovedProposalVersion || run.ApprovedProposalHash == "" {
return creationConflict("请先确认当前方案")
}
var ops []CreationCanvasOp
_ = json.Unmarshal([]byte(run.ApprovedOperationsJSON), &ops)
nodeID := stringValue(req.Input["nodeId"])
metadata, _ := req.Input["metadata"].(map[string]any)
if nodeID == "" {
nodeID = stringValue(metadata["nodeId"])
}
for _, op := range ops {
if op.ID != nodeID || nodeID == "" {
continue
}
if op.Type != "add_node" && op.Type != "update_node" {
continue
}
meta := op.Metadata
if nested, ok := op.Patch["metadata"].(map[string]any); ok {
meta = mergeCreationMaps(nested, meta)
}
prompt := stringValue(meta["prompt"])
if prompt == "" {
prompt = stringValue(meta["text"])
}
if strings.TrimSpace(req.Prompt) != strings.TrimSpace(prompt) || prompt == "" {
return creationConflict("任务提示词已超出获批方案")
}
if stringValue(meta["model"]) == "" || stringValue(meta["model"]) != req.Model {
return creationConflict("模型与已批准方案不同")
}
config, _ := req.Input["config"].(map[string]any)
for _, key := range []string{"size", "videoSeconds", "vquality", "quality"} {
metadataKey := key
if key == "videoSeconds" {
metadataKey = "seconds"
}
if approved := stringValue(meta[metadataKey]); approved != "" && approved != stringValue(config[key]) {
return creationConflict("生成规格与已批准方案不同")
}
}
refs, _ := meta["referenceNodeIds"].([]any)
images, _ := req.Input["referenceImages"].([]any)
if len(refs) != len(images) {
return creationConflict("参考素材数量与已批准方案不同")
}
for index, ref := range refs {
image, _ := images[index].(map[string]any)
if stringValue(ref) != stringValue(image["id"]) {
return creationConflict("参考素材与已批准方案不同")
}
}
for _, kind := range []string{"referenceVideos", "referenceAudios"} {
if values, ok := req.Input[kind].([]any); ok && len(values) > 0 {
return creationConflict("本期方案尚未授权视频或音频参考输入")
}
}
return nil
}
return creationConflict("任务节点不在已批准方案范围内")
}
func (s *Service) PrepareCreationSubmission(userID, id string, req CreationRequest) (*CreationSubmissionOutput, error) {
if req.ItemKey == "" || len(req.ItemKey) > 160 {
return nil, BadAuthRequest("缺少稳定执行项键")
}
if strings.HasPrefix(req.ItemKey, "requote:") {
return nil, BadAuthRequest("该执行项键由报价刷新接口保留")
}
run, err := s.repo.CreationRun(userID, id)
if err != nil {
return nil, creationError(err)
}
if err = validateCreationGuard(run, req.CreationGuard); err != nil {
return nil, err
}
if err = validateCreationSubmissionScope(run, req.ProposalVersion, req.Request); err != nil {
return nil, err
}
item, original, err := s.buildCreationSubmission(userID, run, req.ItemKey, req.ProposalVersion, req.Request)
if err != nil {
return nil, err
}
s.storageMu.Lock()
defer s.storageMu.Unlock()
err = s.repo.MutateCreationRun(userID, id, func(current *model.CreationRun, repo *repository.Repository) error {
if e := validateCreationGuard(current, req.CreationGuard); e != nil {
return e
}
if e := validateCreationSubmissionScope(current, req.ProposalVersion, original); e != nil {
return e
}
items, e := repo.CreationSubmissions(userID, id)
if e != nil {
return e
}
for _, old := range items {
if old.ItemKey == req.ItemKey {
if old.RequestHash != item.RequestHash || old.ProposalVersion != item.ProposalVersion {
return repository.ErrCreationConflict
}
item = old
return nil
}
}
if e = s.validateCreationStorage(repo, userID, false, int64(len(item.RequestJSON)+len(item.QuoteJSON)+len(item.PriceSignature))); e != nil {
return e
}
return repo.SaveCreationSubmission(&item)
})
if err != nil {
return nil, creationError(err)
}
out := creationSubmissionOutput(item)
return &out, nil
}
func (s *Service) buildCreationSubmission(userID string, run *model.CreationRun, itemKey string, proposalVersion int64, request CreateTaskRequest) (model.CreationSubmission, CreateTaskRequest, error) {
// JSON normalization also isolates the immutable snapshot from mutable caller maps.
b, err := json.Marshal(request)
if err != nil {
return model.CreationSubmission{}, CreateTaskRequest{}, err
}
var normalized CreateTaskRequest
if err = json.Unmarshal(b, &normalized); err != nil {
return model.CreationSubmission{}, normalized, err
}
task, order, signature, err := s.prepareCreationTask(userID, normalized)
if err != nil {
return model.CreationSubmission{}, normalized, err
}
expires := time.Now().Add(5 * time.Minute)
quote := creationQuoteFor(task, order, signature, expires)
quoteJSON, _ := json.Marshal(quote)
requestJSON, _ := json.Marshal(normalized)
item := model.CreationSubmission{ID: newID(), UserID: userID, RunID: run.ID, ItemKey: itemKey, ProposalVersion: proposalVersion, ProposalHash: run.ApprovedProposalHash, RequestJSON: string(requestJSON), RequestHash: creationHash(normalized), QuoteJSON: string(quoteJSON), PriceSignature: signature, ExpiresAt: expires}
return item, normalized, nil
}
// Refresh creates an unapproved successor. The old quote and approval remain immutable.
func (s *Service) RefreshCreationSubmission(userID, id string, req CreationRequest) (*CreationSubmissionOutput, error) {
run, err := s.repo.CreationRun(userID, id)
if err != nil {
return nil, creationError(err)
}
if err = validateCreationGuard(run, req.CreationGuard); err != nil {
return nil, err
}
old, err := s.repo.CreationSubmission(userID, id, req.SubmissionID)
if err != nil {
return nil, creationError(err)
}
if old.TaskID != nil {
return nil, creationConflict("任务已提交,请查看原任务,不需要刷新报价")
}
successorKey := "requote:" + old.ID
items, err := s.repo.CreationSubmissions(userID, id)
if err != nil {
return nil, err
}
for _, item := range items {
if item.ItemKey == successorKey {
out := creationSubmissionOutput(item)
return &out, nil
}
}
if old.RevokedAt != nil {
return nil, creationConflict("原方案已撤销,请重新准备生成项")
}
var request CreateTaskRequest
if err = json.Unmarshal([]byte(old.RequestJSON), &request); err != nil {
return nil, err
}
if err = validateCreationSubmissionScope(run, old.ProposalVersion, request); err != nil {
return nil, err
}
if old.ProposalVersion > 0 && old.ProposalHash != run.ApprovedProposalHash {
return nil, creationConflict("原方案已变化,请重新准备生成项")
}
item, normalized, err := s.buildCreationSubmission(userID, run, successorKey, old.ProposalVersion, request)
if err != nil {
return nil, err
}
s.storageMu.Lock()
defer s.storageMu.Unlock()
err = s.repo.MutateCreationRun(userID, id, func(current *model.CreationRun, repo *repository.Repository) error {
if e := validateCreationGuard(current, req.CreationGuard); e != nil {
return e
}
fresh, e := repo.CreationSubmission(userID, id, old.ID)
if e != nil {
return e
}
if fresh.TaskID != nil {
return creationConflict("任务已经提交,不能刷新报价")
}
all, e := repo.CreationSubmissions(userID, id)
if e != nil {
return e
}
for _, existing := range all {
if existing.ItemKey == successorKey {
item = existing
return nil
}
}
if fresh.RevokedAt != nil {
return creationConflict("原报价已撤销")
}
if e = validateCreationSubmissionScope(current, fresh.ProposalVersion, normalized); e != nil {
return e
}
if fresh.ProposalVersion > 0 && fresh.ProposalHash != current.ApprovedProposalHash {
return repository.ErrCreationConflict
}
if e = s.validateCreationStorage(repo, userID, false, int64(len(item.RequestJSON)+len(item.QuoteJSON)+len(item.PriceSignature))); e != nil {
return e
}
now := time.Now()
fresh.RevokedAt = &now
if e = repo.SaveCreationSubmission(fresh); e != nil {
return e
}
return repo.SaveCreationSubmission(&item)
})
if err != nil {
return nil, creationError(err)
}
out := creationSubmissionOutput(item)
return &out, nil
}
func (s *Service) validateCreationStorage(repo *repository.Repository, userID string, creating bool, delta int64) error {
usage, err := repo.UserStorageUsage(userID)
if err != nil {
return err
}
count, bytes, err := repo.CreationStorageUsage(userID)
if err != nil {
return err
}
usage.SessionCount += count
usage.SessionBytes += bytes
policy, err := s.RuntimePolicy()
if err != nil {
return err
}
return validateStructuredStorageQuotaWithPolicy(usage, "session", creating, delta, policy.Resource)
}
func (s *Service) ApproveCreationSubmissions(userID, id string, req CreationRequest) (map[string]any, error) {
if len(req.SubmissionIDs) == 0 || len(req.SubmissionIDs) > 20 {
return nil, BadAuthRequest("请选择 1 到 20 项生成任务")
}
// Compute current prices before the write transaction; transaction checks their config signatures again.
prepared := map[string]*model.Task{}
signatures := map[string]string{}
for _, sid := range req.SubmissionIDs {
item, err := s.repo.CreationSubmission(userID, id, sid)
if err != nil {
return nil, creationError(err)
}
var request CreateTaskRequest
_ = json.Unmarshal([]byte(item.RequestJSON), &request)
task, order, sig, err := s.prepareCreationTask(userID, request)
if err != nil {
return nil, err
}
if creationQuoteFor(task, order, sig, item.ExpiresAt).QuoteHash != creationSubmissionOutput(*item).Quote.QuoteHash {
return nil, creationConflict("报价已变化,请重新准备并确认")
}
prepared[sid] = task
signatures[sid] = sig
}
out := []CreationSubmissionOutput{}
err := s.repo.MutateCreationRun(userID, id, func(run *model.CreationRun, repo *repository.Repository) error {
if e := validateCreationGuard(run, req.CreationGuard); e != nil {
return e
}
now := time.Now()
for _, sid := range req.SubmissionIDs {
item, e := repo.CreationSubmission(userID, id, sid)
if e != nil {
return e
}
if item.RevokedAt != nil || !item.ExpiresAt.After(now) {
return creationConflict("报价已过期或撤销")
}
var request CreateTaskRequest
_ = json.Unmarshal([]byte(item.RequestJSON), &request)
if e = validateCreationSubmissionScope(run, item.ProposalVersion, request); e != nil {
return e
}
if item.ProposalVersion > 0 && item.ProposalHash != run.ApprovedProposalHash {
return repository.ErrCreationConflict
}
if e = checkCreationPriceSignature(repo, prepared[sid], signatures[sid]); e != nil {
return e
}
if item.ApprovedAt == nil {
item.ApprovedAt = &now
if e = repo.SaveCreationSubmission(item); e != nil {
return e
}
}
out = append(out, creationSubmissionOutput(*item))
}
return nil
})
return map[string]any{"submissions": out}, creationError(err)
}
func checkCreationPriceSignature(repo *repository.Repository, task *model.Task, want string) error {
var input map[string]any
_ = json.Unmarshal([]byte(task.InputJSON), &input)
config, _ := input["config"].(map[string]any)
got, err := repo.CreationPriceSignature(task, stringValue(config["channelId"]), stringValue(config["model"]))
if err != nil {
return err
}
if got != want {
return creationConflict("模型或价格配置已更新,请重新确认")
}
return nil
}
func (s *Service) ExecuteCreationSubmission(userID, id string, req CreationRequest) (*model.Task, error) {
run, err := s.repo.CreationRun(userID, id)
if err != nil {
return nil, creationError(err)
}
if err = validateCreationGuard(run, req.CreationGuard); err != nil {
return nil, err
}
item, err := s.repo.CreationSubmission(userID, id, req.SubmissionID)
if err != nil {
return nil, creationError(err)
}
if item.TaskID != nil {
task, e := s.repo.TaskForUser(userID, *item.TaskID)
if e != nil {
return nil, e
}
return taskForOutput(*task), nil
}
var request CreateTaskRequest
if err = json.Unmarshal([]byte(item.RequestJSON), &request); err != nil {
return nil, err
}
if err = validateCreationSubmissionScope(run, item.ProposalVersion, request); err != nil {
return nil, err
}
task, order, signature, err := s.prepareCreationTask(userID, request)
if err != nil {
return nil, err
}
if creationQuoteFor(task, order, signature, item.ExpiresAt).QuoteHash != creationSubmissionOutput(*item).Quote.QuoteHash {
return nil, creationConflict("报价已变化,请重新确认")
}
var input map[string]any
_ = json.Unmarshal([]byte(task.InputJSON), &input)
if err = s.protectTaskSecrets(input); err != nil {
return nil, err
}
b, _ := json.Marshal(input)
task.InputJSON = string(b)
task.CreationSubmissionID = &item.ID
if order != nil {
task.BillingOrderID = order.ID
}
policy, err := s.RuntimePolicy()
if err != nil {
return nil, err
}
s.storageMu.Lock()
defer s.storageMu.Unlock()
err = s.repo.MutateCreationRun(userID, id, func(current *model.CreationRun, repo *repository.Repository) error {
if e := validateCreationGuard(current, req.CreationGuard); e != nil {
return e
}
fresh, e := repo.CreationSubmission(userID, id, item.ID)
if e != nil {
return e
}
if fresh.TaskID != nil {
existing, e := repo.TaskForUser(userID, *fresh.TaskID)
if e != nil {
return e
}
task = existing
return nil
}
if fresh.ApprovedAt == nil || fresh.RevokedAt != nil || !fresh.ExpiresAt.After(time.Now()) {
return creationConflict("任务尚未批准或报价已过期")
}
if e = validateCreationSubmissionScope(current, fresh.ProposalVersion, request); e != nil {
return e
}
if fresh.ProposalVersion > 0 && fresh.ProposalHash != current.ApprovedProposalHash {
return repository.ErrCreationConflict
}
if e = checkCreationPriceSignature(repo, task, signature); e != nil {
return e
}
if e = createTaskWithStorageQuotaRepository(repo, task, order, policy); e != nil {
return e
}
fresh.TaskID = &task.ID
if e = repo.SaveCreationSubmission(fresh); e != nil {
return e
}
return nil
})
if err != nil {
return nil, creationError(err)
}
return taskForOutput(*task), nil
}

View File

@@ -0,0 +1,170 @@
package service
import (
"encoding/json"
"strings"
)
func validateAgentResourcePlaceholders(input canvasGenerationInput) error {
_, err := resolveAgentResourcePlaceholders(input, false)
return err
}
// Only hydrated references can replace protocol placeholders. The returned request
// is an in-memory copy and must never be written back to Task.InputJSON.
func resolveAgentResourcePlaceholders(input canvasGenerationInput, hydrate bool) (canvasGenerationInput, error) {
if input.AgentRequests == nil {
return input, nil
}
if hydrate {
encoded, err := json.Marshal(input.AgentRequests)
if err != nil {
return input, err
}
if !strings.Contains(string(encoded), `"resource:`) {
return input, nil
}
}
references := map[string]string{}
for _, media := range input.ReferenceImages {
if !strings.HasPrefix(media.StorageKey, "resource:") {
return input, BadAuthRequest("规划图片必须引用当前账号资源")
}
value := media.StorageKey
if hydrate {
value = firstNonEmpty(media.DataURL, media.URL)
if value == "" {
return input, BadAuthRequest("规划图片尚未读取成功")
}
}
references[media.StorageKey] = value
}
b, err := json.Marshal(input.AgentRequests)
if err != nil {
return input, err
}
var root any
if err = json.Unmarshal(b, &root); err != nil {
return input, err
}
var visit func(any) (any, error)
visit = func(value any) (any, error) {
switch typed := value.(type) {
case string:
if strings.HasPrefix(typed, "resource:") {
resolved, ok := references[typed]
if !ok {
return nil, BadAuthRequest("模型协议引用了未获准的图片")
}
return resolved, nil
}
return typed, nil
case []any:
for i, child := range typed {
next, e := visit(child)
if e != nil {
return nil, e
}
typed[i] = next
}
return typed, nil
case map[string]any:
if !hydrate {
var imageRef any
switch typed["type"] {
case "image_url":
imageRef = typed["image_url"]
case "input_image":
imageRef = typed["image_url"]
case "image":
source, _ := typed["source"].(map[string]any)
if source["type"] != "url" {
return nil, BadAuthRequest("规划图片必须使用资源占位")
}
imageRef = source["url"]
}
if object, ok := imageRef.(map[string]any); ok {
imageRef = object["url"]
}
if imageRef != nil {
ref, ok := imageRef.(string)
if !ok || references[ref] == "" {
return nil, BadAuthRequest("规划图片不在获准资源清单")
}
}
if raw, ok := typed["fileData"].(map[string]any); ok {
ref, _ := raw["fileUri"].(string)
if references[ref] == "" {
return nil, BadAuthRequest("规划图片不在获准资源清单")
}
}
if typed["inlineData"] != nil {
return nil, BadAuthRequest("规划图片不能持久化内嵌数据")
}
}
// Claude URL source and Gemini fileData need protocol-specific base64 envelopes.
if hydrate && typed["type"] == "url" {
if ref, ok := typed["url"].(string); ok && strings.HasPrefix(ref, "resource:") {
data, ok := references[ref]
if !ok {
return nil, BadAuthRequest("模型图片未获准")
}
mime, payload, ok := splitCreationDataURL(data)
if !ok {
return nil, BadAuthRequest("模型图片需要可读取的图片数据")
}
return map[string]any{"type": "base64", "media_type": mime, "data": payload}, nil
}
}
if hydrate {
if raw, ok := typed["fileData"].(map[string]any); ok {
if ref, ok := raw["fileUri"].(string); ok && strings.HasPrefix(ref, "resource:") {
data, ok := references[ref]
if !ok {
return nil, BadAuthRequest("模型图片未获准")
}
mime, payload, ok := splitCreationDataURL(data)
if !ok {
return nil, BadAuthRequest("模型图片需要可读取的图片数据")
}
delete(typed, "fileData")
typed["inlineData"] = map[string]any{"mimeType": mime, "data": payload}
}
}
}
for key, child := range typed {
next, e := visit(child)
if e != nil {
return nil, e
}
typed[key] = next
}
return typed, nil
}
return value, nil
}
root, err = visit(root)
if err != nil {
return input, err
}
b, err = json.Marshal(root)
if err != nil {
return input, err
}
var requests agentToolRequests
if err = json.Unmarshal(b, &requests); err != nil {
return input, err
}
input.AgentRequests = &requests
return input, nil
}
func splitCreationDataURL(value string) (string, string, bool) {
if !strings.HasPrefix(value, "data:") {
return "", "", false
}
parts := strings.SplitN(strings.TrimPrefix(value, "data:"), ";base64,", 2)
if len(parts) != 2 || !strings.HasPrefix(parts[0], "image/") {
return "", "", false
}
return parts[0], parts[1], true
}

View File

@@ -0,0 +1,56 @@
package service
import (
"encoding/json"
"strings"
"testing"
)
func TestCreationAgentImagesResolveOnlyApprovedResources(t *testing.T) {
input := canvasGenerationInput{ReferenceImages: []providerMedia{{StorageKey: "resource:allowed", DataURL: "data:image/png;base64,aW1hZ2U="}}, AgentRequests: &agentToolRequests{
ChatCompletion: map[string]any{"messages": []any{map[string]any{"role": "user", "content": []any{map[string]any{"type": "image_url", "image_url": map[string]any{"url": "resource:allowed"}}}}}},
Claude: map[string]any{"messages": []any{map[string]any{"content": []any{map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": "resource:allowed"}}}}}},
Gemini: map[string]any{"contents": []any{map[string]any{"parts": []any{map[string]any{"fileData": map[string]any{"fileUri": "resource:allowed", "mimeType": "image/png"}}}}}},
}}
if err := validateAgentResourcePlaceholders(input); err != nil {
t.Fatal(err)
}
resolved, err := resolveAgentResourcePlaceholders(input, true)
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(resolved.AgentRequests)
text := string(b)
for _, want := range []string{"data:image/png;base64,aW1hZ2U=", `"type":"base64"`, `"inlineData"`} {
if !strings.Contains(text, want) {
t.Fatalf("missing protocol image: %s", want)
}
}
original, _ := json.Marshal(input.AgentRequests)
if strings.Contains(string(original), "base64") {
t.Fatal("persistable protocol mutated")
}
input.AgentRequests.Responses = map[string]any{"input": []any{map[string]any{"image_url": "resource:other"}}}
if err := validateAgentResourcePlaceholders(input); err == nil {
t.Fatal("unlisted resource allowed")
}
}
func TestCreationManagedConfigSecretsAreNotPersisted(t *testing.T) {
s, _, id, guard := creationTestService(t)
request := creationTextRequest()
config := request.Input["config"].(map[string]any)
config["apiKey"] = "system-sentinel"
config["baseUrl"] = "/api/ai/system/channel"
item, err := s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "sanitized", Request: request})
if err != nil {
t.Fatal(err)
}
stored, err := s.repo.CreationSubmission("user", id, item.ID)
if err != nil {
t.Fatal(err)
}
if strings.Contains(stored.RequestJSON, "system-sentinel") || strings.Contains(stored.RequestJSON, "baseUrl") {
t.Fatal("sentinel config persisted")
}
}

View File

@@ -0,0 +1,581 @@
package service
import (
"encoding/json"
"reflect"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
type CreationCanvasOp struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
NodeType string `json:"nodeType,omitempty"`
Title string `json:"title,omitempty"`
Position map[string]any `json:"position,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
Width *float64 `json:"width,omitempty"`
Height *float64 `json:"height,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Patch map[string]any `json:"patch,omitempty"`
FromNodeID string `json:"fromNodeId,omitempty"`
ToNodeID string `json:"toNodeId,omitempty"`
FromHandleID string `json:"fromHandleId,omitempty"`
ToHandleID string `json:"toHandleId,omitempty"`
IDs []string `json:"ids,omitempty"`
}
func validateCreationOps(ops []CreationCanvasOp) error {
if len(ops) == 0 || len(ops) > 100 {
return BadAuthRequest("方案必须包含 1 到 100 项明确画布操作")
}
if err := validateCreationJSON(ops); err != nil {
return err
}
ids := map[string]bool{}
for _, op := range ops {
switch op.Type {
case "add_node":
if op.ID == "" || ids[op.ID] {
return BadAuthRequest("新增节点必须使用不重复的稳定 ID")
}
ids[op.ID] = true
switch op.NodeType {
case "image", "video", "text", "markdown", "frame", "script":
default:
return BadAuthRequest("该节点类型不在本期创作范围")
}
case "update_node":
if op.ID == "" {
return BadAuthRequest("更新节点缺少 ID")
}
for key := range op.Patch {
switch key {
case "title", "position", "width", "height", "metadata":
default:
return BadAuthRequest("方案包含不支持的节点更新字段")
}
}
case "connect_nodes":
if op.ID == "" || op.FromNodeID == "" || op.ToNodeID == "" {
return BadAuthRequest("连线必须有稳定 ID 和两个端点")
}
case "select_nodes":
default:
return BadAuthRequest("创作方案仅允许新增、连线、更新和选择节点")
}
}
return nil
}
func mergeCreationMaps(left, right map[string]any) map[string]any {
out := map[string]any{}
for k, v := range left {
out[k] = v
}
for k, v := range right {
out[k] = v
}
return out
}
func creationDocument(raw string) (map[string]any, error) {
var doc map[string]any
err := json.Unmarshal([]byte(raw), &doc)
if doc == nil && err == nil {
return nil, BadAuthRequest("画布文档格式无效")
}
return doc, err
}
func creationApprovalBaseline(raw string, ops []CreationCanvasOp) (string, error) {
doc, err := creationDocument(raw)
if err != nil {
return "", err
}
nodes, err := creationObjects(doc["nodes"])
if err != nil {
return "", err
}
selected := []any{}
for _, op := range ops {
if op.Type != "update_node" {
continue
}
node := nodes[op.ID]
if node == nil {
return "", creationConflict("待修改节点不存在")
}
baseline := map[string]any{"id": op.ID}
for key := range op.Patch {
if key != "metadata" {
baseline[key] = node[key]
}
}
metadata, _ := node["metadata"].(map[string]any)
patch, _ := op.Patch["metadata"].(map[string]any)
saved := map[string]any{}
for key := range mergeCreationMaps(patch, op.Metadata) {
saved[key] = metadata[key]
}
baseline["metadata"] = saved
selected = append(selected, baseline)
}
value := map[string]any{"nodes": selected}
if err = validateCreationJSON(value); err != nil {
return "", err
}
b, err := json.Marshal(value)
return string(b), err
}
func (s *Service) CreationCanvasSnapshot(userID, id string) (map[string]any, error) {
run, err := s.repo.CreationRun(userID, id)
if err != nil {
return nil, creationError(err)
}
canvas, err := s.repo.CanvasProjectForUser(userID, run.CanvasID)
if err != nil {
return nil, creationError(err)
}
doc, err := creationDocument(canvas.PayloadJSON)
return map[string]any{"document": doc, "snapshotHash": creationHash(doc)}, err
}
func (s *Service) CreateRunCanvas(userID, id string, req CreationRequest) (map[string]any, error) {
s.storageMu.Lock()
defer s.storageMu.Unlock()
var out map[string]any
err := s.repo.MutateCreationRun(userID, id, func(run *model.CreationRun, repo *repository.Repository) error {
if err := validateCreationGuard(run, req.CreationGuard); err != nil {
return err
}
if run.Status == "paused" || run.Status == "cancelled" {
return creationConflict("请先恢复创作任务")
}
if run.ApprovedAt == nil {
return creationConflict("请先确认方案")
}
if run.CanvasID != "" {
if _, err := repo.CanvasProjectForUser(userID, run.CanvasID); err != nil {
return creationConflict("已关联画布失效,请核对后继续")
}
out = map[string]any{"run": creationRunOutput(*run), "canvasId": run.CanvasID}
return nil
}
now := time.Now()
cid := newID()
doc := map[string]any{"id": cid, "title": "智能创作", "createdAt": now.Format(time.RFC3339Nano), "updatedAt": now.Format(time.RFC3339Nano), "nodes": []any{}, "connections": []any{}, "chatSessions": []any{}, "activeChatId": nil, "backgroundMode": "dots", "showImageInfo": true, "viewport": map[string]any{"x": 0, "y": 0, "k": 1}, "directorScenes": []any{}}
raw, _ := json.Marshal(doc)
canvas := model.CanvasProject{ID: cid, UserID: userID, Title: "智能创作", PayloadJSON: string(raw), CreatedAt: now, UpdatedAt: now}
policy, err := s.RuntimePolicy()
if err != nil {
return err
}
usage, err := repo.UserStorageUsage(userID)
if err != nil {
return err
}
if err = validateStructuredStorageQuotaWithPolicy(usage, "canvas", true, int64(len(raw)), policy.Resource); err != nil {
return err
}
if err = repo.CreateCreationCanvas(&canvas); err != nil {
return err
}
run.CanvasID = cid
run.Revision++
run.Status = "waiting_canvas"
out = map[string]any{"run": creationRunOutput(*run), "canvasId": cid}
return nil
})
return out, creationError(err)
}
func (s *Service) CommitCreationCanvas(userID, id string, req CreationRequest) (map[string]any, error) {
s.storageMu.Lock()
defer s.storageMu.Unlock()
if err := validateSyncedPayload(req.Document, "画布"); err != nil {
return nil, err
}
if err := validateCreationJSON(req.Document); err != nil {
return nil, err
}
if err := s.validateCanvasMediaAssets(userID, req.Document); err != nil {
return nil, err
}
doc, err := creationDocument(string(req.Document))
if err != nil {
return nil, err
}
var out map[string]any
err = s.repo.MutateCreationRun(userID, id, func(run *model.CreationRun, repo *repository.Repository) error {
if err := validateCreationGuard(run, req.CreationGuard); err != nil {
return err
}
if run.Status == "paused" || run.Status == "cancelled" {
return creationConflict("请先恢复创作任务")
}
if run.ApprovedAt == nil {
return creationConflict("当前方案尚未批准")
}
canvas, err := repo.CanvasProjectForUser(userID, run.CanvasID)
if err != nil {
return err
}
before, err := creationDocument(canvas.PayloadJSON)
if err != nil {
return err
}
if creationHash(before) != req.ExpectedSnapshotHash {
return creationConflict("画布已变化,请重新读取后核对")
}
var ops []CreationCanvasOp
if err = json.Unmarshal([]byte(run.ApprovedOperationsJSON), &ops); err != nil {
return err
}
if err = validateCreationCanvasDiff(repo, userID, run, before, doc, ops); err != nil {
return err
}
previous := canvas.PayloadJSON
canvas.PayloadJSON = string(req.Document)
policy, err := s.RuntimePolicy()
if err != nil {
return err
}
usage, err := repo.UserStorageUsage(userID)
if err != nil {
return err
}
if err = validateStructuredStorageQuotaWithPolicy(usage, "canvas", false, int64(len(req.Document)-len(previous)), policy.Resource); err != nil {
return err
}
if err = repo.CompareSaveCreationCanvas(canvas, previous); err != nil {
return err
}
out = map[string]any{"snapshotHash": creationHash(doc)}
return nil
})
return out, creationError(err)
}
func creationObjects(value any) (map[string]map[string]any, error) {
list, ok := value.([]any)
if !ok {
return nil, BadAuthRequest("画布节点或连线格式无效")
}
out := map[string]map[string]any{}
for _, raw := range list {
item, ok := raw.(map[string]any)
if !ok {
return nil, BadAuthRequest("画布对象格式无效")
}
id := stringValue(item["id"])
if id == "" || out[id] != nil {
return nil, BadAuthRequest("画布对象 ID 缺失或重复")
}
out[id] = item
}
return out, nil
}
func validateCreationCanvasDiff(repo *repository.Repository, userID string, run *model.CreationRun, before, after map[string]any, ops []CreationCanvasOp) error {
baselineNodes := map[string]map[string]any{}
if run.ApprovedCanvasJSON != "" {
baseline, e := creationDocument(run.ApprovedCanvasJSON)
if e != nil {
return e
}
baselineNodes, e = creationObjects(baseline["nodes"])
if e != nil {
return e
}
}
for key, value := range before {
if key == "nodes" || key == "connections" || key == "updatedAt" {
continue
}
if !reflect.DeepEqual(value, after[key]) {
return creationConflict("画布提交修改了方案外内容")
}
}
for key := range after {
if _, ok := before[key]; !ok && key != "updatedAt" {
return creationConflict("画布提交包含方案外字段")
}
}
oldNodes, err := creationObjects(before["nodes"])
if err != nil {
return err
}
newNodes, err := creationObjects(after["nodes"])
if err != nil {
return err
}
oldEdges, err := creationObjects(before["connections"])
if err != nil {
return err
}
newEdges, err := creationObjects(after["connections"])
if err != nil {
return err
}
add := map[string]CreationCanvasOp{}
updates := map[string][]CreationCanvasOp{}
edges := map[string]CreationCanvasOp{}
approved := map[string]bool{}
for _, op := range ops {
switch op.Type {
case "add_node":
add[op.ID] = op
approved[op.ID] = true
case "update_node":
updates[op.ID] = append(updates[op.ID], op)
approved[op.ID] = true
case "connect_nodes":
edges[op.ID] = op
}
}
for id, old := range oldNodes {
next := newNodes[id]
if next == nil {
return creationConflict("不允许删除已有节点")
}
if reflect.DeepEqual(old, next) {
continue
}
expected := mergeCreationMaps(old, nil)
metadata, _ := old["metadata"].(map[string]any)
metadata = mergeCreationMaps(metadata, nil)
for _, op := range updates[id] {
if baseline := baselineNodes[id]; baseline != nil {
for key, want := range op.Patch {
if key == "metadata" {
continue
}
if !reflect.DeepEqual(old[key], baseline[key]) && !reflect.DeepEqual(old[key], want) {
return creationConflict("节点已被手工编辑,请重新确认修改范围")
}
}
baselineMeta, _ := baseline["metadata"].(map[string]any)
patchMeta, _ := op.Patch["metadata"].(map[string]any)
for key, want := range mergeCreationMaps(patchMeta, op.Metadata) {
if !reflect.DeepEqual(metadata[key], baselineMeta[key]) && !reflect.DeepEqual(metadata[key], want) {
return creationConflict("节点内容已被手工编辑,请重新确认")
}
}
}
for key, value := range op.Patch {
if key == "metadata" {
m, _ := value.(map[string]any)
metadata = mergeCreationMaps(metadata, m)
} else {
expected[key] = value
}
}
metadata = mergeCreationMaps(metadata, op.Metadata)
}
expected["metadata"] = metadata
if reflect.DeepEqual(expected, next) {
continue
}
if !approved[id] {
return creationConflict("不能修改方案外节点")
}
actualMeta, _ := next["metadata"].(map[string]any)
withoutMeta := mergeCreationMaps(next, nil)
withoutMeta["metadata"] = metadata
if !reflect.DeepEqual(expected, withoutMeta) {
return creationConflict("节点字段超出批准范围")
}
if err := validateCreationResultMetadata(repo, userID, run.ID, id, metadata, actualMeta); err != nil {
return err
}
}
for id, node := range newNodes {
if oldNodes[id] != nil {
continue
}
op, ok := add[id]
if !ok {
return creationConflict("节点未获方案批准")
}
expected := creationAddedNode(op)
if !reflect.DeepEqual(expected, node) {
return creationConflict("新增节点参数与批准方案不同")
}
}
for id, edge := range oldEdges {
if !reflect.DeepEqual(edge, newEdges[id]) {
return creationConflict("不允许修改或删除已有连线")
}
}
for id, edge := range newEdges {
if oldEdges[id] != nil {
continue
}
op, ok := edges[id]
if !ok {
return creationConflict("连线未获批准")
}
expected := map[string]any{"id": id, "fromNodeId": op.FromNodeID, "toNodeId": op.ToNodeID}
if op.FromHandleID != "" {
expected["fromHandleId"] = op.FromHandleID
}
if op.ToHandleID != "" {
expected["toHandleId"] = op.ToHandleID
}
if !reflect.DeepEqual(expected, edge) || newNodes[op.FromNodeID] == nil || newNodes[op.ToNodeID] == nil {
return creationConflict("连线参数与批准范围不同")
}
}
return nil
}
func creationAddedNode(op CreationCanvasOp) map[string]any {
width, height, title := 340.0, 240.0, "Note"
metadata := map[string]any{"content": "", "status": "idle"}
switch op.NodeType {
case "image":
width, height, title = 720, 405, "图片"
case "video":
width, height, title = 720, 405, "视频"
case "markdown":
width, height, title = 420, 320, "Markdown"
case "frame":
width, height, title = 760, 520, "未命名背板"
metadata = map[string]any{"frame": map[string]any{"collapsed": false, "expandedWidth": float64(760), "expandedHeight": float64(520)}}
case "script":
width, height, title = 920, 360, "分镜脚本"
metadata = map[string]any{"status": "idle", "workflowKind": "script", "storyboard": map[string]any{"rows": []any{}, "visibleColumns": []any{"shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"}, "referenceNodeIds": []any{}}}
case "text":
metadata["fontSize"] = float64(14)
}
if op.Width != nil {
width = *op.Width
}
if op.Height != nil {
height = *op.Height
}
if op.Title != "" {
title = op.Title
}
position := op.Position
if position == nil {
position = map[string]any{"x": float64(0), "y": float64(0)}
if op.X != nil {
position["x"] = *op.X
}
if op.Y != nil {
position["y"] = *op.Y
}
}
return map[string]any{"id": op.ID, "type": op.NodeType, "title": title, "position": position, "width": width, "height": height, "metadata": mergeCreationMaps(metadata, op.Metadata)}
}
func validateCreationResultMetadata(repo *repository.Repository, userID, runID, nodeID string, before, after map[string]any) error {
taskID := stringValue(after["taskId"])
if taskID == "" {
taskID = stringValue(after["generationTaskId"])
}
if taskID == "" {
return creationConflict("结果回写缺少真实任务")
}
items, err := repo.CreationSubmissions(userID, runID)
if err != nil {
return err
}
found := false
for _, item := range items {
if item.TaskID == nil || *item.TaskID != taskID {
continue
}
var request CreateTaskRequest
_ = json.Unmarshal([]byte(item.RequestJSON), &request)
if stringValue(request.Input["nodeId"]) == nodeID {
found = true
break
}
}
if !found {
return creationConflict("任务不属于当前创作节点")
}
task, err := repo.TaskForUser(userID, taskID)
if err != nil {
return err
}
if task.Status != model.TaskStatusSucceeded {
return creationConflict("生成任务尚未成功")
}
var result any
if err = json.Unmarshal([]byte(task.ResultJSON), &result); err != nil {
return err
}
storageKey := stringValue(after["storageKey"])
if !strings.HasPrefix(storageKey, "resource:") || !creationResultContains(result, []string{"storageKey"}, storageKey) {
return creationConflict("回写素材不是任务生成资源")
}
resource, err := repo.ResourceForUser(userID, strings.TrimPrefix(storageKey, "resource:"))
if err != nil {
return err
}
if resource.Status != model.ResourceStatusReady {
return creationConflict("任务资源尚未就绪")
}
for key := range before {
if _, ok := after[key]; !ok {
return creationConflict("结果回写不能移除已有字段")
}
}
for key, value := range after {
if reflect.DeepEqual(value, before[key]) {
continue
}
switch key {
case "taskId", "generationTaskId":
if value != taskID {
return creationConflict("任务引用不匹配")
}
case "status":
if value != "success" {
return creationConflict("结果状态不匹配")
}
case "assetId":
assets, e := repo.AssetsForUserIDs(userID, []string{stringValue(value)})
if e != nil {
return e
}
if len(assets) != 1 || !documentReferencesResources(assets[0].PayloadJSON, map[string]struct{}{resource.ID: {}}) {
return creationConflict("素材记录不属于任务结果资源")
}
case "content", "storageKey", "naturalWidth", "naturalHeight", "durationMs", "bytes", "mimeType":
resourceValue := map[string]any{"content": "/api/resources/" + resource.ID + "/file", "storageKey": storageKey, "naturalWidth": float64(resource.Width), "naturalHeight": float64(resource.Height), "durationMs": float64(resource.DurationMs), "bytes": float64(resource.Size), "mimeType": resource.MimeType}
if reflect.DeepEqual(value, resourceValue[key]) {
continue
}
keys := map[string][]string{"content": {"url", "imageUrl", "videoUrl", "content"}, "storageKey": {"storageKey"}, "naturalWidth": {"width", "naturalWidth"}, "naturalHeight": {"height", "naturalHeight"}, "durationMs": {"durationMs"}, "bytes": {"bytes", "size"}, "mimeType": {"mimeType"}}
if !creationResultContains(result, keys[key], value) {
return creationConflict("回写资源字段不属于真实任务结果")
}
default:
return creationConflict("结果回写试图修改手工字段")
}
}
return nil
}
func creationResultContains(value any, keys []string, want any) bool {
switch v := value.(type) {
case map[string]any:
for key, child := range v {
for _, allowed := range keys {
if key == allowed && reflect.DeepEqual(child, want) {
return true
}
}
if creationResultContains(child, keys, want) {
return true
}
}
case []any:
for _, child := range v {
if creationResultContains(child, keys, want) {
return true
}
}
}
return false
}

View File

@@ -0,0 +1,417 @@
package service
import (
"encoding/json"
"path/filepath"
"sync"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"infinite-canvas/backend/internal/database"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
func creationTestService(t *testing.T) (*Service, *gorm.DB, string, CreationGuard) {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "creation.db")+"?_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(database.Models()...); err != nil {
t.Fatal(err)
}
s := &Service{repo: repository.New(db), dataDir: t.TempDir()}
capabilityConfig := mustEncodeModelCapabilityConfig(t, DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "text-test"))
for _, item := range []any{&model.ModelChannel{ID: "channel", Scope: model.ChannelScopeSystem, Enabled: true, Name: "受控测试"}, &model.ChannelModel{ID: "cm", ChannelID: "channel", ModelKey: "text-test", Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, CapabilityConfigJSON: capabilityConfig, BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true}, &model.ChannelModelPriceTier{ID: "tier", ChannelModelID: "cm", SelectorKey: "{}", SelectorJSON: "{}", BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true}, &model.CreditAccount{UserID: "user", AvailableMicrocredits: 10000}} {
if err = db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
detail, err := s.CreateCreationRun("user", CreationRequest{ClientKey: "client", State: map[string]any{}})
if err != nil {
t.Fatal(err)
}
id := detail.Run.ID
if _, err = s.ChangeCreationRun("user", id, "claim", CreationRequest{ExpectedEpoch: 0, CreationGuard: CreationGuard{Owner: "page"}}); err != nil {
t.Fatal(err)
}
return s, db, id, CreationGuard{ExecutionEpoch: 1, Owner: "page"}
}
func creationTextRequest() CreateTaskRequest {
return CreateTaskRequest{Type: "canvas_text", Prompt: "只输出测试", Model: "text-test", Input: map[string]any{"mode": "text", "prompt": "只输出测试", "config": map[string]any{"channelId": "channel", "model": "text-test"}}}
}
func TestCreationStoryboardRequiresApprovalAndCanvasScope(t *testing.T) {
s, _, id, guard := creationTestService(t)
req := creationTextRequest()
req.Type = "agent_storyboard_rows"
req.Operation = "storyboard_rows"
item, err := s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "storyboard", Request: req})
if err != nil {
t.Fatal(err)
}
if _, err = s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID}); err == nil {
t.Fatal("unapproved storyboard executed")
}
if _, err = s.ApproveCreationSubmissions("user", id, CreationRequest{CreationGuard: guard, SubmissionIDs: []string{item.ID}}); err != nil {
t.Fatal(err)
}
task, err := s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID})
if err != nil || task.Type != "agent_storyboard_rows" {
t.Fatalf("professional storyboard not submitted: task=%v error=%v", task, err)
}
if err = validateCreationSubmissionScope(&model.CreationRun{CanvasID: "canvas", Status: "running"}, 0, CreateTaskRequest{Type: "agent_storyboard_rows", ProjectID: "other"}); err == nil {
t.Fatal("cross-canvas storyboard accepted")
}
}
func prepareApprovedCreation(t *testing.T, s *Service, id string, guard CreationGuard) *CreationSubmissionOutput {
t.Helper()
item, err := s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "text-1", Request: creationTextRequest()})
if err != nil {
t.Fatal(err)
}
if _, err = s.ApproveCreationSubmissions("user", id, CreationRequest{CreationGuard: guard, SubmissionIDs: []string{item.ID}}); err != nil {
t.Fatal(err)
}
return item
}
func TestCreationRunOwnershipCASAndFence(t *testing.T) {
s, _, id, guard := creationTestService(t)
if _, err := s.GetCreationRun("other", id); err == nil {
t.Fatal("cross-user read accepted")
}
if _, err := s.ChangeCreationRun("user", id, "save", CreationRequest{CreationGuard: guard, Revision: 1, Status: "running", State: map[string]any{}}); err == nil {
t.Fatal("stale revision accepted")
}
if _, err := s.ChangeCreationRun("user", id, "save", CreationRequest{CreationGuard: guard, Revision: 2, Status: "running", State: map[string]any{"approved": true}}); err != nil {
t.Fatal(err)
}
if _, err := s.CreateRunCanvas("user", id, CreationRequest{CreationGuard: guard}); err == nil {
t.Fatal("untrusted state approved canvas")
}
if _, err := s.ChangeCreationRun("user", id, "claim", CreationRequest{ExpectedEpoch: 1, CreationGuard: CreationGuard{Owner: "new-page"}}); err != nil {
t.Fatal(err)
}
if _, err := s.ChangeCreationRun("user", id, "heartbeat", CreationRequest{CreationGuard: guard}); err == nil {
t.Fatal("old owner renewed lease")
}
if _, err := s.ChangeCreationRun("user", id, "release", CreationRequest{CreationGuard: guard}); err == nil {
t.Fatal("old owner released new lease")
}
}
func TestCreationSubmissionApprovalExpiryAndChangedQuote(t *testing.T) {
s, db, id, guard := creationTestService(t)
item, err := s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "text-1", Request: creationTextRequest()})
if err != nil {
t.Fatal(err)
}
if _, err = s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID}); err == nil {
t.Fatal("unapproved task executed")
}
changed := creationTextRequest()
changed.Prompt = "changed"
changed.Input["prompt"] = "changed"
if _, err = s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "text-1", Request: changed}); err == nil {
t.Fatal("same item accepted different request")
}
if err = db.Model(&model.CreationSubmission{}).Where("id = ?", item.ID).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
t.Fatal(err)
}
if _, err = s.ApproveCreationSubmissions("user", id, CreationRequest{CreationGuard: guard, SubmissionIDs: []string{item.ID}}); err == nil {
t.Fatal("expired quote approved")
}
item, err = s.PrepareCreationSubmission("user", id, CreationRequest{CreationGuard: guard, ItemKey: "text-2", Request: creationTextRequest()})
if err != nil {
t.Fatal(err)
}
if err = db.Model(&model.ChannelModelPriceTier{}).Where("id = ?", "tier").Updates(map[string]any{"unit_price_microcredits": 200, "price_version": 2}).Error; err != nil {
t.Fatal(err)
}
if _, err = s.ApproveCreationSubmissions("user", id, CreationRequest{CreationGuard: guard, SubmissionIDs: []string{item.ID}}); err == nil {
t.Fatal("changed quote approved")
}
var count int64
db.Model(&model.Task{}).Count(&count)
if count != 0 {
t.Fatalf("unexpected tasks: %d", count)
}
}
func TestCreationConcurrentExecuteChargesOnceAndRollback(t *testing.T) {
s, db, id, guard := creationTestService(t)
item := prepareApprovedCreation(t, s, id, guard)
if err := db.Callback().Create().Before("gorm:create").Register("creation_force_failure", func(tx *gorm.DB) {
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "Task" {
tx.AddError(creationConflict("injected failure"))
}
}); err != nil {
t.Fatal(err)
}
if _, err := s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID}); err == nil {
t.Fatal("injected failure not propagated")
}
for _, table := range []any{&model.Task{}, &model.BillingOrder{}, &model.CreditLedgerEntry{}} {
var count int64
db.Model(table).Count(&count)
if count != 0 {
t.Fatalf("partial transaction committed: %T %d", table, count)
}
}
var sub model.CreationSubmission
db.First(&sub, "id = ?", item.ID)
if sub.TaskID != nil || sub.ApprovedAt == nil {
t.Fatal("failed admission consumed approval")
}
if err := db.Callback().Create().Remove("creation_force_failure"); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
ids := make(chan string, 8)
errs := make(chan error, 8)
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
task, err := s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID})
if err != nil {
errs <- err
return
}
ids <- task.ID
}()
}
wg.Wait()
close(errs)
close(ids)
for err := range errs {
t.Error(err)
}
first := ""
for tid := range ids {
if first == "" {
first = tid
}
if first != tid {
t.Fatal("duplicate task")
}
}
for _, table := range []any{&model.Task{}, &model.BillingOrder{}, &model.CreditLedgerEntry{}} {
var count int64
db.Model(table).Count(&count)
if count != 1 {
t.Fatalf("expected one %T, got %d", table, count)
}
}
var account model.CreditAccount
db.First(&account, "user_id = ?", "user")
if account.AvailableMicrocredits != 9900 || account.ReservedMicrocredits != 100 {
t.Fatalf("account = %#v", account)
}
db.Model(&model.CreationSubmission{}).Where("id = ?", item.ID).Update("expires_at", time.Now().Add(-time.Hour))
if task, err := s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: item.ID}); err != nil || task.ID != first {
t.Fatalf("consumed replay: %v %v", task, err)
}
db.Model(&model.Task{}).Where("id = ?", first).Update("status", model.TaskStatusFailed)
if _, err := s.RetryTask("user", first); err == nil {
t.Fatal("legacy retry bypass accepted")
}
}
func TestCreationCanvasScopeAndStableCreate(t *testing.T) {
s, _, id, guard := creationTestService(t)
ops := []CreationCanvasOp{{Type: "add_node", ID: "node", NodeType: "text", Title: "计划", Position: map[string]any{"x": float64(0), "y": float64(0)}, Metadata: map[string]any{"content": "已批准"}}}
if _, err := s.ChangeCreationRun("user", id, "proposal-approve", CreationRequest{CreationGuard: guard, Revision: 2, ProposalVersion: 1, Proposal: json.RawMessage(`{"title":"计划"}`), Ops: ops}); err != nil {
t.Fatal(err)
}
one, err := s.CreateRunCanvas("user", id, CreationRequest{CreationGuard: guard})
if err != nil {
t.Fatal(err)
}
two, err := s.CreateRunCanvas("user", id, CreationRequest{CreationGuard: guard})
if err != nil || one["canvasId"] != two["canvasId"] {
t.Fatal("canvas creation not stable")
}
snapshot, err := s.CreationCanvasSnapshot("user", id)
if err != nil {
t.Fatal(err)
}
doc := snapshot["document"].(map[string]any)
doc["nodes"] = []any{creationAddedNode(ops[0])}
raw, _ := json.Marshal(doc)
if _, err = s.CommitCreationCanvas("user", id, CreationRequest{CreationGuard: guard, ExpectedSnapshotHash: snapshot["snapshotHash"].(string), Document: raw}); err != nil {
t.Fatal(err)
}
if _, err = s.CommitCreationCanvas("user", id, CreationRequest{CreationGuard: guard, ExpectedSnapshotHash: snapshot["snapshotHash"].(string), Document: raw}); err == nil {
t.Fatal("stale canvas hash accepted")
}
snapshot, _ = s.CreationCanvasSnapshot("user", id)
doc = snapshot["document"].(map[string]any)
doc["title"] = "unauthorized"
raw, _ = json.Marshal(doc)
if _, err = s.CommitCreationCanvas("user", id, CreationRequest{CreationGuard: guard, ExpectedSnapshotHash: snapshot["snapshotHash"].(string), Document: raw}); err == nil {
t.Fatal("out-of-scope document accepted")
}
}
func TestCreationResultWritesOnlyBoundResources(t *testing.T) {
s, db, id, _ := creationTestService(t)
sid := "sub-result"
taskID := "task-result"
request := creationTextRequest()
request.Input["nodeId"] = "image-node"
raw, _ := json.Marshal(request)
for _, item := range []any{&model.Resource{ID: "output", UserID: "user", Status: model.ResourceStatusReady, Kind: "media", MimeType: "image/png", Size: 123, Width: 720, Height: 1280}, &model.Asset{ID: "asset-output", UserID: "user", Kind: "image", PayloadJSON: `{"storageKey":"resource:output"}`}, &model.Task{ID: taskID, UserID: "user", CreationSubmissionID: &sid, Status: model.TaskStatusSucceeded, ResultJSON: `{"images":[{"storageKey":"resource:output","url":"/api/resources/output/file"}]}`}, &model.CreationSubmission{ID: sid, UserID: "user", RunID: id, ItemKey: "result", RequestJSON: string(raw), TaskID: &taskID}} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
before := map[string]any{"prompt": "保留手工提示词"}
after := mergeCreationMaps(before, map[string]any{"content": "/api/resources/output/file", "storageKey": "resource:output", "assetId": "asset-output", "generationTaskId": taskID, "status": "success", "naturalWidth": float64(720)})
if err := validateCreationResultMetadata(s.repo, "user", id, "image-node", before, after); err != nil {
t.Fatal(err)
}
after["prompt"] = "覆盖"
if err := validateCreationResultMetadata(s.repo, "user", id, "image-node", before, after); err == nil {
t.Fatal("result changed manual prompt")
}
after["prompt"] = before["prompt"]
after["storageKey"] = "resource:other"
if err := validateCreationResultMetadata(s.repo, "user", id, "image-node", before, after); err == nil {
t.Fatal("unbound resource accepted")
}
}
func TestCreationApprovedUpdateDoesNotOverwriteLaterManualEdit(t *testing.T) {
before := map[string]any{"id": "canvas", "nodes": []any{map[string]any{"id": "n", "type": "text", "title": "before", "metadata": map[string]any{"content": "original"}}}, "connections": []any{}}
ops := []CreationCanvasOp{{Type: "update_node", ID: "n", Metadata: map[string]any{"content": "approved"}}}
raw, _ := json.Marshal(before)
baseline, err := creationApprovalBaseline(string(raw), ops)
if err != nil {
t.Fatal(err)
}
run := &model.CreationRun{ApprovedCanvasJSON: baseline}
nodes := before["nodes"].([]any)
nodes[0].(map[string]any)["metadata"] = map[string]any{"content": "later manual edit"}
afterRaw, _ := json.Marshal(before)
var after map[string]any
_ = json.Unmarshal(afterRaw, &after)
after["nodes"].([]any)[0].(map[string]any)["metadata"] = map[string]any{"content": "approved"}
if err := validateCreationCanvasDiff(nil, "user", run, before, after, ops); err == nil {
t.Fatal("approved operation overwrote later manual edit")
}
}
func TestCreationMediaScopeRejectsChangedModelSpecAndReferences(t *testing.T) {
now := time.Now()
ops := []CreationCanvasOp{{Type: "add_node", ID: "v", NodeType: "video", Metadata: map[string]any{"prompt": "短片", "model": "managed-video", "size": "9:16", "seconds": "15", "referenceNodeIds": []any{"ref"}}}}
b, _ := json.Marshal(ops)
run := &model.CreationRun{CanvasID: "canvas", Status: "running", ApprovedProposalVersion: 1, ApprovedProposalHash: "hash", ApprovedOperationsJSON: string(b), ApprovedAt: &now}
request := CreateTaskRequest{Type: "canvas_video", ProjectID: "canvas", Prompt: "短片", Model: "managed-video", Input: map[string]any{"nodeId": "v", "config": map[string]any{"size": "9:16", "videoSeconds": "15"}, "referenceImages": []any{map[string]any{"id": "ref"}}}}
if err := validateCreationSubmissionScope(run, 1, request); err != nil {
t.Fatal(err)
}
request.Input["config"].(map[string]any)["videoSeconds"] = "5"
if err := validateCreationSubmissionScope(run, 1, request); err == nil {
t.Fatal("duration silently changed")
}
request.Input["config"].(map[string]any)["videoSeconds"] = "15"
request.Model = "other"
if err := validateCreationSubmissionScope(run, 1, request); err == nil {
t.Fatal("model changed")
}
request.Model = "managed-video"
request.Input["referenceImages"] = []any{map[string]any{"id": "other"}}
if err := validateCreationSubmissionScope(run, 1, request); err == nil {
t.Fatal("reference changed")
}
}
func TestCreationRefreshCreatesOneUnapprovedSuccessor(t *testing.T) {
s, db, id, guard := creationTestService(t)
old := prepareApprovedCreation(t, s, id, guard)
if err := db.Model(&model.CreationSubmission{}).Where("id = ?", old.ID).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
t.Fatal(err)
}
stored, err := s.repo.CreationSubmission("user", id, old.ID)
if err != nil {
t.Fatal(err)
}
originalQuote := stored.QuoteJSON
originalApprovedAt := stored.ApprovedAt
// A changed server price must produce a new quote without modifying the old evidence.
if err = db.Model(&model.ChannelModelPriceTier{}).Where("id = ?", "tier").Updates(map[string]any{"unit_price_microcredits": 200, "price_version": 2}).Error; err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
results := make(chan *CreationSubmissionOutput, 6)
errs := make(chan error, 6)
for i := 0; i < 6; i++ {
wg.Add(1)
go func() {
defer wg.Done()
out, e := s.RefreshCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: old.ID, Request: CreateTaskRequest{Prompt: "must be ignored"}})
if e != nil {
errs <- e
return
}
results <- out
}()
}
wg.Wait()
close(results)
close(errs)
for e := range errs {
t.Error(e)
}
var successor *CreationSubmissionOutput
for out := range results {
if successor == nil {
successor = out
}
if out.ID != successor.ID {
t.Fatal("refresh created duplicate successors")
}
}
if successor == nil {
t.Fatal("no successor")
}
if successor.ApprovedAt != nil || successor.TaskID != nil || successor.Quote.AmountMicrocredits != 200 || !successor.Quote.ExpiresAt.After(time.Now()) {
t.Fatalf("bad successor: %#v", successor)
}
stored, err = s.repo.CreationSubmission("user", id, old.ID)
if err != nil {
t.Fatal(err)
}
if stored.RevokedAt == nil || stored.QuoteJSON != originalQuote || !stored.ApprovedAt.Equal(*originalApprovedAt) {
t.Fatal("old quote evidence was changed")
}
var count int64
db.Model(&model.CreationSubmission{}).Where("run_id = ?", id).Count(&count)
if count != 2 {
t.Fatalf("submission count = %d", count)
}
if _, err = s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: old.ID}); err == nil {
t.Fatal("old revoked quote executed")
}
if _, err = s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: successor.ID}); err == nil {
t.Fatal("refresh inherited old approval")
}
if _, err = s.ApproveCreationSubmissions("user", id, CreationRequest{CreationGuard: guard, SubmissionIDs: []string{successor.ID}}); err != nil {
t.Fatal(err)
}
if _, err = s.ExecuteCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: successor.ID}); err != nil {
t.Fatal(err)
}
if _, err = s.RefreshCreationSubmission("user", id, CreationRequest{CreationGuard: guard, SubmissionID: successor.ID}); err == nil {
t.Fatal("consumed submission refreshed")
}
if _, err = s.RefreshCreationSubmission("other", id, CreationRequest{CreationGuard: guard, SubmissionID: old.ID}); err == nil {
t.Fatal("cross user refresh allowed")
}
}

View File

@@ -22,6 +22,7 @@ func newProjectSettingsTestService(t *testing.T) (*Service, *gorm.DB) {
&model.CanvasProject{}, &model.StyleProfile{}, &model.ProjectAssetCandidate{}, &model.WorkflowInstance{}, &model.WorkflowStepInstance{},
&model.Shot{}, &model.ShotArtifact{}, &model.VoiceProfile{},
&model.Task{}, &model.Session{}, &model.Message{}, &model.TaskLog{}, &model.Result{},
&model.CreationRun{}, &model.CreationSubmission{},
&model.Announcement{}, &model.AnnouncementImageDraft{},
); err != nil {
t.Fatal(err)

View File

@@ -48,6 +48,7 @@ type canvasTextOptions struct {
}
type agentToolRequests struct {
Canonical *canonicalAgentRequest `json:"canonical,omitempty"`
Responses map[string]interface{} `json:"responses"`
ChatCompletion map[string]interface{} `json:"chatCompletion"`
Claude map[string]interface{} `json:"claude"`
@@ -421,6 +422,10 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string
return runImageTask(ctx, input)
case "text":
if input.AgentRequests != nil {
input, err = resolveAgentResourcePlaceholders(input, true)
if err != nil {
return nil, err
}
return runAgentToolTask(ctx, input)
}
result, taskErr := runTextTask(ctx, input)

View File

@@ -0,0 +1,391 @@
package service
import (
"encoding/json"
"errors"
"strings"
"infinite-canvas/backend/internal/model"
)
// The browser sends one protocol-neutral conversation. Only the selected
// provider body is materialized; declarative plugins retain their request paths.
type canonicalAgentRequest struct {
Messages []map[string]interface{} `json:"messages"`
Tools []map[string]interface{} `json:"tools"`
ToolChoice interface{} `json:"toolChoice"`
SystemPrompt string `json:"systemPrompt"`
}
func expandCanonicalAgentRequest(source *canonicalAgentRequest, config providerConfig, declarative bool) (*agentToolRequests, error) {
if len(source.Messages) == 0 {
return nil, errors.New("画布 Agent 请求缺少会话内容")
}
for _, message := range source.Messages {
if stringField(message, "type") == "function_call" {
if stringField(message, "call_id") == "" || stringField(message, "name") == "" {
return nil, errors.New("画布 Agent 工具调用缺少标识或名称")
}
if _, ok := message["arguments"].(string); !ok {
return nil, errors.New("画布 Agent 工具调用参数无效")
}
continue
}
if stringField(message, "type") != "" {
return nil, errors.New("画布 Agent 会话条目类型无效")
}
switch stringField(message, "role") {
case "system", "user", "assistant":
case "tool":
if stringField(message, "tool_call_id") == "" {
return nil, errors.New("画布 Agent 工具结果缺少调用标识")
}
if _, ok := message["content"].(string); !ok {
return nil, errors.New("画布 Agent 工具结果内容无效")
}
default:
return nil, errors.New("画布 Agent 会话角色无效")
}
if err := validateCanonicalAgentContent(message["content"]); err != nil {
return nil, err
}
}
for _, tool := range source.Tools {
function, ok := tool["function"].(map[string]interface{})
if !ok || stringField(function, "name") == "" {
return nil, errors.New("画布 Agent 工具定义缺少名称")
}
}
switch choice := source.ToolChoice.(type) {
case string:
if choice != "auto" && choice != "required" {
return nil, errors.New("画布 Agent 工具选择无效")
}
case map[string]interface{}:
if stringField(choice, "type") != "function" || stringField(choice, "name") == "" {
return nil, errors.New("画布 Agent 工具选择缺少名称")
}
default:
return nil, errors.New("画布 Agent 请求缺少工具选择")
}
messages := source.Messages
if prompt := strings.TrimSpace(source.SystemPrompt); prompt != "" {
present := false
for _, message := range messages {
if stringField(message, "role") == "system" && strings.TrimSpace(stringField(message, "content")) == prompt {
present = true
break
}
}
if !present {
messages = append([]map[string]interface{}{{"role": "system", "content": prompt}}, messages...)
}
}
request := *source
request.Messages = messages
result := &agentToolRequests{}
if declarative {
result.ChatCompletion = canonicalAgentChatBody(&request, false)
result.Responses = canonicalAgentResponsesBody(&request)
result.Claude = claudeAgentBody(canonicalAgentChatBody(&request, true))
result.Claude["model"] = config.Model
result.Gemini = canonicalAgentGeminiBody(&request)
return result, nil
}
switch config.InterfaceType {
case string(model.ChannelInterfaceOpenAIResponse):
result.Responses = canonicalAgentResponsesBody(&request)
case string(model.ChannelInterfaceClaudeAPI):
result.Claude = claudeAgentBody(canonicalAgentChatBody(&request, true))
default:
result.ChatCompletion = canonicalAgentChatBody(&request, false)
}
return result, nil
}
func canonicalAgentChatBody(source *canonicalAgentRequest, claude bool) map[string]interface{} {
messages := make([]interface{}, 0, len(source.Messages))
format := "chat"
if claude {
format = "claude"
}
for index := 0; index < len(source.Messages); {
message := source.Messages[index]
if stringField(message, "type") == "function_call" {
calls := []interface{}{}
for index < len(source.Messages) && stringField(source.Messages[index], "type") == "function_call" {
call := source.Messages[index]
arguments := call["arguments"]
if claude {
arguments = canonicalAgentJSONObject(arguments)
}
calls = append(calls, map[string]interface{}{
"id": call["call_id"], "type": "function",
"function": map[string]interface{}{"name": call["name"], "arguments": arguments},
})
index++
}
messages = append(messages, map[string]interface{}{"role": "assistant", "content": nil, "tool_calls": calls})
continue
}
converted := map[string]interface{}{"role": message["role"], "content": canonicalAgentContent(message["content"], format)}
if claude && stringField(message, "role") == "system" {
converted["content"] = canonicalAgentText(message["content"])
}
if stringField(message, "role") == "tool" {
converted["tool_call_id"] = message["tool_call_id"]
}
messages = append(messages, converted)
index++
}
tools := make([]interface{}, 0, len(source.Tools))
for _, tool := range source.Tools {
tools = append(tools, tool)
}
choice := source.ToolChoice
if named, ok := choice.(map[string]interface{}); ok {
choice = map[string]interface{}{"type": "function", "function": map[string]interface{}{"name": named["name"]}}
}
return map[string]interface{}{"messages": messages, "tools": tools, "tool_choice": choice, "parallel_tool_calls": false}
}
func canonicalAgentResponsesBody(source *canonicalAgentRequest) map[string]interface{} {
messages := make([]interface{}, 0, len(source.Messages))
for _, message := range source.Messages {
switch {
case stringField(message, "type") == "function_call":
messages = append(messages, map[string]interface{}{
"type": "function_call", "call_id": message["call_id"], "name": message["name"], "arguments": message["arguments"],
})
case stringField(message, "role") == "tool":
messages = append(messages, map[string]interface{}{"type": "function_call_output", "call_id": message["tool_call_id"], "output": message["content"]})
default:
messages = append(messages, map[string]interface{}{"role": message["role"], "content": canonicalAgentContent(message["content"], "responses")})
}
}
tools := make([]interface{}, 0, len(source.Tools))
for _, tool := range source.Tools {
function, _ := tool["function"].(map[string]interface{})
converted := cloneStringAnyMap(function)
converted["type"] = "function"
tools = append(tools, converted)
}
return map[string]interface{}{"input": messages, "tools": tools, "tool_choice": source.ToolChoice, "parallel_tool_calls": false}
}
func canonicalAgentGeminiBody(source *canonicalAgentRequest) map[string]interface{} {
contents := make([]interface{}, 0, len(source.Messages))
var system []string
callNames := map[string]string{}
for _, message := range source.Messages {
role := "user"
var parts interface{}
switch {
case stringField(message, "type") == "function_call":
id, name := stringField(message, "call_id"), stringField(message, "name")
callNames[id] = name
part := map[string]interface{}{"functionCall": map[string]interface{}{"id": id, "name": name, "args": canonicalAgentJSONObject(message["arguments"])}}
if signature := stringField(message, "thoughtSignature"); signature != "" {
part["thoughtSignature"] = signature
}
role, parts = "model", []interface{}{part}
case stringField(message, "role") == "system":
if text := canonicalAgentText(message["content"]); text != "" {
system = append(system, text)
}
continue
case stringField(message, "role") == "tool":
id := stringField(message, "tool_call_id")
name := callNames[id]
if name == "" {
name = "tool_result"
}
parts = []interface{}{map[string]interface{}{"functionResponse": map[string]interface{}{
"id": id, "name": name, "response": map[string]interface{}{"result": canonicalAgentJSONValue(message["content"])},
}}}
default:
if stringField(message, "role") == "assistant" {
role = "model"
}
parts = canonicalAgentContent(message["content"], "gemini")
}
contents = append(contents, map[string]interface{}{"role": role, "parts": parts})
}
body := map[string]interface{}{"contents": contents}
if len(system) > 0 {
body["systemInstruction"] = map[string]interface{}{"parts": []interface{}{map[string]interface{}{"text": strings.Join(system, "\n\n")}}}
}
if len(source.Tools) > 0 {
declarations := make([]interface{}, 0, len(source.Tools))
for _, tool := range source.Tools {
function, _ := tool["function"].(map[string]interface{})
declaration := cloneStringAnyMap(function)
delete(declaration, "strict")
declarations = append(declarations, declaration)
}
choice := map[string]interface{}{"mode": "AUTO"}
if named, ok := source.ToolChoice.(map[string]interface{}); ok {
choice["mode"], choice["allowedFunctionNames"] = "ANY", []interface{}{named["name"]}
} else if source.ToolChoice == "required" {
choice["mode"] = "ANY"
}
body["tools"] = []interface{}{map[string]interface{}{"functionDeclarations": declarations}}
body["toolConfig"] = map[string]interface{}{"functionCallingConfig": choice}
}
return body
}
func canonicalAgentContent(content interface{}, format string) interface{} {
items, array := content.([]interface{})
if !array {
if content == nil {
content = ""
}
if format == "gemini" {
return []interface{}{map[string]interface{}{"text": content}}
}
return content
}
result := make([]interface{}, 0, len(items))
for _, value := range items {
item, _ := value.(map[string]interface{})
kind := stringField(item, "type")
if kind == "text" {
part := map[string]interface{}{"text": item["text"]}
if format != "gemini" {
part["type"] = "text"
if format == "responses" {
part["type"] = "input_text"
}
}
result = append(result, part)
continue
}
file, _ := item[kind].(map[string]interface{})
url := stringField(file, "url")
mimeType := stringField(file, "mimeType")
if kind == "image_url" {
mimeType = "image/png"
}
inlineMIME, inlineData, inline := canonicalAgentDataURL(url)
var part map[string]interface{}
switch format {
case "responses":
part = map[string]interface{}{"type": "input_image", "image_url": url}
if kind == "file_url" {
part = map[string]interface{}{"type": "input_file", "filename": file["name"]}
if strings.HasPrefix(url, "data:") {
part["file_data"] = url
} else {
part["file_url"] = url
}
}
case "claude":
if inline {
blockType := "image"
if kind == "file_url" && inlineMIME == "application/pdf" {
blockType = "document"
}
part = map[string]interface{}{"type": blockType, "source": map[string]interface{}{"type": "base64", "media_type": inlineMIME, "data": inlineData}}
} else if kind == "image_url" {
part = map[string]interface{}{"type": "image", "source": map[string]interface{}{"type": "url", "url": url}}
} else {
part = map[string]interface{}{"type": "text", "text": stringField(file, "name") + ": " + url}
}
case "gemini":
if inline {
part = map[string]interface{}{"inlineData": map[string]interface{}{"mimeType": inlineMIME, "data": inlineData}}
} else {
part = map[string]interface{}{"fileData": map[string]interface{}{"fileUri": url, "mimeType": mimeType}}
}
default:
part = item
if kind == "file_url" {
converted := map[string]interface{}{"filename": file["name"]}
if strings.HasPrefix(url, "data:") {
converted["file_data"] = url
} else {
converted["file_url"] = url
}
part = map[string]interface{}{"type": "file", "file": converted}
}
}
result = append(result, part)
}
return result
}
func canonicalAgentDataURL(value string) (string, string, bool) {
if !strings.HasPrefix(value, "data:") {
return "", "", false
}
mimeType, data, ok := strings.Cut(strings.TrimPrefix(value, "data:"), ";base64,")
return mimeType, data, ok && mimeType != "" && data != ""
}
func validateCanonicalAgentContent(content interface{}) error {
if _, ok := content.(string); ok {
return nil
}
parts, ok := content.([]interface{})
if !ok {
return errors.New("画布 Agent 消息内容无效")
}
for _, value := range parts {
part, _ := value.(map[string]interface{})
switch kind := stringField(part, "type"); kind {
case "text":
if _, ok := part["text"].(string); !ok {
return errors.New("画布 Agent 文本内容无效")
}
case "image_url", "file_url":
file, _ := part[kind].(map[string]interface{})
if stringField(file, "url") == "" {
return errors.New("画布 Agent 素材地址为空")
}
if kind == "file_url" && (stringField(file, "name") == "" || stringField(file, "mimeType") == "") {
return errors.New("画布 Agent 文件缺少名称或类型")
}
default:
return errors.New("画布 Agent 消息内容类型无效")
}
}
return nil
}
func canonicalAgentJSONValue(value interface{}) interface{} {
if raw, ok := value.(string); ok {
var parsed interface{}
if json.Unmarshal([]byte(raw), &parsed) == nil {
return parsed
}
}
return value
}
func canonicalAgentJSONObject(value interface{}) map[string]interface{} {
if parsed, ok := canonicalAgentJSONValue(value).(map[string]interface{}); ok {
return parsed
}
return map[string]interface{}{}
}
func canonicalAgentText(content interface{}) string {
if text, ok := content.(string); ok {
return text
}
var texts []string
if parts, ok := content.([]interface{}); ok {
for _, value := range parts {
part, _ := value.(map[string]interface{})
if stringField(part, "type") == "text" {
texts = append(texts, stringField(part, "text"))
} else if image, ok := part["image_url"].(map[string]interface{}); ok {
texts = append(texts, stringField(image, "url"))
} else if file, ok := part["file_url"].(map[string]interface{}); ok {
texts = append(texts, stringField(file, "name")+": "+stringField(file, "url"))
}
}
}
return strings.Join(texts, "\n")
}

View File

@@ -19,6 +19,16 @@ import (
)
func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
// 浏览器持久化的是协议中立请求;资源水合和模型路由完成后才展开上游协议,
// 防止供应商请求体反向污染任务记录,也避免切换模型时复用错误协议。
if input.AgentRequests != nil && input.AgentRequests.Canonical != nil {
_, declarative := agentProtocolAdapterForContext(ctx, input.Config.InterfaceType)
requests, err := expandCanonicalAgentRequest(input.AgentRequests.Canonical, input.Config, declarative)
if err != nil {
return nil, err
}
input.AgentRequests = requests
}
if adapter, ok := agentProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok {
return runDeclarativeAgentTask(ctx, input, adapter)
}
@@ -33,6 +43,9 @@ func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[str
path = "/responses"
protocol = "responses"
} else if input.Config.InterfaceType == string(model.ChannelInterfaceClaudeAPI) {
if input.AgentRequests.Claude != nil {
request = input.AgentRequests.Claude
}
path = "/messages"
protocol = "claude-api"
}
@@ -40,7 +53,7 @@ func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[str
return nil, errors.New("画布 Agent 工具请求缺少协议参数")
}
body := cloneStringAnyMap(request)
if protocol == "claude-api" {
if protocol == "claude-api" && input.AgentRequests.Claude == nil {
body = claudeAgentBody(body)
}
body["model"] = input.Config.Model

View File

@@ -83,17 +83,18 @@ type CreateSessionRequest struct {
}
type CreateTaskRequest struct {
SessionID string `json:"sessionId"`
ProjectID string `json:"projectId"`
Type string `json:"type"`
Operation string `json:"operation"`
Prompt string `json:"prompt"`
Provider string `json:"provider"`
Model string `json:"model"`
LogicalModelID string `json:"logicalModelId"`
Input map[string]any `json:"input"`
TraceID string `json:"-"`
RequestID string `json:"-"`
creationPrepare *creationTaskPreparation
SessionID string `json:"sessionId"`
ProjectID string `json:"projectId"`
Type string `json:"type"`
Operation string `json:"operation"`
Prompt string `json:"prompt"`
Provider string `json:"provider"`
Model string `json:"model"`
LogicalModelID string `json:"logicalModelId"`
Input map[string]any `json:"input"`
TraceID string `json:"-"`
RequestID string `json:"-"`
}
type SessionDetail struct {

View File

@@ -97,7 +97,11 @@ func validateStructuredReplacementQuotaWithPolicy(usage repository.UserStorageUs
func (s *Service) createTaskWithinStorageQuota(task *model.Task, billingOrder *model.BillingOrder, policy RuntimePolicySetting) error {
s.storageMu.Lock()
defer s.storageMu.Unlock()
usage, err := s.repo.UserStorageUsage(task.UserID)
return createTaskWithStorageQuotaRepository(s.repo, task, billingOrder, policy)
}
func createTaskWithStorageQuotaRepository(repo *repository.Repository, task *model.Task, billingOrder *model.BillingOrder, policy RuntimePolicySetting) error {
usage, err := repo.UserStorageUsage(task.UserID)
if err != nil {
return err
}
@@ -106,9 +110,9 @@ func (s *Service) createTaskWithinStorageQuota(task *model.Task, billingOrder *m
return err
}
if billingOrder != nil {
return s.repo.CreateTaskWithCreditReservation(task, billingOrder, policy.Task.ActiveTaskLimit)
return repo.CreateTaskWithCreditReservation(task, billingOrder, policy.Task.ActiveTaskLimit)
}
return s.repo.CreateTaskWithActiveLimit(task, policy.Task.ActiveTaskLimit)
return repo.CreateTaskWithActiveLimit(task, policy.Task.ActiveTaskLimit)
}
// 任务完成会同时扩张任务历史和 Agent 会话数据,必须在同一临界区核算并原子写入。

View File

@@ -103,6 +103,15 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
if err != nil {
return nil, err
}
if req.creationPrepare != nil {
encoded, encodeErr := json.Marshal(normalizedInput)
if encodeErr != nil {
return nil, encodeErr
}
task.InputJSON = string(encoded)
req.creationPrepare.Order = billingOrder
return &task, nil
}
if err := s.protectTaskSecrets(normalizedInput); err != nil {
return nil, err
}

View File

@@ -38,6 +38,9 @@ func (w *taskLifecycleCoordinator) retryTask(userID string, id string) (*model.T
if err != nil {
return nil, err
}
if task.CreationSubmissionID != nil {
return nil, creationConflict("智能创作重做需要新的报价批准,请回到创作会话继续")
}
if task.Status != model.TaskStatusFailed && task.Status != model.TaskStatusCancelled {
return nil, errors.New("only failed or cancelled tasks can be retried")
}