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:
soroush.asadi
2026-05-29 23:29:31 +03:30
parent 53ea78a00d
commit 90ac0b81d1
7636 changed files with 3707504 additions and 240 deletions
+19
View File
@@ -0,0 +1,19 @@
# Build output — rebuilt inside container
**/bin/
**/obj/
# Local dev secrets
**/appsettings.Development.json
**/appsettings.*.Local.json
**/*.user
# IDE / OS
.vs/
.idea/
.DS_Store
Thumbs.db
# Docker files
Dockerfile
.dockerignore
docker-compose*.yml
+24
View File
@@ -0,0 +1,24 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080
# The .NET base image ships neither wget nor curl, which the container healthcheck needs.
# Copy a single static busybox binary named `wget` (busybox dispatches on argv[0]).
# This stays fully offline — no apt/network — matching the vendored Go builds.
COPY --from=busybox:1.36 /bin/busybox /usr/bin/wget
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore is its own cached layer: it only re-runs when the .csproj (deps) changes,
# not on every source edit. Critical here — NuGet restore is the slow step.
COPY NuGet.Config .
COPY ["FlatRender.StudioSvc/FlatRender.StudioSvc.csproj", "FlatRender.StudioSvc/"]
RUN dotnet restore "FlatRender.StudioSvc/FlatRender.StudioSvc.csproj"
COPY . .
# Single publish compiles + packages; --no-restore reuses the cached restore above.
RUN dotnet publish "FlatRender.StudioSvc/FlatRender.StudioSvc.csproj" \
-c Release -o /app/publish --no-restore /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "FlatRender.StudioSvc.dll"]
@@ -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
};
}
@@ -0,0 +1,57 @@
using System.Security.Claims;
using FlatRender.StudioSvc.Application.Services;
using FlatRender.StudioSvc.Models.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.StudioSvc.Controllers;
[ApiController]
[Route("v1/saved-projects")]
[Authorize]
public class StudioController(StudioService svc) : ControllerBase
{
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
private Guid TenantId => Guid.Parse(
User.FindFirstValue("tenant_id") ?? "00000000-0000-0000-0000-000000000001");
[HttpGet]
public async Task<IActionResult> List([FromQuery] SavedProjectListRequest req) =>
Ok(await svc.ListProjectsAsync(UserId, req));
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id) =>
Ok(await svc.GetProjectAsync(id, UserId));
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateSavedProjectRequest req)
{
var result = await svc.CreateProjectAsync(UserId, TenantId, req);
return CreatedAtAction(nameof(Get), new { id = result.Id }, result);
}
[HttpPatch("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateSavedProjectRequest req) =>
Ok(await svc.UpdateProjectAsync(id, UserId, req));
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await svc.DeleteProjectAsync(id, UserId);
return NoContent();
}
/// <summary>
/// Save all scenes for a project (full atomic replace).
/// Called by the studio editor on every save.
/// </summary>
[HttpPut("{id:guid}/scenes")]
public async Task<IActionResult> SaveScenes(Guid id, [FromBody] List<SaveSceneRequest> scenes) =>
Ok(await svc.SaveScenesAsync(id, UserId, scenes));
/// <summary>Internal endpoint: get project for render service (no user-ownership check).</summary>
[HttpGet("{id:guid}/render-payload")]
[Authorize(Roles = "Service")]
public async Task<IActionResult> GetRenderPayload(Guid id) =>
Ok(await svc.GetProjectForRenderAsync(id));
}
@@ -0,0 +1,289 @@
using FlatRender.StudioSvc.Domain.Enums;
namespace FlatRender.StudioSvc.Domain.Entities;
public class SavedProject
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
// Source template snapshot
public Guid OriginalProjectId { get; set; }
public string OriginalProjectName { get; set; } = default!;
public Guid? OriginalContainerId { get; set; }
public string? OriginalContainerSlug { get; set; }
// Identity
public string Name { get; set; } = default!;
public string? Image { get; set; }
public SavedProjectType Type { get; set; } = SavedProjectType.Draft;
// Project metadata snapshot
public int FrameRate { get; set; } = 30;
public decimal ProjectDurationSec { get; set; }
public string Resolution { get; set; } = "FullHD";
public string ChooseMode { get; set; } = "FLEXIBLE";
public decimal VipFactor { get; set; } = 1.0m;
// Audio
public Guid? MusicFileId { get; set; }
public Guid? MusicTrackId { get; set; }
public decimal MusicVolume { get; set; } = 0.7m;
public Guid? VoiceoverFileId { get; set; }
public decimal VoiceoverVolume { get; set; } = 1.0m;
public bool VoiceoverRecordedInBrowser { get; set; }
public decimal SfxVolume { get; set; } = 1.0m;
public bool SfxEnabled { get; set; } = true;
public string? AudioVisualizerMusicUrl { get; set; }
public decimal? AudioVisualizerDurationSec { get; set; }
// Customization
public bool ManualColorPicker { get; set; }
public Guid? SelectedPresetStoryId { get; set; }
// State
public string? LastEditStep { get; set; }
public string EditState { get; set; } = "{}";
public DateTime CreateDate { get; set; } = DateTime.UtcNow;
public DateTime LastEditDate { get; set; } = DateTime.UtcNow;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
public ICollection<SavedScene> Scenes { get; set; } = [];
public ICollection<SavedSharedColor> SharedColors { get; set; } = [];
public ICollection<SavedSharedColorPreset> SharedColorPresets { get; set; } = [];
public ICollection<SavedSharedLayer> SharedLayers { get; set; } = [];
}
public class SavedScene
{
public long Id { get; set; }
public Guid SavedProjectId { get; set; }
public SavedProject SavedProject { get; set; } = default!;
public Guid? OriginalSceneId { get; set; }
public string Key { get; set; } = default!;
public string? Title { get; set; }
public string? Image { get; set; }
public string? Demo { get; set; }
public string? SceneColorSvg { get; set; }
public string SceneType { get; set; } = "Normal";
// Timing
public int Sort { get; set; }
public decimal SceneLengthSec { get; set; }
public decimal? MinDurationSec { get; set; }
public decimal? MaxDurationSec { get; set; }
public decimal OverlapAtEndSec { get; set; }
public bool CanHandleDuration { get; set; } = true;
// Customization
public bool ManualColorSelection { get; set; }
public long? SelectedColorPresetId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<SavedSceneContent> Contents { get; set; } = [];
public ICollection<SavedSceneColor> Colors { get; set; } = [];
public ICollection<SavedSceneColorPreset> ColorPresets { get; set; } = [];
public ICollection<SavedSceneCharacter> Characters { get; set; } = [];
}
public class SavedSceneContent
{
public long Id { get; set; }
public long SavedSceneId { get; set; }
public SavedScene SavedScene { get; set; } = default!;
public string Key { get; set; } = default!;
public string? Title { get; set; }
public string? LocalizedTitle { get; set; }
public string? Hint { get; set; }
public string Type { get; set; } = default!;
// Value
public string? Value { get; set; }
public Guid? ValueFileId { get; set; }
public string? InsertedFileType { get; set; }
public string? FileUrlCached { get; set; }
public DateTime? FileUrlCachedAt { get; set; }
// Text styling
public string? FontFace { get; set; }
public string? FontFaceName { get; set; }
public int? FontSize { get; set; }
public int? DefaultFontSize { get; set; }
public string? DefaultFontFace { get; set; }
public string? Justify { get; set; }
public int PositionInContainer { get; set; }
public int DirectionLayerValue { get; set; }
public bool IsTextBox { get; set; }
// AI
public string? AiInputType { get; set; }
// Design pattern
public int? SelectedDp { get; set; }
// Repeater
public string? RepeaterItemKey { get; set; }
public int? RepeaterIndex { get; set; }
// State
public bool IsFocused { get; set; }
public string? Status { get; set; }
public string? MappedList { get; set; }
public string? Thumbnail { get; set; }
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class SavedSceneColor
{
public long Id { get; set; }
public long SavedSceneId { get; set; }
public SavedScene SavedScene { get; set; } = default!;
public string ElementKey { get; set; } = default!;
public string? Title { get; set; }
public string? Icon { get; set; }
public string AttrValue { get; set; } = "fill";
public string Value { get; set; } = default!;
public bool IsSelected { get; set; } = true;
public int Sort { get; set; }
}
public class SavedSceneColorPreset
{
public long Id { get; set; }
public long SavedSceneId { get; set; }
public SavedScene SavedScene { get; set; } = default!;
public bool IsSelected { get; set; }
public int Sort { get; set; }
public ICollection<SavedSceneColorPresetItem> Items { get; set; } = [];
}
public class SavedSceneColorPresetItem
{
public long Id { get; set; }
public long PresetId { get; set; }
public SavedSceneColorPreset Preset { get; set; } = default!;
public string ElementKey { get; set; } = default!;
public string Value { get; set; } = default!;
public int Sort { get; set; }
}
public class SavedSceneCharacter
{
public long Id { get; set; }
public long SavedSceneId { get; set; }
public SavedScene SavedScene { get; set; } = default!;
public Guid Key { get; set; }
public string? Name { get; set; }
public string? Icon { get; set; }
public ICollection<SavedSceneCharacterController> Controllers { get; set; } = [];
}
public class SavedSceneCharacterController
{
public long Id { get; set; }
public long SavedSceneCharacterId { get; set; }
public SavedSceneCharacter Character { get; set; } = default!;
public string? Name { get; set; }
public string Key { get; set; } = default!;
public string Value { get; set; } = default!;
public int Sort { get; set; }
}
public class SavedSharedColor
{
public long Id { get; set; }
public Guid SavedProjectId { get; set; }
public SavedProject SavedProject { get; set; } = default!;
public string ElementKey { get; set; } = default!;
public string? Title { get; set; }
public string? Icon { get; set; }
public string AttrValue { get; set; } = "fill";
public string Value { get; set; } = default!;
public bool IsSelected { get; set; } = true;
public int Sort { get; set; }
}
public class SavedSharedColorPreset
{
public long Id { get; set; }
public Guid SavedProjectId { get; set; }
public SavedProject SavedProject { get; set; } = default!;
public string? Name { get; set; }
public bool IsSelected { get; set; }
public int Sort { get; set; }
public ICollection<SavedSharedColorPresetItem> Items { get; set; } = [];
}
public class SavedSharedColorPresetItem
{
public long Id { get; set; }
public long PresetId { get; set; }
public SavedSharedColorPreset Preset { get; set; } = default!;
public string ElementKey { get; set; } = default!;
public string Value { get; set; } = default!;
public int Sort { get; set; }
}
public class SavedSharedLayer
{
public long Id { get; set; }
public Guid SavedProjectId { get; set; }
public SavedProject SavedProject { get; set; } = default!;
public string Key { get; set; } = default!;
public string? Title { get; set; }
public string? LocalizedTitle { get; set; }
public string? Hint { get; set; }
public string Type { get; set; } = default!;
public string? Value { get; set; }
public Guid? ValueFileId { get; set; }
public string? FileUrlCached { get; set; }
public DateTime? FileUrlCachedAt { get; set; }
public string? FontFace { get; set; }
public string? FontFaceName { get; set; }
public int? FontSize { get; set; }
public int? DefaultFontSize { get; set; }
public string? DefaultFontFace { get; set; }
public string? Justify { get; set; }
public int PositionInContainer { get; set; }
public int DirectionLayerValue { get; set; }
public bool IsTextBox { get; set; }
public string? AiInputType { get; set; }
public string? MappedList { get; set; }
public string? Thumbnail { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public decimal? MinDurationSec { get; set; }
public decimal? MaxDurationSec { get; set; }
public bool IsFocused { get; set; }
public bool IsFontChangeable { get; set; } = true;
public bool IsFontSizeChangeable { get; set; } = true;
public string? Status { get; set; }
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,3 @@
namespace FlatRender.StudioSvc.Domain.Enums;
public enum SavedProjectType { Draft, Active, Archived, Trash }
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.*" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.*" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ActiveDebugProfile>https</ActiveDebugProfile>
</PropertyGroup>
</Project>
@@ -0,0 +1,6 @@
@FlatRender.StudioSvc_HostAddress = http://localhost:5074
GET {{FlatRender.StudioSvc_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,17 @@
using Npgsql;
namespace FlatRender.StudioSvc.Infrastructure.Data;
/// <summary>
/// Npgsql name translator that returns CLR names verbatim. The database enum labels
/// match the C# enum member names exactly, so no snake_case translation may be applied
/// to enum values. PG type names are passed explicitly, so type-name translation is moot.
/// </summary>
public sealed class PreserveCaseNameTranslator : INpgsqlNameTranslator
{
public static readonly PreserveCaseNameTranslator Instance = new();
public string TranslateTypeName(string clrName) => clrName;
public string TranslateMemberName(string clrName) => clrName;
}
@@ -0,0 +1,292 @@
using FlatRender.StudioSvc.Domain.Entities;
using FlatRender.StudioSvc.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.StudioSvc.Infrastructure.Data;
public class StudioDbContext(DbContextOptions<StudioDbContext> options) : DbContext(options)
{
public DbSet<SavedProject> SavedProjects => Set<SavedProject>();
public DbSet<SavedScene> SavedScenes => Set<SavedScene>();
public DbSet<SavedSceneContent> SavedSceneContents => Set<SavedSceneContent>();
public DbSet<SavedSceneColor> SavedSceneColors => Set<SavedSceneColor>();
public DbSet<SavedSceneColorPreset> SavedSceneColorPresets => Set<SavedSceneColorPreset>();
public DbSet<SavedSceneColorPresetItem> SavedSceneColorPresetItems => Set<SavedSceneColorPresetItem>();
public DbSet<SavedSceneCharacter> SavedSceneCharacters => Set<SavedSceneCharacter>();
public DbSet<SavedSceneCharacterController> SavedSceneCharacterControllers => Set<SavedSceneCharacterController>();
public DbSet<SavedSharedColor> SavedSharedColors => Set<SavedSharedColor>();
public DbSet<SavedSharedColorPreset> SavedSharedColorPresets => Set<SavedSharedColorPreset>();
public DbSet<SavedSharedColorPresetItem> SavedSharedColorPresetItems => Set<SavedSharedColorPresetItem>();
public DbSet<SavedSharedLayer> SavedSharedLayers => Set<SavedSharedLayer>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.HasDefaultSchema("studio");
// Native PostgreSQL enum registered on the EF provider via npgsql.MapEnum<T>()
// in Program.cs (EF Core 9+ approach), covering model + runtime ADO mapping.
ConfigureSavedProjects(mb);
ConfigureSavedScenes(mb);
ConfigureSharedData(mb);
}
private static void ConfigureSavedProjects(ModelBuilder mb)
{
mb.Entity<SavedProject>(e =>
{
e.ToTable("saved_projects");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.UserId).HasColumnName("user_id");
e.Property(x => x.OriginalProjectId).HasColumnName("original_project_id");
e.Property(x => x.OriginalProjectName).HasColumnName("original_project_name").IsRequired();
e.Property(x => x.OriginalContainerId).HasColumnName("original_container_id");
e.Property(x => x.OriginalContainerSlug).HasColumnName("original_container_slug").HasColumnType("citext");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Image).HasColumnName("image");
e.Property(x => x.Type).HasColumnName("type");
e.Property(x => x.FrameRate).HasColumnName("frame_rate");
e.Property(x => x.ProjectDurationSec).HasColumnName("project_duration_sec");
e.Property(x => x.Resolution).HasColumnName("resolution").IsRequired();
e.Property(x => x.ChooseMode).HasColumnName("choose_mode").IsRequired();
e.Property(x => x.VipFactor).HasColumnName("vip_factor");
e.Property(x => x.MusicFileId).HasColumnName("music_file_id");
e.Property(x => x.MusicTrackId).HasColumnName("music_track_id");
e.Property(x => x.MusicVolume).HasColumnName("music_volume");
e.Property(x => x.VoiceoverFileId).HasColumnName("voiceover_file_id");
e.Property(x => x.VoiceoverVolume).HasColumnName("voiceover_volume");
e.Property(x => x.VoiceoverRecordedInBrowser).HasColumnName("voiceover_recorded_in_browser");
e.Property(x => x.SfxVolume).HasColumnName("sfx_volume");
e.Property(x => x.SfxEnabled).HasColumnName("sfx_enabled");
e.Property(x => x.AudioVisualizerMusicUrl).HasColumnName("audio_visualizer_music_url");
e.Property(x => x.AudioVisualizerDurationSec).HasColumnName("audio_visualizer_duration_sec");
e.Property(x => x.ManualColorPicker).HasColumnName("manual_color_picker");
e.Property(x => x.SelectedPresetStoryId).HasColumnName("selected_preset_story_id");
e.Property(x => x.LastEditStep).HasColumnName("last_edit_step");
e.Property(x => x.EditState).HasColumnName("edit_state").HasColumnType("jsonb").IsRequired();
e.Property(x => x.CreateDate).HasColumnName("create_date");
e.Property(x => x.LastEditDate).HasColumnName("last_edit_date");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
e.HasQueryFilter(x => x.DeletedAt == null);
});
}
private static void ConfigureSavedScenes(ModelBuilder mb)
{
mb.Entity<SavedScene>(e =>
{
e.ToTable("saved_scenes");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").UseIdentityByDefaultColumn();
e.Property(x => x.SavedProjectId).HasColumnName("saved_project_id");
e.Property(x => x.OriginalSceneId).HasColumnName("original_scene_id");
e.Property(x => x.Key).HasColumnName("key").IsRequired();
e.Property(x => x.Title).HasColumnName("title");
e.Property(x => x.Image).HasColumnName("image");
e.Property(x => x.Demo).HasColumnName("demo");
e.Property(x => x.SceneColorSvg).HasColumnName("scene_color_svg");
e.Property(x => x.SceneType).HasColumnName("scene_type").IsRequired();
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.SceneLengthSec).HasColumnName("scene_length_sec");
e.Property(x => x.MinDurationSec).HasColumnName("min_duration_sec");
e.Property(x => x.MaxDurationSec).HasColumnName("max_duration_sec");
e.Property(x => x.OverlapAtEndSec).HasColumnName("overlap_at_end_sec");
e.Property(x => x.CanHandleDuration).HasColumnName("can_handle_duration");
e.Property(x => x.ManualColorSelection).HasColumnName("manual_color_selection");
e.Property(x => x.SelectedColorPresetId).HasColumnName("selected_color_preset_id");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.SavedProject).WithMany(x => x.Scenes).HasForeignKey(x => x.SavedProjectId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SavedSceneContent>(e =>
{
e.ToTable("saved_scene_contents");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").UseIdentityByDefaultColumn();
e.Property(x => x.SavedSceneId).HasColumnName("saved_scene_id");
e.Property(x => x.Key).HasColumnName("key").IsRequired();
e.Property(x => x.Title).HasColumnName("title");
e.Property(x => x.LocalizedTitle).HasColumnName("localized_title").HasColumnType("jsonb");
e.Property(x => x.Hint).HasColumnName("hint");
e.Property(x => x.Type).HasColumnName("type").IsRequired();
e.Property(x => x.Value).HasColumnName("value");
e.Property(x => x.ValueFileId).HasColumnName("value_file_id");
e.Property(x => x.InsertedFileType).HasColumnName("inserted_file_type");
e.Property(x => x.FileUrlCached).HasColumnName("file_url_cached");
e.Property(x => x.FileUrlCachedAt).HasColumnName("file_url_cached_at");
e.Property(x => x.FontFace).HasColumnName("font_face");
e.Property(x => x.FontFaceName).HasColumnName("font_face_name");
e.Property(x => x.FontSize).HasColumnName("font_size");
e.Property(x => x.DefaultFontSize).HasColumnName("default_font_size");
e.Property(x => x.DefaultFontFace).HasColumnName("default_font_face");
e.Property(x => x.Justify).HasColumnName("justify");
e.Property(x => x.PositionInContainer).HasColumnName("position_in_container");
e.Property(x => x.DirectionLayerValue).HasColumnName("direction_layer_value");
e.Property(x => x.IsTextBox).HasColumnName("is_text_box");
e.Property(x => x.AiInputType).HasColumnName("ai_input_type");
e.Property(x => x.SelectedDp).HasColumnName("selected_dp");
e.Property(x => x.RepeaterItemKey).HasColumnName("repeater_item_key");
e.Property(x => x.RepeaterIndex).HasColumnName("repeater_index");
e.Property(x => x.IsFocused).HasColumnName("is_focused");
e.Property(x => x.Status).HasColumnName("status");
e.Property(x => x.MappedList).HasColumnName("mapped_list").HasColumnType("jsonb");
e.Property(x => x.Thumbnail).HasColumnName("thumbnail");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.SavedScene).WithMany(x => x.Contents).HasForeignKey(x => x.SavedSceneId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SavedSceneColor>(e =>
{
e.ToTable("saved_scene_colors");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").UseIdentityByDefaultColumn();
e.Property(x => x.SavedSceneId).HasColumnName("saved_scene_id");
e.Property(x => x.ElementKey).HasColumnName("element_key").IsRequired();
e.Property(x => x.Title).HasColumnName("title");
e.Property(x => x.Icon).HasColumnName("icon");
e.Property(x => x.AttrValue).HasColumnName("attr_value").IsRequired();
e.Property(x => x.Value).HasColumnName("value").IsRequired();
e.Property(x => x.IsSelected).HasColumnName("is_selected");
e.Property(x => x.Sort).HasColumnName("sort");
e.HasOne(x => x.SavedScene).WithMany(x => x.Colors).HasForeignKey(x => x.SavedSceneId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SavedSceneColorPreset>(e =>
{
e.ToTable("saved_scene_color_presets");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").UseIdentityByDefaultColumn();
e.Property(x => x.SavedSceneId).HasColumnName("saved_scene_id");
e.Property(x => x.IsSelected).HasColumnName("is_selected");
e.Property(x => x.Sort).HasColumnName("sort");
e.HasOne(x => x.SavedScene).WithMany(x => x.ColorPresets).HasForeignKey(x => x.SavedSceneId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SavedSceneColorPresetItem>(e =>
{
e.ToTable("saved_scene_color_preset_items");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").UseIdentityByDefaultColumn();
e.Property(x => x.PresetId).HasColumnName("preset_id");
e.Property(x => x.ElementKey).HasColumnName("element_key").IsRequired();
e.Property(x => x.Value).HasColumnName("value").IsRequired();
e.Property(x => x.Sort).HasColumnName("sort");
e.HasOne(x => x.Preset).WithMany(x => x.Items).HasForeignKey(x => x.PresetId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SavedSceneCharacter>(e =>
{
e.ToTable("saved_scene_characters");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").UseIdentityByDefaultColumn();
e.Property(x => x.SavedSceneId).HasColumnName("saved_scene_id");
e.Property(x => x.Key).HasColumnName("key");
e.Property(x => x.Name).HasColumnName("name");
e.Property(x => x.Icon).HasColumnName("icon");
e.HasOne(x => x.SavedScene).WithMany(x => x.Characters).HasForeignKey(x => x.SavedSceneId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SavedSceneCharacterController>(e =>
{
e.ToTable("saved_scene_character_controllers");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").UseIdentityByDefaultColumn();
e.Property(x => x.SavedSceneCharacterId).HasColumnName("saved_scene_character_id");
e.Property(x => x.Name).HasColumnName("name");
e.Property(x => x.Key).HasColumnName("key").IsRequired();
e.Property(x => x.Value).HasColumnName("value").IsRequired();
e.Property(x => x.Sort).HasColumnName("sort");
e.HasOne(x => x.Character).WithMany(x => x.Controllers).HasForeignKey(x => x.SavedSceneCharacterId).OnDelete(DeleteBehavior.Cascade);
});
}
private static void ConfigureSharedData(ModelBuilder mb)
{
mb.Entity<SavedSharedColor>(e =>
{
e.ToTable("saved_shared_colors");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").UseIdentityByDefaultColumn();
e.Property(x => x.SavedProjectId).HasColumnName("saved_project_id");
e.Property(x => x.ElementKey).HasColumnName("element_key").IsRequired();
e.Property(x => x.Title).HasColumnName("title");
e.Property(x => x.Icon).HasColumnName("icon");
e.Property(x => x.AttrValue).HasColumnName("attr_value").IsRequired();
e.Property(x => x.Value).HasColumnName("value").IsRequired();
e.Property(x => x.IsSelected).HasColumnName("is_selected");
e.Property(x => x.Sort).HasColumnName("sort");
e.HasOne(x => x.SavedProject).WithMany(x => x.SharedColors).HasForeignKey(x => x.SavedProjectId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SavedSharedColorPreset>(e =>
{
e.ToTable("saved_shared_color_presets");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").UseIdentityByDefaultColumn();
e.Property(x => x.SavedProjectId).HasColumnName("saved_project_id");
e.Property(x => x.Name).HasColumnName("name");
e.Property(x => x.IsSelected).HasColumnName("is_selected");
e.Property(x => x.Sort).HasColumnName("sort");
e.HasOne(x => x.SavedProject).WithMany(x => x.SharedColorPresets).HasForeignKey(x => x.SavedProjectId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SavedSharedColorPresetItem>(e =>
{
e.ToTable("saved_shared_color_preset_items");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").UseIdentityByDefaultColumn();
e.Property(x => x.PresetId).HasColumnName("preset_id");
e.Property(x => x.ElementKey).HasColumnName("element_key").IsRequired();
e.Property(x => x.Value).HasColumnName("value").IsRequired();
e.Property(x => x.Sort).HasColumnName("sort");
e.HasOne(x => x.Preset).WithMany(x => x.Items).HasForeignKey(x => x.PresetId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SavedSharedLayer>(e =>
{
e.ToTable("saved_shared_layers");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").UseIdentityByDefaultColumn();
e.Property(x => x.SavedProjectId).HasColumnName("saved_project_id");
e.Property(x => x.Key).HasColumnName("key").IsRequired();
e.Property(x => x.Title).HasColumnName("title");
e.Property(x => x.LocalizedTitle).HasColumnName("localized_title").HasColumnType("jsonb");
e.Property(x => x.Hint).HasColumnName("hint");
e.Property(x => x.Type).HasColumnName("type").IsRequired();
e.Property(x => x.Value).HasColumnName("value");
e.Property(x => x.ValueFileId).HasColumnName("value_file_id");
e.Property(x => x.FileUrlCached).HasColumnName("file_url_cached");
e.Property(x => x.FileUrlCachedAt).HasColumnName("file_url_cached_at");
e.Property(x => x.FontFace).HasColumnName("font_face");
e.Property(x => x.FontFaceName).HasColumnName("font_face_name");
e.Property(x => x.FontSize).HasColumnName("font_size");
e.Property(x => x.DefaultFontSize).HasColumnName("default_font_size");
e.Property(x => x.DefaultFontFace).HasColumnName("default_font_face");
e.Property(x => x.Justify).HasColumnName("justify");
e.Property(x => x.PositionInContainer).HasColumnName("position_in_container");
e.Property(x => x.DirectionLayerValue).HasColumnName("direction_layer_value");
e.Property(x => x.IsTextBox).HasColumnName("is_text_box");
e.Property(x => x.AiInputType).HasColumnName("ai_input_type");
e.Property(x => x.MappedList).HasColumnName("mapped_list").HasColumnType("jsonb");
e.Property(x => x.Thumbnail).HasColumnName("thumbnail");
e.Property(x => x.Width).HasColumnName("width");
e.Property(x => x.Height).HasColumnName("height");
e.Property(x => x.MinDurationSec).HasColumnName("min_duration_sec");
e.Property(x => x.MaxDurationSec).HasColumnName("max_duration_sec");
e.Property(x => x.IsFocused).HasColumnName("is_focused");
e.Property(x => x.IsFontChangeable).HasColumnName("is_font_changeable");
e.Property(x => x.IsFontSizeChangeable).HasColumnName("is_font_size_changeable");
e.Property(x => x.Status).HasColumnName("status");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.SavedProject).WithMany(x => x.SharedLayers).HasForeignKey(x => x.SavedProjectId).OnDelete(DeleteBehavior.Cascade);
});
}
}
@@ -0,0 +1,33 @@
using System.Text.Json;
using FlatRender.StudioSvc.Models.Responses;
namespace FlatRender.StudioSvc.Middleware;
public class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
public async Task InvokeAsync(HttpContext ctx)
{
try { await next(ctx); }
catch (Exception ex)
{
logger.LogError(ex, "Unhandled exception");
var (status, code) = ex switch
{
KeyNotFoundException => (404, "not_found"),
UnauthorizedAccessException => (401, "unauthorized"),
InvalidOperationException => (400, "invalid_operation"),
ArgumentException => (400, "bad_request"),
_ => (500, "internal_error")
};
ctx.Response.StatusCode = status;
ctx.Response.ContentType = "application/json";
var body = new { error = new ApiError(code, ex.Message, ctx.TraceIdentifier) };
await ctx.Response.WriteAsync(JsonSerializer.Serialize(body, JsonOptions));
}
}
}
@@ -0,0 +1,125 @@
namespace FlatRender.StudioSvc.Models.Requests;
public record CreateSavedProjectRequest(
Guid OriginalProjectId,
string? Name,
Guid? PresetStoryId,
bool CopyDefaultValues = true
);
public record UpdateSavedProjectRequest(
string? Name,
string? Image,
string? Type,
Guid? MusicFileId,
Guid? MusicTrackId,
decimal? MusicVolume,
Guid? VoiceoverFileId,
decimal? VoiceoverVolume,
bool? VoiceoverRecordedInBrowser,
decimal? SfxVolume,
bool? SfxEnabled,
string? AudioVisualizerMusicUrl,
decimal? AudioVisualizerDurationSec,
bool? ManualColorPicker,
Guid? SelectedPresetStoryId,
string? LastEditStep,
string? EditState
);
public record SaveSceneRequest(
Guid? OriginalSceneId,
string Key,
string? Title,
string? Image,
string? SceneColorSvg,
string SceneType,
int Sort,
decimal SceneLengthSec,
decimal? MinDurationSec,
decimal? MaxDurationSec,
decimal OverlapAtEndSec,
bool CanHandleDuration,
bool ManualColorSelection,
List<SaveSceneContentRequest> Contents,
List<SaveSceneColorRequest> Colors,
List<SaveSharedColorRequest> SharedColors,
List<SaveSharedLayerRequest> SharedLayers
);
public record SaveSceneContentRequest(
string Key,
string? Title,
string Type,
string? Value,
Guid? ValueFileId,
string? InsertedFileType,
string? FontFace,
string? FontFaceName,
int? FontSize,
int? DefaultFontSize,
string? DefaultFontFace,
string? Justify,
int PositionInContainer,
int DirectionLayerValue,
bool IsTextBox,
string? AiInputType,
int? SelectedDp,
string? RepeaterItemKey,
int? RepeaterIndex,
bool IsFocused,
string? MappedList,
string? Thumbnail,
int Sort
);
public record SaveSceneColorRequest(
string ElementKey,
string? Title,
string? Icon,
string AttrValue,
string Value,
bool IsSelected,
int Sort
);
public record SaveSharedColorRequest(
string ElementKey,
string? Title,
string? Icon,
string AttrValue,
string Value,
bool IsSelected,
int Sort
);
public record SaveSharedLayerRequest(
string Key,
string? Title,
string Type,
string? Value,
Guid? ValueFileId,
string? FontFace,
string? FontFaceName,
int? FontSize,
string? Justify,
int PositionInContainer,
int DirectionLayerValue,
bool IsTextBox,
string? AiInputType,
string? MappedList,
string? Thumbnail,
int? Width,
int? Height,
bool IsFocused,
bool IsFontChangeable,
bool IsFontSizeChangeable,
int Sort
);
public record SavedProjectListRequest(
int Page = 1,
int PageSize = 20,
string? Q = null,
string? Type = null
);
@@ -0,0 +1,174 @@
namespace FlatRender.StudioSvc.Models.Responses;
public record PagedResponse<T>(IEnumerable<T> Items, PaginationMeta Meta);
public record PaginationMeta(int Page, int PageSize, long Total, int TotalPages);
public record ApiError(string Code, string Message, string? TraceId = null);
public record SavedProjectSummaryResponse(
Guid Id,
Guid UserId,
Guid OriginalProjectId,
string OriginalProjectName,
string? OriginalContainerSlug,
string Name,
string? Image,
string Type,
string Resolution,
string ChooseMode,
decimal ProjectDurationSec,
DateTime LastEditDate,
DateTime CreatedAt
);
public record SavedProjectFullResponse(
Guid Id,
Guid UserId,
Guid OriginalProjectId,
string OriginalProjectName,
string? OriginalContainerSlug,
string Name,
string? Image,
string Type,
int FrameRate,
decimal ProjectDurationSec,
string Resolution,
string ChooseMode,
decimal VipFactor,
Guid? MusicFileId,
Guid? MusicTrackId,
decimal MusicVolume,
Guid? VoiceoverFileId,
decimal VoiceoverVolume,
bool VoiceoverRecordedInBrowser,
decimal SfxVolume,
bool SfxEnabled,
string? AudioVisualizerMusicUrl,
decimal? AudioVisualizerDurationSec,
bool ManualColorPicker,
Guid? SelectedPresetStoryId,
string? LastEditStep,
string EditState,
DateTime LastEditDate,
DateTime CreatedAt,
List<SavedSceneResponse> Scenes,
List<SavedSharedColorResponse> SharedColors,
List<SavedSharedColorPresetResponse> SharedColorPresets,
List<SavedSharedLayerResponse> SharedLayers
);
public record SavedSceneResponse(
long Id,
Guid? OriginalSceneId,
string Key,
string? Title,
string? Image,
string SceneType,
int Sort,
decimal SceneLengthSec,
decimal? MinDurationSec,
decimal? MaxDurationSec,
decimal OverlapAtEndSec,
bool CanHandleDuration,
bool ManualColorSelection,
long? SelectedColorPresetId,
List<SavedSceneContentResponse> Contents,
List<SavedSceneColorResponse> Colors,
List<SavedSceneColorPresetResponse> ColorPresets,
List<SavedSceneCharacterResponse> Characters
);
public record SavedSceneContentResponse(
long Id,
string Key,
string? Title,
string Type,
string? Value,
Guid? ValueFileId,
string? FileUrlCached,
string? FontFace,
int? FontSize,
string? Justify,
int PositionInContainer,
int DirectionLayerValue,
bool IsTextBox,
string? AiInputType,
int? SelectedDp,
string? RepeaterItemKey,
int? RepeaterIndex,
bool IsFocused,
string? MappedList,
int Sort
);
public record SavedSceneColorResponse(
long Id,
string ElementKey,
string? Title,
string? Icon,
string AttrValue,
string Value,
bool IsSelected,
int Sort
);
public record SavedSceneColorPresetResponse(
long Id,
bool IsSelected,
int Sort,
List<SavedColorPresetItemResponse> Items
);
public record SavedColorPresetItemResponse(long Id, string ElementKey, string Value, int Sort);
public record SavedSceneCharacterResponse(
long Id,
Guid Key,
string? Name,
string? Icon,
List<SavedCharacterControllerResponse> Controllers
);
public record SavedCharacterControllerResponse(long Id, string? Name, string Key, string Value, int Sort);
public record SavedSharedColorResponse(
long Id,
string ElementKey,
string? Title,
string? Icon,
string AttrValue,
string Value,
bool IsSelected,
int Sort
);
public record SavedSharedColorPresetResponse(
long Id,
string? Name,
bool IsSelected,
int Sort,
List<SavedColorPresetItemResponse> Items
);
public record SavedSharedLayerResponse(
long Id,
string Key,
string? Title,
string Type,
string? Value,
Guid? ValueFileId,
string? FileUrlCached,
string? FontFace,
int? FontSize,
string? Justify,
int PositionInContainer,
int DirectionLayerValue,
bool IsTextBox,
string? AiInputType,
string? MappedList,
int? Width,
int? Height,
bool IsFocused,
bool IsFontChangeable,
bool IsFontSizeChangeable,
int Sort
);
@@ -0,0 +1,120 @@
using System.Text;
using FlatRender.StudioSvc.Application.Services;
using FlatRender.StudioSvc.Domain.Enums;
using FlatRender.StudioSvc.Infrastructure.Data;
using FlatRender.StudioSvc.Middleware;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Npgsql;
var builder = WebApplication.CreateBuilder(args);
// ── Database ─────────────────────────────────────────────────────────────────
// Native PostgreSQL enums are mapped on the EF provider so Npgsql can read/write them
// at runtime (HasPostgresEnum in the model alone is not enough on Npgsql 8+).
builder.Services.AddDbContext<StudioDbContext>(opt =>
opt.UseNpgsql(
builder.Configuration.GetConnectionString("Default"),
npgsql => npgsql.MapEnum<SavedProjectType>("saved_project_type", "studio", PreserveCaseNameTranslator.Instance))
.UseSnakeCaseNamingConvention());
// ── Application services ──────────────────────────────────────────────────────
builder.Services.AddScoped<StudioService>();
// ── Auth ──────────────────────────────────────────────────────────────────────
var jwtKey = builder.Configuration["Jwt:Key"]
?? throw new InvalidOperationException("Jwt:Key is required");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
// ── Controllers ───────────────────────────────────────────────────────────────
builder.Services.AddRouting(opts =>
{
opts.LowercaseUrls = true;
opts.AppendTrailingSlash = false;
});
builder.Services.AddControllers();
// ── Swagger ───────────────────────────────────────────────────────────────────
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "FlatRender Studio Service", Version = "v1" });
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
},
[]
}
});
});
// ── CORS ──────────────────────────────────────────────────────────────────────
builder.Services.AddCors(opt =>
opt.AddDefaultPolicy(p => p
.WithOrigins(
builder.Configuration.GetSection("Cors:Origins").Get<string[]>()
?? ["http://localhost:3000"])
.AllowAnyHeader()
.AllowAnyMethod()));
// ── Health check ──────────────────────────────────────────────────────────────
builder.Services.AddHealthChecks();
// ─────────────────────────────────────────────────────────────────────────────
var app = builder.Build();
// ── Auto-migrate in Development ───────────────────────────────────────────────
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<StudioDbContext>()
.Database.MigrateAsync();
}
// ── Middleware pipeline ───────────────────────────────────────────────────────
app.UseMiddleware<ExceptionMiddleware>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Studio v1"));
}
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5074",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7231;http://localhost:5074",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,23 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=flatrender;Username=postgres;Password=postgres;Search Path=studio,public"
},
"Jwt": {
"Key": "change-me-to-a-32-char-secret-key!!",
"Issuer": "flatrender",
"Audience": "flatrender"
},
"Cors": {
"Origins": [
"http://localhost:3000",
"http://localhost:5173"
]
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="http://171.22.25.73:8081/repository/nuget-group/index.json" protocolVersion="3" allowInsecureConnections="true" />
</packageSources>
</configuration>
+29
View File
@@ -0,0 +1,29 @@
services:
studio-svc:
build: .
ports:
- "5013:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ConnectionStrings__Default: "Host=postgres;Port=5432;Database=flatrender;Username=postgres;Password=postgres;Search Path=studio,public"
Jwt__Key: "${JWT_KEY}"
Jwt__Issuer: "${JWT_ISSUER:-flatrender}"
Jwt__Audience: "${JWT_AUDIENCE:-flatrender}"
Cors__Origins__0: "${CORS_ORIGIN_0:-http://localhost:3000}"
depends_on:
- postgres
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: flatrender
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
pgdata: