perf(platform): 短剧创作工作台 - 拆分长篇项目接口并优化章节与素材分页

This commit is contained in:
ddCat committed 2026-08-30 01:32:33 +08:00
1 parent 40fd0c54a4
commit d89f170a08
22 files changed
+1497 -222

No files matched your search

+127
View File
@@ -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 {
+19 -19
View File
@@ -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 {
+3 -3
View File
@@ -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)
}
}