Files
flatrender/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs
T
soroush.asadi cf5dd4f195
Build backend images / build content-svc (push) Failing after 21s
Build backend images / build file-svc (push) Failing after 3m49s
Build backend images / build gateway (push) Failing after 1m2s
Build backend images / build identity-svc (push) Failing after 1m1s
Build backend images / build notification-svc (push) Failing after 1m2s
Build backend images / build render-svc (push) Failing after 1m0s
Build backend images / build studio-svc (push) Failing after 58s
feat(admin): category SEO fields, Templates admin, safe project PATCH
- categories/tags admin forms: add meta title/description/keywords, bot-follow,
  sort, is_active (backend already supported these)
- new Templates admin (/admin/templates): container CRUD with description,
  keywords, publishing, premium, primary mode, category/tag assignment, plus
  editable per-variant aspect & resolution
- content-svc: PATCH /v1/projects/{id} partial update so aspect/resolution edits
  never wipe render/colour data (SharedColorsSvg, RenderAepComp, Folder)
- admin resource proxy: add PATCH passthrough

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:26:44 +03:30

331 lines
16 KiB
C#

using FlatRender.ContentSvc.Domain.Entities;
using FlatRender.ContentSvc.Domain.Enums;
using FlatRender.ContentSvc.Infrastructure.Data;
using FlatRender.ContentSvc.Models.Requests;
using FlatRender.ContentSvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.ContentSvc.Application.Services;
public class TemplateService(ContentDbContext db)
{
public async Task<PagedResponse<ContainerSummaryResponse>> GetContainersAsync(ContainerListRequest req)
{
var q = db.ProjectContainers
.Include(x => x.ContainerCategories).ThenInclude(x => x.Category)
.Include(x => x.ContainerTags).ThenInclude(x => x.Tag)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Search))
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{req.Search}%") ||
EF.Functions.ILike(x.Slug, $"%{req.Search}%"));
if (req.CategoryId.HasValue)
q = q.Where(x => x.ContainerCategories.Any(cc => cc.CategoryId == req.CategoryId));
if (!string.IsNullOrWhiteSpace(req.TagSlug))
q = q.Where(x => x.ContainerTags.Any(ct => ct.Tag.Slug == req.TagSlug));
if (req.IsPublished.HasValue)
q = q.Where(x => x.IsPublished == req.IsPublished);
if (req.IsPremium.HasValue)
q = q.Where(x => x.IsPremium == req.IsPremium);
if (!string.IsNullOrWhiteSpace(req.Mode))
{
if (Enum.TryParse<ChooseMode>(req.Mode, true, out var mode))
q = q.Where(x => x.PrimaryMode == mode);
}
q = req.Sort switch
{
"sort_asc" => q.OrderBy(x => x.Sort),
"name_asc" => q.OrderBy(x => x.Name),
"view_count_desc" => q.OrderByDescending(x => x.ViewCount),
_ => q.OrderByDescending(x => x.SortDate)
};
var total = await q.LongCountAsync();
var items = await q.Skip((req.Page - 1) * req.PageSize).Take(req.PageSize).ToListAsync();
return new PagedResponse<ContainerSummaryResponse>(
items.Select(MapContainerSummary),
new PaginationMeta(req.Page, req.PageSize, total, (int)Math.Ceiling((double)total / req.PageSize))
);
}
public async Task<ContainerDetailResponse> GetContainerBySlugAsync(string slug)
{
var container = await db.ProjectContainers
.Include(x => x.ContainerCategories).ThenInclude(x => x.Category)
.Include(x => x.ContainerTags).ThenInclude(x => x.Tag)
.Include(x => x.Projects.Where(p => p.DeletedAt == null))
.FirstOrDefaultAsync(x => x.Slug == slug)
?? throw new KeyNotFoundException($"Container '{slug}' not found");
return MapContainerDetail(container);
}
public async Task<ContainerDetailResponse> GetContainerByIdAsync(Guid id)
{
var container = await db.ProjectContainers
.Include(x => x.ContainerCategories).ThenInclude(x => x.Category)
.Include(x => x.ContainerTags).ThenInclude(x => x.Tag)
.Include(x => x.Projects.Where(p => p.DeletedAt == null))
.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Container {id} not found");
return MapContainerDetail(container);
}
public async Task<ContainerDetailResponse> CreateContainerAsync(CreateContainerRequest req)
{
if (!Enum.TryParse<ChooseMode>(req.PrimaryMode, true, out var mode))
throw new ArgumentException($"Invalid PrimaryMode: {req.PrimaryMode}");
var container = new ProjectContainer
{
Slug = req.Slug, Name = req.Name, Description = req.Description, Keywords = req.Keywords,
NewsText = req.NewsText, Image = req.Image, Demo = req.Demo, FullDemo = req.FullDemo,
MiniDemo = req.MiniDemo, DemoScriptTag = req.DemoScriptTag, IsPublished = req.IsPublished,
IsPremium = req.IsPremium, IsMockup = req.IsMockup, PrimaryMode = mode, Sort = req.Sort,
SortDate = DateTime.UtcNow
};
db.ProjectContainers.Add(container);
await db.SaveChangesAsync();
await SyncContainerCategoriesAsync(container.Id, req.CategoryIds);
await SyncContainerTagsAsync(container.Id, req.TagIds);
return await GetContainerByIdAsync(container.Id);
}
public async Task<ContainerDetailResponse> UpdateContainerAsync(Guid id, UpdateContainerRequest req)
{
var container = await db.ProjectContainers.FindAsync(id)
?? throw new KeyNotFoundException($"Container {id} not found");
if (!Enum.TryParse<ChooseMode>(req.PrimaryMode, true, out var mode))
throw new ArgumentException($"Invalid PrimaryMode: {req.PrimaryMode}");
container.Slug = req.Slug; container.Name = req.Name; container.Description = req.Description;
container.Keywords = req.Keywords; container.NewsText = req.NewsText; container.Image = req.Image;
container.Demo = req.Demo; container.FullDemo = req.FullDemo; container.MiniDemo = req.MiniDemo;
container.DemoScriptTag = req.DemoScriptTag; container.IsPublished = req.IsPublished;
container.IsPremium = req.IsPremium; container.IsMockup = req.IsMockup;
container.PrimaryMode = mode; container.Sort = req.Sort; container.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await SyncContainerCategoriesAsync(id, req.CategoryIds);
await SyncContainerTagsAsync(id, req.TagIds);
return await GetContainerByIdAsync(id);
}
public async Task DeleteContainerAsync(Guid id)
{
var container = await db.ProjectContainers.FindAsync(id)
?? throw new KeyNotFoundException($"Container {id} not found");
container.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<ProjectDetailResponse> GetProjectDetailAsync(Guid id)
{
var project = await db.Projects
.Include(x => x.Scenes.Where(s => s.DeletedAt == null)).ThenInclude(x => x.RepeaterItems)
.Include(x => x.Scenes).ThenInclude(x => x.ContentElements)
.Include(x => x.Scenes).ThenInclude(x => x.ColorElements)
.Include(x => x.Scenes).ThenInclude(x => x.ColorPresets).ThenInclude(x => x.Items)
.Include(x => x.Scenes).ThenInclude(x => x.Characters).ThenInclude(x => x.Controllers).ThenInclude(x => x.Options)
.Include(x => x.SharedColors)
.Include(x => x.SharedLayers)
.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Project {id} not found");
return MapProjectDetail(project);
}
public async Task<ProjectDetailResponse> CreateProjectAsync(CreateProjectRequest req)
{
if (!Enum.TryParse<ChooseMode>(req.ChooseMode, true, out var chooseMode))
throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}");
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var resolution))
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
var project = new Project
{
ContainerId = req.ContainerId, ProjectServerId = req.ProjectServerId, Name = req.Name,
Description = req.Description, Image = req.Image, FullDemo = req.FullDemo,
DemoScriptTag = req.DemoScriptTag, DownloadLink = req.DownloadLink, Folder = req.Folder,
OriginalWidth = req.OriginalWidth, OriginalHeight = req.OriginalHeight, Aspect = req.Aspect,
ProjectDurationSec = req.ProjectDurationSec, MinDurationSec = req.MinDurationSec,
MaxDurationSec = req.MaxDurationSec, FreeFps = req.FreeFps, ChooseMode = chooseMode,
Resolution = resolution, VipFactor = req.VipFactor, RenderAepComp = req.RenderAepComp,
IsPublished = req.IsPublished, Sort = req.Sort
};
db.Projects.Add(project);
await db.SaveChangesAsync();
return await GetProjectDetailAsync(project.Id);
}
public async Task<ProjectDetailResponse> UpdateProjectAsync(Guid id, UpdateProjectRequest req)
{
var project = await db.Projects.FindAsync(id)
?? throw new KeyNotFoundException($"Project {id} not found");
if (!Enum.TryParse<ChooseMode>(req.ChooseMode, true, out var chooseMode))
throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}");
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var resolution))
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
project.Name = req.Name; project.Description = req.Description; project.Image = req.Image;
project.FullDemo = req.FullDemo; project.DemoScriptTag = req.DemoScriptTag;
project.DownloadLink = req.DownloadLink; project.Folder = req.Folder;
project.OriginalWidth = req.OriginalWidth; project.OriginalHeight = req.OriginalHeight;
project.Aspect = req.Aspect; project.ProjectDurationSec = req.ProjectDurationSec;
project.MinDurationSec = req.MinDurationSec; project.MaxDurationSec = req.MaxDurationSec;
project.FreeFps = req.FreeFps; project.ChooseMode = chooseMode; project.Resolution = resolution;
project.VipFactor = req.VipFactor; project.RenderAepComp = req.RenderAepComp;
project.SharedLayerImage = req.SharedLayerImage; project.SharedColorsSvg = req.SharedColorsSvg;
project.SharedColorPresetsSvg = req.SharedColorPresetsSvg;
project.IsPublished = req.IsPublished; project.Sort = req.Sort;
project.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return await GetProjectDetailAsync(project.Id);
}
/// <summary>Partial update — only applies supplied (non-null) fields, so editing an
/// aspect/resolution never clears render/colour data the full update would require.</summary>
public async Task<ProjectDetailResponse> PatchProjectAsync(Guid id, PatchProjectRequest req)
{
var project = await db.Projects.FindAsync(id)
?? throw new KeyNotFoundException($"Project {id} not found");
if (req.Name != null) project.Name = req.Name;
if (req.Description != null) project.Description = req.Description;
if (req.Aspect != null) project.Aspect = req.Aspect;
if (req.Resolution != null)
{
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var resolution))
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
project.Resolution = resolution;
}
if (req.ChooseMode != null)
{
if (!Enum.TryParse<ChooseMode>(req.ChooseMode, true, out var chooseMode))
throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}");
project.ChooseMode = chooseMode;
}
if (req.OriginalWidth.HasValue) project.OriginalWidth = req.OriginalWidth.Value;
if (req.OriginalHeight.HasValue) project.OriginalHeight = req.OriginalHeight.Value;
if (req.ProjectDurationSec.HasValue) project.ProjectDurationSec = req.ProjectDurationSec.Value;
if (req.MinDurationSec.HasValue) project.MinDurationSec = req.MinDurationSec;
if (req.MaxDurationSec.HasValue) project.MaxDurationSec = req.MaxDurationSec;
if (req.FreeFps.HasValue) project.FreeFps = req.FreeFps.Value;
if (req.IsPublished.HasValue) project.IsPublished = req.IsPublished.Value;
if (req.Sort.HasValue) project.Sort = req.Sort.Value;
project.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return await GetProjectDetailAsync(project.Id);
}
public async Task DeleteProjectAsync(Guid id)
{
var project = await db.Projects.FindAsync(id)
?? throw new KeyNotFoundException($"Project {id} not found");
project.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task IncrementContainerViewAsync(Guid id)
{
await db.ProjectContainers
.Where(x => x.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ViewCount, x => x.ViewCount + 1));
}
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task SyncContainerCategoriesAsync(Guid containerId, List<Guid> categoryIds)
{
var existing = await db.ContainerCategories.Where(x => x.ContainerId == containerId).ToListAsync();
db.ContainerCategories.RemoveRange(existing);
for (int i = 0; i < categoryIds.Count; i++)
db.ContainerCategories.Add(new ContainerCategory { ContainerId = containerId, CategoryId = categoryIds[i], Sort = i });
await db.SaveChangesAsync();
}
private async Task SyncContainerTagsAsync(Guid containerId, List<Guid> tagIds)
{
var existing = await db.ContainerTags.Where(x => x.ContainerId == containerId).ToListAsync();
db.ContainerTags.RemoveRange(existing);
foreach (var tagId in tagIds)
db.ContainerTags.Add(new ContainerTag { ContainerId = containerId, TagId = tagId });
await db.SaveChangesAsync();
}
private static ContainerSummaryResponse MapContainerSummary(ProjectContainer c) => new(
c.Id, c.Slug, c.Name, c.Description, c.Image, c.Demo, c.MiniDemo,
c.IsPublished, c.IsPremium, c.IsMockup, c.PrimaryMode.ToString(),
c.RateAvg, c.RateCount, c.ViewCount, c.UseCount, c.Sort, c.SortDate,
c.ContainerCategories.Select(cc => cc.Category.Slug).ToList(),
c.ContainerTags.Select(ct => ct.Tag.Name).ToList()
);
private static ContainerDetailResponse MapContainerDetail(ProjectContainer c) => new(
c.Id, c.Slug, c.Name, c.Description, c.Keywords, c.NewsText,
c.Image, c.Demo, c.FullDemo, c.MiniDemo, c.DemoScriptTag,
c.IsPublished, c.IsPremium, c.IsMockup, c.PrimaryMode.ToString(),
c.RateAvg, c.RateCount, c.ViewCount, c.UseCount, c.Sort, c.SortDate,
c.Projects.Select(MapProject).ToList(),
c.ContainerCategories.Select(cc => MapCategoryFlat(cc.Category)).ToList(),
c.ContainerTags.Select(ct => new TagResponse(ct.Tag.Id, ct.Tag.Name, ct.Tag.LatinName, ct.Tag.Slug, ct.Tag.AppliesToMode, ct.Tag.IsActive)).ToList()
);
private static CategoryResponse MapCategoryFlat(Category c) => new(
c.Id, c.ParentId, c.Name, c.Slug, c.Description, c.ImageUrl, c.Icon,
c.IsActive, c.Sort, []
);
private static ProjectResponse MapProject(Project p) => new(
p.Id, p.ContainerId, p.Name, p.Image, p.FullDemo,
p.OriginalWidth, p.OriginalHeight, p.Aspect,
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(),
p.IsPublished, p.Sort
);
private static ProjectDetailResponse MapProjectDetail(Project p) => new(
p.Id, p.ContainerId, p.Name, p.Description, p.Image, p.FullDemo, p.DemoScriptTag, p.DownloadLink,
p.OriginalWidth, p.OriginalHeight, p.Aspect,
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), p.VipFactor, p.RenderAepComp,
p.SharedLayerImage, p.IsPublished, p.Sort,
p.Scenes.Select(MapScene).ToList(),
p.SharedColors.Select(sc => new SharedColorResponse(sc.Id, sc.ElementKey, sc.Title, sc.Icon, sc.AttrValue.ToString(), sc.DefaultColor, sc.Sort)).ToList(),
p.SharedLayers.Select(MapSharedLayer).ToList()
);
private static SceneResponse MapScene(Scene s) => new(
s.Id, s.ProjectId, s.Key, s.Title, s.LocalizedTitle, s.SceneType.ToString(),
s.Image, s.Demo, s.SnapshotUrl, s.GenerateKf,
s.DefaultDurationSec, s.MinDurationSec, s.MaxDurationSec, s.OverlapAtEndSec,
s.CanHandleDuration, s.ManualColorSelection, s.Sort, s.IsActive
);
private static SharedLayerResponse MapSharedLayer(SharedLayer sl) => new(
sl.Id, sl.Key, sl.Title, sl.LocalizedTitle, sl.Hint, sl.Type.ToString(), sl.DefaultValue,
sl.FontId, sl.FontFace, sl.FontSize, sl.IsFontChangeable, sl.IsFontSizeChangeable,
sl.Justify.ToString(), sl.CanJustify, sl.PositionInContainer, sl.IsTextBox, sl.MaxSize,
sl.VideoSupport, sl.MinDurationSec, sl.MaxDurationSec, sl.Width, sl.Height,
sl.MappedList, sl.AiInputType.ToString(), sl.IsHidden, sl.IsFocused,
sl.VirtualCount, sl.Sort
);
}