feat: V2 microservices stack — backend services, gateway, JWT auth
Add full V2 architecture: identity, content, studio (.NET 10) and file, render, notification, gateway (Go) services with vendored deps, plus DB migrations, event/API contracts, and an init-db script. Wire the Next.js frontend to the gateway: server-side JWT auth routes (login/register/refresh/logout/me), gateway fetch helper, and session/ cookie/jwt helpers under src/lib. Containerize the stack via docker-compose.v2.yml and per-service Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via next/font/local to avoid Google Fonts (geo-blocked). Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
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 CmsService(ContentDbContext db)
|
||||
{
|
||||
// ── Blogs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<PagedResponse<BlogSummaryResponse>> GetBlogsAsync(BlogListRequest req)
|
||||
{
|
||||
var kind = Enum.TryParse<BlogKind>(req.Kind, true, out var k) ? k : BlogKind.Blog;
|
||||
var q = db.Blogs.Where(x => x.Kind == kind);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||
q = q.Where(x => EF.Functions.ILike(x.Title, $"%{req.Search}%"));
|
||||
|
||||
if (req.IsPublished.HasValue)
|
||||
q = q.Where(x => x.IsPublished == req.IsPublished);
|
||||
|
||||
var total = await q.LongCountAsync();
|
||||
var items = await q.OrderByDescending(x => x.PublishDate ?? x.CreatedAt)
|
||||
.Skip((req.Page - 1) * req.PageSize).Take(req.PageSize).ToListAsync();
|
||||
|
||||
return new PagedResponse<BlogSummaryResponse>(
|
||||
items.Select(MapBlogSummary),
|
||||
new PaginationMeta(req.Page, req.PageSize, total, (int)Math.Ceiling((double)total / req.PageSize))
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<BlogDetailResponse> GetBlogBySlugAsync(string slug)
|
||||
{
|
||||
var blog = await db.Blogs.FirstOrDefaultAsync(x => x.Slug == slug)
|
||||
?? throw new KeyNotFoundException($"Blog '{slug}' not found");
|
||||
await db.Blogs.Where(x => x.Id == blog.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ViewCount, x => x.ViewCount + 1));
|
||||
return MapBlogDetail(blog);
|
||||
}
|
||||
|
||||
public async Task<BlogDetailResponse> CreateBlogAsync(BlogListRequest _, CreateBlogRequest req, Guid? authorId)
|
||||
{
|
||||
var kind = Enum.TryParse<BlogKind>(req.Kind, true, out var k) ? k : BlogKind.Blog;
|
||||
var blog = new Blog
|
||||
{
|
||||
TenantId = null, Kind = kind, Slug = req.Slug, Title = req.Title,
|
||||
ShortDescription = req.ShortDescription, Content = req.Content,
|
||||
MetaTitle = req.MetaTitle, MetaDescription = req.MetaDescription, MetaKeywords = req.MetaKeywords,
|
||||
IncludeInSiteMap = req.IncludeInSiteMap, Image = req.Image, Cover = req.Cover,
|
||||
AuthorUserId = authorId, AuthorDisplayName = req.AuthorDisplayName,
|
||||
IsPublished = req.IsPublished, PublishDate = req.PublishDate
|
||||
};
|
||||
db.Blogs.Add(blog);
|
||||
await db.SaveChangesAsync();
|
||||
return MapBlogDetail(blog);
|
||||
}
|
||||
|
||||
public async Task<BlogDetailResponse> UpdateBlogAsync(Guid id, UpdateBlogRequest req)
|
||||
{
|
||||
var blog = await db.Blogs.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Blog {id} not found");
|
||||
blog.Slug = req.Slug; blog.Title = req.Title; blog.ShortDescription = req.ShortDescription;
|
||||
blog.Content = req.Content; blog.MetaTitle = req.MetaTitle; blog.MetaDescription = req.MetaDescription;
|
||||
blog.MetaKeywords = req.MetaKeywords; blog.IncludeInSiteMap = req.IncludeInSiteMap;
|
||||
blog.Image = req.Image; blog.Cover = req.Cover; blog.AuthorDisplayName = req.AuthorDisplayName;
|
||||
blog.IsPublished = req.IsPublished; blog.PublishDate = req.PublishDate;
|
||||
blog.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return MapBlogDetail(blog);
|
||||
}
|
||||
|
||||
public async Task DeleteBlogAsync(Guid id)
|
||||
{
|
||||
var blog = await db.Blogs.FindAsync(id) ?? throw new KeyNotFoundException($"Blog {id} not found");
|
||||
blog.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ── Comments ──────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<PagedResponse<CommentResponse>> GetCommentsAsync(
|
||||
int page, int pageSize, Guid? blogId, Guid? containerId, bool? isApproved)
|
||||
{
|
||||
var q = db.Comments.AsQueryable();
|
||||
if (blogId.HasValue) q = q.Where(x => x.BlogId == blogId);
|
||||
if (containerId.HasValue) q = q.Where(x => x.ContainerId == containerId);
|
||||
if (isApproved.HasValue) q = q.Where(x => x.IsApproved == isApproved);
|
||||
|
||||
var total = await q.LongCountAsync();
|
||||
var items = await q.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
|
||||
return new PagedResponse<CommentResponse>(
|
||||
items.Select(MapComment),
|
||||
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<CommentResponse> CreateCommentAsync(CreateCommentRequest req, Guid userId)
|
||||
{
|
||||
if (req.BlogId == null && req.ContainerId == null)
|
||||
throw new ArgumentException("Either BlogId or ContainerId must be provided");
|
||||
if (req.BlogId != null && req.ContainerId != null)
|
||||
throw new ArgumentException("Only one of BlogId or ContainerId may be provided");
|
||||
|
||||
var comment = new Comment
|
||||
{
|
||||
UserId = userId, BlogId = req.BlogId, ContainerId = req.ContainerId,
|
||||
ParentCommentId = req.ParentCommentId, Content = req.Content, Rate = req.Rate
|
||||
};
|
||||
db.Comments.Add(comment);
|
||||
await db.SaveChangesAsync();
|
||||
return MapComment(comment);
|
||||
}
|
||||
|
||||
public async Task ApproveCommentAsync(Guid id, bool approve)
|
||||
{
|
||||
var comment = await db.Comments.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Comment {id} not found");
|
||||
comment.IsApproved = approve;
|
||||
comment.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteCommentAsync(Guid id)
|
||||
{
|
||||
var comment = await db.Comments.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Comment {id} not found");
|
||||
comment.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ── Slides ────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<SlideResponse>> GetSlidesAsync(Guid? tenantId)
|
||||
{
|
||||
var q = db.NewSlides.Where(x => x.IsActive &&
|
||||
(x.TenantId == null || x.TenantId == tenantId) &&
|
||||
(x.ExpireDate == null || x.ExpireDate > DateTime.UtcNow));
|
||||
|
||||
return await q.OrderBy(x => x.Sort).Select(s => new SlideResponse(
|
||||
s.Id, s.Keyword, s.Title, s.Image, s.Parameter, s.SlideType.ToString(),
|
||||
s.ExpireDate, s.Sort, s.IsActive
|
||||
)).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<SlideResponse> CreateSlideAsync(CreateSlideRequest req)
|
||||
{
|
||||
if (!Enum.TryParse<SlideType>(req.SlideType, true, out var type))
|
||||
throw new ArgumentException($"Invalid SlideType: {req.SlideType}");
|
||||
|
||||
var slide = new NewSlide
|
||||
{
|
||||
Keyword = req.Keyword, Title = req.Title, Image = req.Image,
|
||||
Parameter = req.Parameter, SlideType = type, ExpireDate = req.ExpireDate,
|
||||
Sort = req.Sort, IsActive = req.IsActive
|
||||
};
|
||||
db.NewSlides.Add(slide);
|
||||
await db.SaveChangesAsync();
|
||||
return new SlideResponse(slide.Id, slide.Keyword, slide.Title, slide.Image, slide.Parameter,
|
||||
slide.SlideType.ToString(), slide.ExpireDate, slide.Sort, slide.IsActive);
|
||||
}
|
||||
|
||||
public async Task DeleteSlideAsync(Guid id)
|
||||
{
|
||||
var slide = await db.NewSlides.FindAsync(id) ?? throw new KeyNotFoundException($"Slide {id} not found");
|
||||
db.NewSlides.Remove(slide);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ── Home Page Events ──────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<HomePageEventResponse>> GetHomePageEventsAsync(Guid? tenantId)
|
||||
{
|
||||
return await db.HomePageEvents
|
||||
.Where(x => x.IsActive && (x.TenantId == null || x.TenantId == tenantId))
|
||||
.OrderBy(x => x.Sort)
|
||||
.Select(e => new HomePageEventResponse(
|
||||
e.Id, e.Title, e.Subtitle, e.Description, e.Badge, e.BadgeClass,
|
||||
e.ButtonText, e.ButtonUrl, e.ButtonClass, e.Color, e.BackgroundColor,
|
||||
e.TextColor, e.Image, e.IsActive, e.Sort, e.StartsAt, e.EndsAt
|
||||
)).ToListAsync();
|
||||
}
|
||||
|
||||
// ── Website Settings ──────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<WebsiteSettingResponse>> GetSettingsAsync(Guid? tenantId, bool includeSecret = false)
|
||||
{
|
||||
var q = db.WebsiteSettings.Where(x => x.TenantId == tenantId);
|
||||
if (!includeSecret) q = q.Where(x => !x.IsSecret);
|
||||
return await q.Select(s => new WebsiteSettingResponse(s.Id, s.Key, s.Value, s.Description, s.IsSecret)).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<WebsiteSettingResponse> UpsertSettingAsync(Guid? tenantId, UpsertWebsiteSettingRequest req)
|
||||
{
|
||||
var setting = await db.WebsiteSettings.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Key == req.Key);
|
||||
if (setting == null)
|
||||
{
|
||||
setting = new WebsiteSetting { TenantId = tenantId, Key = req.Key };
|
||||
db.WebsiteSettings.Add(setting);
|
||||
}
|
||||
setting.Value = req.Value;
|
||||
setting.Description = req.Description;
|
||||
setting.IsSecret = req.IsSecret;
|
||||
setting.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return new WebsiteSettingResponse(setting.Id, setting.Key, setting.Value, setting.Description, setting.IsSecret);
|
||||
}
|
||||
|
||||
// ── Favorites ─────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<FavoriteFolderResponse>> GetFavoriteFoldersAsync(Guid userId)
|
||||
{
|
||||
return await db.FavoriteFolders
|
||||
.Where(x => x.UserId == userId)
|
||||
.Select(f => new FavoriteFolderResponse(
|
||||
f.Id, f.Name, f.Description,
|
||||
db.FavoriteContainers.Count(fc => fc.FolderId == f.Id),
|
||||
f.CreatedAt
|
||||
)).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<FavoriteFolderResponse> CreateFavoriteFolderAsync(Guid userId, Guid tenantId, CreateFavoriteFolderRequest req)
|
||||
{
|
||||
var folder = new FavoriteFolder { UserId = userId, TenantId = tenantId, Name = req.Name, Description = req.Description };
|
||||
db.FavoriteFolders.Add(folder);
|
||||
await db.SaveChangesAsync();
|
||||
return new FavoriteFolderResponse(folder.Id, folder.Name, folder.Description, 0, folder.CreatedAt);
|
||||
}
|
||||
|
||||
public async Task DeleteFavoriteFolderAsync(Guid userId, Guid id)
|
||||
{
|
||||
var folder = await db.FavoriteFolders.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId)
|
||||
?? throw new KeyNotFoundException($"Folder {id} not found");
|
||||
db.FavoriteFolders.Remove(folder);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task AddFavoriteContainerAsync(Guid userId, Guid tenantId, AddFavoriteContainerRequest req)
|
||||
{
|
||||
var exists = await db.FavoriteContainers.AnyAsync(x => x.UserId == userId && x.ContainerId == req.ContainerId);
|
||||
if (exists) return;
|
||||
|
||||
db.FavoriteContainers.Add(new FavoriteContainer
|
||||
{
|
||||
UserId = userId, TenantId = tenantId, ContainerId = req.ContainerId,
|
||||
FolderId = req.FolderId, Note = req.Note
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task RemoveFavoriteContainerAsync(Guid userId, Guid containerId)
|
||||
{
|
||||
var fav = await db.FavoriteContainers.FirstOrDefaultAsync(x => x.UserId == userId && x.ContainerId == containerId)
|
||||
?? throw new KeyNotFoundException($"Favorite not found");
|
||||
db.FavoriteContainers.Remove(fav);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ── Mappers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static BlogSummaryResponse MapBlogSummary(Blog b) => new(
|
||||
b.Id, b.Slug, b.Title, b.ShortDescription, b.Image, b.Cover,
|
||||
b.AuthorDisplayName, b.IsPublished, b.PublishDate, b.ViewCount, b.CreatedAt
|
||||
);
|
||||
|
||||
private static BlogDetailResponse MapBlogDetail(Blog b) => new(
|
||||
b.Id, b.Slug, b.Title, b.ShortDescription, b.Content, b.MetaTitle, b.MetaDescription,
|
||||
b.MetaKeywords, b.IncludeInSiteMap, b.Image, b.Cover, b.AuthorUserId, b.AuthorDisplayName,
|
||||
b.IsPublished, b.PublishDate, b.ViewCount, b.CreatedAt, b.UpdatedAt
|
||||
);
|
||||
|
||||
private static CommentResponse MapComment(Comment c) => new(
|
||||
c.Id, c.UserId, c.BlogId, c.ContainerId, c.ParentCommentId,
|
||||
c.Content, c.Rate, c.IsApproved, c.IsPinned, c.CreatedAt
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using FlatRender.ContentSvc.Domain.Entities;
|
||||
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 TaxonomyService(ContentDbContext db)
|
||||
{
|
||||
public async Task<List<CategoryResponse>> GetCategoryTreeAsync()
|
||||
{
|
||||
var all = await db.Categories
|
||||
.Where(x => x.ParentId == null)
|
||||
.Include(x => x.Children)
|
||||
.OrderBy(x => x.Sort)
|
||||
.ToListAsync();
|
||||
|
||||
return all.Select(MapCategory).ToList();
|
||||
}
|
||||
|
||||
public async Task<CategoryResponse> CreateCategoryAsync(CreateCategoryRequest req)
|
||||
{
|
||||
var cat = new Category
|
||||
{
|
||||
ParentId = req.ParentId,
|
||||
Name = req.Name,
|
||||
Slug = req.Slug,
|
||||
Description = req.Description,
|
||||
ImageUrl = req.ImageUrl,
|
||||
Icon = req.Icon,
|
||||
MetaTitle = req.MetaTitle,
|
||||
MetaDescription = req.MetaDescription,
|
||||
MetaKeywords = req.MetaKeywords,
|
||||
BotFollow = req.BotFollow,
|
||||
Sort = req.Sort,
|
||||
IsActive = req.IsActive
|
||||
};
|
||||
db.Categories.Add(cat);
|
||||
await db.SaveChangesAsync();
|
||||
return MapCategory(cat);
|
||||
}
|
||||
|
||||
public async Task<CategoryResponse> UpdateCategoryAsync(Guid id, UpdateCategoryRequest req)
|
||||
{
|
||||
var cat = await db.Categories.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Category {id} not found");
|
||||
|
||||
cat.ParentId = req.ParentId;
|
||||
cat.Name = req.Name;
|
||||
cat.Slug = req.Slug;
|
||||
cat.Description = req.Description;
|
||||
cat.ImageUrl = req.ImageUrl;
|
||||
cat.Icon = req.Icon;
|
||||
cat.MetaTitle = req.MetaTitle;
|
||||
cat.MetaDescription = req.MetaDescription;
|
||||
cat.MetaKeywords = req.MetaKeywords;
|
||||
cat.BotFollow = req.BotFollow;
|
||||
cat.Sort = req.Sort;
|
||||
cat.IsActive = req.IsActive;
|
||||
cat.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return MapCategory(cat);
|
||||
}
|
||||
|
||||
public async Task DeleteCategoryAsync(Guid id)
|
||||
{
|
||||
var cat = await db.Categories.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Category {id} not found");
|
||||
cat.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<TagResponse>> GetTagsAsync(int page, int pageSize, string? search)
|
||||
{
|
||||
var q = db.Tags.AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{search}%"));
|
||||
|
||||
var total = await q.LongCountAsync();
|
||||
var items = await q.OrderBy(x => x.Name).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
|
||||
return new PagedResponse<TagResponse>(
|
||||
items.Select(t => new TagResponse(t.Id, t.Name, t.LatinName, t.Slug, t.AppliesToMode, t.IsActive)),
|
||||
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<TagResponse> CreateTagAsync(CreateTagRequest req)
|
||||
{
|
||||
var tag = new Tag
|
||||
{
|
||||
Name = req.Name,
|
||||
LatinName = req.LatinName,
|
||||
Slug = req.Slug,
|
||||
AppliesToMode = req.AppliesToMode,
|
||||
IsActive = req.IsActive
|
||||
};
|
||||
db.Tags.Add(tag);
|
||||
await db.SaveChangesAsync();
|
||||
return new TagResponse(tag.Id, tag.Name, tag.LatinName, tag.Slug, tag.AppliesToMode, tag.IsActive);
|
||||
}
|
||||
|
||||
public async Task<TagResponse> UpdateTagAsync(Guid id, UpdateTagRequest req)
|
||||
{
|
||||
var tag = await db.Tags.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Tag {id} not found");
|
||||
tag.Name = req.Name;
|
||||
tag.LatinName = req.LatinName;
|
||||
tag.Slug = req.Slug;
|
||||
tag.AppliesToMode = req.AppliesToMode;
|
||||
tag.IsActive = req.IsActive;
|
||||
tag.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return new TagResponse(tag.Id, tag.Name, tag.LatinName, tag.Slug, tag.AppliesToMode, tag.IsActive);
|
||||
}
|
||||
|
||||
public async Task DeleteTagAsync(Guid id)
|
||||
{
|
||||
var tag = await db.Tags.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Tag {id} not found");
|
||||
tag.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<FontResponse>> GetFontsAsync(int page, int pageSize, string? search, string? direction)
|
||||
{
|
||||
var q = db.Fonts.AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{search}%"));
|
||||
if (!string.IsNullOrWhiteSpace(direction))
|
||||
q = q.Where(x => x.Direction == direction);
|
||||
|
||||
var total = await q.LongCountAsync();
|
||||
var items = await q.OrderBy(x => x.Sort).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
|
||||
return new PagedResponse<FontResponse>(
|
||||
items.Select(MapFont),
|
||||
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<FontResponse> CreateFontAsync(CreateFontRequest req)
|
||||
{
|
||||
var font = new Font
|
||||
{
|
||||
Name = req.Name, OriginalName = req.OriginalName, SystemName = req.SystemName,
|
||||
Family = req.Family, Weight = req.Weight, Style = req.Style, Direction = req.Direction,
|
||||
FileUrl = req.FileUrl, SampleImageUrl = req.SampleImageUrl, IsPremium = req.IsPremium,
|
||||
IsActive = req.IsActive, InstalledOnNodes = req.InstalledOnNodes, Sort = req.Sort
|
||||
};
|
||||
db.Fonts.Add(font);
|
||||
await db.SaveChangesAsync();
|
||||
return MapFont(font);
|
||||
}
|
||||
|
||||
public async Task<FontResponse> UpdateFontAsync(Guid id, UpdateFontRequest req)
|
||||
{
|
||||
var font = await db.Fonts.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Font {id} not found");
|
||||
font.Name = req.Name; font.OriginalName = req.OriginalName; font.SystemName = req.SystemName;
|
||||
font.Family = req.Family; font.Weight = req.Weight; font.Style = req.Style; font.Direction = req.Direction;
|
||||
font.FileUrl = req.FileUrl; font.SampleImageUrl = req.SampleImageUrl; font.IsPremium = req.IsPremium;
|
||||
font.IsActive = req.IsActive; font.InstalledOnNodes = req.InstalledOnNodes; font.Sort = req.Sort;
|
||||
font.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return MapFont(font);
|
||||
}
|
||||
|
||||
public async Task DeleteFontAsync(Guid id)
|
||||
{
|
||||
var font = await db.Fonts.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"Font {id} not found");
|
||||
font.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<MusicTrackResponse>> GetMusicTracksAsync(int page, int pageSize, string? search)
|
||||
{
|
||||
var q = db.MusicTracks.AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{search}%"));
|
||||
|
||||
var total = await q.LongCountAsync();
|
||||
var items = await q.OrderBy(x => x.Sort).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
|
||||
return new PagedResponse<MusicTrackResponse>(
|
||||
items.Select(MapMusic),
|
||||
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<MusicTrackResponse> CreateMusicTrackAsync(CreateMusicTrackRequest req)
|
||||
{
|
||||
var track = new MusicTrack
|
||||
{
|
||||
Name = req.Name, Caption = req.Caption, Keywords = req.Keywords, Url = req.Url,
|
||||
WaveformData = req.WaveformData, DurationSec = req.DurationSec, Bpm = req.Bpm,
|
||||
Genre = req.Genre, Mood = req.Mood, IsPremium = req.IsPremium, IsActive = req.IsActive, Sort = req.Sort
|
||||
};
|
||||
db.MusicTracks.Add(track);
|
||||
await db.SaveChangesAsync();
|
||||
return MapMusic(track);
|
||||
}
|
||||
|
||||
public async Task DeleteMusicTrackAsync(Guid id)
|
||||
{
|
||||
var track = await db.MusicTracks.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"MusicTrack {id} not found");
|
||||
track.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static CategoryResponse MapCategory(Category c) => new(
|
||||
c.Id, c.ParentId, c.Name, c.Slug, c.Description, c.ImageUrl, c.Icon,
|
||||
c.IsActive, c.Sort, c.Children.Select(MapCategory).ToList()
|
||||
);
|
||||
|
||||
private static FontResponse MapFont(Font f) => new(
|
||||
f.Id, f.Name, f.OriginalName, f.SystemName, f.Family, f.Weight, f.Style,
|
||||
f.Direction, f.FileUrl, f.SampleImageUrl, f.IsPremium, f.IsActive, f.InstalledOnNodes
|
||||
);
|
||||
|
||||
private static MusicTrackResponse MapMusic(MusicTrack m) => new(
|
||||
m.Id, m.Name, m.Caption, m.Url, m.WaveformData, m.DurationSec, m.Bpm, m.Genre, m.Mood, m.IsPremium
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user