Database Setup, raporlama, Demo, BackgroundWorker,

This commit is contained in:
Sedat ÖZTÜRK 2026-06-29 12:37:52 +03:00
parent 9689ef1bc6
commit 3446691162
14 changed files with 327 additions and 45 deletions

View file

@ -10,36 +10,41 @@ namespace Sozsoft.Languages.Localization;
public class DatabaseLocalizationResourceContributor : ILocalizationResourceContributor
{
private LocalizationResourceBase _resource;
private IDatabaseResourceLocalizer _databaseResourceLocalizer;
private ILanguageProvider _languageProvider;
private System.Lazy<IDatabaseResourceLocalizer> _databaseResourceLocalizer;
private System.Lazy<ILanguageProvider> _languageProvider;
public bool IsDynamic => false;
public void Initialize(LocalizationResourceInitializationContext context)
{
_resource = context.Resource;
_databaseResourceLocalizer = context.ServiceProvider.GetRequiredService<IDatabaseResourceLocalizer>();
_languageProvider = context.ServiceProvider.GetRequiredService<ILanguageProvider>();
// ABP calls Initialize while AbpStringLocalizerFactory holds its internal lock.
// Resolving repositories/caches here can request another IStringLocalizer and
// re-enter the same lock, preventing Kestrel from ever starting.
_databaseResourceLocalizer = new System.Lazy<IDatabaseResourceLocalizer>(
() => context.ServiceProvider.GetRequiredService<IDatabaseResourceLocalizer>());
_languageProvider = new System.Lazy<ILanguageProvider>(
() => context.ServiceProvider.GetRequiredService<ILanguageProvider>());
}
public LocalizedString GetOrNull(string cultureName, string name)
{
return _databaseResourceLocalizer.GetOrNull(_resource, cultureName, name);
return _databaseResourceLocalizer.Value.GetOrNull(_resource, cultureName, name);
}
public async Task FillAsync(string cultureName, Dictionary<string, LocalizedString> dictionary)
{
await _databaseResourceLocalizer.FillAsync(_resource, cultureName, dictionary);
await _databaseResourceLocalizer.Value.FillAsync(_resource, cultureName, dictionary);
}
public void Fill(string cultureName, Dictionary<string, LocalizedString> dictionary)
{
_databaseResourceLocalizer.Fill(_resource, cultureName, dictionary);
_databaseResourceLocalizer.Value.Fill(_resource, cultureName, dictionary);
}
public async Task<IEnumerable<string>> GetSupportedCulturesAsync()
{
var langs = await _languageProvider.GetLanguagesAsync();
var langs = await _languageProvider.Value.GetLanguagesAsync();
return langs.Select(a => a.CultureName).ToList();
}
}

View file

@ -0,0 +1,60 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Volo.Abp.BackgroundJobs;
using Volo.Abp.BackgroundWorkers;
using Volo.Abp.DependencyInjection;
using Volo.Abp.OpenIddict.Tokens;
namespace Sozsoft.Platform;
public class AbpBackgroundWorkerRecurringJobInitializer : ITransientDependency
{
private readonly IServiceProvider _serviceProvider;
private readonly IBackgroundWorkerManager _backgroundWorkerManager;
private readonly ILogger<AbpBackgroundWorkerRecurringJobInitializer> _logger;
public AbpBackgroundWorkerRecurringJobInitializer(
IServiceProvider serviceProvider,
IBackgroundWorkerManager backgroundWorkerManager,
ILogger<AbpBackgroundWorkerRecurringJobInitializer> logger)
{
_serviceProvider = serviceProvider;
_backgroundWorkerManager = backgroundWorkerManager;
_logger = logger;
}
public async Task RunAsync(CancellationToken cancellationToken = default)
{
await AddWorkerIfAvailableAsync<BackgroundJobWorker>(
nameof(BackgroundJobWorker),
cancellationToken);
await AddWorkerIfAvailableAsync<TokenCleanupBackgroundWorker>(
nameof(TokenCleanupBackgroundWorker),
cancellationToken);
}
private async Task AddWorkerIfAvailableAsync<TWorker>(
string workerName,
CancellationToken cancellationToken)
where TWorker : class, IBackgroundWorker
{
var worker = _serviceProvider.GetService<TWorker>();
if (worker == null)
{
_logger.LogWarning(
"ABP background worker {WorkerName} could not be resolved from DI. Recurring job was not registered.",
workerName);
return;
}
await _backgroundWorkerManager.AddAsync(worker, cancellationToken);
_logger.LogInformation(
"ABP background worker {WorkerName} recurring job registration has been ensured.",
workerName);
}
}

