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,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
|
||||
@@ -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"]
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
###
|
||||
+17
@@ -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": "*"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user