feat(*): 生图与视频工作台 - 新增独立生成流程并整合平台配置

This commit is contained in:
ddcat666 committed 2026-10-13 09:04:34 +08:00
1 parent 4c1d2b4012
commit 1f3f2c6020
69 files changed
+7108 -87

No files matched your search

@@ -371,11 +371,15 @@ func (r *Repository) ClaimNextTaskProviderCancellation(owner string, leaseDurati
}
func (r *Repository) Tasks(userID string, limit int, projectID string, activeOnly bool) ([]model.Task, error) {
return r.TasksWithWorkbenchKind(userID, limit, projectID, activeOnly, "")
}
func (r *Repository) TasksWithWorkbenchKind(userID string, limit int, projectID string, activeOnly bool, workbenchKind string) ([]model.Task, error) {
var tasks []model.Task
if limit <= 0 || limit > 100 {
limit = 50
}
query := r.db.Select("id", "project_id", "type", "status", "stage", "media_stage", "media_recovery_json", "progress", "prompt", "operation", "provider", "model", "input_json", "result_json", "billing_order_id", "provider_request_id", "provider_cancel_status", "provider_cancel_error", "provider_cancel_attempts", "provider_cancel_requested_at", "provider_cancelled_at", "provider_cancel_next_check_at", "attempts", "started_at", "completed_at", "created_at", "updated_at", "agent_run_id", "generation_id", "approval_id", "authorized_charge_microcredits", "execution_diagnostic_json", "cancellation_source", "cancellation_actor_id", "cancellation_requested_at").
query := r.db.Select("id", "project_id", "type", "workbench_kind", "status", "stage", "media_stage", "media_recovery_json", "progress", "prompt", "operation", "provider", "model", "input_json", "result_json", "billing_order_id", "provider_request_id", "provider_cancel_status", "provider_cancel_error", "provider_cancel_attempts", "provider_cancel_requested_at", "provider_cancelled_at", "provider_cancel_next_check_at", "attempts", "started_at", "completed_at", "created_at", "updated_at", "agent_run_id", "generation_id", "approval_id", "authorized_charge_microcredits", "execution_diagnostic_json", "cancellation_source", "cancellation_actor_id", "cancellation_requested_at").
Where("user_id = ?", userID)
if strings.TrimSpace(projectID) != "" {
query = query.Where("project_id = ?", strings.TrimSpace(projectID))
@@ -383,6 +387,14 @@ func (r *Repository) Tasks(userID string, limit int, projectID string, activeOnl
if activeOnly {
query = query.Where("status IN ?", []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning})
}
if workbenchKind != "" {
taskType := "canvas_" + workbenchKind
if r.db.Dialector.Name() == "postgres" {
query = query.Where("(workbench_kind = ? AND type = ?) OR (type = ? AND input_json::jsonb -> 'metadata' ->> 'source' = ? AND input_json::jsonb -> 'metadata' ->> 'workbenchKind' = ?)", workbenchKind, taskType, taskType, "workbench", workbenchKind)
} else {
query = query.Where("(workbench_kind = ? AND type = ?) OR (type = ? AND json_extract(input_json, '$.metadata.source') = ? AND json_extract(input_json, '$.metadata.workbenchKind') = ?)", workbenchKind, taskType, taskType, "workbench", workbenchKind)
}
}
err := query.Order("created_at desc").Limit(limit).Find(&tasks).Error
return tasks, err
}
@@ -157,6 +157,19 @@ func (r *Repository) ResourceReferenceSnapshotExcludingAssets(userID string, exc
}
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "工具", ID: strconv.FormatInt(tool.ID, 10), Title: tool.Label, PrimaryJSON: string(payload)})
}
var workbenchTools []model.WorkbenchTool
if err := r.db.Where("(icon_resource_id IN ? OR cover_resource_id IN ?) AND enabled = ?", resourceIDs, resourceIDs, true).Find(&workbenchTools).Error; err != nil {
return snapshot, err
}
for _, tool := range workbenchTools {
if tool.IconResourceID != "" && slices.Contains(resourceIDs, tool.IconResourceID) {
snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "工作台工具图标", ID: tool.ID, Title: tool.Name, ResourceID: tool.IconResourceID})
}
if tool.CoverResourceID != "" && slices.Contains(resourceIDs, tool.CoverResourceID) {
snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "工作台工具封面", ID: tool.ID, Title: tool.Name, ResourceID: tool.CoverResourceID})
}
}
var assets []model.Asset
assetQuery := r.db.Where("user_id = ?", userID)
if len(excludingAssetIDs) > 0 {
+129
View File
@@ -0,0 +1,129 @@
package repository
import (
"yingce/backend/internal/model"
"gorm.io/gorm"
)
func (r *Repository) WorkbenchTools(kind string, enabledOnly bool) ([]model.WorkbenchTool, error) {
var items []model.WorkbenchTool
query := r.db.Model(&model.WorkbenchTool{})
if kind != "" {
query = query.Where("kind = ?", kind)
}
if enabledOnly {
query = query.Where("enabled = ?", true)
}
err := query.Order("kind asc, sort_order asc, name asc, id asc").Find(&items).Error
return items, err
}
func (r *Repository) WorkbenchTool(id string) (*model.WorkbenchTool, error) {
var item model.WorkbenchTool
if err := r.db.First(&item, "id = ?", id).Error; err != nil {
return nil, err
}
return &item, nil
}
func (r *Repository) CreateWorkbenchTool(item *model.WorkbenchTool) error {
return r.db.Create(item).Error
}
func (r *Repository) SaveWorkbenchTool(item *model.WorkbenchTool) error { return r.db.Save(item).Error }
func (r *Repository) SaveWorkbenchToolWithPresets(item *model.WorkbenchTool, presets *[]model.WorkbenchPreset, create bool) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var err error
if create {
err = tx.Create(item).Error
} else {
err = tx.Save(item).Error
}
if err != nil || presets == nil {
return err
}
ids := make([]string, 0, len(*presets))
for index := range *presets {
preset := &(*presets)[index]
ids = append(ids, preset.ID)
if err := tx.Save(preset).Error; err != nil {
return err
}
}
// Omitted presets remain available to task history but leave the public catalog.
query := tx.Model(&model.WorkbenchPreset{}).Where("tool_id = ? AND enabled = ?", item.ID, true)
if len(ids) > 0 {
query = query.Where("id NOT IN ?", ids)
}
return query.Updates(map[string]any{"enabled": false, "updated_by": item.UpdatedBy, "updated_at": item.UpdatedAt, "version": gorm.Expr("version + 1")}).Error
})
}
func (r *Repository) WorkbenchPresets(toolID string, enabledOnly bool) ([]model.WorkbenchPreset, error) {
var items []model.WorkbenchPreset
query := r.db.Model(&model.WorkbenchPreset{})
if toolID != "" {
query = query.Where("tool_id = ?", toolID)
}
if enabledOnly {
query = query.Where("enabled = ?", true)
}
err := query.Order("sort_order asc, name asc, id asc").Find(&items).Error
return items, err
}
func (r *Repository) WorkbenchPreset(id string) (*model.WorkbenchPreset, error) {
var item model.WorkbenchPreset
if err := r.db.First(&item, "id = ?", id).Error; err != nil {
return nil, err
}
return &item, nil
}
func (r *Repository) CreateWorkbenchPreset(item *model.WorkbenchPreset) error {
return r.db.Create(item).Error
}
func (r *Repository) SaveWorkbenchPreset(item *model.WorkbenchPreset) error {
return r.db.Save(item).Error
}
func (r *Repository) WorkbenchToolAndPreset(toolID, presetID string, publishedOnly bool) (*model.WorkbenchTool, *model.WorkbenchPreset, error) {
var tool model.WorkbenchTool
query := r.db.Where("id = ?", toolID)
if publishedOnly {
query = query.Where("enabled = ?", true)
}
if err := query.First(&tool).Error; err != nil {
return nil, nil, err
}
if presetID == "" {
return &tool, nil, nil
}
var preset model.WorkbenchPreset
query = r.db.Where("id = ? AND tool_id = ?", presetID, toolID)
if publishedOnly {
query = query.Where("enabled = ?", true)
}
if err := query.First(&preset).Error; err != nil {
return nil, nil, err
}
return &tool, &preset, nil
}
func (r *Repository) WorkbenchEnabledPresetsForTools(toolIDs []string) (map[string][]model.WorkbenchPreset, error) {
result := make(map[string][]model.WorkbenchPreset, len(toolIDs))
if len(toolIDs) == 0 {
return result, nil
}
var presets []model.WorkbenchPreset
if err := r.db.Where("tool_id IN ? AND enabled = ?", toolIDs, true).Order("sort_order asc, name asc, id asc").Find(&presets).Error; err != nil {
return nil, err
}
for _, preset := range presets {
result[preset.ToolID] = append(result[preset.ToolID], preset)
}
return result, nil
}
var _ = gorm.ErrRecordNotFound
@@ -0,0 +1,23 @@
package repository
import (
"yingce/backend/internal/model"
)
type TaskPage struct {
Tasks []model.Task `json:"tasks"`
NextCursor string `json:"nextCursor,omitempty"`
}
func (r *Repository) WorkbenchTasks(userID, kind string, activeOnly bool, cursor string, limit int) (TaskPage, error) {
if limit < 1 || limit > 100 { limit = 30 }
if kind != model.WorkbenchKindImage && kind != model.WorkbenchKindVideo { return TaskPage{}, nil }
query := r.db.Model(&model.Task{}).Where("user_id = ? AND workbench_kind = ?", userID, kind)
if activeOnly { query = query.Where("status IN ?", []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}) }
if cursor != "" { query = query.Where("id < ?", cursor) }
var tasks []model.Task
if err := query.Order("created_at desc, id desc").Limit(limit + 1).Find(&tasks).Error; err != nil { return TaskPage{}, err }
page := TaskPage{Tasks: tasks}
if len(tasks) > limit { page.Tasks = tasks[:limit]; page.NextCursor = page.Tasks[len(page.Tasks)-1].ID }
return page, nil
}
@@ -0,0 +1,63 @@
package repository
import (
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"yingce/backend/internal/model"
)
func TestTasksWithWorkbenchKindRequiresMatchingTaskType(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Task{}); err != nil {
t.Fatal(err)
}
legacyImage := `{"metadata":{"source":"workbench","workbenchKind":"image"}}`
legacyVideo := `{"metadata":{"source":"workbench","workbenchKind":"video"}}`
createdAt := time.Now()
tasks := []model.Task{
{ID: "image-column", UserID: "user", Type: "canvas_image", WorkbenchKind: "image", InputJSON: `{}`, CreatedAt: createdAt},
{ID: "image-legacy", UserID: "user", Type: "canvas_image", InputJSON: legacyImage, CreatedAt: createdAt.Add(time.Second)},
{ID: "video-column", UserID: "user", Type: "canvas_video", WorkbenchKind: "video", InputJSON: `{}`, CreatedAt: createdAt.Add(2 * time.Second)},
{ID: "video-legacy", UserID: "user", Type: "canvas_video", InputJSON: legacyVideo, CreatedAt: createdAt.Add(3 * time.Second)},
{ID: "wrong-column-kind", UserID: "user", Type: "canvas_video", WorkbenchKind: "image", InputJSON: `{}`, CreatedAt: createdAt.Add(4 * time.Second)},
{ID: "wrong-legacy-type", UserID: "user", Type: "canvas_video", InputJSON: legacyImage, CreatedAt: createdAt.Add(5 * time.Second)},
{ID: "wrong-source", UserID: "user", Type: "canvas_image", InputJSON: `{"metadata":{"source":"canvas","workbenchKind":"image"}}`, CreatedAt: createdAt.Add(6 * time.Second)},
{ID: "other-user", UserID: "other", Type: "canvas_image", InputJSON: legacyImage, CreatedAt: createdAt.Add(7 * time.Second)},
}
if err := db.Create(&tasks).Error; err != nil {
t.Fatal(err)
}
repo := New(db)
for _, tc := range []struct {
kind string
want map[string]bool
}{
{kind: "image", want: map[string]bool{"image-column": true, "image-legacy": true}},
{kind: "video", want: map[string]bool{"video-column": true, "video-legacy": true}},
} {
got, err := repo.TasksWithWorkbenchKind("user", 50, "", false, tc.kind)
if err != nil {
t.Fatalf("list %s tasks: %v", tc.kind, err)
}
if len(got) != len(tc.want) {
t.Fatalf("%s task IDs = %#v, want %d tasks", tc.kind, got, len(tc.want))
}
for _, task := range got {
if !tc.want[task.ID] {
t.Errorf("%s list included unexpected task %q (type=%q kind=%q)", tc.kind, task.ID, task.Type, task.WorkbenchKind)
}
delete(tc.want, task.ID)
}
if len(tc.want) != 0 {
t.Errorf("%s list omitted expected task IDs: %#v", tc.kind, tc.want)
}
}
}