View file

@ -40,19 +40,41 @@ public class BackgroundWorkerStartupService : IHostedService
private async Task InitializeBackgroundWorkersAsync()
{
try
{
_logger.LogInformation("Background worker startup initialization started.");
const int maxAttempts = 5;
using var scope = _serviceProvider.CreateScope();
var initializer = scope.ServiceProvider.GetRequiredService<BackgroundWorkerInitializer>();
await initializer.RunAsync();
_logger.LogInformation("Background worker startup initialization completed.");
}
catch (Exception ex)
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
_logger.LogError(ex, "Background worker initialization failed.");
try
{
_logger.LogInformation(
"Background worker startup initialization started. Attempt {Attempt}/{MaxAttempts}.",
attempt,
maxAttempts);
using var scope = _serviceProvider.CreateScope();
var abpRecurringJobInitializer = scope.ServiceProvider.GetRequiredService<AbpBackgroundWorkerRecurringJobInitializer>();
await abpRecurringJobInitializer.RunAsync();
var initializer = scope.ServiceProvider.GetRequiredService<BackgroundWorkerInitializer>();
await initializer.RunAsync();
_logger.LogInformation("Background worker startup initialization completed.");
return;
}
catch (Exception ex)
{
if (attempt == maxAttempts)
{
_logger.LogError(ex, "Background worker initialization failed.");
return;
}
_logger.LogWarning(
ex,
"Background worker initialization failed. It will be retried.");
await Task.Delay(TimeSpan.FromSeconds(3));
}
}
}
}

View file

