perf(platform): 短剧创作工作台 - 拆分长篇项目接口并优化章节与素材分页
This commit is contained in:
1 parent
40fd0c54a4
commit
d89f170a08
22 files changed
+1497
-222
No files matched your search
@@ -95,6 +95,36 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, detail)
|
||||
})
|
||||
r.GET("/projects/:id/core", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
core, err := svc.ProjectCore(user.ID, c.Param("id"))
|
||||
if err != nil {
|
||||
if service.IsProjectNotFound(err) {
|
||||
fail(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, core)
|
||||
})
|
||||
r.GET("/projects/:id/overview", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
overview, err := svc.ProjectOverview(user.ID, c.Param("id"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, overview)
|
||||
})
|
||||
r.PATCH("/projects/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
@@ -157,6 +187,19 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"unit": unit})
|
||||
})
|
||||
r.GET("/projects/:id/units", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
units, err := svc.ProjectUnitSummaries(user.ID, c.Param("id"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, units)
|
||||
})
|
||||
r.GET("/projects/:id/units/:unitId", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
@@ -174,6 +217,19 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"unit": unit})
|
||||
})
|
||||
r.GET("/projects/:id/units/:unitId/workspace", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
workspace, err := svc.ProjectUnitWorkspace(user.ID, c.Param("id"), c.Param("unitId"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, workspace)
|
||||
})
|
||||
r.POST("/projects/:id/units/import", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
@@ -318,12 +374,60 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"canvasId": c.Param("canvasId")})
|
||||
})
|
||||
r.GET("/projects/:id/canvases", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, err := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
pageSize, err := parsePositiveQueryInt(c.Query("page_size"), 40)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.ProjectCanvasesPage(user.ID, c.Param("id"), page, pageSize)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, result)
|
||||
})
|
||||
r.GET("/projects/:id/assets", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
pageParam, hasPage := c.GetQuery("page")
|
||||
pageSizeParam, hasPageSize := c.GetQuery("page_size")
|
||||
if hasPage || hasPageSize {
|
||||
page, pageErr := parsePositiveQueryInt(pageParam, 1)
|
||||
if pageErr != nil {
|
||||
fail(c, http.StatusBadRequest, pageErr)
|
||||
return
|
||||
}
|
||||
pageSize, pageSizeErr := parsePositiveQueryInt(pageSizeParam, 40)
|
||||
if pageSizeErr != nil {
|
||||
fail(c, http.StatusBadRequest, pageSizeErr)
|
||||
return
|
||||
}
|
||||
var folderID *string
|
||||
if value, present := c.GetQuery("folder_id"); present {
|
||||
folderID = &value
|
||||
}
|
||||
assets, pageErr := svc.ProjectAssetsPage(user.ID, c.Param("id"), page, pageSize, c.Query("category"), c.Query("media_type"), c.Query("status"), folderID, c.Query("q"))
|
||||
if pageErr != nil {
|
||||
failService(c, pageErr)
|
||||
return
|
||||
}
|
||||
ok(c, assets)
|
||||
return
|
||||
}
|
||||
assets, err := svc.FilterProjectAssets(user.ID, c.Param("id"), service.ProjectAssetFilter{Category: c.Query("category"), MediaType: c.Query("mediaType"), Status: c.Query("status"), Usage: c.Query("usage")})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -759,6 +863,29 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"candidates": candidates})
|
||||
})
|
||||
r.GET("/projects/:id/asset-candidates", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, err := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
pageSize, err := parsePositiveQueryInt(c.Query("page_size"), 100)
|
||||
if err != nil {
|
||||
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"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, result)
|
||||
})
|
||||
r.POST("/projects/:id/asset-candidates/:candidateId/confirm", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
|
||||
@@ -85,10 +85,10 @@ type Asset struct {
|
||||
|
||||
type ProjectAssetLink struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1;index:idx_project_asset_links_project_folder_position,priority:1"`
|
||||
AssetID string `json:"assetId" gorm:"index;size:80;uniqueIndex:idx_project_asset_links_unique,priority:2"`
|
||||
FolderID string `json:"folderId,omitempty" gorm:"index;size:36"`
|
||||
Position int `json:"position" gorm:"index"`
|
||||
FolderID string `json:"folderId,omitempty" gorm:"index;size:36;index:idx_project_asset_links_project_folder_position,priority:2"`
|
||||
Position int `json:"position" gorm:"index;index:idx_project_asset_links_project_folder_position,priority:3"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -108,12 +108,12 @@ type ProjectAssetFolder struct {
|
||||
|
||||
type ProjectAssetCandidate struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36"`
|
||||
UnitID string `json:"unitId,omitempty" gorm:"index;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36;index:idx_project_asset_candidates_project_unit_status,priority:1;index:idx_project_asset_candidates_project_status_category,priority:1"`
|
||||
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"`
|
||||
Category AssetCategory `json:"category" gorm:"index;size:32"`
|
||||
Status string `json:"status" gorm:"index;size:32"`
|
||||
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"`
|
||||
DetailsJSON string `json:"detailsJson" gorm:"type:text"`
|
||||
ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:80"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
@@ -204,35 +204,35 @@ type StyleProfile struct {
|
||||
|
||||
type ProjectUnit struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36;index:idx_project_units_project_position,priority:1"`
|
||||
ParentID string `json:"parentId,omitempty" gorm:"index;size:36"`
|
||||
Kind ProjectUnitKind `json:"kind" gorm:"index;size:24"`
|
||||
Title string `json:"title" gorm:"size:240"`
|
||||
SourceText string `json:"sourceText" gorm:"type:text"`
|
||||
WordCount int `json:"wordCount"`
|
||||
Status ProjectUnitStatus `json:"status" gorm:"index;size:24"`
|
||||
Position int `json:"position"`
|
||||
Position int `json:"position" gorm:"index:idx_project_units_project_position,priority:2"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type CanvasUnitLink struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:1"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:1;index:idx_canvas_unit_links_project_unit,priority:1"`
|
||||
CanvasID string `json:"canvasId" gorm:"index;size:80;uniqueIndex:idx_canvas_unit_links_unique,priority:2"`
|
||||
UnitID string `json:"unitId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:3"`
|
||||
UnitID string `json:"unitId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:3;index:idx_canvas_unit_links_project_unit,priority:2"`
|
||||
Role string `json:"role" gorm:"size:32"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Shot struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36"`
|
||||
UnitID string `json:"unitId" gorm:"index;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36;index:idx_shots_project_unit_position,priority:1"`
|
||||
UnitID string `json:"unitId" gorm:"index;size:36;index:idx_shots_project_unit_position,priority:2"`
|
||||
CurrentRevisionID string `json:"currentRevisionId,omitempty" gorm:"index;size:36"`
|
||||
Title string `json:"title" gorm:"size:240"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Position int `json:"position"`
|
||||
Position int `json:"position" gorm:"index:idx_shots_project_unit_position,priority:3"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Status string `json:"status" gorm:"index;size:24"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
@@ -263,8 +263,8 @@ type ShotRevision struct {
|
||||
// ShotArtifact 是镜头的版本化生产产物。修改分镜或资产引用时只标记 stale,不删除历史。
|
||||
type ShotArtifact struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36"`
|
||||
UnitID string `json:"unitId" gorm:"index;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36;index:idx_shot_artifacts_project_unit,priority:1"`
|
||||
UnitID string `json:"unitId" gorm:"index;size:36;index:idx_shot_artifacts_project_unit,priority:2"`
|
||||
ShotID string `json:"shotId" gorm:"index;size:36;uniqueIndex:idx_shot_artifacts_version,priority:1"`
|
||||
RevisionID string `json:"revisionId,omitempty" gorm:"index;size:36"`
|
||||
TaskID string `json:"taskId,omitempty" gorm:"index;size:36"`
|
||||
@@ -347,12 +347,12 @@ type ProductionTaskLink struct {
|
||||
|
||||
type CanvasProject struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:80"`
|
||||
UserID string `json:"userId" gorm:"index;size:36;index:idx_canvas_projects_user_updated,priority:1"`
|
||||
ProjectID string `json:"projectId,omitempty" gorm:"index;size:36"`
|
||||
UserID string `json:"userId" gorm:"index;size:36;index:idx_canvas_projects_user_updated,priority:1;index:idx_canvas_projects_user_project_updated,priority:1"`
|
||||
ProjectID string `json:"projectId,omitempty" gorm:"index;size:36;index:idx_canvas_projects_user_project_updated,priority:2"`
|
||||
Title string `json:"title" gorm:"size:240"`
|
||||
PayloadJSON string `json:"payloadJson" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_canvas_projects_user_updated,priority:2"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_canvas_projects_user_updated,priority:2;index:idx_canvas_projects_user_project_updated,priority:3"`
|
||||
}
|
||||
|
||||
type CanvasShare struct {
|
||||
|
||||
@@ -4,11 +4,11 @@ import "time"
|
||||
|
||||
type Task struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
UserID string `json:"userId" gorm:"index;size:36;index:idx_tasks_user_created,priority:1"`
|
||||
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"`
|
||||
RequestID string `json:"-" gorm:"index;size:96"`
|
||||
SessionID string `json:"sessionId" gorm:"index;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:80"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:80;index:idx_tasks_user_project_created,priority:2"`
|
||||
Type string `json:"type" gorm:"index;size:64"`
|
||||
Status TaskStatus `json:"status" gorm:"index;size:24;index:idx_tasks_status_created,priority:1;index:idx_tasks_claim,priority:1;index:idx_tasks_provider_cancel,priority:1"`
|
||||
Stage string `json:"stage" gorm:"size:80"`
|
||||
@@ -42,7 +42,7 @@ type Task struct {
|
||||
Attempts int `json:"attempts"`
|
||||
StartedAt *time.Time `json:"startedAt"`
|
||||
CompletedAt *time.Time `json:"completedAt"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"index:idx_tasks_user_created,priority:2;index:idx_tasks_status_created,priority:2;index:idx_tasks_claim,priority:3"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"index:idx_tasks_user_created,priority:2;index:idx_tasks_status_created,priority:2;index:idx_tasks_claim,priority:3;index:idx_tasks_user_project_created,priority:3"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
type ProjectOverviewMetrics struct {
|
||||
UnitCount int64 `gorm:"column:unit_count"`
|
||||
CompletedUnitCount int64 `gorm:"column:completed_unit_count"`
|
||||
TotalWordCount int64 `gorm:"column:total_word_count"`
|
||||
UnitsWithoutText int64 `gorm:"column:units_without_text"`
|
||||
UnitsWithoutShots int64 `gorm:"column:units_without_shots"`
|
||||
CanvasCount int64 `gorm:"column:canvas_count"`
|
||||
AssetCount int64 `gorm:"column:asset_count"`
|
||||
ShotCount int64 `gorm:"column:shot_count"`
|
||||
PendingCandidateCount int64 `gorm:"column:pending_candidate_count"`
|
||||
ReadyStoryboardCount int64 `gorm:"column:ready_storyboard_count"`
|
||||
ReadyPrevizCount int64 `gorm:"column:ready_previz_count"`
|
||||
ReadyVideoCount int64 `gorm:"column:ready_video_count"`
|
||||
StaleArtifactCount int64 `gorm:"column:stale_artifact_count"`
|
||||
}
|
||||
|
||||
type ProjectOverviewUnitRow struct {
|
||||
model.ProjectUnit
|
||||
ShotCount int64 `gorm:"column:shot_count"`
|
||||
CandidateCount int64 `gorm:"column:candidate_count"`
|
||||
CanvasCount int64 `gorm:"column:canvas_count"`
|
||||
}
|
||||
|
||||
type ProjectAssetCountRow struct {
|
||||
Key string `gorm:"column:key"`
|
||||
Count int64 `gorm:"column:count"`
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectUnitCanvasCounts(projectID string) (map[string]int64, error) {
|
||||
var rows []ProjectAssetCountRow
|
||||
if err := r.db.Table("canvas_unit_links").Select("unit_id AS key, COUNT(DISTINCT canvas_id) AS count").Where("project_id = ?", projectID).Group("unit_id").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := make(map[string]int64, len(rows))
|
||||
for _, row := range rows {
|
||||
counts[row.Key] = row.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectOverviewMetrics(projectID string) (ProjectOverviewMetrics, error) {
|
||||
var metrics ProjectOverviewMetrics
|
||||
err := r.db.Raw(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM project_units WHERE project_id = ?) AS unit_count,
|
||||
(SELECT COUNT(*) FROM project_units WHERE project_id = ? AND status = ?) AS completed_unit_count,
|
||||
(SELECT COALESCE(SUM(word_count), 0) FROM project_units WHERE project_id = ?) AS total_word_count,
|
||||
(SELECT COUNT(*) FROM project_units WHERE project_id = ? AND word_count = 0) AS units_without_text,
|
||||
(SELECT COUNT(*) FROM project_units pu WHERE pu.project_id = ? AND pu.status <> ? AND NOT EXISTS (SELECT 1 FROM shots s WHERE s.project_id = pu.project_id AND s.unit_id = pu.id)) AS units_without_shots,
|
||||
(SELECT COUNT(*) FROM canvas_projects WHERE project_id = ?) AS canvas_count,
|
||||
(SELECT COUNT(*) FROM project_asset_links WHERE project_id = ?) AS asset_count,
|
||||
(SELECT COUNT(*) FROM shots WHERE project_id = ?) AS shot_count,
|
||||
(SELECT COUNT(*) FROM project_asset_candidates WHERE project_id = ? AND status = 'pending_confirmation') AS pending_candidate_count,
|
||||
(SELECT COUNT(DISTINCT shot_id) FROM shot_artifacts WHERE project_id = ? AND type = 'storyboard' AND selected = ? AND status = 'ready') AS ready_storyboard_count,
|
||||
(SELECT COUNT(DISTINCT shot_id) FROM shot_artifacts WHERE project_id = ? AND type = 'action_board' AND selected = ? AND status = 'ready') AS ready_previz_count,
|
||||
(SELECT COUNT(DISTINCT shot_id) FROM shot_artifacts WHERE project_id = ? AND type = 'video' AND selected = ? AND status = 'ready') AS ready_video_count,
|
||||
(SELECT COUNT(*) FROM shot_artifacts WHERE project_id = ? AND status = 'stale') AS stale_artifact_count
|
||||
`, projectID, projectID, model.ProjectUnitStatusCompleted, projectID, projectID, projectID, model.ProjectUnitStatusDraft, projectID, projectID, projectID, projectID, projectID, true, projectID, true, projectID, true, projectID).Scan(&metrics).Error
|
||||
return metrics, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectOverviewUnits(projectID string, limit int) ([]ProjectOverviewUnitRow, error) {
|
||||
if limit <= 0 || limit > 20 {
|
||||
limit = 8
|
||||
}
|
||||
var rows []ProjectOverviewUnitRow
|
||||
err := r.db.Raw(`
|
||||
SELECT pu.id, pu.project_id, pu.parent_id, pu.kind, pu.title, pu.word_count, pu.status, pu.position, pu.created_at, pu.updated_at,
|
||||
(SELECT COUNT(*) FROM shots s WHERE s.project_id = pu.project_id AND s.unit_id = pu.id) AS shot_count,
|
||||
(SELECT COUNT(*) FROM project_asset_candidates pac WHERE pac.project_id = pu.project_id AND pac.unit_id = pu.id) AS candidate_count,
|
||||
(SELECT COUNT(DISTINCT cul.canvas_id) FROM canvas_unit_links cul WHERE cul.project_id = pu.project_id AND cul.unit_id = pu.id) AS canvas_count
|
||||
FROM project_units pu
|
||||
WHERE pu.project_id = ?
|
||||
ORDER BY pu.position ASC, pu.created_at ASC
|
||||
LIMIT ?
|
||||
`, projectID, limit).Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectUnitShots(projectID string, unitID string) ([]model.Shot, error) {
|
||||
var shots []model.Shot
|
||||
err := r.db.Where("project_id = ? AND unit_id = ?", projectID, unitID).Order("position asc, created_at asc").Find(&shots).Error
|
||||
return shots, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectUnitShotRevisions(projectID string, unitID string) ([]model.ShotRevision, error) {
|
||||
var revisions []model.ShotRevision
|
||||
err := r.db.Table("shot_revisions").Select("shot_revisions.*").Joins("JOIN shots ON shots.id = shot_revisions.shot_id").Where("shots.project_id = ? AND shots.unit_id = ?", projectID, unitID).Order("shots.position asc, shot_revisions.version asc").Scan(&revisions).Error
|
||||
return revisions, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectUnitShotArtifacts(projectID string, unitID string) ([]model.ShotArtifact, error) {
|
||||
var artifacts []model.ShotArtifact
|
||||
err := r.db.Where("project_id = ? AND unit_id = ?", projectID, unitID).Order("shot_id asc, type asc, version asc").Find(&artifacts).Error
|
||||
return artifacts, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectUnitShotAssetReferences(projectID string, unitID string) ([]model.ShotAssetReference, error) {
|
||||
var references []model.ShotAssetReference
|
||||
err := r.db.Table("shot_asset_references").Select("shot_asset_references.*").Joins("JOIN shots ON shots.id = shot_asset_references.shot_id").Where("shots.project_id = ? AND shots.unit_id = ?", projectID, unitID).Order("shot_asset_references.created_at asc").Scan(&references).Error
|
||||
return references, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectUnitAssetCandidates(projectID string, unitID string) ([]model.ProjectAssetCandidate, error) {
|
||||
var candidates []model.ProjectAssetCandidate
|
||||
err := r.db.Where("project_id = ? AND (unit_id = ? OR unit_id = '')", projectID, unitID).Order("created_at asc").Find(&candidates).Error
|
||||
return candidates, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectUnitAssets(userID string, projectID string, unitID string) ([]model.Asset, error) {
|
||||
var assets []model.Asset
|
||||
err := r.db.Table("assets").Distinct("assets.*").
|
||||
Joins("JOIN project_asset_links pal ON pal.asset_id = assets.id AND pal.project_id = ?", projectID).
|
||||
Joins("JOIN asset_versions av ON av.asset_id = assets.id").
|
||||
Joins("JOIN shot_asset_references sar ON sar.asset_version_id = av.id").
|
||||
Joins("JOIN shots s ON s.id = sar.shot_id AND s.project_id = ? AND s.unit_id = ?", projectID, unitID).
|
||||
Where("assets.user_id = ?", userID).
|
||||
Order("assets.updated_at desc").
|
||||
Scan(&assets).Error
|
||||
return assets, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectWorkflowInstancesForUnit(projectID string, unitID string) ([]model.WorkflowInstance, error) {
|
||||
var instances []model.WorkflowInstance
|
||||
err := r.db.Where("project_id = ? AND unit_id = ?", projectID, unitID).Order("created_at asc").Find(&instances).Error
|
||||
return instances, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectCanvasSummariesPage(userID string, projectID string, page int, pageSize int) ([]model.CanvasProject, int64, error) {
|
||||
var canvases []model.CanvasProject
|
||||
var total int64
|
||||
query := r.db.Model(&model.CanvasProject{}).Where("user_id = ? AND project_id = ?", userID, projectID)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
err := query.Select("id", "user_id", "project_id", "title", "created_at", "updated_at").Order("updated_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&canvases).Error
|
||||
return canvases, total, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectCanvasUnitLinksForCanvases(projectID string, canvasIDs []string) ([]model.CanvasUnitLink, error) {
|
||||
if len(canvasIDs) == 0 {
|
||||
return []model.CanvasUnitLink{}, nil
|
||||
}
|
||||
var links []model.CanvasUnitLink
|
||||
err := r.db.Where("project_id = ? AND canvas_id IN ?", projectID, canvasIDs).Order("created_at asc").Find(&links).Error
|
||||
return links, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectAssetCandidatesPage(projectID string, page int, pageSize int, unitID string, status string, category string) ([]model.ProjectAssetCandidate, int64, error) {
|
||||
var candidates []model.ProjectAssetCandidate
|
||||
var total int64
|
||||
query := r.db.Model(&model.ProjectAssetCandidate{}).Where("project_id = ?", projectID)
|
||||
if value := strings.TrimSpace(unitID); value != "" {
|
||||
query = query.Where("unit_id = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(status); value != "" {
|
||||
query = query.Where("status = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(category); value != "" {
|
||||
query = query.Where("category = ?", value)
|
||||
}
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
err := query.Order("created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&candidates).Error
|
||||
return candidates, total, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectAssetsPage(userID string, projectID string, page int, pageSize int, category string, mediaType string, status string, folderID *string, queryText string) ([]model.Asset, int64, error) {
|
||||
var assets []model.Asset
|
||||
var total int64
|
||||
query := r.db.Table("assets").Joins("JOIN project_asset_links ON project_asset_links.asset_id = assets.id").Where("assets.user_id = ? AND project_asset_links.project_id = ?", userID, projectID)
|
||||
if value := strings.TrimSpace(category); value != "" {
|
||||
query = query.Where("assets.category = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(mediaType); value != "" {
|
||||
query = query.Where("assets.kind = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(status); value != "" {
|
||||
query = query.Where("assets.status = ?", value)
|
||||
}
|
||||
if folderID != nil {
|
||||
query = query.Where("project_asset_links.folder_id = ?", strings.TrimSpace(*folderID))
|
||||
}
|
||||
if value := strings.TrimSpace(queryText); value != "" {
|
||||
query = query.Where("LOWER(assets.title) LIKE ?", "%"+strings.ToLower(value)+"%")
|
||||
}
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
err := query.Select("assets.*").Order("assets.updated_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&assets).Error
|
||||
return assets, total, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectAssetFacets(projectID string) ([]ProjectAssetCountRow, []ProjectAssetCountRow, error) {
|
||||
var categoryRows []ProjectAssetCountRow
|
||||
if err := r.db.Table("assets").Select("assets.category AS key, COUNT(*) AS count").
|
||||
Joins("JOIN project_asset_links pal ON pal.asset_id = assets.id").
|
||||
Where("pal.project_id = ?", projectID).
|
||||
Group("assets.category").Scan(&categoryRows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var folderRows []ProjectAssetCountRow
|
||||
if err := r.db.Table("project_asset_links").Select("folder_id AS key, COUNT(*) AS count").
|
||||
Where("project_id = ?", projectID).
|
||||
Group("folder_id").Scan(&folderRows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return categoryRows, folderRows, nil
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type ProjectCore struct {
|
||||
Project model.Project `json:"project"`
|
||||
}
|
||||
|
||||
type ProjectUnitSummaries struct {
|
||||
Units []model.ProjectUnit `json:"units"`
|
||||
CanvasCounts map[string]int64 `json:"canvasCounts"`
|
||||
}
|
||||
|
||||
type ProjectOverview struct {
|
||||
Metrics ProjectOverviewMetrics `json:"metrics"`
|
||||
Units []ProjectOverviewUnit `json:"units"`
|
||||
}
|
||||
|
||||
type ProjectOverviewMetrics struct {
|
||||
UnitCount int64 `json:"unitCount"`
|
||||
CompletedUnitCount int64 `json:"completedUnitCount"`
|
||||
TotalWordCount int64 `json:"totalWordCount"`
|
||||
UnitsWithoutText int64 `json:"unitsWithoutText"`
|
||||
UnitsWithoutShots int64 `json:"unitsWithoutShots"`
|
||||
CanvasCount int64 `json:"canvasCount"`
|
||||
AssetCount int64 `json:"assetCount"`
|
||||
ShotCount int64 `json:"shotCount"`
|
||||
PendingCandidateCount int64 `json:"pendingCandidateCount"`
|
||||
ReadyStoryboardCount int64 `json:"readyStoryboardCount"`
|
||||
ReadyPrevizCount int64 `json:"readyPrevizCount"`
|
||||
ReadyVideoCount int64 `json:"readyVideoCount"`
|
||||
StaleArtifactCount int64 `json:"staleArtifactCount"`
|
||||
}
|
||||
|
||||
type ProjectOverviewUnit struct {
|
||||
Unit model.ProjectUnit `json:"unit"`
|
||||
ShotCount int64 `json:"shotCount"`
|
||||
CandidateCount int64 `json:"candidateCount"`
|
||||
CanvasCount int64 `json:"canvasCount"`
|
||||
}
|
||||
|
||||
type ProjectUnitWorkspace struct {
|
||||
Unit model.ProjectUnit `json:"unit"`
|
||||
Workflows []ProjectWorkflowDetail `json:"workflows"`
|
||||
Shots []model.Shot `json:"shots"`
|
||||
ShotRevisions []model.ShotRevision `json:"shotRevisions"`
|
||||
ShotArtifacts []model.ShotArtifact `json:"shotArtifacts"`
|
||||
ShotReferences []model.ShotAssetReference `json:"shotReferences"`
|
||||
AssetCandidates []model.ProjectAssetCandidate `json:"assetCandidates"`
|
||||
Assets []ProjectAssetSummary `json:"assets"`
|
||||
Tasks []TaskSummary `json:"tasks"`
|
||||
}
|
||||
|
||||
type ProjectCanvasPage struct {
|
||||
Canvases []model.CanvasProject `json:"canvases"`
|
||||
CanvasUnitLinks []model.CanvasUnitLink `json:"canvasUnitLinks"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Total int64 `json:"total"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
type ProjectAssetCandidatePage struct {
|
||||
Candidates []model.ProjectAssetCandidate `json:"candidates"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Total int64 `json:"total"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
type ProjectAssetPage struct {
|
||||
Assets []ProjectAssetSummary `json:"assets"`
|
||||
CategoryCounts map[string]int64 `json:"categoryCounts"`
|
||||
FolderCounts map[string]int64 `json:"folderCounts"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Total int64 `json:"total"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
func (s *Service) ProjectCore(userID string, projectID string) (ProjectCore, error) {
|
||||
project, err := s.repo.ProjectForUser(userID, projectID)
|
||||
if err != nil {
|
||||
return ProjectCore{}, err
|
||||
}
|
||||
if s.reconcileCharacterTurnaroundTasks(userID, project.ID) {
|
||||
project, err = s.repo.ProjectForUser(userID, projectID)
|
||||
if err != nil {
|
||||
return ProjectCore{}, err
|
||||
}
|
||||
}
|
||||
return ProjectCore{Project: *project}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ProjectUnitSummaries(userID string, projectID string) (ProjectUnitSummaries, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
return ProjectUnitSummaries{}, err
|
||||
}
|
||||
var units []model.ProjectUnit
|
||||
var canvasCounts map[string]int64
|
||||
var group errgroup.Group
|
||||
group.Go(func() error {
|
||||
var err error
|
||||
units, err = s.repo.ProjectUnitSummaries(projectID)
|
||||
return err
|
||||
})
|
||||
group.Go(func() error {
|
||||
var err error
|
||||
canvasCounts, err = s.repo.ProjectUnitCanvasCounts(projectID)
|
||||
return err
|
||||
})
|
||||
if err := group.Wait(); err != nil {
|
||||
return ProjectUnitSummaries{}, err
|
||||
}
|
||||
return ProjectUnitSummaries{Units: units, CanvasCounts: canvasCounts}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ProjectOverview(userID string, projectID string) (ProjectOverview, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
return ProjectOverview{}, err
|
||||
}
|
||||
var metrics ProjectOverviewMetrics
|
||||
var units []ProjectOverviewUnit
|
||||
var group errgroup.Group
|
||||
group.Go(func() error {
|
||||
row, err := s.repo.ProjectOverviewMetrics(projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metrics = ProjectOverviewMetrics{
|
||||
UnitCount: row.UnitCount, CompletedUnitCount: row.CompletedUnitCount, TotalWordCount: row.TotalWordCount,
|
||||
UnitsWithoutText: row.UnitsWithoutText, UnitsWithoutShots: row.UnitsWithoutShots, CanvasCount: row.CanvasCount,
|
||||
AssetCount: row.AssetCount, ShotCount: row.ShotCount, PendingCandidateCount: row.PendingCandidateCount,
|
||||
ReadyStoryboardCount: row.ReadyStoryboardCount, ReadyPrevizCount: row.ReadyPrevizCount, ReadyVideoCount: row.ReadyVideoCount,
|
||||
StaleArtifactCount: row.StaleArtifactCount,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
group.Go(func() error {
|
||||
rows, err := s.repo.ProjectOverviewUnits(projectID, 8)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
units = make([]ProjectOverviewUnit, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
units = append(units, ProjectOverviewUnit{Unit: row.ProjectUnit, ShotCount: row.ShotCount, CandidateCount: row.CandidateCount, CanvasCount: row.CanvasCount})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err := group.Wait(); err != nil {
|
||||
return ProjectOverview{}, err
|
||||
}
|
||||
return ProjectOverview{Metrics: metrics, Units: units}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ProjectUnitWorkspace(userID string, projectID string, unitID string) (ProjectUnitWorkspace, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
return ProjectUnitWorkspace{}, err
|
||||
}
|
||||
unit, err := s.repo.ProjectUnit(projectID, unitID)
|
||||
if err != nil {
|
||||
return ProjectUnitWorkspace{}, err
|
||||
}
|
||||
result := ProjectUnitWorkspace{Unit: *unit}
|
||||
var projectTasks []TaskSummary
|
||||
var group errgroup.Group
|
||||
group.Go(func() error {
|
||||
var err error
|
||||
result.Shots, err = s.repo.ProjectUnitShots(projectID, unitID)
|
||||
return err
|
||||
})
|
||||
group.Go(func() error {
|
||||
var err error
|
||||
result.ShotRevisions, err = s.repo.ProjectUnitShotRevisions(projectID, unitID)
|
||||
return err
|
||||
})
|
||||
group.Go(func() error {
|
||||
var err error
|
||||
result.ShotArtifacts, err = s.repo.ProjectUnitShotArtifacts(projectID, unitID)
|
||||
return err
|
||||
})
|
||||
group.Go(func() error {
|
||||
var err error
|
||||
result.ShotReferences, err = s.repo.ProjectUnitShotAssetReferences(projectID, unitID)
|
||||
return err
|
||||
})
|
||||
group.Go(func() error {
|
||||
var err error
|
||||
result.AssetCandidates, err = s.repo.ProjectUnitAssetCandidates(projectID, unitID)
|
||||
return err
|
||||
})
|
||||
group.Go(func() error {
|
||||
assets, err := s.repo.ProjectUnitAssets(userID, projectID, unitID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.Assets = make([]ProjectAssetSummary, len(assets))
|
||||
var summaries errgroup.Group
|
||||
summaries.SetLimit(8)
|
||||
for index := range assets {
|
||||
index := index
|
||||
summaries.Go(func() error {
|
||||
summary, summaryErr := s.projectAssetSummary(userID, projectID, &assets[index])
|
||||
if summaryErr == nil {
|
||||
result.Assets[index] = summary
|
||||
}
|
||||
return summaryErr
|
||||
})
|
||||
}
|
||||
return summaries.Wait()
|
||||
})
|
||||
group.Go(func() error {
|
||||
instances, err := s.repo.ProjectWorkflowInstancesForUnit(projectID, unitID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.Workflows = make([]ProjectWorkflowDetail, 0, len(instances))
|
||||
for _, instance := range instances {
|
||||
steps, stepsErr := s.repo.WorkflowSteps(instance.ID)
|
||||
if stepsErr != nil {
|
||||
return stepsErr
|
||||
}
|
||||
result.Workflows = append(result.Workflows, ProjectWorkflowDetail{Instance: instance, Steps: steps})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
group.Go(func() error {
|
||||
recent, err := s.TasksWithOptions(userID, TaskListOptions{Limit: 100, ProjectID: projectID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
active, err := s.TasksWithOptions(userID, TaskListOptions{Limit: 100, ProjectID: projectID, ActiveOnly: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen := make(map[string]struct{}, len(recent)+len(active))
|
||||
projectTasks = make([]TaskSummary, 0, len(recent)+len(active))
|
||||
for _, task := range append(active, recent...) {
|
||||
if _, exists := seen[task.ID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[task.ID] = struct{}{}
|
||||
projectTasks = append(projectTasks, task)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err := group.Wait(); err != nil {
|
||||
return ProjectUnitWorkspace{}, err
|
||||
}
|
||||
shotIDs := make(map[string]struct{}, len(result.Shots))
|
||||
for _, shot := range result.Shots {
|
||||
shotIDs[shot.ID] = struct{}{}
|
||||
}
|
||||
result.Tasks = make([]TaskSummary, 0, len(projectTasks))
|
||||
for _, task := range projectTasks {
|
||||
context := task.ClientContext
|
||||
if context == nil {
|
||||
continue
|
||||
}
|
||||
_, belongsToShot := shotIDs[context.ShotID]
|
||||
if context.ChapterID == unitID || belongsToShot {
|
||||
result.Tasks = append(result.Tasks, task)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ProjectCanvasesPage(userID string, projectID string, page int, pageSize int) (ProjectCanvasPage, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
return ProjectCanvasPage{}, err
|
||||
}
|
||||
page, pageSize = normalizeProjectPage(page, pageSize, 100)
|
||||
canvases, total, err := s.repo.ProjectCanvasSummariesPage(userID, projectID, page, pageSize)
|
||||
if err != nil {
|
||||
return ProjectCanvasPage{}, err
|
||||
}
|
||||
ids := make([]string, 0, len(canvases))
|
||||
for _, canvas := range canvases {
|
||||
ids = append(ids, canvas.ID)
|
||||
}
|
||||
links, err := s.repo.ProjectCanvasUnitLinksForCanvases(projectID, ids)
|
||||
if err != nil {
|
||||
return ProjectCanvasPage{}, err
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
if err != nil {
|
||||
return ProjectAssetCandidatePage{}, err
|
||||
}
|
||||
return ProjectAssetCandidatePage{Candidates: candidates, Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ProjectAssetsPage(userID string, projectID string, page int, pageSize int, category string, mediaType string, status string, folderID *string, query string) (ProjectAssetPage, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
return ProjectAssetPage{}, err
|
||||
}
|
||||
page, pageSize = normalizeProjectPage(page, pageSize, 80)
|
||||
assets, total, err := s.repo.ProjectAssetsPage(userID, projectID, page, pageSize, category, mediaType, status, folderID, query)
|
||||
if err != nil {
|
||||
return ProjectAssetPage{}, err
|
||||
}
|
||||
summaries := make([]ProjectAssetSummary, len(assets))
|
||||
var group errgroup.Group
|
||||
group.SetLimit(8)
|
||||
for index := range assets {
|
||||
index := index
|
||||
group.Go(func() error {
|
||||
summary, summaryErr := s.projectAssetSummary(userID, projectID, &assets[index])
|
||||
if summaryErr != nil {
|
||||
return summaryErr
|
||||
}
|
||||
summaries[index] = summary
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := group.Wait(); err != nil {
|
||||
return ProjectAssetPage{}, err
|
||||
}
|
||||
categoryRows, folderRows, err := s.repo.ProjectAssetFacets(projectID)
|
||||
if err != nil {
|
||||
return ProjectAssetPage{}, err
|
||||
}
|
||||
categoryCounts := make(map[string]int64, len(categoryRows))
|
||||
for _, row := range categoryRows {
|
||||
categoryCounts[row.Key] = row.Count
|
||||
}
|
||||
folderCounts := make(map[string]int64, len(folderRows))
|
||||
for _, row := range folderRows {
|
||||
folderCounts[row.Key] = row.Count
|
||||
}
|
||||
return ProjectAssetPage{Assets: summaries, CategoryCounts: categoryCounts, FolderCounts: folderCounts, Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total}, nil
|
||||
}
|
||||
|
||||
func normalizeProjectPage(page int, pageSize int, maximum int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 40
|
||||
}
|
||||
if pageSize > maximum {
|
||||
pageSize = maximum
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newProjectWorkbenchReadTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.Project{}, &model.ProjectUnit{}, &model.CanvasProject{}, &model.CanvasUnitLink{},
|
||||
&model.Asset{}, &model.AssetVersion{}, &model.AssetRepresentation{}, &model.ProjectAssetLink{}, &model.ProjectAssetCandidate{},
|
||||
&model.Shot{}, &model.ShotRevision{}, &model.ShotArtifact{}, &model.ShotAssetReference{},
|
||||
&model.WorkflowInstance{}, &model.WorkflowStepInstance{}, &model.Task{},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Service{repo: repository.New(db)}, db
|
||||
}
|
||||
|
||||
func seedWorkbenchProject(t *testing.T, db *gorm.DB) model.Project {
|
||||
t.Helper()
|
||||
project := model.Project{ID: "project-1", UserID: "user-1", Name: "长篇短剧", Status: model.ProjectStatusActive}
|
||||
if err := db.Create(&project).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return project
|
||||
}
|
||||
|
||||
func TestProjectOverviewUsesAggregatesWithoutLoadingProjectCollections(t *testing.T) {
|
||||
service, db := newProjectWorkbenchReadTestService(t)
|
||||
project := seedWorkbenchProject(t, db)
|
||||
for index := 0; index < 12; index++ {
|
||||
status := model.ProjectUnitStatusReady
|
||||
if index < 3 {
|
||||
status = model.ProjectUnitStatusCompleted
|
||||
}
|
||||
unit := model.ProjectUnit{ID: fmt.Sprintf("unit-%02d", index), ProjectID: project.ID, Title: fmt.Sprintf("第%d章", index+1), Position: index, WordCount: 1200, Status: status}
|
||||
if err := db.Create(&unit).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if index < 4 {
|
||||
if err := db.Create(&model.Shot{ID: fmt.Sprintf("shot-%02d", index), ProjectID: project.ID, UnitID: unit.ID, Position: 0}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
overview, err := service.ProjectOverview("user-1", project.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if overview.Metrics.UnitCount != 12 || overview.Metrics.CompletedUnitCount != 3 || overview.Metrics.ShotCount != 4 {
|
||||
t.Fatalf("unexpected overview metrics: %+v", overview.Metrics)
|
||||
}
|
||||
if overview.Metrics.TotalWordCount != 14_400 || overview.Metrics.UnitsWithoutShots != 8 {
|
||||
t.Fatalf("unexpected aggregate totals: %+v", overview.Metrics)
|
||||
}
|
||||
if len(overview.Units) != 8 {
|
||||
t.Fatalf("overview units = %d, want capped 8", len(overview.Units))
|
||||
}
|
||||
if overview.Units[0].Unit.SourceText != "" {
|
||||
t.Fatal("overview unit unexpectedly contains source text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectUnitWorkspaceIsolatesShotsAndBoundAssetsByUnit(t *testing.T) {
|
||||
service, db := newProjectWorkbenchReadTestService(t)
|
||||
project := seedWorkbenchProject(t, db)
|
||||
unit1 := model.ProjectUnit{ID: "unit-1", ProjectID: project.ID, Title: "第一章", Status: model.ProjectUnitStatusReady}
|
||||
unit2 := model.ProjectUnit{ID: "unit-2", ProjectID: project.ID, Title: "第二章", Position: 1, Status: model.ProjectUnitStatusReady}
|
||||
asset1 := model.Asset{ID: "asset-1", UserID: "user-1", Title: "角色甲", Kind: "image", Category: model.AssetCategoryOther, Status: model.AssetVersionStatusConfirmed}
|
||||
asset2 := model.Asset{ID: "asset-2", UserID: "user-1", Title: "角色乙", Kind: "image", Category: model.AssetCategoryOther, Status: model.AssetVersionStatusConfirmed}
|
||||
seed := []any{
|
||||
&unit1, &unit2, &asset1, &asset2,
|
||||
&model.AssetVersion{ID: "version-1", AssetID: asset1.ID, Version: 1, Status: model.AssetVersionStatusConfirmed},
|
||||
&model.AssetVersion{ID: "version-2", AssetID: asset2.ID, Version: 1, Status: model.AssetVersionStatusConfirmed},
|
||||
&model.ProjectAssetLink{ID: "link-1", ProjectID: project.ID, AssetID: asset1.ID},
|
||||
&model.ProjectAssetLink{ID: "link-2", ProjectID: project.ID, AssetID: asset2.ID},
|
||||
&model.Shot{ID: "shot-1", ProjectID: project.ID, UnitID: unit1.ID, Position: 0},
|
||||
&model.Shot{ID: "shot-2", ProjectID: project.ID, UnitID: unit2.ID, Position: 0},
|
||||
&model.ShotRevision{ID: "revision-1", ShotID: "shot-1", Version: 1},
|
||||
&model.ShotRevision{ID: "revision-2", ShotID: "shot-2", Version: 1},
|
||||
&model.ShotArtifact{ID: "artifact-1", ProjectID: project.ID, UnitID: unit1.ID, ShotID: "shot-1", Version: 1},
|
||||
&model.ShotArtifact{ID: "artifact-2", ProjectID: project.ID, UnitID: unit2.ID, ShotID: "shot-2", Version: 1},
|
||||
&model.ShotAssetReference{ID: "reference-1", ShotID: "shot-1", AssetVersionID: "version-1", Status: "linked"},
|
||||
&model.ShotAssetReference{ID: "reference-2", ShotID: "shot-2", AssetVersionID: "version-2", Status: "linked"},
|
||||
&model.ProjectAssetCandidate{ID: "candidate-1", ProjectID: project.ID, UnitID: unit1.ID, Name: "甲"},
|
||||
&model.ProjectAssetCandidate{ID: "candidate-2", ProjectID: project.ID, UnitID: unit2.ID, Name: "乙"},
|
||||
}
|
||||
for _, item := range seed {
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
workspace, err := service.ProjectUnitWorkspace("user-1", project.ID, unit1.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(workspace.Shots) != 1 || workspace.Shots[0].ID != "shot-1" || len(workspace.ShotRevisions) != 1 || workspace.ShotRevisions[0].ID != "revision-1" {
|
||||
t.Fatalf("workspace leaked another unit: shots=%+v revisions=%+v", workspace.Shots, workspace.ShotRevisions)
|
||||
}
|
||||
if len(workspace.ShotArtifacts) != 1 || workspace.ShotArtifacts[0].ID != "artifact-1" || len(workspace.ShotReferences) != 1 || workspace.ShotReferences[0].ID != "reference-1" {
|
||||
t.Fatalf("workspace artifact isolation failed: artifacts=%+v references=%+v", workspace.ShotArtifacts, workspace.ShotReferences)
|
||||
}
|
||||
if len(workspace.AssetCandidates) != 1 || workspace.AssetCandidates[0].ID != "candidate-1" || len(workspace.Assets) != 1 || workspace.Assets[0].ID != "asset-1" {
|
||||
t.Fatalf("workspace asset isolation failed: candidates=%+v assets=%+v", workspace.AssetCandidates, workspace.Assets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCanvasesPageReturnsLinksOnlyForCurrentPage(t *testing.T) {
|
||||
service, db := newProjectWorkbenchReadTestService(t)
|
||||
project := seedWorkbenchProject(t, db)
|
||||
if err := db.Create(&model.ProjectUnit{ID: "unit-1", ProjectID: project.ID, Title: "第一章"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
for index := 0; index < 5; index++ {
|
||||
canvasID := fmt.Sprintf("canvas-%d", index)
|
||||
if err := db.Create(&model.CanvasProject{ID: canvasID, UserID: "user-1", ProjectID: project.ID, Title: canvasID, UpdatedAt: now.Add(time.Duration(index) * time.Minute)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.CanvasUnitLink{ID: "link-" + canvasID, ProjectID: project.ID, CanvasID: canvasID, UnitID: "unit-1"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
page, err := service.ProjectCanvasesPage("user-1", project.ID, 2, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 5 || len(page.Canvases) != 2 || len(page.CanvasUnitLinks) != 2 || !page.HasMore {
|
||||
t.Fatalf("unexpected canvas page: %+v", page)
|
||||
}
|
||||
pageIDs := map[string]bool{page.Canvases[0].ID: true, page.Canvases[1].ID: true}
|
||||
for _, link := range page.CanvasUnitLinks {
|
||||
if !pageIDs[link.CanvasID] {
|
||||
t.Fatalf("link %s does not belong to the current canvas page", link.CanvasID)
|
||||
}
|
||||
}
|
||||
units, err := service.ProjectUnitSummaries("user-1", project.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if units.CanvasCounts["unit-1"] != 5 {
|
||||
t.Fatalf("unit canvas count = %d, want 5", units.CanvasCounts["unit-1"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAssetsPagePaginatesAndReturnsFacets(t *testing.T) {
|
||||
service, db := newProjectWorkbenchReadTestService(t)
|
||||
project := seedWorkbenchProject(t, db)
|
||||
for index := 0; index < 6; index++ {
|
||||
category := model.AssetCategoryProp
|
||||
folderID := "folder-b"
|
||||
if index < 2 {
|
||||
category = model.AssetCategoryCharacter
|
||||
folderID = "folder-a"
|
||||
}
|
||||
asset := model.Asset{ID: fmt.Sprintf("asset-%d", index), UserID: "user-1", Title: fmt.Sprintf("资产%d", index), Kind: "image", Category: category, Status: model.AssetVersionStatusConfirmed}
|
||||
if err := db.Create(&asset).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.ProjectAssetLink{ID: fmt.Sprintf("link-%d", index), ProjectID: project.ID, AssetID: asset.ID, FolderID: folderID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
page, err := service.ProjectAssetsPage("user-1", project.ID, 2, 2, "prop", "image", "", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 4 || len(page.Assets) != 2 || page.CategoryCounts["character"] != 2 || page.CategoryCounts["prop"] != 4 {
|
||||
t.Fatalf("unexpected asset page/facets: %+v", page)
|
||||
}
|
||||
if page.FolderCounts["folder-a"] != 2 || page.FolderCounts["folder-b"] != 4 {
|
||||
t.Fatalf("unexpected folder facets: %+v", page.FolderCounts)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user