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,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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user