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
- 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>
331 lines
16 KiB
C#
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
|
|
);
|
|
}
|