@ -21,13 +21,19 @@ public class SetupController : ControllerBase
{
private readonly IConfiguration _configuration;
private readonly IHostEnvironment _env;
private readonly BackgroundWorkerInitializer _backgroundWorkerInitializer;
private readonly AbpBackgroundWorkerRecurringJobInitializer _abpBackgroundWorkerRecurringJobInitializer;
public SetupController(
IConfiguration configuration,
IHostEnvironment env)
IHostEnvironment env,
BackgroundWorkerInitializer backgroundWorkerInitializer,
AbpBackgroundWorkerRecurringJobInitializer abpBackgroundWorkerRecurringJobInitializer)
{
_configuration = configuration;
_env = env;
_backgroundWorkerInitializer = backgroundWorkerInitializer;
_abpBackgroundWorkerRecurringJobInitializer = abpBackgroundWorkerRecurringJobInitializer;
}
[HttpGet("application-status")]
@ -38,8 +44,24 @@ public class SetupController : ControllerBase
}
[HttpPost("migrate")]
[AllowAnonymous]
public async Task Migrate(CancellationToken ct)
{
var databaseIsReady = SetupAppRunner.DatabaseIsReady(_configuration);
if (databaseIsReady)
{
if (User?.IsInRole("admin") != true)
{
Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
}
else if (!SetupAppRunner.IsValidSetupPassword(_configuration, Request.Headers))
{
Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
Response.ContentType = "text/event-stream; charset=utf-8";
Response.Headers["Cache-Control"] = "no-cache, no-store";
Response.Headers["X-Accel-Buffering"] = "no";
@ -141,7 +163,22 @@ public class SetupController : ControllerBase
if (process.ExitCode == 0)
{
await Send("success", "Migration and seed completed successfully.");
await Send("info", "Migration and seed completed successfully.");
try
{
await Send("info", "Loading background workers into Hangfire...");
await _abpBackgroundWorkerRecurringJobInitializer.RunAsync(ct);
await _backgroundWorkerInitializer.RunAsync();
await Send("success", "Background workers loaded successfully.");
}
catch (Exception ex)
{
await Send("error", $"Background workers could not be loaded: {ex.Message}");
await Send("done", "Failed.");
return;
}
await Send("done", "Completed.");
}
else

View file

@ -22,6 +22,8 @@ namespace Sozsoft.Platform;
/// </summary>
internal static class SetupAppRunner
{
private const string SetupPasswordHeaderName = "X-Setup-Password";
// Veritabanı Hazırlık Kontrolü
/// <summary>
@ -51,6 +53,18 @@ internal static class SetupAppRunner
}
}
public static bool IsValidSetupPassword(IConfiguration configuration, IHeaderDictionary headers)
{
var configuredPassword = configuration["Setup:MigrationPassword"];
if (string.IsNullOrWhiteSpace(configuredPassword))
{
return false;
}
return headers.TryGetValue(SetupPasswordHeaderName, out var providedPassword) &&
string.Equals(providedPassword.ToString(), configuredPassword, StringComparison.Ordinal);
}
private static bool SqlServerIsReady(string connectionString)
{
var csb = new SqlConnectionStringBuilder(connectionString);
@ -175,6 +189,13 @@ internal static class SetupAppRunner
app.MapPost("/api/setup/migrate", async (IConfiguration cfg, IHostEnvironment env,
IHostApplicationLifetime lifetime, HttpContext ctx, CancellationToken ct) =>
{
if (!IsValidSetupPassword(cfg, ctx.Request.Headers))
{
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
await ctx.Response.WriteAsync("Invalid setup password.", ct);
return;
}
ctx.Response.ContentType = "text/event-stream; charset=utf-8";
ctx.Response.Headers["Cache-Control"] = "no-cache, no-store";
ctx.Response.Headers["X-Accel-Buffering"] = "no";

View file

@ -1,6 +1,7 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
using Microsoft.AspNetCore.Http;
using Volo.Abp;
using Volo.Abp.DependencyInjection;
@ -29,6 +30,12 @@ public class PlatformSessionValidationMiddleware : IMiddleware, ITransientDepend
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (context.Request.Path.StartsWithSegments("/api/setup"))
{
await next(context);
return;
}
if (context.User.Identity?.IsAuthenticated != true)
{
await next(context);
@ -47,14 +54,22 @@ public class PlatformSessionValidationMiddleware : IMiddleware, ITransientDepend
var tenantId = ParseTenantId(context.User.FindFirst(AbpClaimTypes.TenantId)?.Value);
using (currentTenant.Change(tenantId))
{
using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false);
var sessions = await sessionRepository.GetListAsync(userId: userId);
var isCurrentSession = sessions.Any(session => session.SessionId == sessionId);
await uow.CompleteAsync();
if (!isCurrentSession)
try
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false);
var sessions = await sessionRepository.GetListAsync(userId: userId);
var isCurrentSession = sessions.Any(session => session.SessionId == sessionId);
await uow.CompleteAsync();
if (!isCurrentSession)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
}
catch (Exception ex) when (IsDatabaseUnavailableException(ex))
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
return;
}
}
@ -66,4 +81,13 @@ public class PlatformSessionValidationMiddleware : IMiddleware, ITransientDepend
{
return !string.IsNullOrWhiteSpace(value) && Guid.TryParse(value, out var id) ? id : null;
}
private static bool IsDatabaseUnavailableException(Exception exception)
{
return exception is SqlException ||
exception is TimeoutException ||
exception is TaskCanceledException ||
exception is InvalidOperationException && exception.InnerException != null &&
IsDatabaseUnavailableException(exception.InnerException);
}
}

View file

@ -342,7 +342,11 @@ public class PlatformHttpApiHostModule : AbpModule
private void ConfigureHangfire(ServiceConfigurationContext context, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString(DefaultDatabaseProvider);
if (connectionString.IsNullOrWhiteSpace() || !SetupAppRunner.DatabaseIsReady(configuration))
// Program has already selected the full application only after the database
// readiness check succeeds. Repeating the probe here creates a race: one
// transient failure skips AddHangfire while ABP still starts its Hangfire
// modules, causing JobStorage initialization to crash the host.
if (connectionString.IsNullOrWhiteSpace())
{
return;
}

View file

@ -152,7 +152,7 @@ public class DynamicGridReport : XtraReport
localizer);
reportColumns = await ApplyImageCountsAsync(reportColumns, dynamicDataRepository, selectQuery, connectionString, imageBaseUrl);
ConfigurePage(reportColumns);
ConfigurePage(reportColumns, isSubReport);
ConfigureDataSource(
selectQuery,
connectionString,
@ -162,13 +162,14 @@ public class DynamicGridReport : XtraReport
}
#pragma warning disable CA1416
private void ConfigurePage(IReadOnlyCollection<ReportColumn> visibleFields)
private void ConfigurePage(IReadOnlyCollection<ReportColumn> visibleFields, bool isSubReport)
{
Margins = CreateReportMargins(HorizontalMargin, HorizontalMargin, 35, 35);
PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4;
var portraitContentWidth = A4PortraitWidth - Margins.Left - Margins.Right;
var requestedContentWidth = visibleFields.Sum(x => x.IsImage ? GetImageColumnWidth(x) : GetPreferredColumnWidth(x.Field));
Landscape = visibleFields.Count >= LandscapeColumnThreshold || requestedContentWidth > portraitContentWidth;
Landscape = !isSubReport &&
(visibleFields.Count >= LandscapeColumnThreshold || requestedContentWidth > portraitContentWidth);
Font = new DXFont("Arial", 8.25F);
}

View file

@ -29,6 +29,9 @@
"RefreshTokenMinutes": 90
}
},
"Setup": {
"MigrationPassword": "sozsoft"
},
"StringEncryption": {
"DefaultPassPhrase": "UQpiYfT79zRZ3yYH"
},

