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,297 @@
|
||||
using FlatRender.StudioSvc.Domain.Entities;
|
||||
using FlatRender.StudioSvc.Domain.Enums;
|
||||
using FlatRender.StudioSvc.Infrastructure.Data;
|
||||
using FlatRender.StudioSvc.Models.Requests;
|
||||
using FlatRender.StudioSvc.Models.Responses;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace FlatRender.StudioSvc.Application.Services;
|
||||
|
||||
public class StudioService(StudioDbContext db)
|
||||
{
|
||||
public async Task<PagedResponse<SavedProjectSummaryResponse>> ListProjectsAsync(
|
||||
Guid userId, SavedProjectListRequest req)
|
||||
{
|
||||
var q = db.SavedProjects.Where(x => x.UserId == userId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.Q))
|
||||
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{req.Q}%"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.Type) &&
|
||||
Enum.TryParse<SavedProjectType>(req.Type, true, out var t))
|
||||
q = q.Where(x => x.Type == t);
|
||||
|
||||
var total = await q.LongCountAsync();
|
||||
var items = await q
|
||||
.OrderByDescending(x => x.LastEditDate)
|
||||
.Skip((req.Page - 1) * req.PageSize)
|
||||
.Take(req.PageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return new PagedResponse<SavedProjectSummaryResponse>(
|
||||
items.Select(MapSummary),
|
||||
new PaginationMeta(req.Page, req.PageSize, total,
|
||||
(int)Math.Ceiling((double)total / req.PageSize)));
|
||||
}
|
||||
|
||||
public async Task<SavedProjectFullResponse> GetProjectAsync(Guid id, Guid userId)
|
||||
{
|
||||
var p = await LoadProjectQuery()
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId)
|
||||
?? throw new KeyNotFoundException($"Project {id} not found");
|
||||
|
||||
return MapFull(p);
|
||||
}
|
||||
|
||||
/// <summary>Internal: load project without user-ownership check (for service-to-service calls).</summary>
|
||||
public async Task<SavedProjectFullResponse> GetProjectForRenderAsync(Guid id)
|
||||
{
|
||||
var p = await LoadProjectQuery()
|
||||
.FirstOrDefaultAsync(x => x.Id == id)
|
||||
?? throw new KeyNotFoundException($"Project {id} not found");
|
||||
|
||||
return MapFull(p);
|
||||
}
|
||||
|
||||
private IQueryable<Domain.Entities.SavedProject> LoadProjectQuery() =>
|
||||
db.SavedProjects
|
||||
.Include(x => x.Scenes.OrderBy(s => s.Sort))
|
||||
.ThenInclude(s => s.Contents.OrderBy(c => c.Sort))
|
||||
.Include(x => x.Scenes).ThenInclude(s => s.Colors.OrderBy(c => c.Sort))
|
||||
.Include(x => x.Scenes).ThenInclude(s => s.ColorPresets.OrderBy(c => c.Sort))
|
||||
.ThenInclude(cp => cp.Items.OrderBy(i => i.Sort))
|
||||
.Include(x => x.Scenes).ThenInclude(s => s.Characters)
|
||||
.ThenInclude(ch => ch.Controllers.OrderBy(c => c.Sort))
|
||||
.Include(x => x.SharedColors.OrderBy(c => c.Sort))
|
||||
.Include(x => x.SharedColorPresets.OrderBy(c => c.Sort))
|
||||
.ThenInclude(cp => cp.Items.OrderBy(i => i.Sort))
|
||||
.Include(x => x.SharedLayers.OrderBy(l => l.Sort));
|
||||
|
||||
public async Task<SavedProjectFullResponse> CreateProjectAsync(
|
||||
Guid userId, Guid tenantId, CreateSavedProjectRequest req)
|
||||
{
|
||||
var project = new SavedProject
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
OriginalProjectId = req.OriginalProjectId,
|
||||
OriginalProjectName = req.Name ?? $"Project {DateTime.UtcNow:yyyy-MM-dd}",
|
||||
Name = req.Name ?? $"Project {DateTime.UtcNow:yyyy-MM-dd}",
|
||||
SelectedPresetStoryId = req.PresetStoryId,
|
||||
ProjectDurationSec = 0,
|
||||
};
|
||||
|
||||
db.SavedProjects.Add(project);
|
||||
await db.SaveChangesAsync();
|
||||
return await GetProjectAsync(project.Id, userId);
|
||||
}
|
||||
|
||||
public async Task<SavedProjectFullResponse> UpdateProjectAsync(
|
||||
Guid id, Guid userId, UpdateSavedProjectRequest req)
|
||||
{
|
||||
var project = await db.SavedProjects
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId)
|
||||
?? throw new KeyNotFoundException($"Project {id} not found");
|
||||
|
||||
if (req.Name != null) project.Name = req.Name;
|
||||
if (req.Image != null) project.Image = req.Image;
|
||||
if (req.Type != null && Enum.TryParse<SavedProjectType>(req.Type, true, out var t))
|
||||
project.Type = t;
|
||||
if (req.MusicFileId.HasValue) project.MusicFileId = req.MusicFileId;
|
||||
if (req.MusicTrackId.HasValue) project.MusicTrackId = req.MusicTrackId;
|
||||
if (req.MusicVolume.HasValue) project.MusicVolume = req.MusicVolume.Value;
|
||||
if (req.VoiceoverFileId.HasValue) project.VoiceoverFileId = req.VoiceoverFileId;
|
||||
if (req.VoiceoverVolume.HasValue) project.VoiceoverVolume = req.VoiceoverVolume.Value;
|
||||
if (req.VoiceoverRecordedInBrowser.HasValue)
|
||||
project.VoiceoverRecordedInBrowser = req.VoiceoverRecordedInBrowser.Value;
|
||||
if (req.SfxVolume.HasValue) project.SfxVolume = req.SfxVolume.Value;
|
||||
if (req.SfxEnabled.HasValue) project.SfxEnabled = req.SfxEnabled.Value;
|
||||
if (req.AudioVisualizerMusicUrl != null) project.AudioVisualizerMusicUrl = req.AudioVisualizerMusicUrl;
|
||||
if (req.AudioVisualizerDurationSec.HasValue)
|
||||
project.AudioVisualizerDurationSec = req.AudioVisualizerDurationSec;
|
||||
if (req.ManualColorPicker.HasValue) project.ManualColorPicker = req.ManualColorPicker.Value;
|
||||
if (req.SelectedPresetStoryId.HasValue) project.SelectedPresetStoryId = req.SelectedPresetStoryId;
|
||||
if (req.LastEditStep != null) project.LastEditStep = req.LastEditStep;
|
||||
if (req.EditState != null) project.EditState = req.EditState;
|
||||
|
||||
project.LastEditDate = DateTime.UtcNow;
|
||||
project.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return await GetProjectAsync(id, userId);
|
||||
}
|
||||
|
||||
public async Task DeleteProjectAsync(Guid id, Guid userId)
|
||||
{
|
||||
var project = await db.SavedProjects
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId)
|
||||
?? throw new KeyNotFoundException($"Project {id} not found");
|
||||
project.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full scene save — replaces all scene data atomically.
|
||||
/// Called by the studio editor on every save checkpoint.
|
||||
/// </summary>
|
||||
public async Task<SavedProjectFullResponse> SaveScenesAsync(
|
||||
Guid projectId, Guid userId, List<SaveSceneRequest> scenes)
|
||||
{
|
||||
var project = await db.SavedProjects
|
||||
.FirstOrDefaultAsync(x => x.Id == projectId && x.UserId == userId)
|
||||
?? throw new KeyNotFoundException($"Project {projectId} not found");
|
||||
|
||||
// Delete existing scenes (cascade deletes children)
|
||||
var existing = await db.SavedScenes
|
||||
.Where(x => x.SavedProjectId == projectId).ToListAsync();
|
||||
db.SavedScenes.RemoveRange(existing);
|
||||
|
||||
foreach (var (sceneReq, idx) in scenes.Select((s, i) => (s, i)))
|
||||
{
|
||||
var scene = new SavedScene
|
||||
{
|
||||
SavedProjectId = projectId,
|
||||
OriginalSceneId = sceneReq.OriginalSceneId,
|
||||
Key = sceneReq.Key,
|
||||
Title = sceneReq.Title,
|
||||
Image = sceneReq.Image,
|
||||
SceneColorSvg = sceneReq.SceneColorSvg,
|
||||
SceneType = sceneReq.SceneType,
|
||||
Sort = sceneReq.Sort,
|
||||
SceneLengthSec = sceneReq.SceneLengthSec,
|
||||
MinDurationSec = sceneReq.MinDurationSec,
|
||||
MaxDurationSec = sceneReq.MaxDurationSec,
|
||||
OverlapAtEndSec = sceneReq.OverlapAtEndSec,
|
||||
CanHandleDuration = sceneReq.CanHandleDuration,
|
||||
ManualColorSelection = sceneReq.ManualColorSelection,
|
||||
};
|
||||
|
||||
foreach (var c in sceneReq.Contents)
|
||||
scene.Contents.Add(MapContentEntity(c));
|
||||
|
||||
foreach (var c in sceneReq.Colors)
|
||||
scene.Colors.Add(new SavedSceneColor
|
||||
{
|
||||
ElementKey = c.ElementKey, Title = c.Title, Icon = c.Icon,
|
||||
AttrValue = c.AttrValue, Value = c.Value, IsSelected = c.IsSelected, Sort = c.Sort
|
||||
});
|
||||
|
||||
db.SavedScenes.Add(scene);
|
||||
}
|
||||
|
||||
// Sync shared colors
|
||||
var sharedColors = scenes.SelectMany(s => s.SharedColors).DistinctBy(x => x.ElementKey).ToList();
|
||||
if (sharedColors.Count > 0)
|
||||
{
|
||||
var existingSC = await db.SavedSharedColors
|
||||
.Where(x => x.SavedProjectId == projectId).ToListAsync();
|
||||
db.SavedSharedColors.RemoveRange(existingSC);
|
||||
foreach (var c in sharedColors)
|
||||
db.SavedSharedColors.Add(new SavedSharedColor
|
||||
{
|
||||
SavedProjectId = projectId, ElementKey = c.ElementKey, Title = c.Title,
|
||||
Icon = c.Icon, AttrValue = c.AttrValue, Value = c.Value,
|
||||
IsSelected = c.IsSelected, Sort = c.Sort
|
||||
});
|
||||
}
|
||||
|
||||
// Sync shared layers
|
||||
var sharedLayers = scenes.SelectMany(s => s.SharedLayers).DistinctBy(x => x.Key).ToList();
|
||||
if (sharedLayers.Count > 0)
|
||||
{
|
||||
var existingSL = await db.SavedSharedLayers
|
||||
.Where(x => x.SavedProjectId == projectId).ToListAsync();
|
||||
db.SavedSharedLayers.RemoveRange(existingSL);
|
||||
foreach (var l in sharedLayers)
|
||||
db.SavedSharedLayers.Add(MapSharedLayerEntity(projectId, l));
|
||||
}
|
||||
|
||||
project.LastEditDate = DateTime.UtcNow;
|
||||
project.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Recalc total duration
|
||||
project.ProjectDurationSec = scenes.Sum(s => s.SceneLengthSec - s.OverlapAtEndSec);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return await GetProjectAsync(projectId, userId);
|
||||
}
|
||||
|
||||
// ── Mappers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static SavedProjectSummaryResponse MapSummary(SavedProject p) => new(
|
||||
p.Id, p.UserId, p.OriginalProjectId, p.OriginalProjectName, p.OriginalContainerSlug,
|
||||
p.Name, p.Image, p.Type.ToString(), p.Resolution, p.ChooseMode,
|
||||
p.ProjectDurationSec, p.LastEditDate, p.CreatedAt);
|
||||
|
||||
private static SavedProjectFullResponse MapFull(SavedProject p) => new(
|
||||
p.Id, p.UserId, p.OriginalProjectId, p.OriginalProjectName, p.OriginalContainerSlug,
|
||||
p.Name, p.Image, p.Type.ToString(), p.FrameRate, p.ProjectDurationSec,
|
||||
p.Resolution, p.ChooseMode, p.VipFactor,
|
||||
p.MusicFileId, p.MusicTrackId, p.MusicVolume,
|
||||
p.VoiceoverFileId, p.VoiceoverVolume, p.VoiceoverRecordedInBrowser,
|
||||
p.SfxVolume, p.SfxEnabled, p.AudioVisualizerMusicUrl, p.AudioVisualizerDurationSec,
|
||||
p.ManualColorPicker, p.SelectedPresetStoryId,
|
||||
p.LastEditStep, p.EditState, p.LastEditDate, p.CreatedAt,
|
||||
p.Scenes.Select(MapSceneResponse).ToList(),
|
||||
p.SharedColors.Select(MapSharedColorResponse).ToList(),
|
||||
p.SharedColorPresets.Select(MapSharedColorPresetResponse).ToList(),
|
||||
p.SharedLayers.Select(MapSharedLayerResponse).ToList()
|
||||
);
|
||||
|
||||
private static SavedSceneResponse MapSceneResponse(SavedScene s) => new(
|
||||
s.Id, s.OriginalSceneId, s.Key, s.Title, s.Image, s.SceneType,
|
||||
s.Sort, s.SceneLengthSec, s.MinDurationSec, s.MaxDurationSec,
|
||||
s.OverlapAtEndSec, s.CanHandleDuration, s.ManualColorSelection, s.SelectedColorPresetId,
|
||||
s.Contents.Select(MapContentResponse).ToList(),
|
||||
s.Colors.Select(c => new SavedSceneColorResponse(c.Id, c.ElementKey, c.Title, c.Icon, c.AttrValue, c.Value, c.IsSelected, c.Sort)).ToList(),
|
||||
s.ColorPresets.Select(cp => new SavedSceneColorPresetResponse(cp.Id, cp.IsSelected, cp.Sort,
|
||||
cp.Items.Select(i => new SavedColorPresetItemResponse(i.Id, i.ElementKey, i.Value, i.Sort)).ToList()
|
||||
)).ToList(),
|
||||
s.Characters.Select(ch => new SavedSceneCharacterResponse(ch.Id, ch.Key, ch.Name, ch.Icon,
|
||||
ch.Controllers.Select(c => new SavedCharacterControllerResponse(c.Id, c.Name, c.Key, c.Value, c.Sort)).ToList()
|
||||
)).ToList()
|
||||
);
|
||||
|
||||
private static SavedSceneContentResponse MapContentResponse(SavedSceneContent c) => new(
|
||||
c.Id, c.Key, c.Title, c.Type, c.Value, c.ValueFileId, c.FileUrlCached,
|
||||
c.FontFace, c.FontSize, c.Justify, c.PositionInContainer, c.DirectionLayerValue,
|
||||
c.IsTextBox, c.AiInputType, c.SelectedDp, c.RepeaterItemKey, c.RepeaterIndex,
|
||||
c.IsFocused, c.MappedList, c.Sort);
|
||||
|
||||
private static SavedSharedColorResponse MapSharedColorResponse(SavedSharedColor c) =>
|
||||
new(c.Id, c.ElementKey, c.Title, c.Icon, c.AttrValue, c.Value, c.IsSelected, c.Sort);
|
||||
|
||||
private static SavedSharedColorPresetResponse MapSharedColorPresetResponse(SavedSharedColorPreset cp) =>
|
||||
new(cp.Id, cp.Name, cp.IsSelected, cp.Sort,
|
||||
cp.Items.Select(i => new SavedColorPresetItemResponse(i.Id, i.ElementKey, i.Value, i.Sort)).ToList());
|
||||
|
||||
private static SavedSharedLayerResponse MapSharedLayerResponse(SavedSharedLayer l) => new(
|
||||
l.Id, l.Key, l.Title, l.Type, l.Value, l.ValueFileId, l.FileUrlCached,
|
||||
l.FontFace, l.FontSize, l.Justify, l.PositionInContainer, l.DirectionLayerValue,
|
||||
l.IsTextBox, l.AiInputType, l.MappedList, l.Width, l.Height,
|
||||
l.IsFocused, l.IsFontChangeable, l.IsFontSizeChangeable, l.Sort);
|
||||
|
||||
private static SavedSceneContent MapContentEntity(SaveSceneContentRequest c) => new()
|
||||
{
|
||||
Key = c.Key, Title = c.Title, Type = c.Type, Value = c.Value,
|
||||
ValueFileId = c.ValueFileId, InsertedFileType = c.InsertedFileType,
|
||||
FontFace = c.FontFace, FontFaceName = c.FontFaceName, FontSize = c.FontSize,
|
||||
DefaultFontSize = c.DefaultFontSize, DefaultFontFace = c.DefaultFontFace,
|
||||
Justify = c.Justify, PositionInContainer = c.PositionInContainer,
|
||||
DirectionLayerValue = c.DirectionLayerValue, IsTextBox = c.IsTextBox,
|
||||
AiInputType = c.AiInputType, SelectedDp = c.SelectedDp,
|
||||
RepeaterItemKey = c.RepeaterItemKey, RepeaterIndex = c.RepeaterIndex,
|
||||
IsFocused = c.IsFocused, MappedList = c.MappedList, Thumbnail = c.Thumbnail, Sort = c.Sort
|
||||
};
|
||||
|
||||
private static SavedSharedLayer MapSharedLayerEntity(Guid projectId, SaveSharedLayerRequest l) => new()
|
||||
{
|
||||
SavedProjectId = projectId, Key = l.Key, Title = l.Title, Type = l.Type,
|
||||
Value = l.Value, ValueFileId = l.ValueFileId, FontFace = l.FontFace,
|
||||
FontFaceName = l.FontFaceName, FontSize = l.FontSize, Justify = l.Justify,
|
||||
PositionInContainer = l.PositionInContainer, DirectionLayerValue = l.DirectionLayerValue,
|
||||
IsTextBox = l.IsTextBox, AiInputType = l.AiInputType, MappedList = l.MappedList,
|
||||
Thumbnail = l.Thumbnail, Width = l.Width, Height = l.Height, IsFocused = l.IsFocused,
|
||||
IsFontChangeable = l.IsFontChangeable, IsFontSizeChangeable = l.IsFontSizeChangeable, Sort = l.Sort
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user