feat(*): 分镜与资产 - 完善五类资产、候选去重、搜索、中文镜头编号与分类整理
This commit is contained in:
@@ -5,13 +5,16 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const CurrentSchemaVersion int64 = 2
|
||||
const CurrentSchemaVersion int64 = 3
|
||||
|
||||
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
|
||||
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
|
||||
const assetTaxonomyCandidateIdentityChecksum = "sha256:asset-taxonomy-candidate-identity-v3-20260831-r1"
|
||||
|
||||
const postgresSchemaMigrationLockID int64 = 73123910420260830
|
||||
|
||||
@@ -40,12 +43,52 @@ type migration struct {
|
||||
var schemaMigrations = []migration{
|
||||
{version: 1, name: "baseline_gorm_schema", checksum: baselineSchemaChecksum, apply: migrateSchemaV1},
|
||||
{version: 2, name: "schema_migrations_applied_at_index", checksum: schemaMigrationAppliedAtIndexChecksum, apply: migrateSchemaV2},
|
||||
{version: 3, name: "asset_taxonomy_candidate_identity", checksum: assetTaxonomyCandidateIdentityChecksum, apply: migrateSchemaV3},
|
||||
}
|
||||
|
||||
func migrateSchemaV2(tx *gorm.DB) error {
|
||||
return tx.Exec("CREATE INDEX IF NOT EXISTS idx_schema_migrations_applied_at ON schema_migrations (applied_at)").Error
|
||||
}
|
||||
|
||||
func migrateSchemaV3(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&model.ProjectAssetCandidate{}); err != nil {
|
||||
return fmt.Errorf("扩展资产候选身份字段:%w", err)
|
||||
}
|
||||
if err := tx.Exec("UPDATE assets SET category = 'prop' WHERE category IN ('wardrobe', 'weapon', 'accessory')").Error; err != nil {
|
||||
return fmt.Errorf("合并资产道具分类:%w", err)
|
||||
}
|
||||
if err := tx.Exec("UPDATE assets SET category = 'material' WHERE category = 'style' OR (category = 'other' AND kind IN ('image', 'video', 'audio', 'model'))").Error; err != nil {
|
||||
return fmt.Errorf("迁移资产素材分类:%w", err)
|
||||
}
|
||||
if err := tx.Exec("UPDATE project_asset_candidates SET category = 'prop' WHERE category IN ('wardrobe', 'weapon', 'accessory')").Error; err != nil {
|
||||
return fmt.Errorf("合并候选道具分类:%w", err)
|
||||
}
|
||||
if err := tx.Exec("UPDATE project_asset_candidates SET category = 'material' WHERE category = 'style'").Error; err != nil {
|
||||
return fmt.Errorf("迁移候选素材分类:%w", err)
|
||||
}
|
||||
var candidates []model.ProjectAssetCandidate
|
||||
if err := tx.Order("created_at asc, id asc").Find(&candidates).Error; err != nil {
|
||||
return fmt.Errorf("读取资产候选身份:%w", err)
|
||||
}
|
||||
seenPending := make(map[string]string, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
nameKey := model.AssetCandidateNameKey(candidate.Name)
|
||||
updates := map[string]any{"name_key": nameKey}
|
||||
identity := candidate.ProjectID + ":" + string(candidate.Category) + ":" + nameKey
|
||||
if candidate.Status == "pending_confirmation" && nameKey != "" {
|
||||
if _, exists := seenPending[identity]; exists {
|
||||
updates["status"] = "ignored"
|
||||
} else {
|
||||
seenPending[identity] = candidate.ID
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&model.ProjectAssetCandidate{}).Where("id = ?", candidate.ID).Updates(updates).Error; err != nil {
|
||||
return fmt.Errorf("回填资产候选身份 %s:%w", candidate.ID, err)
|
||||
}
|
||||
}
|
||||
return tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_asset_candidates_pending_identity ON project_asset_candidates(project_id, category, name_key) WHERE status = 'pending_confirmation' AND name_key <> ''").Error
|
||||
}
|
||||
|
||||
func MigrateSchema(db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,9 @@ func TestMigrateSchemaRecordsAndValidatesVersion(t *testing.T) {
|
||||
if !db.Migrator().HasIndex(&schemaMigration{}, "idx_schema_migrations_applied_at") {
|
||||
t.Fatal("schema migration v2 did not create the applied_at index")
|
||||
}
|
||||
if !db.Migrator().HasIndex(&model.ProjectAssetCandidate{}, "idx_project_asset_candidates_pending_identity") {
|
||||
t.Fatal("schema migration v3 did not create candidate identity index")
|
||||
}
|
||||
if err := MigrateSchema(db); err != nil {
|
||||
t.Fatalf("migration should be idempotent: %v", err)
|
||||
}
|
||||
@@ -50,6 +55,39 @@ func TestMigrateSchemaRejectsChecksumMismatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaV3NormalizesLegacyAccessoryCategory(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-asset-taxonomy?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Asset{}, &model.ProjectAssetCandidate{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asset := model.Asset{ID: "asset-1", UserID: "user-1", Kind: "image", Category: model.AssetCategory("accessory"), Title: "旧配饰"}
|
||||
candidate := model.ProjectAssetCandidate{ID: "candidate-1", ProjectID: "project-1", Name: "旧配饰候选", Category: model.AssetCategory("accessory"), Status: "pending_confirmation"}
|
||||
if err := db.Create(&asset).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&candidate).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrateSchemaV3(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&asset, "id = ?", asset.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&candidate, "id = ?", candidate.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asset.Category != model.AssetCategoryProp || candidate.Category != model.AssetCategoryProp {
|
||||
t.Fatalf("legacy accessory categories = %q/%q, want prop/prop", asset.Category, candidate.Category)
|
||||
}
|
||||
if candidate.NameKey != model.AssetCandidateNameKey(candidate.Name) {
|
||||
t.Fatalf("candidate name key = %q", candidate.NameKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRollsBackFailedMigration(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-rollback?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
|
||||
@@ -879,7 +879,7 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.ProjectAssetCandidatesPage(user.ID, c.Param("id"), page, pageSize, c.Query("unit_id"), c.Query("status"), c.Query("category"))
|
||||
result, err := svc.ProjectAssetCandidatesPage(user.ID, c.Param("id"), page, pageSize, c.Query("unit_id"), c.Query("status"), c.Query("category"), c.Query("q"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
|
||||
28
backend/internal/model/asset_category.go
Normal file
28
backend/internal/model/asset_category.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
import "strings"
|
||||
|
||||
// NormalizeAssetCategory keeps persisted and synced assets inside the current
|
||||
// five-category contract while preserving the intended meaning of legacy data.
|
||||
func NormalizeAssetCategory(value AssetCategory, kind string) AssetCategory {
|
||||
switch strings.ToLower(strings.TrimSpace(string(value))) {
|
||||
case string(AssetCategoryCharacter):
|
||||
return AssetCategoryCharacter
|
||||
case string(AssetCategoryEnvironment):
|
||||
return AssetCategoryEnvironment
|
||||
case string(AssetCategoryProp), "wardrobe", "weapon", "accessory":
|
||||
return AssetCategoryProp
|
||||
case string(AssetCategoryMaterial), "style":
|
||||
return AssetCategoryMaterial
|
||||
case string(AssetCategoryOther):
|
||||
return AssetCategoryOther
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(kind)) {
|
||||
case "entity":
|
||||
return AssetCategoryCharacter
|
||||
case "image", "video", "audio", "model":
|
||||
return AssetCategoryMaterial
|
||||
default:
|
||||
return AssetCategoryOther
|
||||
}
|
||||
}
|
||||
@@ -144,10 +144,8 @@ const (
|
||||
|
||||
AssetCategoryCharacter AssetCategory = "character"
|
||||
AssetCategoryEnvironment AssetCategory = "environment"
|
||||
AssetCategoryWardrobe AssetCategory = "wardrobe"
|
||||
AssetCategoryProp AssetCategory = "prop"
|
||||
AssetCategoryWeapon AssetCategory = "weapon"
|
||||
AssetCategoryStyle AssetCategory = "style"
|
||||
AssetCategoryMaterial AssetCategory = "material"
|
||||
AssetCategoryOther AssetCategory = "other"
|
||||
|
||||
AssetVersionStatusDraft AssetVersionStatus = "draft"
|
||||
|
||||
@@ -112,8 +112,10 @@ type ProjectAssetCandidate struct {
|
||||
UnitID string `json:"unitId,omitempty" gorm:"index;size:36;index:idx_project_asset_candidates_project_unit_status,priority:2"`
|
||||
ShotID string `json:"shotId,omitempty" gorm:"index;size:36"`
|
||||
Name string `json:"name" gorm:"size:240"`
|
||||
NameKey string `json:"-" gorm:"index;size:240"`
|
||||
Category AssetCategory `json:"category" gorm:"index;size:32;index:idx_project_asset_candidates_project_status_category,priority:3"`
|
||||
Status string `json:"status" gorm:"index;size:32;index:idx_project_asset_candidates_project_unit_status,priority:3;index:idx_project_asset_candidates_project_status_category,priority:2"`
|
||||
Source string `json:"source,omitempty" gorm:"index;size:48"`
|
||||
DetailsJSON string `json:"detailsJson" gorm:"type:text"`
|
||||
ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:80"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
17
backend/internal/model/project_asset_identity.go
Normal file
17
backend/internal/model/project_asset_identity.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// AssetCandidateNameKey removes presentation differences from a candidate name.
|
||||
// The key is only an identity guard; the original name remains the display value.
|
||||
func AssetCandidateNameKey(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
return unicode.ToLower(r)
|
||||
}
|
||||
return -1
|
||||
}, strings.TrimSpace(value))
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func (r *Repository) ProjectCanvasUnitLinksForCanvases(projectID string, canvasI
|
||||
return links, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectAssetCandidatesPage(projectID string, page int, pageSize int, unitID string, status string, category string) ([]model.ProjectAssetCandidate, int64, error) {
|
||||
func (r *Repository) ProjectAssetCandidatesPage(projectID string, page int, pageSize int, unitID string, status string, category string, queryText string) ([]model.ProjectAssetCandidate, int64, error) {
|
||||
var candidates []model.ProjectAssetCandidate
|
||||
var total int64
|
||||
query := r.db.Model(&model.ProjectAssetCandidate{}).Where("project_id = ?", projectID)
|
||||
@@ -188,6 +188,9 @@ func (r *Repository) ProjectAssetCandidatesPage(projectID string, page int, page
|
||||
if value := strings.TrimSpace(category); value != "" {
|
||||
query = query.Where("category = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(queryText); value != "" {
|
||||
query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(value)+"%")
|
||||
}
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -2041,6 +2041,11 @@ func (r *Repository) CreateProjectAssetCandidates(candidates []model.ProjectAsse
|
||||
return r.db.Create(&candidates).Error
|
||||
}
|
||||
|
||||
func (r *Repository) CreateProjectAssetCandidate(candidate *model.ProjectAssetCandidate) (bool, error) {
|
||||
result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(candidate)
|
||||
return result.RowsAffected == 1, result.Error
|
||||
}
|
||||
|
||||
// ConfirmProjectAssetCandidate 将正式资产身份、首版本、项目引用和候选状态放在同一事务中,避免出现半确认数据。
|
||||
func (r *Repository) ConfirmProjectAssetCandidate(candidate *model.ProjectAssetCandidate, asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink, createAsset bool) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
@@ -462,7 +462,7 @@ func containsString(values []string, target string) bool {
|
||||
|
||||
func validAssetCategory(category model.AssetCategory) bool {
|
||||
switch category {
|
||||
case model.AssetCategoryCharacter, model.AssetCategoryEnvironment, model.AssetCategoryWardrobe, model.AssetCategoryProp, model.AssetCategoryWeapon, model.AssetCategoryStyle, model.AssetCategoryOther:
|
||||
case model.AssetCategoryCharacter, model.AssetCategoryEnvironment, model.AssetCategoryProp, model.AssetCategoryMaterial, model.AssetCategoryOther:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -61,8 +62,11 @@ type AssetCandidateInput struct {
|
||||
|
||||
type CreateAssetCandidatesRequest struct {
|
||||
Candidates []AssetCandidateInput `json:"candidates"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
const assetCandidateSourceChapterCharacter = "chapter_character_extract"
|
||||
|
||||
func (s *Service) CreateProjectShot(userID string, projectID string, req CreateProjectShotRequest) (model.Shot, error) {
|
||||
if _, err := s.activeProjectForUser(userID, projectID); err != nil {
|
||||
return model.Shot{}, err
|
||||
@@ -301,14 +305,31 @@ func (s *Service) CreateProjectAssetCandidates(userID string, projectID string,
|
||||
if len(req.Candidates) == 0 || len(req.Candidates) > 100 {
|
||||
return nil, BadAuthRequest("资产候选数量必须在 1 到 100 之间")
|
||||
}
|
||||
source := strings.TrimSpace(req.Source)
|
||||
existingCandidates, err := s.repo.ProjectAssetCandidates(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projectAssets, err := s.repo.ProjectAssets(userID, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
knownKeys := projectAssetCandidateIdentityKeys(existingCandidates, projectAssets)
|
||||
now := time.Now()
|
||||
candidates := make([]model.ProjectAssetCandidate, 0, len(req.Candidates))
|
||||
for _, input := range req.Candidates {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
nameKey := model.AssetCandidateNameKey(name)
|
||||
category := model.AssetCategory(strings.TrimSpace(input.Category))
|
||||
if name == "" || !validAssetCategory(category) {
|
||||
if name == "" || nameKey == "" || !validAssetCategory(category) {
|
||||
return nil, BadAuthRequest("资产候选名称或分类无效")
|
||||
}
|
||||
if category == model.AssetCategoryCharacter && source != assetCandidateSourceChapterCharacter {
|
||||
return nil, BadAuthRequest("角色候选只能从剧情章节的角色提取流程创建")
|
||||
}
|
||||
if category == model.AssetCategoryCharacter && strings.TrimSpace(input.UnitID) == "" {
|
||||
return nil, BadAuthRequest("角色候选必须关联剧情章节")
|
||||
}
|
||||
if input.UnitID != "" {
|
||||
if _, err := s.repo.ProjectUnit(projectID, input.UnitID); err != nil {
|
||||
return nil, err
|
||||
@@ -328,17 +349,106 @@ func (s *Service) CreateProjectAssetCandidates(userID string, projectID string,
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, model.ProjectAssetCandidate{ID: newID(), ProjectID: projectID, UnitID: strings.TrimSpace(input.UnitID), ShotID: strings.TrimSpace(input.ShotID), Name: name, Category: category, Status: "pending_confirmation", DetailsJSON: detailsJSON, CreatedAt: now, UpdatedAt: now})
|
||||
identityKeys := assetCandidateInputIdentityKeys(name, input.Details)
|
||||
if assetCandidateIdentityExists(knownKeys, category, identityKeys) {
|
||||
continue
|
||||
}
|
||||
candidate := model.ProjectAssetCandidate{ID: newID(), ProjectID: projectID, UnitID: strings.TrimSpace(input.UnitID), ShotID: strings.TrimSpace(input.ShotID), Name: name, NameKey: nameKey, Category: category, Status: "pending_confirmation", Source: source, DetailsJSON: detailsJSON, CreatedAt: now, UpdatedAt: now}
|
||||
inserted, createErr := s.repo.CreateProjectAssetCandidate(&candidate)
|
||||
if createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
if !inserted {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, candidate)
|
||||
addAssetCandidateIdentityKeys(knownKeys, category, identityKeys)
|
||||
}
|
||||
if err := s.repo.CreateProjectAssetCandidates(candidates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.BumpProjectRevision(projectID); err != nil {
|
||||
return nil, err
|
||||
if len(candidates) > 0 {
|
||||
if err := s.repo.BumpProjectRevision(projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func projectAssetCandidateIdentityKeys(candidates []model.ProjectAssetCandidate, assets []model.Asset) map[string]struct{} {
|
||||
known := make(map[string]struct{}, len(candidates)+len(assets))
|
||||
for _, candidate := range candidates {
|
||||
keys := []string{candidate.NameKey}
|
||||
if strings.TrimSpace(candidate.DetailsJSON) != "" {
|
||||
var details map[string]any
|
||||
if json.Unmarshal([]byte(candidate.DetailsJSON), &details) == nil {
|
||||
keys = append(keys, assetCandidateAliases(details)...)
|
||||
}
|
||||
}
|
||||
addAssetCandidateIdentityKeys(known, candidate.Category, keys)
|
||||
}
|
||||
for _, asset := range assets {
|
||||
keys := []string{model.AssetCandidateNameKey(asset.Title)}
|
||||
if asset.Category == model.AssetCategoryCharacter {
|
||||
keys = append(keys, characterAssetAliasKeys(asset.PayloadJSON)...)
|
||||
}
|
||||
addAssetCandidateIdentityKeys(known, asset.Category, keys)
|
||||
}
|
||||
return known
|
||||
}
|
||||
|
||||
func assetCandidateInputIdentityKeys(name string, details map[string]any) []string {
|
||||
return append([]string{model.AssetCandidateNameKey(name)}, assetCandidateAliases(details)...)
|
||||
}
|
||||
|
||||
func assetCandidateAliases(details map[string]any) []string {
|
||||
if details == nil {
|
||||
return nil
|
||||
}
|
||||
values, ok := details["aliases"].([]any)
|
||||
if !ok {
|
||||
if stringsValue, stringsOK := details["aliases"].([]string); stringsOK {
|
||||
values = make([]any, len(stringsValue))
|
||||
for index, value := range stringsValue {
|
||||
values[index] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if key := model.AssetCandidateNameKey(fmt.Sprint(value)); key != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func characterAssetAliasKeys(payloadJSON string) []string {
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Definition map[string]any `json:"definition"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal([]byte(payloadJSON), &payload) != nil {
|
||||
return nil
|
||||
}
|
||||
return assetCandidateAliases(payload.Data.Definition)
|
||||
}
|
||||
|
||||
func assetCandidateIdentityExists(known map[string]struct{}, category model.AssetCategory, keys []string) bool {
|
||||
for _, key := range keys {
|
||||
if _, exists := known[string(category)+":"+key]; exists {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func addAssetCandidateIdentityKeys(known map[string]struct{}, category model.AssetCategory, keys []string) {
|
||||
for _, key := range keys {
|
||||
if key != "" {
|
||||
known[string(category)+":"+key] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validShotAssetRole(role string) bool {
|
||||
switch role {
|
||||
case "reference", "start_frame", "end_frame", "keyframe", "storyboard", "output":
|
||||
|
||||
@@ -354,12 +354,12 @@ func (s *Service) ProjectCanvasesPage(userID string, projectID string, page int,
|
||||
return ProjectCanvasPage{Canvases: canvases, CanvasUnitLinks: links, Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ProjectAssetCandidatesPage(userID string, projectID string, page int, pageSize int, unitID string, status string, category string) (ProjectAssetCandidatePage, error) {
|
||||
func (s *Service) ProjectAssetCandidatesPage(userID string, projectID string, page int, pageSize int, unitID string, status string, category string, query string) (ProjectAssetCandidatePage, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
return ProjectAssetCandidatePage{}, err
|
||||
}
|
||||
page, pageSize = normalizeProjectPage(page, pageSize, 200)
|
||||
candidates, total, err := s.repo.ProjectAssetCandidatesPage(projectID, page, pageSize, unitID, status, category)
|
||||
candidates, total, err := s.repo.ProjectAssetCandidatesPage(projectID, page, pageSize, unitID, status, category, query)
|
||||
if err != nil {
|
||||
return ProjectAssetCandidatePage{}, err
|
||||
}
|
||||
|
||||
@@ -232,3 +232,23 @@ func TestProjectAssetsPagePaginatesAndReturnsFacets(t *testing.T) {
|
||||
t.Fatalf("unexpected folder facets: %+v", page.FolderCounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAssetCandidatesPageSearchesNamesWithinFilters(t *testing.T) {
|
||||
service, db := newProjectWorkbenchReadTestService(t)
|
||||
project := seedWorkbenchProject(t, db)
|
||||
candidates := []model.ProjectAssetCandidate{
|
||||
{ID: "candidate-1", ProjectID: project.ID, UnitID: "unit-1", Name: "红色雨伞", Category: model.AssetCategoryProp, Status: "pending_confirmation"},
|
||||
{ID: "candidate-2", ProjectID: project.ID, UnitID: "unit-1", Name: "蓝色雨衣", Category: model.AssetCategoryProp, Status: "pending_confirmation"},
|
||||
{ID: "candidate-3", ProjectID: project.ID, UnitID: "unit-2", Name: "红色雨伞场景", Category: model.AssetCategoryEnvironment, Status: "pending_confirmation"},
|
||||
}
|
||||
if err := db.Create(&candidates).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := service.ProjectAssetCandidatesPage("user-1", project.ID, 1, 20, "unit-1", "pending_confirmation", "prop", "雨伞")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 || len(page.Candidates) != 1 || page.Candidates[0].ID != "candidate-1" {
|
||||
t.Fatalf("unexpected candidate search result: %+v", page)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,12 +602,12 @@ func (s *Service) ensureGeneratedProjectAsset(task model.Task, projectID string,
|
||||
}
|
||||
title += " · " + label
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"id": assetID, "kind": mediaType, "category": model.AssetCategoryOther, "status": model.AssetVersionStatusConfirmed,
|
||||
"id": assetID, "kind": mediaType, "category": model.AssetCategoryMaterial, "status": model.AssetVersionStatusConfirmed,
|
||||
"primaryVersionId": versionID, "title": title,
|
||||
"data": map[string]any{"storageKey": "resource:" + resourceID, "url": "/api/resources/" + resourceID + "/file", "mimeType": resource.MimeType, "bytes": resource.Size},
|
||||
"metadata": map[string]any{"source": "short-drama-workflow", "taskId": task.ID, "shotId": shot.ID, "projectIds": []string{projectID}},
|
||||
})
|
||||
asset := &model.Asset{ID: assetID, UserID: task.UserID, Kind: mediaType, Category: model.AssetCategoryOther, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: title, PayloadJSON: string(payload), CreatedAt: now, UpdatedAt: now}
|
||||
asset := &model.Asset{ID: assetID, UserID: task.UserID, Kind: mediaType, Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: title, PayloadJSON: string(payload), CreatedAt: now, UpdatedAt: now}
|
||||
version := &model.AssetVersion{ID: versionID, AssetID: assetID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: "{}", Prompt: task.Prompt, Note: "工作流生成产物", CreatedAt: now, UpdatedAt: now}
|
||||
folderID, err := s.resolveProjectAssetFolderID(projectID, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -20,7 +20,7 @@ func newProjectWorkflowV2TestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.Project{}, &model.ProjectUnit{}, &model.CanvasProject{}, &model.Asset{}, &model.AssetVersion{}, &model.ProjectAssetLink{}, &model.ProjectAssetCandidate{},
|
||||
&model.AssetRepresentation{},
|
||||
&model.AssetRepresentation{}, &model.CharacterVoiceBinding{},
|
||||
&model.Shot{}, &model.ShotRevision{}, &model.ShotArtifact{}, &model.ShotAssetReference{},
|
||||
&model.WorkflowTemplateVersion{}, &model.WorkflowInstance{}, &model.WorkflowStepInstance{}, &model.WorkflowStepTask{},
|
||||
&model.ProductionTaskLink{}, &model.Task{}, &model.Resource{},
|
||||
@@ -131,6 +131,79 @@ func TestShortDramaWorkflowV2UsesProductionOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectCharacterCandidatesIsIdempotentAcrossPendingAndConfirmed(t *testing.T) {
|
||||
service, db := newProjectWorkflowV2TestService(t)
|
||||
project, unit := seedWorkflowProject(t, db)
|
||||
request := CreateAssetCandidatesRequest{
|
||||
Source: assetCandidateSourceChapterCharacter,
|
||||
Candidates: []AssetCandidateInput{{
|
||||
UnitID: unit.ID, Name: " 小红帽 ", Category: string(model.AssetCategoryCharacter),
|
||||
Details: validCharacterCandidateDetails([]any{"小红"}),
|
||||
}},
|
||||
}
|
||||
|
||||
created, err := service.CreateProjectAssetCandidates("user-1", project.ID, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(created) != 1 {
|
||||
t.Fatalf("created candidates = %d, want 1", len(created))
|
||||
}
|
||||
created, err = service.CreateProjectAssetCandidates("user-1", project.ID, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(created) != 0 {
|
||||
t.Fatalf("duplicate pending candidates = %d, want 0", len(created))
|
||||
}
|
||||
|
||||
if _, err := service.ConfirmProjectAssetCandidate("user-1", project.ID, firstProjectCandidateID(t, db), ConfirmProjectAssetCandidateRequest{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err = service.CreateProjectAssetCandidates("user-1", project.ID, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(created) != 0 {
|
||||
t.Fatalf("confirmed character was extracted again: %d", len(created))
|
||||
}
|
||||
var assetCount int64
|
||||
if err := db.Model(&model.Asset{}).Where("user_id = ? AND category = ?", "user-1", model.AssetCategoryCharacter).Count(&assetCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if assetCount != 1 {
|
||||
t.Fatalf("character assets = %d, want 1", assetCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectCharacterCandidatesRejectsNonChapterSource(t *testing.T) {
|
||||
service, db := newProjectWorkflowV2TestService(t)
|
||||
project, unit := seedWorkflowProject(t, db)
|
||||
_, err := service.CreateProjectAssetCandidates("user-1", project.ID, CreateAssetCandidatesRequest{
|
||||
Source: "agent",
|
||||
Candidates: []AssetCandidateInput{{UnitID: unit.ID, Name: "猎人", Category: string(model.AssetCategoryCharacter), Details: validCharacterCandidateDetails(nil)}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("agent source unexpectedly created a character candidate")
|
||||
}
|
||||
}
|
||||
|
||||
func validCharacterCandidateDetails(aliases []any) map[string]any {
|
||||
return map[string]any{
|
||||
"role": "主角", "aliases": aliases, "appearance": "红色斗篷", "clothing": "红色兜帽与斗篷", "physique": "儿童体型",
|
||||
"personality": "勇敢", "voiceLanguage": "普通话", "voiceAge": "儿童", "voiceTimbre": "清亮",
|
||||
}
|
||||
}
|
||||
|
||||
func firstProjectCandidateID(t *testing.T, db *gorm.DB) string {
|
||||
t.Helper()
|
||||
var candidate model.ProjectAssetCandidate
|
||||
if err := db.Order("created_at asc").First(&candidate).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return candidate.ID
|
||||
}
|
||||
|
||||
func TestWorkflowCompletionRequiresStageGate(t *testing.T) {
|
||||
service, db := newProjectWorkflowV2TestService(t)
|
||||
project, unit := seedWorkflowProject(t, db)
|
||||
|
||||
@@ -292,10 +292,7 @@ func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) {
|
||||
if utf8.RuneCountInString(primaryVersionID) > 36 {
|
||||
return model.Asset{}, BadAuthRequest("素材主版本 ID 不能超过 36 个字符")
|
||||
}
|
||||
category := model.AssetCategory(strings.TrimSpace(payload.Category))
|
||||
if category == "" {
|
||||
category = model.AssetCategoryOther
|
||||
}
|
||||
category := model.NormalizeAssetCategory(model.AssetCategory(payload.Category), payload.Kind)
|
||||
status := model.AssetVersionStatus(strings.TrimSpace(payload.Status))
|
||||
if status == "" {
|
||||
status = model.AssetVersionStatusConfirmed
|
||||
|
||||
@@ -27,6 +27,35 @@ func TestAssetFromJSONAcceptsDeterministicGenerationID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetFromJSONNormalizesLegacyAndUnclassifiedMediaCategories(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
category string
|
||||
want model.AssetCategory
|
||||
}{
|
||||
{name: "legacy accessory", kind: "image", category: "accessory", want: model.AssetCategoryProp},
|
||||
{name: "legacy style", kind: "image", category: "style", want: model.AssetCategoryMaterial},
|
||||
{name: "unclassified video", kind: "video", want: model.AssetCategoryMaterial},
|
||||
{name: "unclassified entity", kind: "entity", want: model.AssetCategoryCharacter},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
raw, err := json.Marshal(map[string]any{"id": "asset-1", "kind": test.kind, "category": test.category, "title": "测试资产"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asset, err := assetFromJSON("user-1", raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asset.Category != test.want {
|
||||
t.Fatalf("category = %q, want %q", asset.Category, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetFromJSONRejectsIDOverLimit(t *testing.T) {
|
||||
raw, err := json.Marshal(map[string]any{"id": strings.Repeat("a", model.AssetIDMaxLength+1), "kind": "image"})
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user