View file

@ -17,3 +17,9 @@ export const useDemo = () => {
return context
}
export const useOptionalDemo = () => {
const context = useContext(DemoContext)
return context ?? { openDemo: () => undefined }
}

View file

@ -270,6 +270,10 @@ const Grid = (props: GridProps) => {
const { listFormCode, searchParams, isSubForm, level, gridDto: extGridDto } = props
const { translate } = useLocalization()
const currentUser = useStoreState((state) => state.auth.user)
const currentCulture = useStoreState(
(state) => state.abpConfig.config?.localization.currentCulture.cultureName,
)
const localizationTexts = useStoreState((state) => state.abpConfig.texts)
const useMobileEditPopup = isMobileViewport() || isTouchLikeDevice()
const gridRef = useRef<DataGridRef>()
@ -1096,7 +1100,7 @@ const Grid = (props: GridProps) => {
})
return cols
}, [gridDto])
}, [gridDto, currentCulture, localizationTexts])
useEffect(() => {
setColumnData(memoizedColumns)

View file

@ -258,6 +258,10 @@ const Tree = (props: TreeProps) => {
const { listFormCode, searchParams, isSubForm, level, gridDto: extGridDto } = props
const { translate } = useLocalization()
const currentUser = useStoreState((state) => state.auth.user)
const currentCulture = useStoreState(
(state) => state.abpConfig.config?.localization.currentCulture.cultureName,
)
const localizationTexts = useStoreState((state) => state.abpConfig.texts)
const useMobileEditPopup = isMobileViewport() || isTouchLikeDevice()
const gridRef = useRef<TreeListRef>()
@ -971,7 +975,7 @@ const Tree = (props: TreeProps) => {
cols,
)
setTreeListDataSource(dataSource)
}, [gridDto, searchParams])
}, [gridDto, searchParams, currentCulture, localizationTexts])
useEffect(() => {
const activeFilters = extraFilters.filter((f) => f.value)

View file

@ -18,7 +18,7 @@ import { DesignerSelection } from './designer/types'
import { useDesignerState } from './designer/useDesignerState'
import { getHome, HomeDto, saveHomePage } from '@/services/home.service'
import { Button, Notification, toast } from '@/components/ui'
import { useDemo } from '@/contexts/DemoContext'
import { useOptionalDemo } from '@/contexts/DemoContext'
interface HomeSlideServiceContent {
icon: string
@ -273,7 +273,7 @@ function buildHomeContent(home: HomeDto | undefined, translate: (key: string) =>
}
const Home: React.FC = () => {
const { openDemo } = useDemo()
const { openDemo } = useOptionalDemo()
const { translate } = useLocalization()
const { setLang } = useStoreActions((actions) => actions.locale)
const { getConfig } = useStoreActions((actions) => actions.abpConfig)

View file

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { APP_NAME } from '@/constants/app.constant'
import { getMigrateUrl, getSetupStatus } from '@/services/setup.service'
import { applicationConfigurationUrl } from '@/services/abpConfig.service'
import { Button } from '@/components/ui'
import { Button, Dialog } from '@/components/ui'
interface LogLine {
level: 'info' | 'warn' | 'error' | 'success' | 'restart' | 'done'
@ -79,6 +79,8 @@ const DatabaseSetup = () => {
const [status, setStatus] = useState<MigrationStatus>('idle')
const [pollCountdown, setPollCountdown] = useState(0)
const [dbExists, setDbExists] = useState<boolean | null>(null)
const [setupPassword, setSetupPassword] = useState('')
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false)
const logEndRef = useRef<HTMLDivElement>(null)
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@ -119,9 +121,15 @@ const DatabaseSetup = () => {
const startMigration = async () => {
if (status === 'running') return
if (!setupPassword.trim()) {
setStatus('error')
addLog('error', 'Setup password is required.')
return
}
setLogs([])
setStatus('running')
setIsPasswordDialogOpen(false)
addLog('info', 'Starting migration...')
const url = getMigrateUrl()
@ -152,6 +160,7 @@ const DatabaseSetup = () => {
if (level === 'success') {
setStatus('success')
pollUntilReady()
} else if (level === 'error') {
setStatus('error')
} else if (level === 'restart') {
@ -166,12 +175,20 @@ const DatabaseSetup = () => {
try {
const response = await fetch(url, {
method: 'POST',
headers: { Accept: 'text/event-stream' },
headers: {
Accept: 'text/event-stream',
'X-Setup-Password': setupPassword,
},
signal: abortController.signal,
})
if (!response.ok || !response.body) {
addLog('error', `Server responded with status ${response.status}`)
addLog(
'error',
response.status === 403
? 'Invalid setup password or setup migration is not allowed.'
: `Server responded with status ${response.status}`,
)
setStatus('error')
return
}
@ -196,9 +213,29 @@ const DatabaseSetup = () => {
return (
<div className="min-h-screen bg-gray-900 flex flex-col items-center justify-center p-6 text-white">
{/* Header */}
<div className="mb-8 text-center">
<h1 className="text-3xl font-bold mb-2">{APP_NAME}</h1>
<p className="text-gray-400 text-lg">Initial Setup Creating Database</p>
<div className="mb-10 text-center sm:text-left">
<div className="flex flex-col items-center gap-2 sm:flex-row">
<div className="relative">
<div className="absolute inset-0 rounded-full bg-sky-400/30 blur-2xl" />
<div className="relative flex h-20 w-20 items-center justify-center">
<img
src="/img/logo/logo-dark-streamline.png"
alt={`${APP_NAME} logo`}
className="h-18 w-18 object-contain drop-shadow-2xl"
/>
</div>
</div>
<div>
<h1 className="tracking-tight text-white">
{APP_NAME}
</h1>
<div className="mt-1 inline-flex items-center gap-2 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-1 py-1 text-xs font-medium uppercase tracking-[0.22em] text-emerald-200">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300 shadow-[0_0_12px_rgba(110,231,183,0.85)]" />
Database creation and migration workspace
</div>
</div>
</div>
</div>
{/* Status Card */}
@ -234,7 +271,7 @@ const DatabaseSetup = () => {
</div>
{/* Action Area */}
<div className="flex items-center justify-between px-5 py-4 bg-gray-700 border-t border-gray-600">
<div className="flex items-center justify-between gap-4 px-5 py-4 bg-gray-700 border-t border-gray-600">
<div className="text-xs text-gray-400">
{status === 'idle' && dbExists === true && 'Database already exists. Migration is not required.'}
{status === 'idle' && dbExists === false && 'Database not found. Press the button to start migration.'}
@ -251,9 +288,10 @@ const DatabaseSetup = () => {
{(status === 'idle' || status === 'error') && !dbExists && (
<Button
variant='solid'
onClick={startMigration}
className="min-w-40"
onClick={() => setIsPasswordDialogOpen(true)}
>
{status === 'error' ? 'Retry' : 'Start Setup'}
{status === 'error' ? 'Retry Migration' : 'Migrate'}
</Button>
)}
@ -267,6 +305,59 @@ const DatabaseSetup = () => {
</div>
</div>
<Dialog
isOpen={isPasswordDialogOpen}
onClose={() => setIsPasswordDialogOpen(false)}
onRequestClose={() => setIsPasswordDialogOpen(false)}
width={440}
>
<Dialog.Header className="-mx-6 -mt-6 border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<div>
<h5 className="text-gray-900 dark:text-gray-100">Setup authorization</h5>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Enter the setup password to start database migration.
</p>
</div>
</Dialog.Header>
<Dialog.Body className="pt-5">
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-200">
Setup password
</label>
<input
autoFocus
type="password"
value={setupPassword}
onChange={(event) => setSetupPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && setupPassword.trim()) {
startMigration()
}
}}
placeholder="Enter setup password"
autoComplete="off"
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500"
/>
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
This protects the migration action when users are redirected to setup after a database
problem.
</p>
</Dialog.Body>
<Dialog.Footer className="mt-2 flex justify-end gap-2 border-t border-gray-200 pt-4 dark:border-gray-700">
<Button
onClick={() => setIsPasswordDialogOpen(false)}
>
Cancel
</Button>
<Button
variant="solid"
disabled={!setupPassword.trim() || status === 'running'}
onClick={startMigration}
>
Start Migration
</Button>
</Dialog.Footer>
</Dialog>
<p className="mt-6 text-xs text-gray-600">
This page is only visible when the database does not exist.
</p>