@@ -44,19 +44,13 @@ public sealed class AssetAccessService(
|
||||
item.Status == ContentStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetAccessException("Asset was not found.", "ASSET_NOT_FOUND");
|
||||
}
|
||||
if (asset is null) throw new AssetAccessException("Asset was not found.", "ASSET_NOT_FOUND");
|
||||
|
||||
try
|
||||
{
|
||||
var access = await ResolveAccessAsync(request, asset, cancellationToken);
|
||||
AssertPublishedAsset(asset);
|
||||
if (accessType == AssetAccessType.Preview)
|
||||
{
|
||||
AssertPreviewable(asset);
|
||||
}
|
||||
if (accessType == AssetAccessType.Preview) AssertPreviewable(asset);
|
||||
|
||||
var ttl = ResolveTtl(request, accessType, asset.Visibility);
|
||||
var signedUrl = await objectStorageService.SignDownloadAsync(
|
||||
@@ -75,10 +69,7 @@ public sealed class AssetAccessService(
|
||||
disposition == AssetAccessDisposition.Inline ? "inline" : "attachment"),
|
||||
cancellationToken);
|
||||
|
||||
if (accessType == AssetAccessType.Download)
|
||||
{
|
||||
asset.DownloadCount++;
|
||||
}
|
||||
if (accessType == AssetAccessType.Download) asset.DownloadCount++;
|
||||
|
||||
dbContext.ContentAssetAccessEvents.Add(CreateAccessEvent(
|
||||
request,
|
||||
@@ -119,19 +110,12 @@ public sealed class AssetAccessService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (asset.Visibility == ContentVisibility.Public || asset.IsPublic)
|
||||
{
|
||||
return new AssetAccessPrincipal(request.UserId, IsMember: false, HasSvip: false);
|
||||
}
|
||||
return new AssetAccessPrincipal(request.UserId, false, false);
|
||||
|
||||
if (asset.Visibility == ContentVisibility.Hidden)
|
||||
{
|
||||
throw new AssetAccessException("Asset is hidden.", "ASSET_HIDDEN");
|
||||
}
|
||||
|
||||
if (!request.UserId.HasValue)
|
||||
{
|
||||
throw new AssetAccessException("Authentication is required.", "AUTH_REQUIRED");
|
||||
}
|
||||
if (!request.UserId.HasValue) throw new AssetAccessException("Authentication is required.", "AUTH_REQUIRED");
|
||||
|
||||
var isMember = await dbContext.TenantMemberships
|
||||
.AnyAsync(
|
||||
@@ -142,22 +126,16 @@ public sealed class AssetAccessService(
|
||||
cancellationToken);
|
||||
|
||||
if (!isMember)
|
||||
{
|
||||
throw new AssetAccessException("Tenant membership is required for this asset.", "ASSET_MEMBERSHIP_REQUIRED");
|
||||
}
|
||||
throw new AssetAccessException("Tenant membership is required for this asset.",
|
||||
"ASSET_MEMBERSHIP_REQUIRED");
|
||||
|
||||
if (asset.Visibility == ContentVisibility.Members)
|
||||
{
|
||||
return new AssetAccessPrincipal(request.UserId, IsMember: true, HasSvip: false);
|
||||
}
|
||||
if (asset.Visibility == ContentVisibility.Members) return new AssetAccessPrincipal(request.UserId, true, false);
|
||||
|
||||
var hasSvip = await HasSvipAccessAsync(request, asset, cancellationToken);
|
||||
if (!hasSvip)
|
||||
{
|
||||
throw new AssetAccessException("SVIP entitlement is required for this asset.", "ASSET_SVIP_REQUIRED");
|
||||
}
|
||||
|
||||
return new AssetAccessPrincipal(request.UserId, IsMember: true, HasSvip: true);
|
||||
return new AssetAccessPrincipal(request.UserId, true, true);
|
||||
}
|
||||
|
||||
private Task<bool> HasSvipAccessAsync(
|
||||
@@ -187,14 +165,10 @@ public sealed class AssetAccessService(
|
||||
private static void AssertPublishedAsset(ContentAsset asset)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(asset.ObjectKey) && asset.UploadStatus != AssetUploadStatus.Verified)
|
||||
{
|
||||
throw new AssetAccessException("Asset upload has not been verified.", "ASSET_UPLOAD_NOT_VERIFIED");
|
||||
}
|
||||
|
||||
if (asset.SecurityScanStatus is not (AssetSecurityScanStatus.Passed or AssetSecurityScanStatus.NotRequired))
|
||||
{
|
||||
throw new AssetAccessException("Asset security scan has not passed.", "ASSET_SECURITY_SCAN_NOT_PASSED");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertPreviewable(ContentAsset asset)
|
||||
@@ -204,9 +178,8 @@ public sealed class AssetAccessService(
|
||||
mimeType == "application/pdf" ||
|
||||
mimeType.StartsWith("image/", StringComparison.Ordinal);
|
||||
if (!previewable)
|
||||
{
|
||||
throw new AssetAccessException("Asset type does not support inline preview.", "ASSET_PREVIEW_NOT_SUPPORTED");
|
||||
}
|
||||
throw new AssetAccessException("Asset type does not support inline preview.",
|
||||
"ASSET_PREVIEW_NOT_SUPPORTED");
|
||||
}
|
||||
|
||||
private static TimeSpan ResolveTtl(
|
||||
@@ -217,10 +190,7 @@ public sealed class AssetAccessService(
|
||||
var fallback = accessType == AssetAccessType.Preview ? DefaultPreviewTtl : DefaultDownloadTtl;
|
||||
var requested = request.RequestedExpiresIn ?? fallback;
|
||||
var max = visibility == ContentVisibility.Public ? TimeSpan.FromHours(2) : TimeSpan.FromMinutes(15);
|
||||
if (requested <= TimeSpan.Zero)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
if (requested <= TimeSpan.Zero) return fallback;
|
||||
|
||||
return requested <= max ? requested : max;
|
||||
}
|
||||
@@ -325,4 +295,4 @@ public sealed class AssetAccessService(
|
||||
_ => ObjectStorageProviders.ExternalUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
@@ -25,6 +18,4 @@ public sealed partial class AssetManagementService(
|
||||
private const int MaxLimit = 500;
|
||||
private static readonly TimeSpan DefaultUploadTtl = TimeSpan.FromMinutes(15);
|
||||
private static readonly TimeSpan MaxUploadTtl = TimeSpan.FromHours(1);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -19,46 +19,29 @@ public sealed class AssetQueryService(TikuDbContext dbContext) : IAssetQueryServ
|
||||
.AsNoTracking()
|
||||
.Where(asset => asset.TenantId == filter.TenantId);
|
||||
|
||||
if (!filter.IncludeInactive)
|
||||
{
|
||||
query = query.Where(asset => asset.Status == ContentStatus.Active);
|
||||
}
|
||||
if (!filter.IncludeInactive) query = query.Where(asset => asset.Status == ContentStatus.Active);
|
||||
|
||||
if (!filter.IncludeLocked)
|
||||
{
|
||||
query = query.Where(asset => asset.Visibility == ContentVisibility.Public || asset.IsPublic);
|
||||
}
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.RegionId == filter.RegionId.Value || asset.RegionId == null);
|
||||
}
|
||||
|
||||
if (filter.SubjectId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.SubjectId == filter.SubjectId.Value || asset.SubjectId == null);
|
||||
}
|
||||
|
||||
if (filter.CategoryId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.CategoryId == filter.CategoryId.Value || asset.CategoryId == null);
|
||||
}
|
||||
|
||||
if (filter.ContentNodeId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.ContentNodeId == filter.ContentNodeId.Value || asset.ContentNodeId == null);
|
||||
}
|
||||
query = query.Where(asset =>
|
||||
asset.ContentNodeId == filter.ContentNodeId.Value || asset.ContentNodeId == null);
|
||||
|
||||
if (filter.AssetId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.Id == filter.AssetId.Value);
|
||||
}
|
||||
if (filter.AssetId.HasValue) query = query.Where(asset => asset.Id == filter.AssetId.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.AssetType) &&
|
||||
Enum.TryParse<ContentAssetType>(filter.AssetType, ignoreCase: true, out var assetType))
|
||||
{
|
||||
Enum.TryParse<ContentAssetType>(filter.AssetType, true, out var assetType))
|
||||
query = query.Where(asset => asset.AssetType == assetType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Category))
|
||||
{
|
||||
@@ -129,10 +112,7 @@ public sealed class AssetQueryService(TikuDbContext dbContext) : IAssetQueryServ
|
||||
.AsNoTracking()
|
||||
.Where(image => image.TenantId == filter.TenantId);
|
||||
|
||||
if (!filter.IncludeLocked)
|
||||
{
|
||||
query = query.Where(image => image.IsPublic);
|
||||
}
|
||||
if (!filter.IncludeLocked) query = query.Where(image => image.IsPublic);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Category))
|
||||
{
|
||||
@@ -212,15 +192,10 @@ public sealed class AssetQueryService(TikuDbContext dbContext) : IAssetQueryServ
|
||||
.AsNoTracking()
|
||||
.Where(video => video.TenantId == filter.TenantId);
|
||||
|
||||
if (!filter.IncludeInactive)
|
||||
{
|
||||
query = query.Where(video => video.IsActive);
|
||||
}
|
||||
if (!filter.IncludeInactive) query = query.Where(video => video.IsActive);
|
||||
|
||||
if (filter.SubjectId.HasValue)
|
||||
{
|
||||
query = query.Where(video => video.SubjectId == filter.SubjectId.Value || video.SubjectId == null);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -262,15 +237,9 @@ public sealed class AssetQueryService(TikuDbContext dbContext) : IAssetQueryServ
|
||||
.AsNoTracking()
|
||||
.Where(video => video.TenantId == filter.TenantId);
|
||||
|
||||
if (filter.QuestionId.HasValue)
|
||||
{
|
||||
query = query.Where(video => video.QuestionId == filter.QuestionId.Value);
|
||||
}
|
||||
if (filter.QuestionId.HasValue) query = query.Where(video => video.QuestionId == filter.QuestionId.Value);
|
||||
|
||||
if (filter.AssetId.HasValue)
|
||||
{
|
||||
query = query.Where(video => video.VideoId == filter.AssetId.Value);
|
||||
}
|
||||
if (filter.AssetId.HasValue) query = query.Where(video => video.VideoId == filter.AssetId.Value);
|
||||
|
||||
var videoExplanations = dbContext.VideoExplanations.AsNoTracking();
|
||||
var items = await query
|
||||
@@ -320,4 +289,4 @@ public sealed class AssetQueryService(TikuDbContext dbContext) : IAssetQueryServ
|
||||
{
|
||||
return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
@@ -23,15 +13,9 @@ public sealed partial class AssetManagementService
|
||||
{
|
||||
var query = dbContext.ContentAssetAccessEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.AssetId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.AssetId == filter.AssetId.Value);
|
||||
}
|
||||
if (filter.AssetId.HasValue) query = query.Where(item => item.AssetId == filter.AssetId.Value);
|
||||
|
||||
if (filter.UserId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.UserId == filter.UserId.Value);
|
||||
}
|
||||
if (filter.UserId.HasValue) query = query.Where(item => item.UserId == filter.UserId.Value);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
@@ -65,10 +49,7 @@ public sealed partial class AssetManagementService
|
||||
{
|
||||
var query = dbContext.ContentAssetSecurityScanEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.AssetId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.AssetId == filter.AssetId.Value);
|
||||
}
|
||||
if (filter.AssetId.HasValue) query = query.Where(item => item.AssetId == filter.AssetId.Value);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
@@ -85,6 +66,4 @@ public sealed partial class AssetManagementService
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<ContentAssetSecurityScanEventItem>(items);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
@@ -25,43 +18,26 @@ public sealed partial class AssetManagementService
|
||||
.AsNoTracking()
|
||||
.Where(asset => asset.TenantId == actor.TenantId);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(asset => asset.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (filter.SubjectId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.SubjectId == filter.SubjectId.Value);
|
||||
}
|
||||
if (filter.SubjectId.HasValue) query = query.Where(asset => asset.SubjectId == filter.SubjectId.Value);
|
||||
|
||||
if (filter.CategoryId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.CategoryId == filter.CategoryId.Value);
|
||||
}
|
||||
if (filter.CategoryId.HasValue) query = query.Where(asset => asset.CategoryId == filter.CategoryId.Value);
|
||||
|
||||
if (filter.ContentNodeId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.ContentNodeId == filter.ContentNodeId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.AssetType) &&
|
||||
Enum.TryParse<ContentAssetType>(filter.AssetType, ignoreCase: true, out var assetType))
|
||||
{
|
||||
Enum.TryParse<ContentAssetType>(filter.AssetType, true, out var assetType))
|
||||
query = query.Where(asset => asset.AssetType == assetType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.UploadStatus) &&
|
||||
Enum.TryParse<AssetUploadStatus>(filter.UploadStatus, ignoreCase: true, out var uploadStatus))
|
||||
{
|
||||
Enum.TryParse<AssetUploadStatus>(filter.UploadStatus, true, out var uploadStatus))
|
||||
query = query.Where(asset => asset.UploadStatus == uploadStatus);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.SecurityScanStatus) &&
|
||||
Enum.TryParse<AssetSecurityScanStatus>(filter.SecurityScanStatus, ignoreCase: true, out var securityScanStatus))
|
||||
{
|
||||
Enum.TryParse<AssetSecurityScanStatus>(filter.SecurityScanStatus, true, out var securityScanStatus))
|
||||
query = query.Where(asset => asset.SecurityScanStatus == securityScanStatus);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Category))
|
||||
{
|
||||
@@ -111,18 +87,24 @@ public sealed partial class AssetManagementService
|
||||
asset.AssetType = ParseEnum(command.AssetType, asset.AssetType);
|
||||
asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic);
|
||||
asset.Status = ParseEnum(command.Status, asset.Status);
|
||||
asset.StorageProvider = ToAssetStorageProvider(objectStorageService.NormalizeProvider(command.Provider, ToObjectStorageProvider(asset.StorageProvider)));
|
||||
asset.StorageProvider =
|
||||
ToAssetStorageProvider(objectStorageService.NormalizeProvider(command.Provider,
|
||||
ToObjectStorageProvider(asset.StorageProvider)));
|
||||
asset.Bucket = string.IsNullOrWhiteSpace(command.Bucket) ? asset.Bucket : command.Bucket.Trim();
|
||||
asset.ObjectKey = string.IsNullOrWhiteSpace(command.ObjectKey)
|
||||
? asset.ObjectKey
|
||||
: objectStorageService.ValidateObjectKey(actor.TenantId, command.ObjectKey.Trim());
|
||||
asset.MimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : objectStorageService.ValidateMimeType(command.MimeType.Trim());
|
||||
asset.MimeType = string.IsNullOrWhiteSpace(command.MimeType)
|
||||
? asset.MimeType
|
||||
: objectStorageService.ValidateMimeType(command.MimeType.Trim());
|
||||
asset.FileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes ?? asset.FileSizeBytes);
|
||||
asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256;
|
||||
asset.PreviewUrl = NormalizeOptional(command.PreviewUrl);
|
||||
asset.PreviewObjectKey = NormalizeOptional(command.PreviewObjectKey) ?? asset.PreviewObjectKey;
|
||||
asset.SortOrder = command.Order ?? asset.SortOrder;
|
||||
asset.AccessRules = command.AccessRules.ValueKind == JsonValueKind.Undefined ? asset.AccessRules : command.AccessRules;
|
||||
asset.AccessRules = command.AccessRules.ValueKind == JsonValueKind.Undefined
|
||||
? asset.AccessRules
|
||||
: command.AccessRules;
|
||||
asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? asset.Metadata : command.Metadata;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
|
||||
@@ -133,6 +115,4 @@ public sealed partial class AssetManagementService
|
||||
cancellationToken);
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
@@ -18,12 +18,14 @@ public sealed class ClamAvOptions
|
||||
public int ChunkBytes { get; set; } = 64 * 1024;
|
||||
public long StreamMaxLength { get; set; } = 500L * 1024 * 1024;
|
||||
|
||||
public static bool BeValid(ClamAvOptions options) =>
|
||||
!string.IsNullOrWhiteSpace(options.Host) &&
|
||||
options.Port is > 0 and <= 65535 &&
|
||||
options.TimeoutSeconds is >= 1 and <= 600 &&
|
||||
options.ChunkBytes is >= 1024 and <= 1024 * 1024 &&
|
||||
options.StreamMaxLength > 0;
|
||||
public static bool BeValid(ClamAvOptions options)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(options.Host) &&
|
||||
options.Port is > 0 and <= 65535 &&
|
||||
options.TimeoutSeconds is >= 1 and <= 600 &&
|
||||
options.ChunkBytes is >= 1024 and <= 1024 * 1024 &&
|
||||
options.StreamMaxLength > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ClamAvAssetSecurityScanner(IOptions<ClamAvOptions> options) : IAssetSecurityScanner
|
||||
@@ -37,11 +39,9 @@ public sealed class ClamAvAssetSecurityScanner(IOptions<ClamAvOptions> options)
|
||||
{
|
||||
var startedTimestamp = Stopwatch.GetTimestamp();
|
||||
if (declaredLength > settings.StreamMaxLength)
|
||||
{
|
||||
throw new AssetSecurityScannerException(
|
||||
"clamav_stream_too_large",
|
||||
"Asset exceeds the configured ClamAV StreamMaxLength.");
|
||||
}
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(settings.TimeoutSeconds));
|
||||
@@ -60,15 +60,14 @@ public sealed class ClamAvAssetSecurityScanner(IOptions<ClamAvOptions> options)
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
if (total > settings.StreamMaxLength)
|
||||
{
|
||||
throw new AssetSecurityScannerException(
|
||||
"clamav_stream_too_large",
|
||||
"Asset exceeds the configured ClamAV StreamMaxLength.");
|
||||
}
|
||||
BinaryPrimitives.WriteUInt32BigEndian(lengthBuffer, (uint)read);
|
||||
await network.WriteAsync(lengthBuffer, timeout.Token);
|
||||
await network.WriteAsync(buffer.AsMemory(0, read), timeout.Token);
|
||||
}
|
||||
|
||||
Array.Clear(lengthBuffer);
|
||||
await network.WriteAsync(lengthBuffer, timeout.Token);
|
||||
await network.FlushAsync(timeout.Token);
|
||||
@@ -78,6 +77,7 @@ public sealed class ClamAvAssetSecurityScanner(IOptions<ClamAvOptions> options)
|
||||
WorkerTelemetry.RecordScan("clean", Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds);
|
||||
return new AssetSecurityScanResult(AssetSecurityScanVerdict.Clean, "clamav", null, total, response);
|
||||
}
|
||||
|
||||
if (response.EndsWith(" FOUND", StringComparison.Ordinal))
|
||||
{
|
||||
var separator = response.IndexOf(": ", StringComparison.Ordinal);
|
||||
@@ -85,9 +85,12 @@ public sealed class ClamAvAssetSecurityScanner(IOptions<ClamAvOptions> options)
|
||||
? response[(separator + 2)..^" FOUND".Length]
|
||||
: "unknown";
|
||||
WorkerTelemetry.RecordScan("infected", Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds);
|
||||
return new AssetSecurityScanResult(AssetSecurityScanVerdict.Infected, "clamav", signature, total, response);
|
||||
return new AssetSecurityScanResult(AssetSecurityScanVerdict.Infected, "clamav", signature, total,
|
||||
response);
|
||||
}
|
||||
throw new AssetSecurityScannerException("clamav_scan_error", $"ClamAV returned an error response: {response}");
|
||||
|
||||
throw new AssetSecurityScannerException("clamav_scan_error",
|
||||
$"ClamAV returned an error response: {response}");
|
||||
}
|
||||
catch (AssetSecurityScannerException)
|
||||
{
|
||||
@@ -133,10 +136,10 @@ public sealed class ClamAvAssetSecurityScanner(IOptions<ClamAvOptions> options)
|
||||
{
|
||||
buffer.WriteByte(single[0]);
|
||||
if (buffer.Length > 4096)
|
||||
{
|
||||
throw new AssetSecurityScannerException("clamav_response_too_large", "ClamAV response exceeded the safety limit.");
|
||||
}
|
||||
throw new AssetSecurityScannerException("clamav_response_too_large",
|
||||
"ClamAV response exceeded the safety limit.");
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.ToArray()).Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
@@ -32,10 +28,7 @@ public sealed partial class AssetManagementService
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value,
|
||||
cancellationToken);
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
|
||||
if (asset is null)
|
||||
@@ -65,7 +58,9 @@ public sealed partial class AssetManagementService
|
||||
asset.StorageProvider = ToAssetStorageProvider(provider);
|
||||
asset.Bucket = bucket;
|
||||
asset.FileSizeBytes = fileSizeBytes;
|
||||
asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : command.Metadata;
|
||||
asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined
|
||||
? JsonDefaults.Object()
|
||||
: command.Metadata;
|
||||
return asset;
|
||||
}
|
||||
|
||||
@@ -80,10 +75,7 @@ public sealed partial class AssetManagementService
|
||||
asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(command.LegacyId))
|
||||
{
|
||||
@@ -93,10 +85,7 @@ public sealed partial class AssetManagementService
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (asset is not null)
|
||||
{
|
||||
return asset;
|
||||
}
|
||||
if (asset is not null) return asset;
|
||||
|
||||
asset = new ContentAsset
|
||||
{
|
||||
@@ -119,12 +108,10 @@ public sealed partial class AssetManagementService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.AssetId && item.Status == ContentStatus.Active,
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.AssetId &&
|
||||
item.Status == ContentStatus.Active,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
|
||||
var provider = ToObjectStorageProvider(asset.StorageProvider);
|
||||
var objectKey = accessType == AssetAccessType.AdminPreview
|
||||
@@ -220,11 +207,9 @@ public sealed partial class AssetManagementService
|
||||
byteDelta,
|
||||
cancellationToken);
|
||||
if (!reserved)
|
||||
{
|
||||
throw new FeatureAccessException(
|
||||
"Tenant storage quota is exhausted.",
|
||||
"feature_quota_exhausted");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -239,18 +224,17 @@ public sealed partial class AssetManagementService
|
||||
CancellationToken.None);
|
||||
throw;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (byteDelta < 0)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
-byteDelta,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static long AccountedStorageBytes(ContentAsset asset)
|
||||
@@ -301,7 +285,8 @@ public sealed partial class AssetManagementService
|
||||
var trimmed = Path.GetFileName(fileName.Trim());
|
||||
return string.Join(
|
||||
"-",
|
||||
trimmed.Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
trimmed.Split(Path.GetInvalidFileNameChars(),
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
@@ -316,10 +301,7 @@ public sealed partial class AssetManagementService
|
||||
|
||||
private static TimeSpan ResolveUploadTtl(int? expiresInSeconds)
|
||||
{
|
||||
if (!expiresInSeconds.HasValue || expiresInSeconds <= 0)
|
||||
{
|
||||
return DefaultUploadTtl;
|
||||
}
|
||||
if (!expiresInSeconds.HasValue || expiresInSeconds <= 0) return DefaultUploadTtl;
|
||||
|
||||
var requested = TimeSpan.FromSeconds(expiresInSeconds.Value);
|
||||
return requested <= MaxUploadTtl ? requested : MaxUploadTtl;
|
||||
@@ -327,10 +309,7 @@ public sealed partial class AssetManagementService
|
||||
|
||||
private static int ResolveLimit(int? limit)
|
||||
{
|
||||
if (!limit.HasValue || limit <= 0)
|
||||
{
|
||||
return DefaultLimit;
|
||||
}
|
||||
if (!limit.HasValue || limit <= 0) return DefaultLimit;
|
||||
|
||||
return Math.Min(limit.Value, MaxLimit);
|
||||
}
|
||||
@@ -338,31 +317,17 @@ public sealed partial class AssetManagementService
|
||||
private static ContentAssetType ResolveAssetType(string? value, string mimeType)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) &&
|
||||
Enum.TryParse<ContentAssetType>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
Enum.TryParse<ContentAssetType>(value, true, out var parsed))
|
||||
return parsed;
|
||||
}
|
||||
|
||||
var normalizedMimeType = mimeType.ToLowerInvariant();
|
||||
if (normalizedMimeType == "application/pdf")
|
||||
{
|
||||
return ContentAssetType.Pdf;
|
||||
}
|
||||
if (normalizedMimeType == "application/pdf") return ContentAssetType.Pdf;
|
||||
|
||||
if (normalizedMimeType.StartsWith("image/", StringComparison.Ordinal))
|
||||
{
|
||||
return ContentAssetType.Image;
|
||||
}
|
||||
if (normalizedMimeType.StartsWith("image/", StringComparison.Ordinal)) return ContentAssetType.Image;
|
||||
|
||||
if (normalizedMimeType.StartsWith("video/", StringComparison.Ordinal))
|
||||
{
|
||||
return ContentAssetType.Video;
|
||||
}
|
||||
if (normalizedMimeType.StartsWith("video/", StringComparison.Ordinal)) return ContentAssetType.Video;
|
||||
|
||||
if (normalizedMimeType.StartsWith("audio/", StringComparison.Ordinal))
|
||||
{
|
||||
return ContentAssetType.Audio;
|
||||
}
|
||||
if (normalizedMimeType.StartsWith("audio/", StringComparison.Ordinal)) return ContentAssetType.Audio;
|
||||
|
||||
return ContentAssetType.Document;
|
||||
}
|
||||
@@ -370,10 +335,8 @@ public sealed partial class AssetManagementService
|
||||
private static ContentVisibility ResolveVisibility(string? value, bool isPublic)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) &&
|
||||
Enum.TryParse<ContentVisibility>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
Enum.TryParse<ContentVisibility>(value, true, out var parsed))
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return isPublic ? ContentVisibility.Public : ContentVisibility.Members;
|
||||
}
|
||||
@@ -381,12 +344,9 @@ public sealed partial class AssetManagementService
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(value)) return fallback;
|
||||
|
||||
return Enum.TryParse<TEnum>(value.Trim(), ignoreCase: true, out var parsed)
|
||||
return Enum.TryParse<TEnum>(value.Trim(), true, out var parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
@@ -409,7 +369,8 @@ public sealed partial class AssetManagementService
|
||||
ObjectStorageProviders.TencentCos => AssetStorageProvider.TencentCos,
|
||||
ObjectStorageProviders.QiniuKodo => AssetStorageProvider.QiniuKodo,
|
||||
ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev,
|
||||
_ => throw new AssetManagementException("Storage provider is not supported.", "storage_provider_not_supported")
|
||||
_ => throw new AssetManagementException("Storage provider is not supported.",
|
||||
"storage_provider_not_supported")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -438,11 +399,9 @@ public sealed partial class AssetManagementService
|
||||
cancellationToken: cancellationToken);
|
||||
var bucket = GetJsonString(account.ConfigPublic, "bucket", "defaultBucket", "default_bucket");
|
||||
if (string.IsNullOrWhiteSpace(bucket))
|
||||
{
|
||||
throw new ObjectStorageException(
|
||||
"Object storage provider bucket is not configured.",
|
||||
"STORAGE_BUCKET_NOT_CONFIGURED");
|
||||
}
|
||||
|
||||
return (objectStorageService.NormalizeProvider(account.Provider), bucket.Trim());
|
||||
}
|
||||
@@ -456,19 +415,12 @@ public sealed partial class AssetManagementService
|
||||
|
||||
private static string? GetJsonString(JsonElement element, params string[] keys)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return value.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
@@ -26,22 +17,16 @@ public sealed partial class AssetManagementService
|
||||
.Where(job => job.TenantId == actor.TenantId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Status) &&
|
||||
Enum.TryParse<ContentImportStatus>(filter.Status, ignoreCase: true, out var status))
|
||||
{
|
||||
Enum.TryParse<ContentImportStatus>(filter.Status, true, out var status))
|
||||
query = query.Where(job => job.Status == status);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.ImportType) &&
|
||||
Enum.TryParse<ContentImportType>(filter.ImportType, ignoreCase: true, out var importType))
|
||||
{
|
||||
Enum.TryParse<ContentImportType>(filter.ImportType, true, out var importType))
|
||||
query = query.Where(job => job.ImportType == importType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.SourceFormat) &&
|
||||
Enum.TryParse<ImportSourceFormat>(filter.SourceFormat, ignoreCase: true, out var sourceFormat))
|
||||
{
|
||||
Enum.TryParse<ImportSourceFormat>(filter.SourceFormat, true, out var sourceFormat))
|
||||
query = query.Where(job => job.SourceFormat == sourceFormat);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(job => job.CreatedAt)
|
||||
@@ -63,10 +48,7 @@ public sealed partial class AssetManagementService
|
||||
.Select(item => ToJobItem(item))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
throw new AssetManagementException("Import job was not found.", "import_job_not_found");
|
||||
}
|
||||
if (job is null) throw new AssetManagementException("Import job was not found.", "import_job_not_found");
|
||||
|
||||
var items = await dbContext.ContentImportItems
|
||||
.AsNoTracking()
|
||||
@@ -107,6 +89,4 @@ public sealed partial class AssetManagementService
|
||||
|
||||
return new ContentImportJobDetail(job, items, issues);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
@@ -24,28 +16,21 @@ public sealed partial class AssetManagementService
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == assetId,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
|
||||
if (asset.Status == ContentStatus.Archived)
|
||||
{
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
var accountedBytes = AccountedStorageBytes(asset);
|
||||
asset.Status = ContentStatus.Archived;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (accountedBytes > 0)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
accountedBytes,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
@@ -65,6 +50,4 @@ public sealed partial class AssetManagementService
|
||||
{
|
||||
return SignAssetAccessAsync(actor, command, AssetAccessType.AdminPreview, "inline", cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
@@ -29,7 +23,8 @@ public sealed partial class AssetManagementService
|
||||
var bucket = storageConfig.Bucket;
|
||||
var mimeType = objectStorageService.ValidateMimeType(command.MimeType.Trim());
|
||||
var fileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes);
|
||||
var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes, cancellationToken);
|
||||
var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes,
|
||||
cancellationToken);
|
||||
var objectKey = objectStorageService.ValidateObjectKey(
|
||||
actor.TenantId,
|
||||
string.IsNullOrWhiteSpace(command.ObjectKey)
|
||||
@@ -65,7 +60,7 @@ public sealed partial class AssetManagementService
|
||||
mimeType,
|
||||
fileSizeBytes,
|
||||
expiresIn,
|
||||
Upsert: true),
|
||||
true),
|
||||
cancellationToken);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
@@ -85,15 +80,11 @@ public sealed partial class AssetManagementService
|
||||
item.Status == ContentStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(asset.ObjectKey) || string.IsNullOrWhiteSpace(asset.Bucket))
|
||||
{
|
||||
throw new AssetManagementException("Asset does not have a writable object location.", "asset_location_missing");
|
||||
}
|
||||
throw new AssetManagementException("Asset does not have a writable object location.",
|
||||
"asset_location_missing");
|
||||
|
||||
var accountedBytesBefore = AccountedStorageBytes(asset);
|
||||
var provider = ToObjectStorageProvider(asset.StorageProvider);
|
||||
@@ -133,8 +124,10 @@ public sealed partial class AssetManagementService
|
||||
asset.UploadStatus = AssetUploadStatus.Failed;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new AssetManagementException("Uploaded object was not found in object storage.", "asset_upload_missing");
|
||||
throw new AssetManagementException("Uploaded object was not found in object storage.",
|
||||
"asset_upload_missing");
|
||||
}
|
||||
|
||||
if (metadata.SizeBytes is not { } verifiedSizeBytes)
|
||||
{
|
||||
asset.UploadStatus = AssetUploadStatus.Failed;
|
||||
@@ -166,12 +159,11 @@ public sealed partial class AssetManagementService
|
||||
"asset_security_scan",
|
||||
JsonSerializer.SerializeToElement(new { assetId = asset.Id }),
|
||||
MaxRetries: 5,
|
||||
IdempotencyKey: $"asset:{asset.Id:N}:{asset.VerifiedChecksumSha256 ?? asset.VerifiedAt?.UtcTicks.ToString()}",
|
||||
IdempotencyKey:
|
||||
$"asset:{asset.Id:N}:{asset.VerifiedChecksumSha256 ?? asset.VerifiedAt?.UtcTicks.ToString()}",
|
||||
IsSystemJob: true),
|
||||
cancellationToken);
|
||||
|
||||
return new AssetUploadConfirmResult(ToItem(asset), metadata);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -22,9 +20,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba
|
||||
var videos = dbContext.VideoExplanations.AsNoTracking()
|
||||
.Where(video => video.TenantId == actor.TenantId && video.IsActive);
|
||||
if (query.SubjectId.HasValue)
|
||||
{
|
||||
videos = videos.Where(video => video.SubjectId == query.SubjectId.Value || video.SubjectId == null);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Keyword))
|
||||
{
|
||||
@@ -51,9 +47,8 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken);
|
||||
if (command.QuestionId.HasValue)
|
||||
{
|
||||
await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, cancellationToken);
|
||||
}
|
||||
await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId,
|
||||
cancellationToken);
|
||||
|
||||
var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken);
|
||||
progress.PlayCount++;
|
||||
@@ -97,23 +92,24 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken);
|
||||
if (command.QuestionId.HasValue)
|
||||
{
|
||||
await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, cancellationToken);
|
||||
}
|
||||
await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId,
|
||||
cancellationToken);
|
||||
|
||||
var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken);
|
||||
var positionSeconds = Math.Max(command.PositionSeconds, 0);
|
||||
var durationSeconds = command.DurationSeconds ?? video.DurationSeconds;
|
||||
var watchedSeconds = Math.Max(command.WatchedSeconds ?? positionSeconds, progress.WatchedSeconds);
|
||||
var completed = command.IsCompleted == true ||
|
||||
durationSeconds is > 0 && positionSeconds >= Math.Max(0, durationSeconds.Value - 3);
|
||||
(durationSeconds is > 0 && positionSeconds >= Math.Max(0, durationSeconds.Value - 3));
|
||||
progress.PositionSeconds = positionSeconds;
|
||||
progress.DurationSeconds = durationSeconds;
|
||||
progress.WatchedSeconds = watchedSeconds;
|
||||
progress.IsCompleted = completed;
|
||||
progress.CompletedAt = completed ? progress.CompletedAt ?? DateTimeOffset.UtcNow : progress.CompletedAt;
|
||||
progress.LastPlayedAt = DateTimeOffset.UtcNow;
|
||||
progress.Metadata = command.Metadata.ValueKind == JsonValueKind.Object ? command.Metadata.Clone() : JsonDefaults.Object();
|
||||
progress.Metadata = command.Metadata.ValueKind == JsonValueKind.Object
|
||||
? command.Metadata.Clone()
|
||||
: JsonDefaults.Object();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToProgressItem(progress);
|
||||
}
|
||||
@@ -127,16 +123,14 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba
|
||||
var questionVideos = dbContext.QuestionVideos.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (query.QuestionId.HasValue)
|
||||
{
|
||||
questionVideos = questionVideos.Where(item => item.QuestionId == query.QuestionId.Value);
|
||||
}
|
||||
|
||||
if (query.QuestionIds is { Count: > 0 })
|
||||
{
|
||||
questionVideos = questionVideos.Where(item => item.QuestionId.HasValue && query.QuestionIds.Contains(item.QuestionId.Value));
|
||||
}
|
||||
questionVideos = questionVideos.Where(item =>
|
||||
item.QuestionId.HasValue && query.QuestionIds.Contains(item.QuestionId.Value));
|
||||
|
||||
var videos = dbContext.VideoExplanations.AsNoTracking().Where(item => item.TenantId == actor.TenantId && item.IsActive);
|
||||
var videos = dbContext.VideoExplanations.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.IsActive);
|
||||
var items = await questionVideos
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.CreatedAt)
|
||||
@@ -176,10 +170,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba
|
||||
item.VideoId == videoId &&
|
||||
item.QuestionId == questionId,
|
||||
cancellationToken);
|
||||
if (progress is not null)
|
||||
{
|
||||
return progress;
|
||||
}
|
||||
if (progress is not null) return progress;
|
||||
|
||||
progress = new VideoPlaybackProgress
|
||||
{
|
||||
@@ -200,9 +191,9 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.VideoExplanations.SingleOrDefaultAsync(
|
||||
video => video.TenantId == tenantId && video.Id == videoId && video.IsActive,
|
||||
cancellationToken)
|
||||
?? throw new VideoPlaybackException("Video was not found.", "video_not_found");
|
||||
video => video.TenantId == tenantId && video.Id == videoId && video.IsActive,
|
||||
cancellationToken)
|
||||
?? throw new VideoPlaybackException("Video was not found.", "video_not_found");
|
||||
}
|
||||
|
||||
private async Task AssertQuestionVideoAsync(
|
||||
@@ -217,10 +208,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba
|
||||
item.QuestionId == questionId &&
|
||||
item.VideoId == videoId,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new VideoPlaybackException("Question video was not found.", "question_video_not_found");
|
||||
}
|
||||
if (!exists) throw new VideoPlaybackException("Question video was not found.", "question_video_not_found");
|
||||
}
|
||||
|
||||
private async Task AssertActiveMemberAsync(VideoPlaybackActor actor, CancellationToken cancellationToken)
|
||||
@@ -232,9 +220,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new VideoPlaybackException("Current user is not a member of the tenant.", "video_access_denied");
|
||||
}
|
||||
}
|
||||
|
||||
private static VideoExplanationCatalogItem ToVideoItem(VideoExplanation video)
|
||||
@@ -271,4 +257,4 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba
|
||||
progress.PlayCount,
|
||||
progress.Metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,16 +34,12 @@ internal sealed class AliyunSmsProvider(
|
||||
|
||||
if (string.Equals(account.Provider, "noop", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(account.Provider, "local_dev", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new SmsProviderSendResult(account.Provider, "accepted");
|
||||
}
|
||||
|
||||
if (!string.Equals(account.Provider, ProviderCode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new SmsProviderException(
|
||||
$"SMS provider '{account.Provider}' is not supported.",
|
||||
"sms_provider_unsupported");
|
||||
}
|
||||
|
||||
var signName = Required(account.ConfigPublic, "signName", "smsSignName");
|
||||
var templateCode = ResolveTemplateCode(account.ConfigPublic, request.Purpose);
|
||||
@@ -74,18 +70,14 @@ internal sealed class AliyunSmsProvider(
|
||||
var response = await client.SendSmsAsync(sendRequest).WaitAsync(cancellationToken);
|
||||
var body = response.Body;
|
||||
if (body is null)
|
||||
{
|
||||
throw new SmsProviderException(
|
||||
"Aliyun SMS returned an empty response.",
|
||||
"aliyun_sms_empty_response");
|
||||
}
|
||||
|
||||
if (!string.Equals(body.Code, "OK", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new SmsProviderException(
|
||||
$"Aliyun SMS send failed: {body.Code}.",
|
||||
"aliyun_sms_send_rejected");
|
||||
}
|
||||
|
||||
return new SmsProviderSendResult(
|
||||
ProviderCode,
|
||||
@@ -115,9 +107,7 @@ internal sealed class AliyunSmsProvider(
|
||||
if (templateCodes.TryGetProperty(purposeKey, out var purposeTemplate) &&
|
||||
purposeTemplate.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(purposeTemplate.GetString()))
|
||||
{
|
||||
return purposeTemplate.GetString()!;
|
||||
}
|
||||
}
|
||||
|
||||
return Required(config, "templateCode", "smsTemplateCode");
|
||||
@@ -137,10 +127,7 @@ internal sealed class AliyunSmsProvider(
|
||||
private static string Required(JsonElement element, params string[] keys)
|
||||
{
|
||||
var value = Optional(element, keys);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||
|
||||
throw new SmsProviderException(
|
||||
"Aliyun SMS provider configuration is incomplete.",
|
||||
@@ -149,21 +136,14 @@ internal sealed class AliyunSmsProvider(
|
||||
|
||||
private static string? Optional(JsonElement element, params string[] keys)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (element.TryGetProperty(key, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(property.GetString()))
|
||||
{
|
||||
return property.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Auth;
|
||||
@@ -18,9 +19,7 @@ internal sealed class AuthAdministrationService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
throw new InvalidCredentialsException("password_reset_reason_required");
|
||||
}
|
||||
|
||||
var permitted = request.TenantId is { } tenantId
|
||||
? await dbContext.TenantMemberships.AnyAsync(
|
||||
@@ -30,26 +29,18 @@ internal sealed class AuthAdministrationService(
|
||||
: await dbContext.PlatformBackendUserRoles.AnyAsync(
|
||||
item => item.UserId == request.TargetUserId,
|
||||
cancellationToken);
|
||||
if (!permitted)
|
||||
{
|
||||
throw new AuthSessionNotFoundException();
|
||||
}
|
||||
if (!permitted) throw new AuthSessionNotFoundException();
|
||||
|
||||
var user = await userManager.FindByIdAsync(request.TargetUserId.ToString())
|
||||
?? throw new AuthSessionNotFoundException();
|
||||
?? throw new AuthSessionNotFoundException();
|
||||
var token = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var reset = await userManager.ResetPasswordAsync(user, token, request.TemporaryPassword);
|
||||
if (!reset.Succeeded)
|
||||
{
|
||||
throw new InvalidCredentialsException("invalid_new_password");
|
||||
}
|
||||
if (!reset.Succeeded) throw new InvalidCredentialsException("invalid_new_password");
|
||||
|
||||
user.ForcePasswordChange = true;
|
||||
var updated = await userManager.UpdateAsync(user);
|
||||
if (!updated.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to require a password change after the administrative reset.");
|
||||
}
|
||||
|
||||
await sessionStore.RevokeAllAsync(user.Id, "administrative_password_reset", cancellationToken);
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
@@ -59,7 +50,7 @@ internal sealed class AuthAdministrationService(
|
||||
Action = "auth.password.reset_by_administrator",
|
||||
TargetType = "user",
|
||||
TargetId = request.TargetUserId.ToString(),
|
||||
Details = System.Text.Json.JsonSerializer.SerializeToElement(new
|
||||
Details = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
request.Reason,
|
||||
ForcePasswordChange = true
|
||||
@@ -67,4 +58,4 @@ internal sealed class AuthAdministrationService(
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
@@ -28,8 +22,10 @@ public sealed partial class AuthService(
|
||||
private const string WechatWebProvider = "wechat_web";
|
||||
private const string WechatMiniAppProvider = "wechat_miniapp";
|
||||
private static readonly string[] WechatWebProviderAliases = ["wechat_web", "wechat-web", "wechat"];
|
||||
private static readonly string[] WechatMiniAppProviderAliases = ["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
|
||||
private static readonly string[] WechatIdentityProviders = ["wechat_web", "wechat-web", "wechat", "wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
|
||||
|
||||
private static readonly string[] WechatMiniAppProviderAliases =
|
||||
["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
|
||||
|
||||
}
|
||||
private static readonly string[] WechatIdentityProviders =
|
||||
["wechat_web", "wechat-web", "wechat", "wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@@ -22,10 +25,16 @@ public sealed class AuthSessionStore(
|
||||
IOptions<AuthorizationCacheOptions>? configuredCacheOptions = null,
|
||||
IAuthorizationStateInvalidator? configuredStateInvalidator = null) : IAuthSessionStore
|
||||
{
|
||||
private readonly IAccessSecurityCache accessSecurityCache =
|
||||
configuredAccessSecurityCache ?? new NullAuthorizationCache();
|
||||
|
||||
private readonly AuthorizationCacheOptions cacheOptions =
|
||||
configuredCacheOptions?.Value ?? new AuthorizationCacheOptions();
|
||||
|
||||
private readonly JwtOptions options = options.Value;
|
||||
private readonly IAccessSecurityCache accessSecurityCache = configuredAccessSecurityCache ?? new NullAuthorizationCache();
|
||||
private readonly AuthorizationCacheOptions cacheOptions = configuredCacheOptions?.Value ?? new AuthorizationCacheOptions();
|
||||
private readonly IAuthorizationStateInvalidator stateInvalidator = configuredStateInvalidator ?? new NullAuthorizationStateInvalidator();
|
||||
|
||||
private readonly IAuthorizationStateInvalidator stateInvalidator =
|
||||
configuredStateInvalidator ?? new NullAuthorizationStateInvalidator();
|
||||
|
||||
public string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId)
|
||||
{
|
||||
@@ -37,12 +46,10 @@ public sealed class AuthSessionStore(
|
||||
public bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator)
|
||||
{
|
||||
locator = default;
|
||||
var parts = refreshToken?.Split('.', 5, StringSplitOptions.None) ?? [];
|
||||
var parts = refreshToken?.Split('.', 5) ?? [];
|
||||
if (parts.Length != 5 || parts[0] != "v2" || parts[4].Length < 64 ||
|
||||
!Guid.TryParseExact(parts[3], "N", out var sessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parts[1] == "p" && parts[2] == "-")
|
||||
{
|
||||
@@ -59,8 +66,11 @@ public sealed class AuthSessionStore(
|
||||
return false;
|
||||
}
|
||||
|
||||
public string HashRefreshToken(string refreshToken) =>
|
||||
Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(refreshToken))).ToLowerInvariant();
|
||||
public string HashRefreshToken(string refreshToken)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken)))
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
|
||||
public async Task<AuthTokenPair> IssueAsync(
|
||||
AuthSessionIssueRequest request,
|
||||
@@ -81,10 +91,7 @@ public sealed class AuthSessionStore(
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryParseRefreshToken(refreshToken, out var locator))
|
||||
{
|
||||
throw new SessionRevokedException();
|
||||
}
|
||||
if (!TryParseRefreshToken(refreshToken, out var locator)) throw new SessionRevokedException();
|
||||
|
||||
var tokenHash = HashRefreshToken(refreshToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -94,10 +101,7 @@ public sealed class AuthSessionStore(
|
||||
item => item.Id == locator.SessionId && item.Realm == locator.Realm &&
|
||||
item.TenantId == locator.TenantId && item.TokenHash == tokenHash,
|
||||
cancellationToken);
|
||||
if (current is null)
|
||||
{
|
||||
throw new SessionRevokedException();
|
||||
}
|
||||
if (current is null) throw new SessionRevokedException();
|
||||
|
||||
if (current.RevokedAt.HasValue || current.ReplacedBySessionId.HasValue || current.ExpiresAt <= now)
|
||||
{
|
||||
@@ -126,6 +130,7 @@ public sealed class AuthSessionStore(
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
throw new SessionRevokedException();
|
||||
}
|
||||
|
||||
var nextId = Guid.NewGuid();
|
||||
var updated = await dbContext.AuthSessions
|
||||
.Where(item => item.Id == current.Id && item.RevokedAt == null && item.ReplacedBySessionId == null)
|
||||
@@ -164,27 +169,22 @@ public sealed class AuthSessionStore(
|
||||
var lookup = new AccessSecurityCacheLookup(sessionId, userId, realm, tenantId);
|
||||
AccessSecurityCacheState? shadowState = null;
|
||||
if (cacheOptions.Mode == AuthorizationCacheMode.Active && accessSecurityCache.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cached = await accessSecurityCache.GetAsync(lookup, cancellationToken);
|
||||
if (cached is not null)
|
||||
{
|
||||
var platformVersionStale = realm == AuthRealm.Platform &&
|
||||
cached.PlatformAccess!.AuthorizationVersion != cached.AuthorizationVersion!.Version;
|
||||
if (!platformVersionStale)
|
||||
{
|
||||
return ValidateCached(cached, lookup);
|
||||
}
|
||||
cached.PlatformAccess!.AuthorizationVersion !=
|
||||
cached.AuthorizationVersion!.Version;
|
||||
if (!platformVersionStale) return ValidateCached(cached, lookup);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// Redis is an acceleration layer; PostgreSQL remains authoritative.
|
||||
}
|
||||
}
|
||||
else if (cacheOptions.Mode == AuthorizationCacheMode.Shadow && accessSecurityCache.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
shadowState = await accessSecurityCache.GetAsync(lookup, cancellationToken);
|
||||
@@ -193,57 +193,61 @@ public sealed class AuthSessionStore(
|
||||
{
|
||||
// Shadow failures never affect the PostgreSQL-authoritative decision.
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
SessionValidationState? state;
|
||||
try
|
||||
{
|
||||
state = await (
|
||||
from session in dbContext.AuthSessions.AsNoTracking()
|
||||
join user in dbContext.Users.AsNoTracking() on session.UserId equals user.Id
|
||||
where session.Id == sessionId &&
|
||||
session.UserId == userId &&
|
||||
session.Realm == realm &&
|
||||
session.TenantId == tenantId
|
||||
select new SessionValidationState(
|
||||
user.Status,
|
||||
user.SecurityStamp!,
|
||||
session.SecurityStamp,
|
||||
realm != AuthRealm.Tenant ||
|
||||
from session in dbContext.AuthSessions.AsNoTracking()
|
||||
join user in dbContext.Users.AsNoTracking() on session.UserId equals user.Id
|
||||
where session.Id == sessionId &&
|
||||
session.UserId == userId &&
|
||||
session.Realm == realm &&
|
||||
session.TenantId == tenantId
|
||||
select new SessionValidationState(
|
||||
user.Status,
|
||||
user.SecurityStamp!,
|
||||
session.SecurityStamp,
|
||||
realm != AuthRealm.Tenant ||
|
||||
(tenantId != null &&
|
||||
dbContext.Tenants.Any(item => item.Id == tenantId && item.Status == TenantStatus.Active) &&
|
||||
dbContext.TenantMemberships.Any(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.UserId == userId &&
|
||||
item.Status == MembershipStatus.Active)),
|
||||
realm != AuthRealm.Platform ||
|
||||
realm != AuthRealm.Platform ||
|
||||
(from userRole in dbContext.PlatformBackendUserRoles
|
||||
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
|
||||
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
|
||||
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
|
||||
where userRole.UserId == userId &&
|
||||
role.Status == BackendRoleStatus.Active &&
|
||||
(permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both)
|
||||
select permission.Id).Any(),
|
||||
realm == AuthRealm.Tenant && tenantId != null
|
||||
? dbContext.Tenants.Where(item => item.Id == tenantId).Select(item => (TenantStatus?)item.Status).FirstOrDefault()
|
||||
: null,
|
||||
realm == AuthRealm.Tenant && tenantId != null
|
||||
? dbContext.TenantMemberships.Where(item => item.TenantId == tenantId && item.UserId == userId)
|
||||
.Select(item => (MembershipStatus?)item.Status).FirstOrDefault()
|
||||
: null,
|
||||
dbContext.AuthorizationScopeVersions
|
||||
.Where(item => item.Realm == realm && item.TenantId == tenantId)
|
||||
.Select(item => (long?)item.Version).FirstOrDefault() ?? 1L,
|
||||
session.ExpiresAt,
|
||||
session.RevokedAt != null))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
|
||||
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
|
||||
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission
|
||||
.Code
|
||||
where userRole.UserId == userId &&
|
||||
role.Status == BackendRoleStatus.Active &&
|
||||
(permission.Area == BackendPermissionArea.Platform ||
|
||||
permission.Area == BackendPermissionArea.Both)
|
||||
select permission.Id).Any(),
|
||||
realm == AuthRealm.Tenant && tenantId != null
|
||||
? dbContext.Tenants.Where(item => item.Id == tenantId)
|
||||
.Select(item => (TenantStatus?)item.Status).FirstOrDefault()
|
||||
: null,
|
||||
realm == AuthRealm.Tenant && tenantId != null
|
||||
? dbContext.TenantMemberships
|
||||
.Where(item => item.TenantId == tenantId && item.UserId == userId)
|
||||
.Select(item => (MembershipStatus?)item.Status).FirstOrDefault()
|
||||
: null,
|
||||
dbContext.AuthorizationScopeVersions
|
||||
.Where(item => item.Realm == realm && item.TenantId == tenantId)
|
||||
.Select(item => (long?)item.Version).FirstOrDefault() ?? 1L,
|
||||
session.ExpiresAt,
|
||||
session.RevokedAt != null))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
throw new AuthorizationSecurityUnavailableException(exception);
|
||||
}
|
||||
|
||||
if (state is null ||
|
||||
state.SessionRevoked ||
|
||||
state.SessionExpiresAt <= now ||
|
||||
@@ -255,7 +259,6 @@ public sealed class AuthSessionStore(
|
||||
if (state is not null &&
|
||||
cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow &&
|
||||
accessSecurityCache.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
await accessSecurityCache.SetAsync(ToCacheState(
|
||||
@@ -265,21 +268,17 @@ public sealed class AuthSessionStore(
|
||||
{
|
||||
// A negative cache write failure does not change the denial decision.
|
||||
}
|
||||
}
|
||||
|
||||
if (shadowState is not null)
|
||||
{
|
||||
AuthorizationCacheTelemetry.ShadowCompared(ValidateCached(shadowState, lookup) is null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new AuthSessionValidationResult(userId, realm, tenantId, state.AuthorizationVersion);
|
||||
if (shadowState is not null)
|
||||
{
|
||||
AuthorizationCacheTelemetry.ShadowCompared(ValidateCached(shadowState, lookup) == result);
|
||||
}
|
||||
if (cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow && accessSecurityCache.IsConfigured)
|
||||
{
|
||||
if (cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow &&
|
||||
accessSecurityCache.IsConfigured)
|
||||
try
|
||||
{
|
||||
await accessSecurityCache.SetAsync(
|
||||
@@ -289,66 +288,10 @@ public sealed class AuthSessionStore(
|
||||
{
|
||||
// The database result is authoritative and remains usable.
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static AuthSessionValidationResult? ValidateCached(
|
||||
AccessSecurityCacheState state, AccessSecurityCacheLookup lookup)
|
||||
{
|
||||
var session = state.Session!;
|
||||
var user = state.User!;
|
||||
var version = state.AuthorizationVersion!;
|
||||
if (session.SessionId != lookup.SessionId || session.UserId != lookup.UserId ||
|
||||
session.Realm != lookup.Realm || session.TenantId != lookup.TenantId ||
|
||||
session.Revoked || session.ExpiresAt <= DateTimeOffset.UtcNow ||
|
||||
user.UserId != lookup.UserId || user.Status != UserStatus.Active ||
|
||||
!string.Equals(user.SecurityStamp, session.SecurityStamp, StringComparison.Ordinal) ||
|
||||
version.Realm != lookup.Realm || version.TenantId != lookup.TenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (lookup.Realm == AuthRealm.Tenant &&
|
||||
(state.Tenant!.Status != TenantStatus.Active || state.Membership!.Status != MembershipStatus.Active))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (lookup.Realm == AuthRealm.Platform &&
|
||||
(!state.PlatformAccess!.Allowed || state.PlatformAccess.AuthorizationVersion != version.Version))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new AuthSessionValidationResult(lookup.UserId, lookup.Realm, lookup.TenantId, version.Version);
|
||||
}
|
||||
|
||||
private static AccessSecurityCacheState ToCacheState(
|
||||
SessionValidationState state, Guid sessionId, Guid userId, AuthRealm realm, Guid? tenantId) => new(
|
||||
new CachedSessionSecurityState(sessionId, userId, realm, tenantId,
|
||||
state.SessionSecurityStamp, state.SessionExpiresAt, state.SessionRevoked),
|
||||
new CachedUserSecurityState(userId, state.UserStatus, state.UserSecurityStamp),
|
||||
realm == AuthRealm.Tenant && state.TenantStatus.HasValue
|
||||
? new CachedTenantSecurityState(tenantId!.Value, state.TenantStatus.Value)
|
||||
: null,
|
||||
realm == AuthRealm.Tenant && state.MembershipStatus.HasValue
|
||||
? new CachedMembershipSecurityState(tenantId!.Value, userId, state.MembershipStatus.Value)
|
||||
: null,
|
||||
realm == AuthRealm.Platform
|
||||
? new CachedPlatformAccessState(userId, state.AuthorizationVersion, state.PlatformAllowed)
|
||||
: null,
|
||||
new CachedAuthorizationVersion(realm, tenantId, state.AuthorizationVersion));
|
||||
|
||||
private sealed record SessionValidationState(
|
||||
UserStatus UserStatus,
|
||||
string UserSecurityStamp,
|
||||
string SessionSecurityStamp,
|
||||
bool TenantAllowed,
|
||||
bool PlatformAllowed,
|
||||
TenantStatus? TenantStatus,
|
||||
MembershipStatus? MembershipStatus,
|
||||
long AuthorizationVersion,
|
||||
DateTimeOffset SessionExpiresAt,
|
||||
bool SessionRevoked);
|
||||
|
||||
public async Task<AuthSessionValidationResult?> ResolveActiveSessionAsync(
|
||||
Guid sessionId,
|
||||
Guid userId,
|
||||
@@ -358,10 +301,7 @@ public sealed class AuthSessionStore(
|
||||
.Where(item => item.Id == sessionId && item.UserId == userId)
|
||||
.Select(item => new { item.Realm, item.TenantId })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (session is null) return null;
|
||||
|
||||
return await ValidateAccessSessionAsync(
|
||||
sessionId,
|
||||
@@ -371,20 +311,16 @@ public sealed class AuthSessionStore(
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default)
|
||||
public async Task RevokeFamilyAsync(string refreshToken, string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryParseRefreshToken(refreshToken, out var locator))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!TryParseRefreshToken(refreshToken, out var locator)) return;
|
||||
|
||||
var hash = HashRefreshToken(refreshToken);
|
||||
var session = await dbContext.AuthSessions.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.Id == locator.SessionId && item.TokenHash == hash, cancellationToken);
|
||||
if (session is not null)
|
||||
{
|
||||
await RevokeFamilyCoreAsync(session.TokenFamilyId, reason, DateTimeOffset.UtcNow, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default)
|
||||
@@ -405,13 +341,11 @@ public sealed class AuthSessionStore(
|
||||
Action = "auth.sessions.revoked_all",
|
||||
TargetType = "user",
|
||||
TargetId = userId.ToString(),
|
||||
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
|
||||
Details = JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
|
||||
}
|
||||
await stateInvalidator.InvalidateUserAsync(userId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -426,10 +360,12 @@ public sealed class AuthSessionStore(
|
||||
ValidateRealm(realm, tenantId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
|
||||
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null)
|
||||
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId &&
|
||||
item.RevokedAt == null)
|
||||
.Select(item => item.Id).ToArrayAsync(cancellationToken);
|
||||
var count = await dbContext.AuthSessions
|
||||
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null)
|
||||
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId &&
|
||||
item.RevokedAt == null)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(item => item.RevokedAt, now)
|
||||
.SetProperty(item => item.RevokedReason, reason), cancellationToken);
|
||||
@@ -442,13 +378,11 @@ public sealed class AuthSessionStore(
|
||||
Action = "auth.sessions.realm_revoked",
|
||||
TargetType = "user",
|
||||
TargetId = userId.ToString(),
|
||||
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now })
|
||||
Details = JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now })
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,8 +392,9 @@ public sealed class AuthSessionStore(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var current = await dbContext.AuthSessions.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken)
|
||||
?? throw new SessionRevokedException();
|
||||
.SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId,
|
||||
cancellationToken)
|
||||
?? throw new SessionRevokedException();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessions = await dbContext.AuthSessions.AsNoTracking()
|
||||
.Where(item => item.UserId == userId && item.Realm == current.Realm && item.TenantId == current.TenantId)
|
||||
@@ -469,7 +404,11 @@ public sealed class AuthSessionStore(
|
||||
return sessions
|
||||
.AsValueEnumerable()
|
||||
.GroupBy(item => item.TokenFamilyId)
|
||||
.Select(group => new { All = group.ToArray(), Active = group.LastOrDefault(item => item.RevokedAt == null && item.ExpiresAt > now) })
|
||||
.Select(group => new
|
||||
{
|
||||
All = group.ToArray(),
|
||||
Active = group.LastOrDefault(item => item.RevokedAt == null && item.ExpiresAt > now)
|
||||
})
|
||||
.Where(value => value.Active is not null)
|
||||
.Select(value => new AuthSessionSummary(
|
||||
value.Active!.TokenFamilyId,
|
||||
@@ -494,39 +433,78 @@ public sealed class AuthSessionStore(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var current = await dbContext.AuthSessions.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken)
|
||||
?? throw new SessionRevokedException();
|
||||
if (current.TokenFamilyId == sessionFamilyId)
|
||||
{
|
||||
throw new CurrentAuthSessionCannotBeRevokedException();
|
||||
}
|
||||
.SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId,
|
||||
cancellationToken)
|
||||
?? throw new SessionRevokedException();
|
||||
if (current.TokenFamilyId == sessionFamilyId) throw new CurrentAuthSessionCannotBeRevokedException();
|
||||
|
||||
var owned = await dbContext.AuthSessions.AsNoTracking().AnyAsync(
|
||||
item => item.UserId == userId && item.TokenFamilyId == sessionFamilyId &&
|
||||
item.Realm == current.Realm && item.TenantId == current.TenantId,
|
||||
cancellationToken);
|
||||
if (!owned)
|
||||
{
|
||||
throw new AuthSessionNotFoundException();
|
||||
}
|
||||
if (!owned) throw new AuthSessionNotFoundException();
|
||||
|
||||
await RevokeFamilyCoreAsync(sessionFamilyId, "user_revoked_device", DateTimeOffset.UtcNow, cancellationToken);
|
||||
}
|
||||
|
||||
private AuthSession CreateSession(AuthSessionIssueRequest request, Guid sessionId) => new()
|
||||
private static AuthSessionValidationResult? ValidateCached(
|
||||
AccessSecurityCacheState state, AccessSecurityCacheLookup lookup)
|
||||
{
|
||||
Id = sessionId,
|
||||
Realm = request.Realm,
|
||||
TenantId = request.TenantId,
|
||||
UserId = request.UserId,
|
||||
TokenFamilyId = request.TokenFamilyId ?? sessionId,
|
||||
ParentSessionId = request.ParentSessionId,
|
||||
SecurityStamp = request.SecurityStamp,
|
||||
Provider = request.Provider,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays),
|
||||
IpAddress = request.IpAddress,
|
||||
UserAgent = request.UserAgent
|
||||
};
|
||||
var session = state.Session!;
|
||||
var user = state.User!;
|
||||
var version = state.AuthorizationVersion!;
|
||||
if (session.SessionId != lookup.SessionId || session.UserId != lookup.UserId ||
|
||||
session.Realm != lookup.Realm || session.TenantId != lookup.TenantId ||
|
||||
session.Revoked || session.ExpiresAt <= DateTimeOffset.UtcNow ||
|
||||
user.UserId != lookup.UserId || user.Status != UserStatus.Active ||
|
||||
!string.Equals(user.SecurityStamp, session.SecurityStamp, StringComparison.Ordinal) ||
|
||||
version.Realm != lookup.Realm || version.TenantId != lookup.TenantId)
|
||||
return null;
|
||||
if (lookup.Realm == AuthRealm.Tenant &&
|
||||
(state.Tenant!.Status != TenantStatus.Active || state.Membership!.Status != MembershipStatus.Active))
|
||||
return null;
|
||||
if (lookup.Realm == AuthRealm.Platform &&
|
||||
(!state.PlatformAccess!.Allowed || state.PlatformAccess.AuthorizationVersion != version.Version))
|
||||
return null;
|
||||
return new AuthSessionValidationResult(lookup.UserId, lookup.Realm, lookup.TenantId, version.Version);
|
||||
}
|
||||
|
||||
private static AccessSecurityCacheState ToCacheState(
|
||||
SessionValidationState state, Guid sessionId, Guid userId, AuthRealm realm, Guid? tenantId)
|
||||
{
|
||||
return new AccessSecurityCacheState(
|
||||
new CachedSessionSecurityState(sessionId, userId, realm, tenantId,
|
||||
state.SessionSecurityStamp, state.SessionExpiresAt, state.SessionRevoked),
|
||||
new CachedUserSecurityState(userId, state.UserStatus, state.UserSecurityStamp),
|
||||
realm == AuthRealm.Tenant && state.TenantStatus.HasValue
|
||||
? new CachedTenantSecurityState(tenantId!.Value, state.TenantStatus.Value)
|
||||
: null,
|
||||
realm == AuthRealm.Tenant && state.MembershipStatus.HasValue
|
||||
? new CachedMembershipSecurityState(tenantId!.Value, userId, state.MembershipStatus.Value)
|
||||
: null,
|
||||
realm == AuthRealm.Platform
|
||||
? new CachedPlatformAccessState(userId, state.AuthorizationVersion, state.PlatformAllowed)
|
||||
: null,
|
||||
new CachedAuthorizationVersion(realm, tenantId, state.AuthorizationVersion));
|
||||
}
|
||||
|
||||
private AuthSession CreateSession(AuthSessionIssueRequest request, Guid sessionId)
|
||||
{
|
||||
return new AuthSession
|
||||
{
|
||||
Id = sessionId,
|
||||
Realm = request.Realm,
|
||||
TenantId = request.TenantId,
|
||||
UserId = request.UserId,
|
||||
TokenFamilyId = request.TokenFamilyId ?? sessionId,
|
||||
ParentSessionId = request.ParentSessionId,
|
||||
SecurityStamp = request.SecurityStamp,
|
||||
Provider = request.Provider,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays),
|
||||
IpAddress = request.IpAddress,
|
||||
UserAgent = request.UserAgent
|
||||
};
|
||||
}
|
||||
|
||||
private AuthTokenPair CreatePair(AuthSessionIssueRequest request, AuthSession session, string refreshToken)
|
||||
{
|
||||
@@ -544,12 +522,13 @@ public sealed class AuthSessionStore(
|
||||
{
|
||||
if (realm == AuthRealm.Tenant && tenantId.HasValue)
|
||||
{
|
||||
var active = await dbContext.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken) &&
|
||||
await dbContext.TenantMemberships.AnyAsync(item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active, cancellationToken);
|
||||
if (active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var active =
|
||||
await dbContext.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active,
|
||||
cancellationToken) &&
|
||||
await dbContext.TenantMemberships.AnyAsync(
|
||||
item => item.TenantId == tenantId && item.UserId == userId &&
|
||||
item.Status == MembershipStatus.Active, cancellationToken);
|
||||
if (active) return;
|
||||
}
|
||||
else if (realm == AuthRealm.Platform)
|
||||
{
|
||||
@@ -559,7 +538,8 @@ public sealed class AuthSessionStore(
|
||||
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
|
||||
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
|
||||
where userRole.UserId == userId && role.Status == BackendRoleStatus.Active &&
|
||||
(permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both)
|
||||
(permission.Area == BackendPermissionArea.Platform ||
|
||||
permission.Area == BackendPermissionArea.Both)
|
||||
select permission.Id).AnyAsync(cancellationToken);
|
||||
if (active) return;
|
||||
}
|
||||
@@ -567,7 +547,8 @@ public sealed class AuthSessionStore(
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
private async Task<int> RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
private async Task<int> RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
|
||||
.Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null)
|
||||
@@ -589,13 +570,11 @@ public sealed class AuthSessionStore(
|
||||
Action = "auth.session_family.revoked",
|
||||
TargetType = "auth_session_family",
|
||||
TargetId = familyId.ToString(),
|
||||
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
|
||||
Details = JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
@@ -603,27 +582,34 @@ public sealed class AuthSessionStore(
|
||||
|
||||
private static void ValidateRealm(AuthRealm realm, Guid? tenantId)
|
||||
{
|
||||
if ((realm == AuthRealm.Tenant) != tenantId.HasValue)
|
||||
{
|
||||
if (realm == AuthRealm.Tenant != tenantId.HasValue)
|
||||
throw new ArgumentException("Tenant sessions require a tenant and platform sessions must not have one.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? MaskIpAddress(string? value)
|
||||
{
|
||||
if (!System.Net.IPAddress.TryParse(value, out var address))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (!IPAddress.TryParse(value, out var address)) return null;
|
||||
|
||||
var bytes = address.GetAddressBytes();
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
bytes[3] = 0;
|
||||
return $"{new System.Net.IPAddress(bytes)}/24";
|
||||
return $"{new IPAddress(bytes)}/24";
|
||||
}
|
||||
|
||||
Array.Clear(bytes, 8, bytes.Length - 8);
|
||||
return $"{new System.Net.IPAddress(bytes)}/64";
|
||||
return $"{new IPAddress(bytes)}/64";
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SessionValidationState(
|
||||
UserStatus UserStatus,
|
||||
string UserSecurityStamp,
|
||||
string SessionSecurityStamp,
|
||||
bool TenantAllowed,
|
||||
bool PlatformAllowed,
|
||||
TenantStatus? TenantStatus,
|
||||
MembershipStatus? MembershipStatus,
|
||||
long AuthorizationVersion,
|
||||
DateTimeOffset SessionExpiresAt,
|
||||
bool SessionRevoked);
|
||||
}
|
||||
@@ -15,10 +15,7 @@ internal sealed class CurrentIdentityQueryService(TikuDbContext dbContext) : ICu
|
||||
.Where(item => item.Id == userId)
|
||||
.Select(item => new { item.Id, item.Phone, item.Email, item.Name })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (user is null) return null;
|
||||
|
||||
var memberships = await dbContext.TenantMemberships.AsNoTracking()
|
||||
.Where(item => item.UserId == userId && item.Status == MembershipStatus.Active)
|
||||
@@ -59,4 +56,4 @@ internal sealed class CurrentIdentityQueryService(TikuDbContext dbContext) : ICu
|
||||
membership.Role))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
@@ -38,10 +37,7 @@ public sealed partial class AuthService
|
||||
var consumed = await dbContext.AuthChallenges
|
||||
.Where(item => item.Id == challenge.Id && item.ConsumedAt == null && item.ExpiresAt > now)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.ConsumedAt, now), cancellationToken);
|
||||
if (consumed != 1)
|
||||
{
|
||||
throw new InvalidAuthChallengeException();
|
||||
}
|
||||
if (consumed != 1) throw new InvalidAuthChallengeException();
|
||||
}
|
||||
|
||||
private async Task<AuthenticationResult> CompleteSuccessfulLoginAsync(
|
||||
@@ -92,11 +88,9 @@ public sealed partial class AuthService
|
||||
}
|
||||
|
||||
if (user.ForcePasswordChange)
|
||||
{
|
||||
return await CreateChallengeResultAsync(
|
||||
user, realm, tenantId, AuthChallengePurpose.PasswordChange, provider,
|
||||
AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken);
|
||||
}
|
||||
|
||||
return await IssueAuthenticatedResultAsync(
|
||||
user, realm, tenant, membership, provider,
|
||||
@@ -154,9 +148,7 @@ public sealed partial class AuthService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Realm != AuthRealm.Tenant || !request.TenantId.HasValue)
|
||||
{
|
||||
throw new InvalidCredentialsException("tenant_realm_required_for_wechat");
|
||||
}
|
||||
|
||||
var config = await LoadWechatProviderOptionsAsync(
|
||||
request.TenantId.Value,
|
||||
@@ -202,10 +194,7 @@ public sealed partial class AuthService
|
||||
// tenant policy and existing membership state have accepted the login.
|
||||
// A denied first login must not leave a user or provider identity behind.
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
request.Realm,
|
||||
@@ -226,7 +215,6 @@ public sealed partial class AuthService
|
||||
{
|
||||
TenantExternalProviderAccount? account = null;
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
try
|
||||
{
|
||||
account = await providerConfigService.GetActiveProviderAsync(
|
||||
@@ -239,19 +227,13 @@ public sealed partial class AuthService
|
||||
catch (TenantExternalProviderException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (account is null)
|
||||
{
|
||||
throw new AuthProviderNotConfiguredException(provider);
|
||||
}
|
||||
if (account is null) throw new AuthProviderNotConfiguredException(provider);
|
||||
|
||||
var appId = GetJsonString(account.ConfigPublic, "appId", "clientId");
|
||||
var appSecret = GetJsonString(account.SecretPayload, "appSecret", "clientSecret", "secret");
|
||||
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret))
|
||||
{
|
||||
throw new AuthProviderNotConfiguredException(provider);
|
||||
}
|
||||
|
||||
return new WechatProviderOptions(appId, appSecret);
|
||||
}
|
||||
@@ -312,10 +294,7 @@ public sealed partial class AuthService
|
||||
string? unionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(unionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(unionId)) return null;
|
||||
|
||||
var identity = await dbContext.UserIdentities
|
||||
.Where(entity =>
|
||||
@@ -341,10 +320,7 @@ public sealed partial class AuthService
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
if (activeMembershipExists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (activeMembershipExists) return;
|
||||
|
||||
var studentMembership = await dbContext.TenantMemberships
|
||||
.FirstOrDefaultAsync(
|
||||
@@ -354,17 +330,12 @@ public sealed partial class AuthService
|
||||
membership.Role == TenantRole.Student,
|
||||
cancellationToken);
|
||||
if (studentMembership is not null)
|
||||
{
|
||||
// Invited and Disabled memberships require an explicit administrator action.
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
var policy = await dbContext.TenantAuthPolicies.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||
if (policy is not null && !policy.AllowExternalStudentSelfRegistration)
|
||||
{
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
if (policy is not null && !policy.AllowExternalStudentSelfRegistration) throw new TenantAccessDeniedException();
|
||||
|
||||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||||
tenantId,
|
||||
@@ -436,23 +407,18 @@ public sealed partial class AuthService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (realm == AuthRealm.Platform)
|
||||
{
|
||||
return await (
|
||||
from userRole in dbContext.PlatformBackendUserRoles
|
||||
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
|
||||
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
|
||||
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
|
||||
where userRole.UserId == userId &&
|
||||
role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active &&
|
||||
(permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Platform ||
|
||||
permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both)
|
||||
role.Status == BackendRoleStatus.Active &&
|
||||
(permission.Area == BackendPermissionArea.Platform ||
|
||||
permission.Area == BackendPermissionArea.Both)
|
||||
select permission.Id).AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (!tenantId.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!tenantId.HasValue) return false;
|
||||
|
||||
return await (
|
||||
from userRole in dbContext.TenantBackendUserRoles
|
||||
@@ -461,14 +427,16 @@ public sealed partial class AuthService
|
||||
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
|
||||
where userRole.TenantId == tenantId.Value && userRole.UserId == userId &&
|
||||
binding.TenantId == tenantId.Value &&
|
||||
role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active &&
|
||||
(permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Tenant ||
|
||||
permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both)
|
||||
role.Status == BackendRoleStatus.Active &&
|
||||
(permission.Area == BackendPermissionArea.Tenant ||
|
||||
permission.Area == BackendPermissionArea.Both)
|
||||
select permission.Id).AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string HashChallengeToken(string token) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant();
|
||||
private static string HashChallengeToken(string token)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task AddSecurityAuditAsync(
|
||||
Guid userId,
|
||||
@@ -479,7 +447,7 @@ public sealed partial class AuthService
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ActorUserId = userId,
|
||||
@@ -521,20 +489,13 @@ public sealed partial class AuthService
|
||||
|
||||
private static string? GetJsonString(JsonElement element, params string[] names)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (element.TryGetProperty(name, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(property.GetString()))
|
||||
{
|
||||
return property.GetString()!.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -549,5 +510,4 @@ public sealed partial class AuthService
|
||||
avatarUrl = identity.AvatarUrl
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,7 @@ internal sealed class JwtKeyRing : IJwtKeyRing, IDisposable
|
||||
var value = options.Value;
|
||||
var signingRsa = RSA.Create(3072);
|
||||
keys.Add(signingRsa);
|
||||
if (!string.IsNullOrWhiteSpace(value.PrivateKeyPem))
|
||||
{
|
||||
signingRsa.ImportFromPem(value.PrivateKeyPem);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(value.PrivateKeyPem)) signingRsa.ImportFromPem(value.PrivateKeyPem);
|
||||
|
||||
var signingKey = CreateKey(signingRsa, value.KeyId);
|
||||
SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);
|
||||
@@ -34,26 +31,26 @@ internal sealed class JwtKeyRing : IJwtKeyRing, IDisposable
|
||||
ValidationKeys = validationKeys;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var key in keys) key.Dispose();
|
||||
}
|
||||
|
||||
public SigningCredentials SigningCredentials { get; }
|
||||
public IReadOnlyCollection<SecurityKey> ValidationKeys { get; }
|
||||
|
||||
private static RsaSecurityKey CreateKey(RSA rsa, string keyId) => new(rsa)
|
||||
private static RsaSecurityKey CreateKey(RSA rsa, string keyId)
|
||||
{
|
||||
KeyId = keyId,
|
||||
// IdentityModel caches signature providers globally by key identity. A key ring owns
|
||||
// and disposes its RSA instances, so a provider retained by another in-process host
|
||||
// could otherwise reference an RSA instance that has already been disposed.
|
||||
CryptoProviderFactory = new CryptoProviderFactory
|
||||
return new RsaSecurityKey(rsa)
|
||||
{
|
||||
CacheSignatureProviders = false
|
||||
}
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
key.Dispose();
|
||||
}
|
||||
KeyId = keyId,
|
||||
// IdentityModel caches signature providers globally by key identity. A key ring owns
|
||||
// and disposes its RSA instances, so a provider retained by another in-process host
|
||||
// could otherwise reference an RSA instance that has already been disposed.
|
||||
CryptoProviderFactory = new CryptoProviderFactory
|
||||
{
|
||||
CacheSignatureProviders = false
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,4 @@ public sealed class LetterAndDigitPasswordValidator<TUser> : IPasswordValidator<
|
||||
Description = "Password must be at least 8 characters and contain both letters and digits."
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,4 @@ internal sealed class NoopSmsProvider : ISmsProvider
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(new SmsProviderSendResult("noop", "accepted"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,8 @@ internal sealed class OwnerActivationService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await CompleteCoreAsync(
|
||||
request, expectedTenantId, expectedHost, ipAddress, userAgent, true, cancellationToken)
|
||||
?? throw Error("Owner activation session could not be established.", "owner_activation_failed");
|
||||
request, expectedTenantId, expectedHost, ipAddress, userAgent, true, cancellationToken)
|
||||
?? throw Error("Owner activation session could not be established.", "owner_activation_failed");
|
||||
await runtimeCacheInvalidator.InvalidateAsync(expectedTenantId, cancellationToken);
|
||||
return result;
|
||||
}
|
||||
@@ -46,8 +46,9 @@ internal sealed class OwnerActivationService(
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
bool authenticate,
|
||||
CancellationToken cancellationToken) =>
|
||||
tenantExecutionScope.ExecuteAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(
|
||||
null,
|
||||
SystemScopeCallerType.Anonymous,
|
||||
@@ -59,34 +60,32 @@ internal sealed class OwnerActivationService(
|
||||
{
|
||||
var dbContext = services.GetRequiredService<TikuDbContext>();
|
||||
var grant = await dbContext.TenantOwnerActivationGrants
|
||||
.SingleOrDefaultAsync(value => value.Id == request.ActivationId, token)
|
||||
?? throw Error("Owner activation was not found.", "owner_activation_invalid");
|
||||
.SingleOrDefaultAsync(value => value.Id == request.ActivationId, token)
|
||||
?? throw Error("Owner activation was not found.", "owner_activation_invalid");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(request.Token))).ToLowerInvariant();
|
||||
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(request.Token)))
|
||||
.ToLowerInvariant();
|
||||
if (grant.ConsumedAt.HasValue)
|
||||
{
|
||||
throw Error("Owner activation was already consumed.", "owner_activation_consumed");
|
||||
}
|
||||
|
||||
if (grant.RevokedAt.HasValue || grant.ExpiresAt <= now || !CryptographicOperations.FixedTimeEquals(
|
||||
Convert.FromHexString(grant.TokenHash),
|
||||
Convert.FromHexString(tokenHash)))
|
||||
{
|
||||
throw Error("Owner activation is invalid or expired.", "owner_activation_invalid");
|
||||
}
|
||||
|
||||
if (expectedTenantId.HasValue)
|
||||
{
|
||||
if (grant.TenantId != expectedTenantId || grant.DomainId is not { } domainId)
|
||||
{
|
||||
throw Error("Owner activation does not belong to this tenant host.", "owner_activation_host_mismatch");
|
||||
}
|
||||
throw Error("Owner activation does not belong to this tenant host.",
|
||||
"owner_activation_host_mismatch");
|
||||
|
||||
var normalizedHost = expectedHost!.Trim().TrimEnd('.').ToLowerInvariant();
|
||||
var domainMatches = await dbContext.TenantDomains.AsNoTracking().AnyAsync(value =>
|
||||
value.Id == domainId && value.TenantId == grant.TenantId && value.IsPrimary &&
|
||||
value.Status == TenantDomainStatus.Active && value.Host == normalizedHost, token);
|
||||
if (!domainMatches)
|
||||
{
|
||||
throw Error("Owner activation does not belong to this tenant host.", "owner_activation_host_mismatch");
|
||||
}
|
||||
throw Error("Owner activation does not belong to this tenant host.",
|
||||
"owner_activation_host_mismatch");
|
||||
}
|
||||
|
||||
var claimed = await dbContext.TenantOwnerActivationGrants
|
||||
@@ -96,31 +95,25 @@ internal sealed class OwnerActivationService(
|
||||
.SetProperty(value => value.ConsumedAt, now)
|
||||
.SetProperty(value => value.UpdatedAt, now), token);
|
||||
if (claimed != 1)
|
||||
{
|
||||
throw Error("Owner activation is invalid or already consumed.", "owner_activation_consumed");
|
||||
}
|
||||
|
||||
var userManager = services.GetRequiredService<UserManager<User>>();
|
||||
var user = await userManager.FindByIdAsync(grant.UserId.ToString())
|
||||
?? throw Error("Owner account was not found.", "owner_activation_invalid");
|
||||
?? throw Error("Owner account was not found.", "owner_activation_invalid");
|
||||
if (await userManager.HasPasswordAsync(user))
|
||||
{
|
||||
throw Error("Owner account was already activated.", "owner_activation_consumed");
|
||||
}
|
||||
|
||||
var result = await userManager.AddPasswordAsync(user, request.NewPassword);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
throw Error(
|
||||
string.Join("; ", result.Errors.Select(error => error.Description)),
|
||||
"owner_activation_password_invalid");
|
||||
}
|
||||
|
||||
user.ForcePasswordChange = false;
|
||||
var updateResult = await userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded)
|
||||
{
|
||||
throw Error("Owner account activation could not be completed.", "owner_activation_failed");
|
||||
}
|
||||
|
||||
await userManager.UpdateSecurityStampAsync(user);
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
@@ -164,6 +157,10 @@ internal sealed class OwnerActivationService(
|
||||
return authentication;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static OwnerActivationException Error(string message, string code) => new(message, code);
|
||||
}
|
||||
private static OwnerActivationException Error(string message, string code)
|
||||
{
|
||||
return new OwnerActivationException(message, code);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
@@ -22,20 +14,14 @@ public sealed partial class AuthService
|
||||
var challenge = await FindChallengeAsync(
|
||||
request.ChallengeToken, AuthChallengePurpose.PasswordChange, cancellationToken);
|
||||
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
|
||||
?? throw new InvalidAuthChallengeException();
|
||||
?? throw new InvalidAuthChallengeException();
|
||||
var resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var reset = await userManager.ResetPasswordAsync(user, resetToken, request.NewPassword);
|
||||
if (!reset.Succeeded)
|
||||
{
|
||||
throw new InvalidCredentialsException("invalid_new_password");
|
||||
}
|
||||
if (!reset.Succeeded) throw new InvalidCredentialsException("invalid_new_password");
|
||||
|
||||
user.ForcePasswordChange = false;
|
||||
var updated = await userManager.UpdateAsync(user);
|
||||
if (!updated.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to clear the password-change requirement.");
|
||||
}
|
||||
if (!updated.Succeeded) throw new InvalidOperationException("Unable to clear the password-change requirement.");
|
||||
|
||||
await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken);
|
||||
await ConsumeChallengeAsync(challenge, cancellationToken);
|
||||
@@ -43,7 +29,8 @@ public sealed partial class AuthService
|
||||
user.Id, challenge.TenantId, "auth.password.changed", null,
|
||||
request.IpAddress, request.UserAgent, cancellationToken);
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
challenge.Realm, challenge.TenantId, user, challenge.Provider, user.Email ?? user.Phone ?? user.Id.ToString(),
|
||||
challenge.Realm, challenge.TenantId, user, challenge.Provider,
|
||||
user.Email ?? user.Phone ?? user.Id.ToString(),
|
||||
request.IpAddress, request.UserAgent, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -60,10 +47,7 @@ public sealed partial class AuthService
|
||||
membership => membership.TenantId == request.TenantId && membership.UserId == userId.Value &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!eligible)
|
||||
{
|
||||
return new SmsSendResult(Guid.NewGuid(), DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
}
|
||||
if (!eligible) return new SmsSendResult(Guid.NewGuid(), DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
|
||||
return await smsVerificationService.CreateCodeAsync(
|
||||
new SendSmsCodeRequest(
|
||||
@@ -88,9 +72,7 @@ public sealed partial class AuthService
|
||||
membership => membership.TenantId == request.TenantId && membership.UserId == user.Id &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken))
|
||||
{
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
await smsVerificationService.VerifyCodeAsync(
|
||||
request.TenantId,
|
||||
@@ -100,17 +82,11 @@ public sealed partial class AuthService
|
||||
cancellationToken);
|
||||
var token = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var reset = await userManager.ResetPasswordAsync(user, token, request.NewPassword);
|
||||
if (!reset.Succeeded)
|
||||
{
|
||||
throw new InvalidCredentialsException("invalid_new_password");
|
||||
}
|
||||
if (!reset.Succeeded) throw new InvalidCredentialsException("invalid_new_password");
|
||||
|
||||
user.ForcePasswordChange = false;
|
||||
var updated = await userManager.UpdateAsync(user);
|
||||
if (!updated.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to finalize the password reset.");
|
||||
}
|
||||
if (!updated.Succeeded) throw new InvalidOperationException("Unable to finalize the password reset.");
|
||||
|
||||
await sessionStore.RevokeAllAsync(user.Id, "password_reset", cancellationToken);
|
||||
await AddSecurityAuditAsync(
|
||||
@@ -132,21 +108,20 @@ public sealed partial class AuthService
|
||||
request.UserId,
|
||||
cancellationToken) ?? throw new SessionRevokedException();
|
||||
var user = await userManager.FindByIdAsync(request.UserId.ToString())
|
||||
?? throw new InvalidCredentialsException();
|
||||
?? throw new InvalidCredentialsException();
|
||||
var changed = await userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword);
|
||||
if (!changed.Succeeded)
|
||||
{
|
||||
var currentPasswordInvalid = changed.Errors.Any(error =>
|
||||
string.Equals(error.Code, "PasswordMismatch", StringComparison.OrdinalIgnoreCase));
|
||||
throw new InvalidCredentialsException(currentPasswordInvalid ? "invalid_credentials" : "invalid_new_password");
|
||||
throw new InvalidCredentialsException(currentPasswordInvalid
|
||||
? "invalid_credentials"
|
||||
: "invalid_new_password");
|
||||
}
|
||||
|
||||
user.ForcePasswordChange = false;
|
||||
var updated = await userManager.UpdateAsync(user);
|
||||
if (!updated.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to finalize the password change.");
|
||||
}
|
||||
if (!updated.Succeeded) throw new InvalidOperationException("Unable to finalize the password change.");
|
||||
|
||||
await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken);
|
||||
await AddSecurityAuditAsync(
|
||||
@@ -167,6 +142,4 @@ public sealed partial class AuthService
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
@@ -24,13 +17,13 @@ public sealed partial class AuthService
|
||||
var normalizedUserName = userManager.NormalizeName(identifier);
|
||||
var user = await dbContext.Users
|
||||
.SingleOrDefaultAsync(entity =>
|
||||
entity.Phone == identifier ||
|
||||
entity.NormalizedEmail == normalizedEmail ||
|
||||
entity.NormalizedUserName == normalizedUserName,
|
||||
entity.Phone == identifier ||
|
||||
entity.NormalizedEmail == normalizedEmail ||
|
||||
entity.NormalizedUserName == normalizedUserName,
|
||||
cancellationToken);
|
||||
var passwordResult = user is null || user.Status != UserStatus.Active
|
||||
? SignInResult.Failed
|
||||
: await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true);
|
||||
: await signInManager.CheckPasswordSignInAsync(user, request.Password, true);
|
||||
|
||||
if (!passwordResult.Succeeded)
|
||||
{
|
||||
@@ -63,6 +56,4 @@ public sealed partial class AuthService
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -58,4 +58,4 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
|
||||
user.Email,
|
||||
user.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
@@ -33,15 +22,10 @@ public sealed partial class AuthService
|
||||
public async Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId.ToString())
|
||||
?? throw new InvalidCredentialsException();
|
||||
?? throw new InvalidCredentialsException();
|
||||
var stampResult = await userManager.UpdateSecurityStampAsync(user);
|
||||
if (!stampResult.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to update the user's security stamp.");
|
||||
}
|
||||
if (!stampResult.Succeeded) throw new InvalidOperationException("Unable to update the user's security stamp.");
|
||||
|
||||
await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -36,4 +36,4 @@ public static class SmsCodeHashing
|
||||
{
|
||||
return phone.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
@@ -25,10 +16,7 @@ public sealed partial class AuthService
|
||||
|
||||
try
|
||||
{
|
||||
if (!request.TenantId.HasValue)
|
||||
{
|
||||
throw new InvalidCredentialsException("tenant_required_for_sms");
|
||||
}
|
||||
if (!request.TenantId.HasValue) throw new InvalidCredentialsException("tenant_required_for_sms");
|
||||
await smsVerificationService.VerifyCodeAsync(
|
||||
request.TenantId.Value,
|
||||
phone,
|
||||
@@ -76,6 +64,4 @@ public sealed partial class AuthService
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
@@ -20,6 +20,10 @@ public sealed class SmsVerificationService(
|
||||
IFeatureAccessService featureAccessService,
|
||||
IOptions<SmsSecurityOptions> securityOptions) : ISmsVerificationService
|
||||
{
|
||||
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
|
||||
private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1);
|
||||
private readonly SmsSecurityOptions options = securityOptions.Value;
|
||||
|
||||
public SmsVerificationService(
|
||||
TikuDbContext dbContext,
|
||||
ISmsProvider smsProvider,
|
||||
@@ -29,10 +33,6 @@ public sealed class SmsVerificationService(
|
||||
{
|
||||
}
|
||||
|
||||
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
|
||||
private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1);
|
||||
private readonly SmsSecurityOptions options = securityOptions.Value;
|
||||
|
||||
public async Task<SmsSendResult> CreateCodeAsync(
|
||||
SendSmsCodeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -48,11 +48,9 @@ public sealed class SmsVerificationService(
|
||||
1,
|
||||
cancellationToken);
|
||||
if (!quotaReserved)
|
||||
{
|
||||
throw new FeatureAccessException(
|
||||
"Tenant SMS quota is exhausted.",
|
||||
"feature_quota_exhausted");
|
||||
}
|
||||
|
||||
var code = RandomNumberGenerator
|
||||
.GetInt32(100000, 1000000)
|
||||
@@ -116,13 +114,11 @@ public sealed class SmsVerificationService(
|
||||
finally
|
||||
{
|
||||
if (!providerAccepted)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
request.TenantId,
|
||||
SaasQuotaMetricCatalog.SmsCount,
|
||||
1,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
await ExpirePreviousCodesAsync(
|
||||
@@ -197,10 +193,7 @@ public sealed class SmsVerificationService(
|
||||
.OrderByDescending(entity => entity.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (verification is null)
|
||||
{
|
||||
throw new InvalidCredentialsException("invalid_sms_code");
|
||||
}
|
||||
if (verification is null) throw new InvalidCredentialsException("invalid_sms_code");
|
||||
|
||||
if (verification.ExpiresAt <= now)
|
||||
{
|
||||
@@ -211,10 +204,7 @@ public sealed class SmsVerificationService(
|
||||
if (HashesMatch(verification.CodeHash, codeHash))
|
||||
{
|
||||
var consumed = await TryConsumeAsync(verification.Id, now, cancellationToken);
|
||||
if (consumed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (consumed) return;
|
||||
|
||||
throw new InvalidCredentialsException("invalid_sms_code");
|
||||
}
|
||||
@@ -229,10 +219,7 @@ public sealed class SmsVerificationService(
|
||||
SmsPurpose purpose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!redisSecurityStore.IsConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!redisSecurityStore.IsConfigured) return;
|
||||
|
||||
var phoneHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(phone)))
|
||||
.ToLowerInvariant();
|
||||
@@ -245,10 +232,7 @@ public sealed class SmsVerificationService(
|
||||
options.MaxVerificationAttempts,
|
||||
CodeLifetime)
|
||||
], cancellationToken);
|
||||
if (!result.Allowed)
|
||||
{
|
||||
throw new SmsRateLimitedException();
|
||||
}
|
||||
if (!result.Allowed) throw new SmsRateLimitedException();
|
||||
}
|
||||
catch (RedisSecurityUnavailableException)
|
||||
{
|
||||
@@ -266,7 +250,6 @@ public sealed class SmsVerificationService(
|
||||
var bucketStart = TruncateToHour(now);
|
||||
|
||||
if (redisSecurityStore.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
var distributed = await redisSecurityStore.ConsumeAsync(
|
||||
@@ -275,16 +258,12 @@ public sealed class SmsVerificationService(
|
||||
limit.Maximum,
|
||||
TimeSpan.FromHours(1))).ToArray(),
|
||||
cancellationToken);
|
||||
if (!distributed.Allowed)
|
||||
{
|
||||
throw new SmsRateLimitedException();
|
||||
}
|
||||
if (!distributed.Allowed) throw new SmsRateLimitedException();
|
||||
}
|
||||
catch (RedisSecurityUnavailableException)
|
||||
{
|
||||
throw new AuthSecurityUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
if (!dbContext.Database.IsRelational())
|
||||
{
|
||||
@@ -297,16 +276,16 @@ public sealed class SmsVerificationService(
|
||||
{
|
||||
var dimension = ToSnakeCase(limit.Dimension);
|
||||
var affected = await dbContext.Database.ExecuteSqlInterpolatedAsync($$"""
|
||||
INSERT INTO sms_send_rate_limits
|
||||
(tenant_id, dimension, scope_hash, bucket_start, request_count, updated_at)
|
||||
VALUES
|
||||
({{request.TenantId}}, {{dimension}}, {{limit.ScopeHash}}, {{bucketStart}}, 1, {{now}})
|
||||
ON CONFLICT (tenant_id, dimension, scope_hash, bucket_start)
|
||||
DO UPDATE SET
|
||||
request_count = sms_send_rate_limits.request_count + 1,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE sms_send_rate_limits.request_count < {{limit.Maximum}}
|
||||
""", cancellationToken);
|
||||
INSERT INTO sms_send_rate_limits
|
||||
(tenant_id, dimension, scope_hash, bucket_start, request_count, updated_at)
|
||||
VALUES
|
||||
({{request.TenantId}}, {{dimension}}, {{limit.ScopeHash}}, {{bucketStart}}, 1, {{now}})
|
||||
ON CONFLICT (tenant_id, dimension, scope_hash, bucket_start)
|
||||
DO UPDATE SET
|
||||
request_count = sms_send_rate_limits.request_count + 1,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE sms_send_rate_limits.request_count < {{limit.Maximum}}
|
||||
""", cancellationToken);
|
||||
|
||||
if (affected == 0)
|
||||
{
|
||||
@@ -334,10 +313,7 @@ public sealed class SmsVerificationService(
|
||||
var counter = await dbContext.SmsSendRateLimits.FindAsync(
|
||||
[tenantId, limit.Dimension, limit.ScopeHash, bucketStart],
|
||||
cancellationToken);
|
||||
if (counter?.RequestCount >= limit.Maximum)
|
||||
{
|
||||
throw new SmsRateLimitedException();
|
||||
}
|
||||
if (counter?.RequestCount >= limit.Maximum) throw new SmsRateLimitedException();
|
||||
|
||||
counters.Add((limit, counter));
|
||||
}
|
||||
@@ -374,27 +350,24 @@ public sealed class SmsVerificationService(
|
||||
var limits = new List<RateLimitSpec>
|
||||
{
|
||||
CreateLimit(SmsRateLimitDimension.Tenant, $"tenant:{request.TenantId:N}", options.TenantRequestsPerHour),
|
||||
CreateLimit(SmsRateLimitDimension.Phone, $"phone:{request.TenantId:N}:{phone}", options.PhoneRequestsPerHour)
|
||||
CreateLimit(SmsRateLimitDimension.Phone, $"phone:{request.TenantId:N}:{phone}",
|
||||
options.PhoneRequestsPerHour)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.IpAddress))
|
||||
{
|
||||
limits.Add(CreateLimit(
|
||||
SmsRateLimitDimension.Ip,
|
||||
$"ip:{request.IpAddress.Trim()}",
|
||||
options.IpRequestsPerHour));
|
||||
}
|
||||
|
||||
var deviceKey = string.IsNullOrWhiteSpace(request.DeviceId)
|
||||
? request.UserAgent
|
||||
: request.DeviceId;
|
||||
if (!string.IsNullOrWhiteSpace(deviceKey))
|
||||
{
|
||||
limits.Add(CreateLimit(
|
||||
SmsRateLimitDimension.Device,
|
||||
$"device:{deviceKey.Trim()}",
|
||||
options.DeviceRequestsPerHour));
|
||||
}
|
||||
|
||||
return limits;
|
||||
}
|
||||
@@ -491,9 +464,7 @@ public sealed class SmsVerificationService(
|
||||
verification.ConsumedAt is not null ||
|
||||
verification.ExpiresAt <= now ||
|
||||
verification.Attempts >= options.MaxVerificationAttempts)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
verification.Status = SmsVerificationStatus.Verified;
|
||||
verification.ConsumedAt = now;
|
||||
@@ -530,15 +501,11 @@ public sealed class SmsVerificationService(
|
||||
verification.ConsumedAt is not null ||
|
||||
verification.ExpiresAt <= now ||
|
||||
verification.Attempts >= options.MaxVerificationAttempts)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
verification.Attempts++;
|
||||
if (verification.Attempts >= options.MaxVerificationAttempts)
|
||||
{
|
||||
verification.Status = SmsVerificationStatus.Blocked;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -546,11 +513,9 @@ public sealed class SmsVerificationService(
|
||||
private void EnsureValidOptions()
|
||||
{
|
||||
if (!SmsSecurityOptions.BeValid(options))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{SmsSecurityOptions.SectionName} must contain a pepper of at least 32 characters, " +
|
||||
"exactly five verification attempts, and positive rate limits.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HashesMatch(string expected, string actual)
|
||||
@@ -588,4 +553,4 @@ public sealed class SmsVerificationService(
|
||||
SmsRateLimitDimension Dimension,
|
||||
string ScopeHash,
|
||||
int Maximum);
|
||||
}
|
||||
}
|
||||
@@ -25,24 +25,16 @@ public sealed class TokenService(IOptions<JwtOptions> options, IJwtKeyRing keyRi
|
||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
|
||||
new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
|
||||
new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
|
||||
ClaimValueTypes.Integer64),
|
||||
new(TikuClaimTypes.Realm, realm.ToString().ToLowerInvariant())
|
||||
};
|
||||
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString()));
|
||||
}
|
||||
if (tenantId.HasValue) claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString()));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(phone))
|
||||
{
|
||||
claims.Add(new Claim(TikuClaimTypes.Phone, phone));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(phone)) claims.Add(new Claim(TikuClaimTypes.Phone, phone));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
claims.Add(new Claim(TikuClaimTypes.Email, email));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(email)) claims.Add(new Claim(TikuClaimTypes.Email, email));
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
options.Issuer,
|
||||
@@ -53,4 +45,4 @@ public sealed class TokenService(IOptions<JwtOptions> options, IJwtKeyRing keyRi
|
||||
|
||||
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
@@ -38,6 +27,4 @@ public sealed partial class AuthService
|
||||
(options, code, token) => wechatOAuthClient.ExchangeMiniAppCodeAsync(options, code, token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -19,25 +19,18 @@ public sealed class WechatOAuthClient : IWechatOAuthClient
|
||||
var token = await OAuthApi.GetAccessTokenAsync(
|
||||
options.AppId,
|
||||
options.AppSecret,
|
||||
code,
|
||||
"authorization_code");
|
||||
code);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EnsureSuccess(token.ErrorCodeValue, token.errmsg);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token.access_token))
|
||||
{
|
||||
throw new InvalidCredentialsException("wechat_access_token_missing");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token.openid))
|
||||
{
|
||||
throw new InvalidCredentialsException("wechat_openid_missing");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(token.openid)) throw new InvalidCredentialsException("wechat_openid_missing");
|
||||
|
||||
var user = await OAuthApi.GetUserInfoAsync(
|
||||
token.access_token,
|
||||
token.openid,
|
||||
Senparc.Weixin.Language.zh_CN);
|
||||
token.openid);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return new WechatIdentity(
|
||||
@@ -74,20 +67,15 @@ public sealed class WechatOAuthClient : IWechatOAuthClient
|
||||
var result = await SnsApi.JsCode2JsonAsync(
|
||||
options.AppId,
|
||||
options.AppSecret,
|
||||
code,
|
||||
"authorization_code");
|
||||
code);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EnsureSuccess(result.ErrorCodeValue, result.errmsg);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(result.openid))
|
||||
{
|
||||
throw new InvalidCredentialsException("wechat_openid_missing");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(result.session_key))
|
||||
{
|
||||
throw new InvalidCredentialsException("wechat_session_key_missing");
|
||||
}
|
||||
|
||||
return new WechatIdentity(
|
||||
result.openid.Trim(),
|
||||
@@ -109,10 +97,7 @@ public sealed class WechatOAuthClient : IWechatOAuthClient
|
||||
|
||||
private static void EnsureSuccess(int errorCode, string? errorMessage)
|
||||
{
|
||||
if (errorCode == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (errorCode == 0) return;
|
||||
|
||||
throw new InvalidCredentialsException(
|
||||
string.IsNullOrWhiteSpace(errorMessage)
|
||||
@@ -141,4 +126,4 @@ public sealed class WechatOAuthClient : IWechatOAuthClient
|
||||
? "wechat_http_error"
|
||||
: "wechat_code_exchange_failed";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using Tiku.Application.Backoffice;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -22,9 +21,7 @@ internal sealed class BackofficeService(
|
||||
{
|
||||
if (!access.IsUserActive || !access.IsCurrentTenantMember ||
|
||||
access.UserId is null || access.TenantId is null)
|
||||
{
|
||||
throw new BackofficeException("Tenant backoffice access is denied.", "tenant_access_denied");
|
||||
}
|
||||
|
||||
var permissionCodes = await FilterTenantPermissionCodesAsync(
|
||||
access.TenantId.Value,
|
||||
@@ -38,7 +35,8 @@ internal sealed class BackofficeService(
|
||||
var features = await featureAccessService.GetEnabledFeaturesAsync(
|
||||
access.TenantId.Value, FeatureAccessOperation.Read, cancellationToken);
|
||||
var quotas = await featureAccessService.GetQuotaSummaryAsync(access.TenantId.Value, cancellationToken);
|
||||
return new BackofficeUiBootstrap(permissionCodes, menus, features.Order(StringComparer.Ordinal).ToArray(), quotas);
|
||||
return new BackofficeUiBootstrap(permissionCodes, menus, features.Order(StringComparer.Ordinal).ToArray(),
|
||||
quotas);
|
||||
}
|
||||
|
||||
public async Task<BackofficeUiBootstrap> GetPlatformUiBootstrapAsync(
|
||||
@@ -46,9 +44,7 @@ internal sealed class BackofficeService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!access.IsUserActive || access.UserId is null || access.PlatformPermissions.Count == 0)
|
||||
{
|
||||
throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied");
|
||||
}
|
||||
|
||||
var permissionCodes = access.PlatformPermissions.Order(StringComparer.Ordinal).ToArray();
|
||||
var menus = await LoadEffectiveMenusAsync(
|
||||
@@ -73,13 +69,15 @@ internal sealed class BackofficeService(
|
||||
permissions.Select(item => item.Code),
|
||||
CapabilityOperation.Read,
|
||||
cancellationToken);
|
||||
permissions = permissions.Where(item => enabledPermissionCodes.Contains(item.Code, StringComparer.Ordinal)).ToArray();
|
||||
permissions = permissions.Where(item => enabledPermissionCodes.Contains(item.Code, StringComparer.Ordinal))
|
||||
.ToArray();
|
||||
var menus = await dbContext.BackendMenus.AsNoTracking()
|
||||
.Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant)
|
||||
.OrderBy(item => item.SortOrder).ThenBy(item => item.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
menus = menus.Where(item => item.PermissionCode is null ||
|
||||
enabledPermissionCodes.Contains(item.PermissionCode, StringComparer.Ordinal)).ToArray();
|
||||
enabledPermissionCodes.Contains(item.PermissionCode, StringComparer.Ordinal))
|
||||
.ToArray();
|
||||
return new BackofficeBootstrap(
|
||||
permissions.Select(ToPermissionItem).ToArray(),
|
||||
menus.Select(ToMenuItem).ToArray(),
|
||||
@@ -135,7 +133,8 @@ internal sealed class BackofficeService(
|
||||
role.DataScope = command.DataScope ?? JsonDefaults.Object();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
|
||||
await AuditAsync(actor, "tenant.role.upserted", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken);
|
||||
await AuditAsync(actor, "tenant.role.upserted", "tenant_backend_roles", role.Id, new { role.Code },
|
||||
cancellationToken);
|
||||
return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -146,8 +145,10 @@ internal sealed class BackofficeService(
|
||||
{
|
||||
RequirePlatformAdmin(actor);
|
||||
var role = command.Id.HasValue
|
||||
? await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Id == command.Id.Value, cancellationToken)
|
||||
: await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Code == NormalizeCode(command.Code), cancellationToken);
|
||||
? await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Id == command.Id.Value,
|
||||
cancellationToken)
|
||||
: await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(
|
||||
item => item.Code == NormalizeCode(command.Code), cancellationToken);
|
||||
if (role is null)
|
||||
{
|
||||
role = new PlatformBackendRole { Code = NormalizeCode(command.Code) };
|
||||
@@ -163,7 +164,8 @@ internal sealed class BackofficeService(
|
||||
role.Description = command.Description?.Trim();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
|
||||
await AuditAsync(actor, "platform.role.upserted", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken);
|
||||
await AuditAsync(actor, "platform.role.upserted", "platform_backend_roles", role.Id, new { role.Code },
|
||||
cancellationToken);
|
||||
return await LoadPlatformRoleAsync(role.Id, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -177,13 +179,13 @@ internal sealed class BackofficeService(
|
||||
item => item.TenantId == tenantId && item.Id == command.RoleId,
|
||||
cancellationToken) ?? throw new BackofficeException("Tenant role was not found.", "role_not_found");
|
||||
if (role.IsSystem)
|
||||
{
|
||||
throw new BackofficeException("System tenant role bindings cannot be modified.", "system_role_locked");
|
||||
}
|
||||
|
||||
await ReplaceTenantBindingsCoreAsync(tenantId, role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken);
|
||||
await ReplaceTenantBindingsCoreAsync(tenantId, role.Id, command.PermissionCodes, command.MenuCodes,
|
||||
cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
|
||||
await AuditAsync(actor, "tenant.role.bindings_replaced", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken);
|
||||
await AuditAsync(actor, "tenant.role.bindings_replaced", "tenant_backend_roles", role.Id, new { role.Code },
|
||||
cancellationToken);
|
||||
return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -197,13 +199,12 @@ internal sealed class BackofficeService(
|
||||
item => item.Id == command.RoleId,
|
||||
cancellationToken) ?? throw new BackofficeException("Platform role was not found.", "role_not_found");
|
||||
if (role.IsSystem)
|
||||
{
|
||||
throw new BackofficeException("System platform role bindings cannot be modified.", "system_role_locked");
|
||||
}
|
||||
|
||||
await ReplacePlatformBindingsCoreAsync(role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
|
||||
await AuditAsync(actor, "platform.role.bindings_replaced", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken);
|
||||
await AuditAsync(actor, "platform.role.bindings_replaced", "platform_backend_roles", role.Id, new { role.Code },
|
||||
cancellationToken);
|
||||
return await LoadPlatformRoleAsync(role.Id, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -225,17 +226,13 @@ internal sealed class BackofficeService(
|
||||
item.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (isActiveOwner && ownerRoleId.HasValue && !roleIds.Contains(ownerRoleId.Value))
|
||||
{
|
||||
throw new BackofficeException("Tenant owner system role cannot be removed.", "system_role_locked");
|
||||
}
|
||||
|
||||
var count = await dbContext.TenantBackendRoles.CountAsync(
|
||||
item => item.TenantId == tenantId && roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
|
||||
cancellationToken);
|
||||
if (count != roleIds.Length)
|
||||
{
|
||||
throw new BackofficeException("One or more tenant roles were not found.", "role_not_found");
|
||||
}
|
||||
|
||||
await dbContext.TenantBackendUserRoles
|
||||
.Where(item => item.TenantId == tenantId && item.UserId == command.UserId)
|
||||
@@ -248,7 +245,8 @@ internal sealed class BackofficeService(
|
||||
}));
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
|
||||
await AuditAsync(actor, "tenant.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken);
|
||||
await AuditAsync(actor, "tenant.user_roles.replaced", "users", command.UserId, new { roleIds },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ReplacePlatformUserRolesAsync(
|
||||
@@ -262,9 +260,7 @@ internal sealed class BackofficeService(
|
||||
item => roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
|
||||
cancellationToken);
|
||||
if (count != roleIds.Length)
|
||||
{
|
||||
throw new BackofficeException("One or more platform roles were not found.", "role_not_found");
|
||||
}
|
||||
|
||||
await dbContext.PlatformBackendUserRoles
|
||||
.Where(item => item.UserId == command.UserId)
|
||||
@@ -276,7 +272,8 @@ internal sealed class BackofficeService(
|
||||
}));
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
|
||||
await AuditAsync(actor, "platform.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken);
|
||||
await AuditAsync(actor, "platform.user_roles.replaced", "users", command.UserId, new { roleIds },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ReplaceTenantBindingsCoreAsync(
|
||||
@@ -292,16 +289,18 @@ internal sealed class BackofficeService(
|
||||
var allowedPermissions = await featureAccessService.FilterPermissionCodesAsync(
|
||||
tenantId, normalizedPermissions, FeatureAccessOperation.Write, cancellationToken);
|
||||
if (allowedPermissions.Count != normalizedPermissions.Length)
|
||||
{
|
||||
throw new BackofficeException(
|
||||
"One or more permissions belong to a feature unavailable to this tenant.",
|
||||
"feature_not_available");
|
||||
}
|
||||
await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Tenant, cancellationToken);
|
||||
await dbContext.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
||||
dbContext.TenantBackendRolePermissions.AddRange(normalizedPermissions.Select(code => new TenantBackendRolePermission { TenantId = tenantId, RoleId = roleId, PermissionCode = code }));
|
||||
dbContext.TenantBackendRoleMenus.AddRange(normalizedMenus.Select(code => new TenantBackendRoleMenu { TenantId = tenantId, RoleId = roleId, MenuCode = code }));
|
||||
await dbContext.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
dbContext.TenantBackendRolePermissions.AddRange(normalizedPermissions.Select(code =>
|
||||
new TenantBackendRolePermission { TenantId = tenantId, RoleId = roleId, PermissionCode = code }));
|
||||
dbContext.TenantBackendRoleMenus.AddRange(normalizedMenus.Select(code => new TenantBackendRoleMenu
|
||||
{ TenantId = tenantId, RoleId = roleId, MenuCode = code }));
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -315,22 +314,25 @@ internal sealed class BackofficeService(
|
||||
var normalizedMenus = NormalizeCodes(menuCodes);
|
||||
await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Platform, cancellationToken);
|
||||
await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Platform, cancellationToken);
|
||||
await dbContext.PlatformBackendRolePermissions.Where(item => item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.PlatformBackendRoleMenus.Where(item => item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
||||
dbContext.PlatformBackendRolePermissions.AddRange(normalizedPermissions.Select(code => new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = code }));
|
||||
dbContext.PlatformBackendRoleMenus.AddRange(normalizedMenus.Select(code => new PlatformBackendRoleMenu { RoleId = roleId, MenuCode = code }));
|
||||
await dbContext.PlatformBackendRolePermissions.Where(item => item.RoleId == roleId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.PlatformBackendRoleMenus.Where(item => item.RoleId == roleId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
dbContext.PlatformBackendRolePermissions.AddRange(normalizedPermissions.Select(code =>
|
||||
new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = code }));
|
||||
dbContext.PlatformBackendRoleMenus.AddRange(normalizedMenus.Select(code => new PlatformBackendRoleMenu
|
||||
{ RoleId = roleId, MenuCode = code }));
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ValidatePermissionCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
|
||||
private async Task ValidatePermissionCodesAsync(string[] codes, BackendPermissionArea area,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var count = await dbContext.BackendPermissions.CountAsync(
|
||||
item => codes.Contains(item.Code) && (item.Area == area || item.Area == BackendPermissionArea.Both),
|
||||
cancellationToken);
|
||||
if (count != codes.Length)
|
||||
{
|
||||
throw new BackofficeException("One or more permissions were not found.", "permission_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<BackofficeMenuItem[]> LoadEffectiveMenusAsync(
|
||||
@@ -341,7 +343,7 @@ internal sealed class BackofficeService(
|
||||
var codes = permissionCodes.ToArray();
|
||||
var menus = await dbContext.BackendMenus.AsNoTracking()
|
||||
.Where(item => item.IsActive && item.Area == area &&
|
||||
(item.PermissionCode == null || codes.Contains(item.PermissionCode)))
|
||||
(item.PermissionCode == null || codes.Contains(item.PermissionCode)))
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
@@ -362,15 +364,13 @@ internal sealed class BackofficeService(
|
||||
return enabled.Order(StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
|
||||
private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var count = await dbContext.BackendMenus.CountAsync(
|
||||
item => codes.Contains(item.Code) && item.Area == area && item.IsActive,
|
||||
cancellationToken);
|
||||
if (count != codes.Length)
|
||||
{
|
||||
throw new BackofficeException("One or more menus were not found.", "menu_not_found");
|
||||
}
|
||||
if (count != codes.Length) throw new BackofficeException("One or more menus were not found.", "menu_not_found");
|
||||
}
|
||||
|
||||
private async Task<BackofficeRoleItem[]> LoadTenantRolesAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
@@ -404,7 +404,8 @@ internal sealed class BackofficeService(
|
||||
return roles.Select(role => ToPlatformRoleItem(role, permissions, menus)).ToArray();
|
||||
}
|
||||
|
||||
private async Task<BackofficeRoleItem> LoadTenantRoleAsync(Guid tenantId, Guid roleId, CancellationToken cancellationToken)
|
||||
private async Task<BackofficeRoleItem> LoadTenantRoleAsync(Guid tenantId, Guid roleId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return (await LoadTenantRolesAsync(tenantId, cancellationToken)).Single(item => item.Id == roleId);
|
||||
}
|
||||
@@ -414,7 +415,8 @@ internal sealed class BackofficeService(
|
||||
return (await LoadPlatformRolesAsync(cancellationToken)).Single(item => item.Id == roleId);
|
||||
}
|
||||
|
||||
private async Task AuditAsync(BackofficeActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken)
|
||||
private async Task AuditAsync(BackofficeActor actor, string action, string targetType, Guid targetId,
|
||||
object details, CancellationToken cancellationToken)
|
||||
{
|
||||
await auditService.WriteAsync(new BackofficeOperationAuditCommand(
|
||||
actor.TenantId,
|
||||
@@ -428,9 +430,7 @@ internal sealed class BackofficeService(
|
||||
private static Guid RequireTenantAdmin(BackofficeActor actor)
|
||||
{
|
||||
if (actor.TenantId is not { } tenantId)
|
||||
{
|
||||
throw new BackofficeException("Tenant backoffice requires a resolved tenant.", "tenant_required");
|
||||
}
|
||||
|
||||
return tenantId;
|
||||
}
|
||||
@@ -438,12 +438,13 @@ internal sealed class BackofficeService(
|
||||
private static void RequirePlatformAdmin(BackofficeActor actor)
|
||||
{
|
||||
if (!actor.IsPlatform)
|
||||
{
|
||||
throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeCode(string code) => code.Trim().ToLowerInvariant();
|
||||
private static string NormalizeCode(string code)
|
||||
{
|
||||
return code.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string[] NormalizeCodes(IEnumerable<string> codes)
|
||||
{
|
||||
@@ -452,15 +453,18 @@ internal sealed class BackofficeService(
|
||||
|
||||
private static BackofficePermissionItem ToPermissionItem(BackendPermission item)
|
||||
{
|
||||
return new BackofficePermissionItem(item.Id, item.Code, item.Name, item.Area, item.PermissionModuleCode, item.Description, item.SortOrder);
|
||||
return new BackofficePermissionItem(item.Id, item.Code, item.Name, item.Area, item.PermissionModuleCode,
|
||||
item.Description, item.SortOrder);
|
||||
}
|
||||
|
||||
private static BackofficeMenuItem ToMenuItem(BackendMenu item)
|
||||
{
|
||||
return new BackofficeMenuItem(item.Id, item.Code, item.ParentCode, item.Title, item.Area, item.Path, item.Icon, item.PermissionCode, item.SortOrder, item.IsActive);
|
||||
return new BackofficeMenuItem(item.Id, item.Code, item.ParentCode, item.Title, item.Area, item.Path, item.Icon,
|
||||
item.PermissionCode, item.SortOrder, item.IsActive);
|
||||
}
|
||||
|
||||
private static BackofficeRoleItem ToTenantRoleItem(TenantBackendRole role, TenantBackendRolePermission[] permissions, TenantBackendRoleMenu[] menus)
|
||||
private static BackofficeRoleItem ToTenantRoleItem(TenantBackendRole role,
|
||||
TenantBackendRolePermission[] permissions, TenantBackendRoleMenu[] menus)
|
||||
{
|
||||
return new BackofficeRoleItem(
|
||||
role.Id,
|
||||
@@ -474,7 +478,8 @@ internal sealed class BackofficeService(
|
||||
role.DataScope);
|
||||
}
|
||||
|
||||
private static BackofficeRoleItem ToPlatformRoleItem(PlatformBackendRole role, PlatformBackendRolePermission[] permissions, PlatformBackendRoleMenu[] menus)
|
||||
private static BackofficeRoleItem ToPlatformRoleItem(PlatformBackendRole role,
|
||||
PlatformBackendRolePermission[] permissions, PlatformBackendRoleMenu[] menus)
|
||||
{
|
||||
return new BackofficeRoleItem(
|
||||
role.Id,
|
||||
@@ -486,5 +491,4 @@ internal sealed class BackofficeService(
|
||||
permissions.Where(item => item.RoleId == role.Id).Select(item => item.PermissionCode).Order().ToArray(),
|
||||
menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray());
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,4 @@ internal sealed class OperationAuditService(TikuDbContext dbContext) : IOperatio
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
@@ -73,7 +73,8 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
|
||||
new(BackendPermissions.TenantHandbookManage, "租户手册管理", BackendPermissionArea.Tenant, "tenant_handbook"),
|
||||
new(BackendPermissions.TenantVideoManage, "租户视频管理", BackendPermissionArea.Tenant, "tenant_video"),
|
||||
new(BackendPermissions.TenantScorelineManage, "租户分数线管理", BackendPermissionArea.Tenant, "tenant_scoreline"),
|
||||
new(BackendPermissions.TenantSiteContentManage, "租户运营内容管理", BackendPermissionArea.Tenant, "tenant_site_content"),
|
||||
new(BackendPermissions.TenantSiteContentManage, "租户运营内容管理", BackendPermissionArea.Tenant,
|
||||
"tenant_site_content"),
|
||||
new(BackendPermissions.TenantSettingsManage, "租户设置管理", BackendPermissionArea.Tenant, "tenant_settings"),
|
||||
new(BackendPermissions.TenantProviderManage, "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"),
|
||||
new(BackendPermissions.TenantCommerceOperate, "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"),
|
||||
@@ -85,11 +86,15 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
|
||||
new(BackendPermissions.PlatformTenantManage, "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"),
|
||||
new(BackendPermissions.PlatformStaffManage, "平台员工管理", BackendPermissionArea.Platform, "platform_staff"),
|
||||
new(BackendPermissions.PlatformRoleManage, "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"),
|
||||
new(BackendPermissions.PlatformQuestionBankManage, "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"),
|
||||
new(BackendPermissions.PlatformQuestionBankManage, "平台公共题库运营", BackendPermissionArea.Platform,
|
||||
"platform_content"),
|
||||
new(BackendPermissions.PlatformAuditView, "平台审计查询", BackendPermissionArea.Platform, "platform_audit"),
|
||||
new(BackendPermissions.PlatformBillingNotification, "平台催缴通知", BackendPermissionArea.Platform, "platform_billing"),
|
||||
new(BackendPermissions.PlatformSaasCatalogManage, "SaaS 商品管理", BackendPermissionArea.Platform, "platform_billing"),
|
||||
new(BackendPermissions.PlatformSaasBillingManage, "SaaS 交易管理", BackendPermissionArea.Platform, "platform_billing"),
|
||||
new(BackendPermissions.PlatformBillingNotification, "平台催缴通知", BackendPermissionArea.Platform,
|
||||
"platform_billing"),
|
||||
new(BackendPermissions.PlatformSaasCatalogManage, "SaaS 商品管理", BackendPermissionArea.Platform,
|
||||
"platform_billing"),
|
||||
new(BackendPermissions.PlatformSaasBillingManage, "SaaS 交易管理", BackendPermissionArea.Platform,
|
||||
"platform_billing"),
|
||||
new(BackendPermissions.PlatformCrmRead, "平台 CRM 查询", BackendPermissionArea.Platform, "platform_crm"),
|
||||
new(BackendPermissions.PlatformCrmWrite, "平台 CRM 管理", BackendPermissionArea.Platform, "platform_crm"),
|
||||
new(BackendPermissions.PlatformSmsRead, "平台短信查询", BackendPermissionArea.Platform, "platform_sms"),
|
||||
@@ -97,12 +102,16 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
|
||||
new(BackendPermissions.PlatformPaymentRead, "平台支付查询", BackendPermissionArea.Platform, "platform_payment"),
|
||||
new(BackendPermissions.PlatformPaymentWrite, "平台支付管理", BackendPermissionArea.Platform, "platform_payment"),
|
||||
new(BackendPermissions.PlatformOperationsView, "平台运维查询", BackendPermissionArea.Platform, "platform_operations"),
|
||||
new(BackendPermissions.PlatformOperationsManage, "平台运维管理", BackendPermissionArea.Platform, "platform_operations"),
|
||||
new(BackendPermissions.PlatformOperationsManage, "平台运维管理", BackendPermissionArea.Platform,
|
||||
"platform_operations"),
|
||||
new(BackendPermissions.PlatformApprovalView, "平台审批查询", BackendPermissionArea.Platform, "platform_governance"),
|
||||
new(BackendPermissions.PlatformApprovalDecide, "平台审批决策", BackendPermissionArea.Platform, "platform_governance"),
|
||||
new(BackendPermissions.PlatformApprovalPolicyManage, "平台审批策略管理", BackendPermissionArea.Platform, "platform_governance"),
|
||||
new(BackendPermissions.PlatformConfigurationManage, "平台配置中心管理", BackendPermissionArea.Platform, "platform_governance"),
|
||||
new(BackendPermissions.PlatformNotificationManage, "平台通知中心管理", BackendPermissionArea.Platform, "platform_governance"),
|
||||
new(BackendPermissions.PlatformApprovalPolicyManage, "平台审批策略管理", BackendPermissionArea.Platform,
|
||||
"platform_governance"),
|
||||
new(BackendPermissions.PlatformConfigurationManage, "平台配置中心管理", BackendPermissionArea.Platform,
|
||||
"platform_governance"),
|
||||
new(BackendPermissions.PlatformNotificationManage, "平台通知中心管理", BackendPermissionArea.Platform,
|
||||
"platform_governance"),
|
||||
new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"),
|
||||
new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
|
||||
new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce")
|
||||
@@ -110,54 +119,108 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
|
||||
|
||||
private static readonly BuiltinMenu[] Menus =
|
||||
[
|
||||
new("tenant.dashboard", null, "租户总览", BackendPermissionArea.Tenant, "/tenant/dashboard", "tenant:dashboard:view", 10),
|
||||
new("tenant.dashboard", null, "租户总览", BackendPermissionArea.Tenant, "/tenant/dashboard",
|
||||
"tenant:dashboard:view", 10),
|
||||
new("tenant.staff", null, "员工与权限", BackendPermissionArea.Tenant, "/tenant/staff", "tenant:staff:manage", 20),
|
||||
new("tenant.students", null, "班级与学生", BackendPermissionArea.Tenant, "/tenant/students", "tenant:student:manage", 30),
|
||||
new("tenant.question-bank", null, "私有题库", BackendPermissionArea.Tenant, "/tenant/question-bank", BackendPermissions.TenantContentManage, 40),
|
||||
new("tenant.vocabulary", null, "词汇", BackendPermissionArea.Tenant, "/tenant/vocabulary", BackendPermissions.TenantVocabularyManage, 50),
|
||||
new("tenant.handbook", null, "知识手册", BackendPermissionArea.Tenant, "/tenant/handbook", BackendPermissions.TenantHandbookManage, 60),
|
||||
new("tenant.video", null, "视频", BackendPermissionArea.Tenant, "/tenant/video", BackendPermissions.TenantVideoManage, 70),
|
||||
new("tenant.scoreline", null, "分数线", BackendPermissionArea.Tenant, "/tenant/scoreline", BackendPermissions.TenantScorelineManage, 80),
|
||||
new("tenant.site-content", null, "运营内容", BackendPermissionArea.Tenant, "/tenant/site-content", BackendPermissions.TenantSiteContentManage, 90),
|
||||
new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", BackendPermissions.TenantProviderManage, 100),
|
||||
new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", BackendPermissions.TenantCommerceOperate, 110),
|
||||
new("tenant.billing", null, "SaaS 账务", BackendPermissionArea.Tenant, "/tenant/billing", BackendPermissions.TenantBillingManage, 120),
|
||||
new("platform.dashboard", null, "经营概览", BackendPermissionArea.Platform, "/", BackendPermissions.PlatformDashboardView, 10),
|
||||
new("platform.tenants", null, "租户管理", BackendPermissionArea.Platform, "/tenants", BackendPermissions.PlatformTenantManage, 20),
|
||||
new("platform.subscriptions", null, "订阅与应收", BackendPermissionArea.Platform, "/subscriptions", BackendPermissions.PlatformSaasBillingManage, 30),
|
||||
new("platform.usage", null, "用量计费", BackendPermissionArea.Platform, "/usage", BackendPermissions.PlatformSaasBillingManage, 40),
|
||||
new("platform.dunning", null, "收款与催缴", BackendPermissionArea.Platform, "/dunning", BackendPermissions.PlatformBillingNotification, 50),
|
||||
new("platform.refunds", null, "退款处理", BackendPermissionArea.Platform, "/refunds", BackendPermissions.PlatformSaasBillingManage, 60),
|
||||
new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/question-banks", BackendPermissions.PlatformQuestionBankManage, 70),
|
||||
new("platform.crm", null, "CRM 服务", BackendPermissionArea.Platform, "/crm", BackendPermissions.PlatformCrmRead, 80),
|
||||
new("platform.sms", null, "短信服务", BackendPermissionArea.Platform, "/sms", BackendPermissions.PlatformSmsRead, 90),
|
||||
new("platform.payment", null, "支付服务", BackendPermissionArea.Platform, "/payments", BackendPermissions.PlatformPaymentRead, 100),
|
||||
new("platform.staff", null, "员工与角色", BackendPermissionArea.Platform, "/staff", BackendPermissions.PlatformStaffManage, 110),
|
||||
new("platform.audit", null, "审计日志", BackendPermissionArea.Platform, "/audit", BackendPermissions.PlatformAuditView, 120),
|
||||
new("platform.alerts", null, "审计告警", BackendPermissionArea.Platform, "/alerts", BackendPermissions.PlatformAuditView, 130),
|
||||
new("platform.operations", null, "运行中心", BackendPermissionArea.Platform, "/operations", BackendPermissions.PlatformOperationsView, 140),
|
||||
new("platform.approvals", null, "审批中心", BackendPermissionArea.Platform, "/approvals", BackendPermissions.PlatformApprovalView, 150),
|
||||
new("platform.configuration", null, "配置中心", BackendPermissionArea.Platform, "/configuration", BackendPermissions.PlatformConfigurationManage, 160),
|
||||
new("platform.notifications", null, "通知中心", BackendPermissionArea.Platform, "/notifications", BackendPermissions.PlatformNotificationManage, 170)
|
||||
new("tenant.students", null, "班级与学生", BackendPermissionArea.Tenant, "/tenant/students", "tenant:student:manage",
|
||||
30),
|
||||
new("tenant.question-bank", null, "私有题库", BackendPermissionArea.Tenant, "/tenant/question-bank",
|
||||
BackendPermissions.TenantContentManage, 40),
|
||||
new("tenant.vocabulary", null, "词汇", BackendPermissionArea.Tenant, "/tenant/vocabulary",
|
||||
BackendPermissions.TenantVocabularyManage, 50),
|
||||
new("tenant.handbook", null, "知识手册", BackendPermissionArea.Tenant, "/tenant/handbook",
|
||||
BackendPermissions.TenantHandbookManage, 60),
|
||||
new("tenant.video", null, "视频", BackendPermissionArea.Tenant, "/tenant/video",
|
||||
BackendPermissions.TenantVideoManage, 70),
|
||||
new("tenant.scoreline", null, "分数线", BackendPermissionArea.Tenant, "/tenant/scoreline",
|
||||
BackendPermissions.TenantScorelineManage, 80),
|
||||
new("tenant.site-content", null, "运营内容", BackendPermissionArea.Tenant, "/tenant/site-content",
|
||||
BackendPermissions.TenantSiteContentManage, 90),
|
||||
new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers",
|
||||
BackendPermissions.TenantProviderManage, 100),
|
||||
new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce",
|
||||
BackendPermissions.TenantCommerceOperate, 110),
|
||||
new("tenant.billing", null, "SaaS 账务", BackendPermissionArea.Tenant, "/tenant/billing",
|
||||
BackendPermissions.TenantBillingManage, 120),
|
||||
new("platform.dashboard", null, "经营概览", BackendPermissionArea.Platform, "/",
|
||||
BackendPermissions.PlatformDashboardView, 10),
|
||||
new("platform.tenants", null, "租户管理", BackendPermissionArea.Platform, "/tenants",
|
||||
BackendPermissions.PlatformTenantManage, 20),
|
||||
new("platform.subscriptions", null, "订阅与应收", BackendPermissionArea.Platform, "/subscriptions",
|
||||
BackendPermissions.PlatformSaasBillingManage, 30),
|
||||
new("platform.usage", null, "用量计费", BackendPermissionArea.Platform, "/usage",
|
||||
BackendPermissions.PlatformSaasBillingManage, 40),
|
||||
new("platform.dunning", null, "收款与催缴", BackendPermissionArea.Platform, "/dunning",
|
||||
BackendPermissions.PlatformBillingNotification, 50),
|
||||
new("platform.refunds", null, "退款处理", BackendPermissionArea.Platform, "/refunds",
|
||||
BackendPermissions.PlatformSaasBillingManage, 60),
|
||||
new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/question-banks",
|
||||
BackendPermissions.PlatformQuestionBankManage, 70),
|
||||
new("platform.crm", null, "CRM 服务", BackendPermissionArea.Platform, "/crm", BackendPermissions.PlatformCrmRead,
|
||||
80),
|
||||
new("platform.sms", null, "短信服务", BackendPermissionArea.Platform, "/sms", BackendPermissions.PlatformSmsRead,
|
||||
90),
|
||||
new("platform.payment", null, "支付服务", BackendPermissionArea.Platform, "/payments",
|
||||
BackendPermissions.PlatformPaymentRead, 100),
|
||||
new("platform.staff", null, "员工与角色", BackendPermissionArea.Platform, "/staff",
|
||||
BackendPermissions.PlatformStaffManage, 110),
|
||||
new("platform.audit", null, "审计日志", BackendPermissionArea.Platform, "/audit",
|
||||
BackendPermissions.PlatformAuditView, 120),
|
||||
new("platform.alerts", null, "审计告警", BackendPermissionArea.Platform, "/alerts",
|
||||
BackendPermissions.PlatformAuditView, 130),
|
||||
new("platform.operations", null, "运行中心", BackendPermissionArea.Platform, "/operations",
|
||||
BackendPermissions.PlatformOperationsView, 140),
|
||||
new("platform.approvals", null, "审批中心", BackendPermissionArea.Platform, "/approvals",
|
||||
BackendPermissions.PlatformApprovalView, 150),
|
||||
new("platform.configuration", null, "配置中心", BackendPermissionArea.Platform, "/configuration",
|
||||
BackendPermissions.PlatformConfigurationManage, 160),
|
||||
new("platform.notifications", null, "通知中心", BackendPermissionArea.Platform, "/notifications",
|
||||
BackendPermissions.PlatformNotificationManage, 170)
|
||||
];
|
||||
|
||||
private static readonly BuiltinPlatformRole[] PlatformRoles =
|
||||
[
|
||||
new("platform_customer_service", "客服运营", "租户开通、客户服务与只读渠道排障",
|
||||
[BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformTenantManage, BackendPermissions.PlatformCrmRead, BackendPermissions.PlatformSmsRead, BackendPermissions.PlatformApprovalView, BackendPermissions.PlatformNotificationManage],
|
||||
["platform.dashboard", "platform.tenants", "platform.crm", "platform.sms", "platform.approvals", "platform.notifications"]),
|
||||
[
|
||||
BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformTenantManage,
|
||||
BackendPermissions.PlatformCrmRead, BackendPermissions.PlatformSmsRead,
|
||||
BackendPermissions.PlatformApprovalView, BackendPermissions.PlatformNotificationManage
|
||||
],
|
||||
[
|
||||
"platform.dashboard", "platform.tenants", "platform.crm", "platform.sms", "platform.approvals",
|
||||
"platform.notifications"
|
||||
]),
|
||||
new("platform_finance", "财务运营", "套餐、订阅、应收、催缴、退款与支付查询",
|
||||
[BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformSaasCatalogManage, BackendPermissions.PlatformSaasBillingManage, BackendPermissions.PlatformBillingNotification, BackendPermissions.PlatformPaymentRead, BackendPermissions.PlatformApprovalView, BackendPermissions.PlatformApprovalDecide],
|
||||
["platform.dashboard", "platform.subscriptions", "platform.usage", "platform.dunning", "platform.refunds", "platform.payment", "platform.approvals"]),
|
||||
[
|
||||
BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformSaasCatalogManage,
|
||||
BackendPermissions.PlatformSaasBillingManage, BackendPermissions.PlatformBillingNotification,
|
||||
BackendPermissions.PlatformPaymentRead, BackendPermissions.PlatformApprovalView,
|
||||
BackendPermissions.PlatformApprovalDecide
|
||||
],
|
||||
[
|
||||
"platform.dashboard", "platform.subscriptions", "platform.usage", "platform.dunning",
|
||||
"platform.refunds", "platform.payment", "platform.approvals"
|
||||
]),
|
||||
new("platform_content_operator", "内容运营", "平台公共题库运营",
|
||||
[BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformQuestionBankManage],
|
||||
["platform.dashboard", "platform.content"]),
|
||||
new("platform_technical_operations", "技术运维", "依赖健康、Worker 与后台任务治理",
|
||||
[BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformOperationsView, BackendPermissions.PlatformOperationsManage, BackendPermissions.PlatformConfigurationManage],
|
||||
[
|
||||
BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformOperationsView,
|
||||
BackendPermissions.PlatformOperationsManage, BackendPermissions.PlatformConfigurationManage
|
||||
],
|
||||
["platform.dashboard", "platform.operations", "platform.configuration"]),
|
||||
new("platform_security_admin", "安全管理员", "平台员工、角色、审计与安全告警治理",
|
||||
[BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformStaffManage, BackendPermissions.PlatformRoleManage, BackendPermissions.PlatformAuditView, BackendPermissions.PlatformApprovalView, BackendPermissions.PlatformApprovalDecide, BackendPermissions.PlatformApprovalPolicyManage, BackendPermissions.PlatformConfigurationManage],
|
||||
["platform.dashboard", "platform.staff", "platform.audit", "platform.alerts", "platform.approvals", "platform.configuration"])
|
||||
[
|
||||
BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformStaffManage,
|
||||
BackendPermissions.PlatformRoleManage, BackendPermissions.PlatformAuditView,
|
||||
BackendPermissions.PlatformApprovalView, BackendPermissions.PlatformApprovalDecide,
|
||||
BackendPermissions.PlatformApprovalPolicyManage, BackendPermissions.PlatformConfigurationManage
|
||||
],
|
||||
[
|
||||
"platform.dashboard", "platform.staff", "platform.audit", "platform.alerts", "platform.approvals",
|
||||
"platform.configuration"
|
||||
])
|
||||
];
|
||||
|
||||
public async Task SeedAsync(CancellationToken cancellationToken = default)
|
||||
@@ -262,6 +325,7 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
|
||||
dbContext.PlatformBackendRoles.Add(role);
|
||||
existingRoles[builtin.Code] = role;
|
||||
}
|
||||
|
||||
role.Name = builtin.Name;
|
||||
role.Description = builtin.Description;
|
||||
role.Status = BackendRoleStatus.Active;
|
||||
@@ -270,29 +334,60 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
|
||||
|
||||
var approvalPolicies = new[]
|
||||
{
|
||||
new PlatformApprovalPolicy { Code = PlatformApprovalPolicyCodes.TenantArchive, Name = "终止租户", RequiredPermission = BackendPermissions.PlatformTenantManage, AlwaysRequireApproval = true },
|
||||
new PlatformApprovalPolicy { Code = PlatformApprovalPolicyCodes.SuperAdminGrant, Name = "平台超级权限变更", RequiredPermission = BackendPermissions.PlatformRoleManage, AlwaysRequireApproval = true },
|
||||
new PlatformApprovalPolicy { Code = PlatformApprovalPolicyCodes.PaymentChannelChange, Name = "支付渠道或密钥引用变更", RequiredPermission = BackendPermissions.PlatformPaymentWrite, AlwaysRequireApproval = true },
|
||||
new PlatformApprovalPolicy { Code = PlatformApprovalPolicyCodes.FinancialAdjustment, Name = "大额退款及手工收款", RequiredPermission = BackendPermissions.PlatformSaasBillingManage, AmountThresholdCents = 5_000_000 }
|
||||
new PlatformApprovalPolicy
|
||||
{
|
||||
Code = PlatformApprovalPolicyCodes.TenantArchive, Name = "终止租户",
|
||||
RequiredPermission = BackendPermissions.PlatformTenantManage, AlwaysRequireApproval = true
|
||||
},
|
||||
new PlatformApprovalPolicy
|
||||
{
|
||||
Code = PlatformApprovalPolicyCodes.SuperAdminGrant, Name = "平台超级权限变更",
|
||||
RequiredPermission = BackendPermissions.PlatformRoleManage, AlwaysRequireApproval = true
|
||||
},
|
||||
new PlatformApprovalPolicy
|
||||
{
|
||||
Code = PlatformApprovalPolicyCodes.PaymentChannelChange, Name = "支付渠道或密钥引用变更",
|
||||
RequiredPermission = BackendPermissions.PlatformPaymentWrite, AlwaysRequireApproval = true
|
||||
},
|
||||
new PlatformApprovalPolicy
|
||||
{
|
||||
Code = PlatformApprovalPolicyCodes.FinancialAdjustment, Name = "大额退款及手工收款",
|
||||
RequiredPermission = BackendPermissions.PlatformSaasBillingManage, AmountThresholdCents = 5_000_000
|
||||
}
|
||||
};
|
||||
var approvalPolicyCodes = approvalPolicies.Select(item => item.Code).ToArray();
|
||||
var existingPolicyCodes = await dbContext.PlatformApprovalPolicies.AsNoTracking()
|
||||
.Where(policy => approvalPolicyCodes.Contains(policy.Code))
|
||||
.Select(policy => policy.Code)
|
||||
.ToHashSetAsync(StringComparer.Ordinal, cancellationToken);
|
||||
dbContext.PlatformApprovalPolicies.AddRange(approvalPolicies.Where(policy => !existingPolicyCodes.Contains(policy.Code)));
|
||||
dbContext.PlatformApprovalPolicies.AddRange(approvalPolicies.Where(policy =>
|
||||
!existingPolicyCodes.Contains(policy.Code)));
|
||||
|
||||
var configurationDefinitions = new[]
|
||||
{
|
||||
new PlatformConfigurationDefinition { Code = "platform.support-contact", Name = "平台支持联系方式", Category = "platform", ValueType = PlatformConfigurationValueType.String },
|
||||
new PlatformConfigurationDefinition { Code = "operations.notification-retention-days", Name = "通知投递保留天数", Category = "operations", ValueType = PlatformConfigurationValueType.Number },
|
||||
new PlatformConfigurationDefinition { Code = "security.jwt-key-ring", Name = "JWT 密钥环", Category = "security", ValueType = PlatformConfigurationValueType.SecretReference, IsSensitive = true, AllowRuntimeManagement = false, Description = "安全配置只能通过部署环境变更。" }
|
||||
new PlatformConfigurationDefinition
|
||||
{
|
||||
Code = "platform.support-contact", Name = "平台支持联系方式", Category = "platform",
|
||||
ValueType = PlatformConfigurationValueType.String
|
||||
},
|
||||
new PlatformConfigurationDefinition
|
||||
{
|
||||
Code = "operations.notification-retention-days", Name = "通知投递保留天数", Category = "operations",
|
||||
ValueType = PlatformConfigurationValueType.Number
|
||||
},
|
||||
new PlatformConfigurationDefinition
|
||||
{
|
||||
Code = "security.jwt-key-ring", Name = "JWT 密钥环", Category = "security",
|
||||
ValueType = PlatformConfigurationValueType.SecretReference, IsSensitive = true,
|
||||
AllowRuntimeManagement = false, Description = "安全配置只能通过部署环境变更。"
|
||||
}
|
||||
};
|
||||
var configurationCodes = configurationDefinitions.Select(item => item.Code).ToArray();
|
||||
var existingConfigurationCodes = await dbContext.PlatformConfigurationDefinitions.AsNoTracking()
|
||||
.Where(item => configurationCodes.Contains(item.Code)).Select(item => item.Code)
|
||||
.ToHashSetAsync(StringComparer.Ordinal, cancellationToken);
|
||||
dbContext.PlatformConfigurationDefinitions.AddRange(configurationDefinitions.Where(item => !existingConfigurationCodes.Contains(item.Code)));
|
||||
dbContext.PlatformConfigurationDefinitions.AddRange(
|
||||
configurationDefinitions.Where(item => !existingConfigurationCodes.Contains(item.Code)));
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
foreach (var builtin in PlatformRoles)
|
||||
@@ -303,7 +398,8 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
dbContext.PlatformBackendRolePermissions.RemoveRange(boundPermissions
|
||||
.Where(binding => !builtin.PermissionCodes.Contains(binding.PermissionCode, StringComparer.Ordinal)));
|
||||
var boundPermissionCodes = boundPermissions.Select(binding => binding.PermissionCode).ToHashSet(StringComparer.Ordinal);
|
||||
var boundPermissionCodes = boundPermissions.Select(binding => binding.PermissionCode)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
dbContext.PlatformBackendRolePermissions.AddRange(builtin.PermissionCodes
|
||||
.Where(code => !boundPermissionCodes.Contains(code))
|
||||
.Select(code => new PlatformBackendRolePermission { RoleId = role.Id, PermissionCode = code }));
|
||||
@@ -342,8 +438,33 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
|
||||
}
|
||||
|
||||
private sealed record BuiltinFeature(string Code, string Name, string Category, bool IsCore, int SortOrder);
|
||||
private sealed record BuiltinPermissionModule(string Code, string Name, BackendPermissionArea Area, string? RequiredFeatureCode, int SortOrder);
|
||||
private sealed record BuiltinPermission(string Code, string Name, BackendPermissionArea Area, string PermissionModuleCode);
|
||||
private sealed record BuiltinMenu(string Code, string? ParentCode, string Title, BackendPermissionArea Area, string Path, string PermissionCode, int SortOrder);
|
||||
private sealed record BuiltinPlatformRole(string Code, string Name, string Description, string[] PermissionCodes, string[] MenuCodes);
|
||||
}
|
||||
|
||||
private sealed record BuiltinPermissionModule(
|
||||
string Code,
|
||||
string Name,
|
||||
BackendPermissionArea Area,
|
||||
string? RequiredFeatureCode,
|
||||
int SortOrder);
|
||||
|
||||
private sealed record BuiltinPermission(
|
||||
string Code,
|
||||
string Name,
|
||||
BackendPermissionArea Area,
|
||||
string PermissionModuleCode);
|
||||
|
||||
private sealed record BuiltinMenu(
|
||||
string Code,
|
||||
string? ParentCode,
|
||||
string Title,
|
||||
BackendPermissionArea Area,
|
||||
string Path,
|
||||
string PermissionCode,
|
||||
int SortOrder);
|
||||
|
||||
private sealed record BuiltinPlatformRole(
|
||||
string Code,
|
||||
string Name,
|
||||
string Description,
|
||||
string[] PermissionCodes,
|
||||
string[] MenuCodes);
|
||||
}
|
||||
@@ -24,10 +24,8 @@ public sealed class BuiltinStarterOfferingSeeder(TikuDbContext dbContext)
|
||||
.Select(feature => feature.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
if (availableFeatureCodes.Length != FeatureCodes.Length)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The built-in feature catalog must be seeded before the starter offering.");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var offering = await dbContext.SaasOfferings
|
||||
@@ -87,7 +85,8 @@ public sealed class BuiltinStarterOfferingSeeder(TikuDbContext dbContext)
|
||||
}
|
||||
else if (version.Status == SaasOfferingVersionStatus.Retired)
|
||||
{
|
||||
throw new InvalidOperationException("The built-in starter offering version 1 is retired and cannot be repaired automatically.");
|
||||
throw new InvalidOperationException(
|
||||
"The built-in starter offering version 1 is retired and cannot be repaired automatically.");
|
||||
}
|
||||
|
||||
var existingFeatureCodes = await dbContext.SaasOfferingVersionFeatures
|
||||
@@ -98,10 +97,8 @@ public sealed class BuiltinStarterOfferingSeeder(TikuDbContext dbContext)
|
||||
.Where(code => !existingFeatureCodes.Contains(code))
|
||||
.ToArray();
|
||||
if (version.Status == SaasOfferingVersionStatus.Published && missingFeatureCodes.Length > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The published built-in starter offering is missing required features and cannot be repaired in place.");
|
||||
}
|
||||
|
||||
dbContext.SaasOfferingVersionFeatures.AddRange(missingFeatureCodes.Select(code =>
|
||||
new SaasOfferingVersionFeature
|
||||
@@ -120,4 +117,4 @@ public sealed class BuiltinStarterOfferingSeeder(TikuDbContext dbContext)
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -188,7 +188,6 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
.Select(module => module.Code)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var code in moduleCodes.Where(code => !existingModuleCodes.Contains(code)))
|
||||
{
|
||||
dbContext.PermissionModules.Add(new PermissionModule
|
||||
{
|
||||
Code = code,
|
||||
@@ -196,14 +195,12 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
Area = BackendPermissionArea.Platform,
|
||||
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code]
|
||||
});
|
||||
}
|
||||
|
||||
var existingPermissionCodes = dbContext.BackendPermissions
|
||||
.Where(permission => permissionCodes.Contains(permission.Code))
|
||||
.Select(permission => permission.Code)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var code in permissionCodes.Where(code => !existingPermissionCodes.Contains(code)))
|
||||
{
|
||||
dbContext.BackendPermissions.Add(new BackendPermission
|
||||
{
|
||||
Code = code,
|
||||
@@ -213,16 +210,12 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
Description = "Built-in platform permission.",
|
||||
IsSystem = true
|
||||
});
|
||||
}
|
||||
|
||||
var superAdminRoleId = dbContext.PlatformBackendRoles
|
||||
.Where(role => role.Code == RoleCode)
|
||||
.Select(role => (Guid?)role.Id)
|
||||
.FirstOrDefault();
|
||||
if (superAdminRoleId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (superAdminRoleId is null) return;
|
||||
|
||||
var boundPermissionCodes = dbContext.PlatformBackendRolePermissions
|
||||
.Where(binding => binding.RoleId == superAdminRoleId.Value)
|
||||
@@ -251,10 +244,7 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
private static Tenant EnsureTenant(TikuDbContext dbContext, string slug, string name)
|
||||
{
|
||||
var tenant = dbContext.Tenants.SingleOrDefault(value => value.Slug == slug);
|
||||
if (tenant is not null)
|
||||
{
|
||||
return tenant;
|
||||
}
|
||||
if (tenant is not null) return tenant;
|
||||
|
||||
tenant = new Tenant
|
||||
{
|
||||
@@ -277,7 +267,6 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
private static void EnsureCrmDemo(TikuDbContext dbContext, Tenant tenant)
|
||||
{
|
||||
if (!dbContext.CrmConfigs.Any(value => value.TenantId == tenant.Id))
|
||||
{
|
||||
dbContext.CrmConfigs.Add(new CrmConfig
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
@@ -292,10 +281,9 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
AssignmentPool = JsonSerializer.SerializeToElement(new[] { "顾问一", "顾问二" }),
|
||||
AssignmentConfig = JsonSerializer.SerializeToElement(new { retry = 3, source = "development_seed" })
|
||||
});
|
||||
}
|
||||
|
||||
if (!dbContext.CrmWebhookQueue.Any(value => value.TenantId == tenant.Id && value.IdempotencyKey == "demo-crm-failed-lead"))
|
||||
{
|
||||
if (!dbContext.CrmWebhookQueue.Any(value =>
|
||||
value.TenantId == tenant.Id && value.IdempotencyKey == "demo-crm-failed-lead"))
|
||||
dbContext.CrmWebhookQueue.Add(new CrmWebhookQueueItem
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
@@ -311,13 +299,13 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
IdempotencyKey = "demo-crm-failed-lead",
|
||||
Payload = JsonSerializer.SerializeToElement(new { student = "王同学", phoneMasked = "138****0001" })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSmsDemo(TikuDbContext dbContext, Tenant tenant, string provider, string templateName)
|
||||
{
|
||||
var scene = templateName.Contains("登录", StringComparison.Ordinal) ? "login" : "marketing";
|
||||
var channel = dbContext.SmsChannels.SingleOrDefault(value => value.TenantId == tenant.Id && value.Provider == provider && value.Scene == scene);
|
||||
var channel = dbContext.SmsChannels.SingleOrDefault(value =>
|
||||
value.TenantId == tenant.Id && value.Provider == provider && value.Scene == scene);
|
||||
if (channel is null)
|
||||
{
|
||||
channel = new SmsChannel
|
||||
@@ -337,7 +325,8 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
}
|
||||
|
||||
var templateCode = $"demo_{provider}_{scene}";
|
||||
var template = dbContext.SmsTemplates.SingleOrDefault(value => value.TenantId == tenant.Id && value.Code == templateCode);
|
||||
var template =
|
||||
dbContext.SmsTemplates.SingleOrDefault(value => value.TenantId == tenant.Id && value.Code == templateCode);
|
||||
if (template is null)
|
||||
{
|
||||
template = new SmsTemplate
|
||||
@@ -357,8 +346,8 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
dbContext.SmsTemplates.Add(template);
|
||||
}
|
||||
|
||||
if (!dbContext.SmsSendLogs.Any(value => value.TenantId == tenant.Id && value.ProviderMessageId == $"demo-{provider}-{scene}-001"))
|
||||
{
|
||||
if (!dbContext.SmsSendLogs.Any(value =>
|
||||
value.TenantId == tenant.Id && value.ProviderMessageId == $"demo-{provider}-{scene}-001"))
|
||||
dbContext.SmsSendLogs.Add(new SmsSendLog
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
@@ -372,7 +361,6 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsurePlatformPaymentDemo(TikuDbContext dbContext, Tenant tenant)
|
||||
@@ -393,7 +381,6 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
}
|
||||
|
||||
if (!dbContext.PlatformPaymentChannels.Any(value => value.AppId == app.Id && value.Provider == "manual"))
|
||||
{
|
||||
dbContext.PlatformPaymentChannels.Add(new PlatformPaymentChannel
|
||||
{
|
||||
AppId = app.Id,
|
||||
@@ -405,7 +392,6 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
CallbackPath = "/api/platform-billing/payments/notify/manual",
|
||||
ConfigPublic = JsonSerializer.SerializeToElement(new { manual = true })
|
||||
});
|
||||
}
|
||||
|
||||
if (!dbContext.PlatformBillingPayments.Any(value => value.PaymentNo == "PB-DEMO-MANUAL-001"))
|
||||
{
|
||||
@@ -460,8 +446,9 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
|
||||
private static void EnsureTenantPaymentDemo(TikuDbContext dbContext, Tenant tenant)
|
||||
{
|
||||
if (!dbContext.TenantExternalProviders.Any(value => value.TenantId == tenant.Id && value.Capability == TenantExternalProviderCapability.Payment && value.Provider == "manual"))
|
||||
{
|
||||
if (!dbContext.TenantExternalProviders.Any(value =>
|
||||
value.TenantId == tenant.Id && value.Capability == TenantExternalProviderCapability.Payment &&
|
||||
value.Provider == "manual"))
|
||||
dbContext.TenantExternalProviders.Add(new TenantExternalProvider
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
@@ -473,18 +460,16 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
ConfigPublic = JsonSerializer.SerializeToElement(new { manual = true }),
|
||||
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateTemporaryPassword() =>
|
||||
$"Tiku!{Convert.ToHexString(RandomNumberGenerator.GetBytes(16))}9a";
|
||||
private static string GenerateTemporaryPassword()
|
||||
{
|
||||
return $"Tiku!{Convert.ToHexString(RandomNumberGenerator.GetBytes(16))}9a";
|
||||
}
|
||||
|
||||
private static void ReloadPostgresTypes(TikuDbContext dbContext)
|
||||
{
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
((NpgsqlConnection)dbContext.Database.GetDbConnection()).ReloadTypes();
|
||||
}
|
||||
if (dbContext.Database.IsNpgsql()) ((NpgsqlConnection)dbContext.Database.GetDbConnection()).ReloadTypes();
|
||||
}
|
||||
|
||||
private static async Task ReloadPostgresTypesAsync(
|
||||
@@ -492,19 +477,15 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
await ((NpgsqlConnection)dbContext.Database.GetDbConnection())
|
||||
.ReloadTypesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureEmailIsAvailable(bool isAssigned)
|
||||
{
|
||||
if (isAssigned)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot create the Development platform administrator because '{Email}' is already assigned.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteFirstLoginInstructions(string temporaryPassword)
|
||||
@@ -514,4 +495,4 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
Console.WriteLine($" Temporary password: {temporaryPassword}");
|
||||
Console.WriteLine(" Change the temporary password at first sign-in. This password is shown only once.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,52 +31,46 @@ public sealed class PlatformAdminBootstrapper(
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var email = options.Email.Trim();
|
||||
if (email.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Platform administrator email is required.", nameof(options));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.TemporaryPassword))
|
||||
{
|
||||
throw new ArgumentException("Platform administrator temporary password is required.", nameof(options));
|
||||
}
|
||||
|
||||
IDbContextTransaction? transaction = null;
|
||||
if (dbContext.Database.IsRelational())
|
||||
{
|
||||
transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken);
|
||||
}
|
||||
transaction =
|
||||
await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken);
|
||||
|
||||
await using (transaction)
|
||||
{
|
||||
var existingAdministrator = await (
|
||||
from binding in dbContext.PlatformBackendUserRoles.AsNoTracking()
|
||||
join boundRole in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals boundRole.Id
|
||||
join boundUser in dbContext.Users.AsNoTracking() on binding.UserId equals boundUser.Id
|
||||
where boundRole.Status == BackendRoleStatus.Active && boundUser.Status == UserStatus.Active
|
||||
select boundUser.Id)
|
||||
from binding in dbContext.PlatformBackendUserRoles.AsNoTracking()
|
||||
join boundRole in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals boundRole
|
||||
.Id
|
||||
join boundUser in dbContext.Users.AsNoTracking() on binding.UserId equals boundUser.Id
|
||||
where boundRole.Status == BackendRoleStatus.Active && boundUser.Status == UserStatus.Active
|
||||
select boundUser.Id)
|
||||
.AnyAsync(cancellationToken);
|
||||
if (existingAdministrator)
|
||||
{
|
||||
throw new PlatformAdminBootstrapException(
|
||||
"A platform administrator already exists. Bootstrap is a one-time operation.",
|
||||
"platform_admin_already_exists");
|
||||
}
|
||||
|
||||
var normalizedEmail = userManager.NormalizeEmail(email);
|
||||
if (await dbContext.Users.AsNoTracking().AnyAsync(
|
||||
user => user.NormalizedEmail == normalizedEmail || user.NormalizedUserName == normalizedEmail,
|
||||
cancellationToken))
|
||||
{
|
||||
throw new PlatformAdminBootstrapException(
|
||||
"The bootstrap email is already assigned to a user.",
|
||||
"bootstrap_user_already_exists");
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Email = email,
|
||||
UserName = email,
|
||||
Name = string.IsNullOrWhiteSpace(options.DisplayName) ? "Platform Administrator" : options.DisplayName.Trim(),
|
||||
Name = string.IsNullOrWhiteSpace(options.DisplayName)
|
||||
? "Platform Administrator"
|
||||
: options.DisplayName.Trim(),
|
||||
EmailConfirmed = true,
|
||||
Status = UserStatus.Active,
|
||||
ForcePasswordChange = true
|
||||
@@ -84,7 +78,8 @@ public sealed class PlatformAdminBootstrapper(
|
||||
var createResult = await userManager.CreateAsync(user, options.TemporaryPassword);
|
||||
if (!createResult.Succeeded)
|
||||
{
|
||||
var errors = string.Join(", ", createResult.Errors.Select(error => $"{error.Code}: {error.Description}"));
|
||||
var errors = string.Join(", ",
|
||||
createResult.Errors.Select(error => $"{error.Code}: {error.Description}"));
|
||||
throw new PlatformAdminBootstrapException(
|
||||
$"Platform administrator could not be created: {errors}",
|
||||
"bootstrap_user_invalid");
|
||||
@@ -121,8 +116,8 @@ public sealed class PlatformAdminBootstrapper(
|
||||
.Where(permission => platformPermissionCodes.Contains(permission.Code))
|
||||
.Select(permission => permission.Code)
|
||||
.ToHashSetAsync(StringComparer.Ordinal, cancellationToken);
|
||||
foreach (var permissionCode in platformPermissionCodes.Where(code => !existingPermissionCodes.Contains(code)))
|
||||
{
|
||||
foreach (var permissionCode in
|
||||
platformPermissionCodes.Where(code => !existingPermissionCodes.Contains(code)))
|
||||
dbContext.BackendPermissions.Add(new BackendPermission
|
||||
{
|
||||
Code = permissionCode,
|
||||
@@ -132,7 +127,6 @@ public sealed class PlatformAdminBootstrapper(
|
||||
Description = "Built-in platform permission.",
|
||||
IsSystem = true
|
||||
});
|
||||
}
|
||||
|
||||
dbContext.PlatformBackendRolePermissions.AddRange(
|
||||
platformPermissionCodes.Select(permissionCode => new PlatformBackendRolePermission
|
||||
@@ -161,10 +155,7 @@ public sealed class PlatformAdminBootstrapper(
|
||||
});
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
return new PlatformAdminBootstrapResult(user.Id, role.Id, email);
|
||||
}
|
||||
@@ -174,4 +165,4 @@ public sealed class PlatformAdminBootstrapper(
|
||||
public sealed class PlatformAdminBootstrapException(string message, string code) : InvalidOperationException(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
}
|
||||
@@ -54,10 +54,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
module.TenantId == filter.TenantId &&
|
||||
module.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(module => module.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(module => module.RegionId == filter.RegionId.Value);
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
@@ -94,29 +91,15 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
node.TenantId == filter.TenantId &&
|
||||
node.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(node => node.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(node => node.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (filter.ModuleId.HasValue)
|
||||
{
|
||||
query = query.Where(node => node.ModuleId == filter.ModuleId.Value);
|
||||
}
|
||||
if (filter.ModuleId.HasValue) query = query.Where(node => node.ModuleId == filter.ModuleId.Value);
|
||||
|
||||
if (filter.ParentIsRoot)
|
||||
{
|
||||
query = query.Where(node => node.ParentId == null);
|
||||
}
|
||||
else if (filter.ParentId.HasValue)
|
||||
{
|
||||
query = query.Where(node => node.ParentId == filter.ParentId.Value);
|
||||
}
|
||||
else if (filter.ParentId.HasValue) query = query.Where(node => node.ParentId == filter.ParentId.Value);
|
||||
|
||||
if (TryParseModuleNodeType(filter.Type, out var nodeType))
|
||||
{
|
||||
query = query.Where(node => node.Type == nodeType);
|
||||
}
|
||||
if (TryParseModuleNodeType(filter.Type, out var nodeType)) query = query.Where(node => node.Type == nodeType);
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
@@ -150,15 +133,9 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
.AsNoTracking()
|
||||
.Where(school => school.TenantId == filter.TenantId);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(school => school.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(school => school.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (filter.ModuleId.HasValue)
|
||||
{
|
||||
query = query.Where(school => school.ModuleId == filter.ModuleId.Value);
|
||||
}
|
||||
if (filter.ModuleId.HasValue) query = query.Where(school => school.ModuleId == filter.ModuleId.Value);
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
@@ -189,15 +166,9 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
major.TenantId == filter.TenantId &&
|
||||
major.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(major => major.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(major => major.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (filter.SchoolId.HasValue)
|
||||
{
|
||||
query = query.Where(major => major.SchoolId == filter.SchoolId.Value);
|
||||
}
|
||||
if (filter.SchoolId.HasValue) query = query.Where(major => major.SchoolId == filter.SchoolId.Value);
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
@@ -230,30 +201,16 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
subject.TenantId == filter.TenantId &&
|
||||
subject.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(subject => subject.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(subject => subject.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (filter.SchoolId.HasValue)
|
||||
{
|
||||
query = query.Where(subject => subject.SchoolId == filter.SchoolId.Value);
|
||||
}
|
||||
if (filter.SchoolId.HasValue) query = query.Where(subject => subject.SchoolId == filter.SchoolId.Value);
|
||||
|
||||
if (filter.MajorId.HasValue)
|
||||
{
|
||||
query = query.Where(subject => subject.MajorId == filter.MajorId.Value);
|
||||
}
|
||||
if (filter.MajorId.HasValue) query = query.Where(subject => subject.MajorId == filter.MajorId.Value);
|
||||
|
||||
if (filter.ModuleId.HasValue)
|
||||
{
|
||||
query = query.Where(subject => subject.ModuleId == filter.ModuleId.Value);
|
||||
}
|
||||
if (filter.ModuleId.HasValue) query = query.Where(subject => subject.ModuleId == filter.ModuleId.Value);
|
||||
|
||||
if (TryParseSubjectType(filter.Type, out var subjectType))
|
||||
{
|
||||
query = query.Where(subject => subject.Type == subjectType);
|
||||
}
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
@@ -291,20 +248,12 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
category.TenantId == filter.TenantId &&
|
||||
category.IsActive);
|
||||
|
||||
if (filter.SubjectId.HasValue)
|
||||
{
|
||||
query = query.Where(category => category.SubjectId == filter.SubjectId.Value);
|
||||
}
|
||||
if (filter.SubjectId.HasValue) query = query.Where(category => category.SubjectId == filter.SubjectId.Value);
|
||||
|
||||
if (filter.NodeId.HasValue)
|
||||
{
|
||||
query = query.Where(category => category.NodeId == filter.NodeId.Value);
|
||||
}
|
||||
if (filter.NodeId.HasValue) query = query.Where(category => category.NodeId == filter.NodeId.Value);
|
||||
|
||||
if (TryParseCategoryType(filter.Type, out var categoryType))
|
||||
{
|
||||
query = query.Where(category => category.CategoryType == categoryType);
|
||||
}
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
@@ -337,10 +286,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
banner.TenantId == filter.TenantId &&
|
||||
banner.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(banner => banner.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(banner => banner.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -383,10 +329,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
faq.TenantId == filter.TenantId &&
|
||||
faq.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(faq => faq.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(faq => faq.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -458,23 +401,17 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
examDate.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(examDate =>
|
||||
examDate.RegionId == filter.RegionId.Value ||
|
||||
examDate.RegionId == null);
|
||||
}
|
||||
|
||||
if (filter.SchoolId.HasValue)
|
||||
{
|
||||
query = query.Where(examDate =>
|
||||
examDate.SchoolId == filter.SchoolId.Value ||
|
||||
examDate.SchoolId == null);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Type))
|
||||
{
|
||||
query = query.Where(examDate => examDate.ExamType == filter.Type.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -520,7 +457,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
examDate.SortOrder,
|
||||
examDate.IsActive,
|
||||
examDate.ExamAt.HasValue
|
||||
? (int?)(examDate.ExamAt.Value.Date - today).Days
|
||||
? (examDate.ExamAt.Value.Date - today).Days
|
||||
: null))
|
||||
.ToArray();
|
||||
|
||||
@@ -537,15 +474,10 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
product.TenantId == filter.TenantId &&
|
||||
product.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(product => product.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(product => product.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (TryParseProductType(filter.Type, out var productType))
|
||||
{
|
||||
query = query.Where(product => product.Type == productType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -588,11 +520,9 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
plan.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(plan =>
|
||||
plan.RegionId == filter.RegionId.Value ||
|
||||
plan.RegionId == null);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -633,10 +563,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
private static IQueryable<T> ApplyKeyword<T>(IQueryable<T> query, string? keyword)
|
||||
where T : class
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
return query;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(keyword)) return query;
|
||||
|
||||
var trimmed = keyword.Trim();
|
||||
return query.Where(entity => EF.Property<string>(entity, nameof(Region.Name)).Contains(trimmed));
|
||||
@@ -649,22 +576,22 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
|
||||
private static bool TryParseSubjectType(string? value, out SubjectType type)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
|
||||
return Enum.TryParse(NormalizeEnumValue(value), true, out type);
|
||||
}
|
||||
|
||||
private static bool TryParseModuleNodeType(string? value, out ModuleNodeType type)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
|
||||
return Enum.TryParse(NormalizeEnumValue(value), true, out type);
|
||||
}
|
||||
|
||||
private static bool TryParseCategoryType(string? value, out CategoryType type)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
|
||||
return Enum.TryParse(NormalizeEnumValue(value), true, out type);
|
||||
}
|
||||
|
||||
private static bool TryParseProductType(string? value, out ProductType type)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
|
||||
return Enum.TryParse(NormalizeEnumValue(value), true, out type);
|
||||
}
|
||||
|
||||
private static string? NormalizeEnumValue(string? value)
|
||||
@@ -674,4 +601,4 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery
|
||||
: value.Replace("_", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("-", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,22 +86,22 @@ public sealed class TaxonomyService(
|
||||
_ => throw new InvalidOperationException("A parent source is required when parentId is provided.")
|
||||
};
|
||||
parent = await tenantExecutionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService),
|
||||
"Validate taxonomy extension parent ownership", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
return await systemDbContext.TaxonomyNodes.AsNoTracking()
|
||||
.Where(node =>
|
||||
node.TenantId == parentOwnerTenantId &&
|
||||
node.Id == command.ParentId.Value &&
|
||||
node.IsActive)
|
||||
.Select(node => new TaxonomyParent(node.Path, node.Depth))
|
||||
.SingleOrDefaultAsync(token);
|
||||
},
|
||||
cancellationToken)
|
||||
?? throw new InvalidOperationException("Taxonomy parent was not found.");
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService),
|
||||
"Validate taxonomy extension parent ownership", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
return await systemDbContext.TaxonomyNodes.AsNoTracking()
|
||||
.Where(node =>
|
||||
node.TenantId == parentOwnerTenantId &&
|
||||
node.Id == command.ParentId.Value &&
|
||||
node.IsActive)
|
||||
.Select(node => new TaxonomyParent(node.Path, node.Depth))
|
||||
.SingleOrDefaultAsync(token);
|
||||
},
|
||||
cancellationToken)
|
||||
?? throw new InvalidOperationException("Taxonomy parent was not found.");
|
||||
}
|
||||
|
||||
var node = new TaxonomyNode
|
||||
@@ -151,4 +151,4 @@ public sealed class TaxonomyService(
|
||||
}
|
||||
|
||||
private sealed record TaxonomyParent(string? Path, int Depth);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -23,23 +15,18 @@ internal sealed partial class CommerceAdminService
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
if (command.TotalCount is < 1 or > 1000)
|
||||
{
|
||||
throw new CommerceException("Code batch total count must be between 1 and 1000.", "invalid_code_batch_count");
|
||||
}
|
||||
throw new CommerceException("Code batch total count must be between 1 and 1000.",
|
||||
"invalid_code_batch_count");
|
||||
|
||||
if (command.Days <= 0)
|
||||
{
|
||||
throw new CommerceException("Activation code days must be positive.", "invalid_activation_days");
|
||||
}
|
||||
|
||||
if (command.RegionId.HasValue)
|
||||
{
|
||||
var regionExists = await dbContext.Regions
|
||||
.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken);
|
||||
if (!regionExists)
|
||||
{
|
||||
throw new CommerceException("Region was not found.", "region_not_found");
|
||||
}
|
||||
.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value,
|
||||
cancellationToken);
|
||||
if (!regionExists) throw new CommerceException("Region was not found.", "region_not_found");
|
||||
}
|
||||
|
||||
var batch = new CodeBatch
|
||||
@@ -59,7 +46,6 @@ internal sealed partial class CommerceAdminService
|
||||
};
|
||||
dbContext.CodeBatches.Add(batch);
|
||||
for (var index = 0; index < command.TotalCount; index++)
|
||||
{
|
||||
dbContext.ActivationCodes.Add(new ActivationCode
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
@@ -70,7 +56,6 @@ internal sealed partial class CommerceAdminService
|
||||
UnitPriceCents = batch.DefaultUnitPriceCents,
|
||||
Remark = batch.Remark
|
||||
});
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToCodeBatchItem(batch);
|
||||
@@ -104,25 +89,20 @@ internal sealed partial class CommerceAdminService
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var code = await dbContext.ActivationCodes
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Code == command.Code.Trim(),
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Activation code was not found.", "activation_code_not_found");
|
||||
if (code.IsUsed)
|
||||
{
|
||||
throw new CommerceException("Activation code has already been used.", "activation_code_used");
|
||||
}
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Code == command.Code.Trim(),
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Activation code was not found.", "activation_code_not_found");
|
||||
if (code.IsUsed) throw new CommerceException("Activation code has already been used.", "activation_code_used");
|
||||
|
||||
var userIsMember = await dbContext.TenantMemberships.AnyAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == command.UserId &&
|
||||
item.Status == MembershipStatus.Active,
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == command.UserId &&
|
||||
item.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!userIsMember)
|
||||
{
|
||||
throw new CommerceException("Target user is not a tenant member.", "tenant_member_not_found");
|
||||
}
|
||||
|
||||
code.IsUsed = true;
|
||||
code.UsedBy = command.UserId;
|
||||
@@ -144,6 +124,4 @@ internal sealed partial class CommerceAdminService
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToActivationCodeItem(code);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -25,9 +17,7 @@ internal sealed partial class CommerceAdminService
|
||||
var vouchers = dbContext.CommerceAdjustmentVouchers.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
vouchers = vouchers.Where(item => item.Status == ParseAdjustmentVoucherStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await vouchers
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
@@ -43,8 +33,9 @@ internal sealed partial class CommerceAdminService
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
return await dbContext.CommerceAdjustmentVouchers.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == voucherId, cancellationToken)
|
||||
?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == voucherId,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
|
||||
}
|
||||
|
||||
public async Task<CommerceAdjustmentVoucher> CreateAdjustmentVoucherAsync(
|
||||
@@ -54,12 +45,18 @@ internal sealed partial class CommerceAdminService
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Reason);
|
||||
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationIssues, actor.TenantId, command.IssueId, "reconciliation_issue_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationBatches, actor.TenantId, command.BatchId, "reconciliation_batch_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationItems, actor.TenantId, command.ItemId, "reconciliation_item_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.Orders, actor.TenantId, command.OrderId, "order_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.Payments, actor.TenantId, command.PaymentId, "payment_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.CommerceRefundRequests, actor.TenantId, command.RefundRequestId, "refund_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationIssues, actor.TenantId, command.IssueId,
|
||||
"reconciliation_issue_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationBatches, actor.TenantId, command.BatchId,
|
||||
"reconciliation_batch_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationItems, actor.TenantId, command.ItemId,
|
||||
"reconciliation_item_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.Orders, actor.TenantId, command.OrderId, "order_not_found",
|
||||
cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.Payments, actor.TenantId, command.PaymentId, "payment_not_found",
|
||||
cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.CommerceRefundRequests, actor.TenantId, command.RefundRequestId,
|
||||
"refund_not_found", cancellationToken);
|
||||
var voucher = new CommerceAdjustmentVoucher
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
@@ -89,7 +86,8 @@ internal sealed partial class CommerceAdminService
|
||||
Note = voucher.Reason,
|
||||
Details = JsonSerializer.SerializeToElement(new { voucher.Direction, voucher.AmountCents })
|
||||
});
|
||||
await AddAuditAsync(actor, "commerce.adjustment_voucher.created", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, voucher.Direction, voucher.AmountCents }, cancellationToken);
|
||||
await AddAuditAsync(actor, "commerce.adjustment_voucher.created", "commerce_adjustment_vouchers", voucher.Id,
|
||||
new { voucher.VoucherNo, voucher.Direction, voucher.AmountCents }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return voucher;
|
||||
}
|
||||
@@ -101,13 +99,13 @@ internal sealed partial class CommerceAdminService
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var voucher = await dbContext.CommerceAdjustmentVouchers.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.VoucherId,
|
||||
cancellationToken) ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.VoucherId,
|
||||
cancellationToken) ??
|
||||
throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
|
||||
var fromStatus = voucher.Status;
|
||||
if (fromStatus != command.Status && !IsAllowedAdjustmentTransition(fromStatus, command.Status))
|
||||
{
|
||||
throw new CommerceException("Adjustment voucher status transition is invalid.", "invalid_adjustment_status_transition");
|
||||
}
|
||||
throw new CommerceException("Adjustment voucher status transition is invalid.",
|
||||
"invalid_adjustment_status_transition");
|
||||
|
||||
voucher.Status = command.Status;
|
||||
if (command.Status is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected)
|
||||
@@ -130,7 +128,8 @@ internal sealed partial class CommerceAdminService
|
||||
Note = command.Note,
|
||||
Details = JsonSerializer.SerializeToElement(new { })
|
||||
});
|
||||
await AddAuditAsync(actor, "commerce.adjustment_voucher.status_changed", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, From = fromStatus, To = command.Status }, cancellationToken);
|
||||
await AddAuditAsync(actor, "commerce.adjustment_voucher.status_changed", "commerce_adjustment_vouchers",
|
||||
voucher.Id, new { voucher.VoucherNo, From = fromStatus, To = command.Status }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return voucher;
|
||||
}
|
||||
@@ -144,10 +143,7 @@ internal sealed partial class CommerceAdminService
|
||||
var exists = await dbContext.CommerceAdjustmentVouchers.AnyAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == voucherId,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
|
||||
}
|
||||
if (!exists) throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
|
||||
|
||||
var events = await dbContext.CommerceAdjustmentVoucherEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.VoucherId == voucherId)
|
||||
@@ -162,15 +158,27 @@ internal sealed partial class CommerceAdminService
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
return new TenantAdjustmentReport(
|
||||
await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft, cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview, cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved, cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed, cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers.CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft,
|
||||
cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers.CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview,
|
||||
cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers.CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved,
|
||||
cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers.CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed,
|
||||
cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.IncreaseRevenue)
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved &&
|
||||
item.Direction == CommerceAdjustmentDirection.IncreaseRevenue)
|
||||
.SumAsync(item => item.AmountCents, cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.DecreaseRevenue)
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved &&
|
||||
item.Direction == CommerceAdjustmentDirection.DecreaseRevenue)
|
||||
.SumAsync(item => item.AmountCents, cancellationToken));
|
||||
}
|
||||
|
||||
@@ -184,9 +192,7 @@ internal sealed partial class CommerceAdminService
|
||||
item => item.TenantId == actor.TenantId && item.Id == batchId,
|
||||
cancellationToken);
|
||||
if (!batchExists)
|
||||
{
|
||||
throw new CommerceException("Reconciliation batch was not found.", "reconciliation_batch_not_found");
|
||||
}
|
||||
|
||||
var items = await dbContext.CommerceReconciliationItems.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.BatchId == batchId)
|
||||
@@ -206,9 +212,7 @@ internal sealed partial class CommerceAdminService
|
||||
item => item.TenantId == actor.TenantId && item.Id == issueId,
|
||||
cancellationToken);
|
||||
if (!issueExists)
|
||||
{
|
||||
throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found");
|
||||
}
|
||||
|
||||
var events = await dbContext.CommerceReconciliationIssueEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.IssueId == issueId)
|
||||
@@ -229,7 +233,8 @@ internal sealed partial class CommerceAdminService
|
||||
item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Processing,
|
||||
cancellationToken);
|
||||
var openIssues = await dbContext.CommerceReconciliationIssues.CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved && item.Status != ReconciliationIssueStatus.Ignored,
|
||||
item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved &&
|
||||
item.Status != ReconciliationIssueStatus.Ignored,
|
||||
cancellationToken);
|
||||
var failedBatches = await dbContext.CommerceReconciliationBatches.CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Status == ReconciliationBatchStatus.Failed,
|
||||
@@ -299,10 +304,10 @@ internal sealed partial class CommerceAdminService
|
||||
foreach (var row in EnumerateImportRows(command.Rows))
|
||||
{
|
||||
rowNo++;
|
||||
var item = CreateReconciliationItem(actor.TenantId, batch.Id, rowNo, NormalizeProvider(command.Provider), row);
|
||||
var item = CreateReconciliationItem(actor.TenantId, batch.Id, rowNo, NormalizeProvider(command.Provider),
|
||||
row);
|
||||
dbContext.CommerceReconciliationItems.Add(item);
|
||||
if (item.MatchStatus != ReconciliationMatchStatus.Matched)
|
||||
{
|
||||
dbContext.CommerceReconciliationIssues.Add(new CommerceReconciliationIssue
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
@@ -330,10 +335,10 @@ internal sealed partial class CommerceAdminService
|
||||
CreatedBy = actor.UserId,
|
||||
Metadata = item.Details
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "commerce.reconciliation.imported", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate, batch.TotalCount }, cancellationToken);
|
||||
await AddAuditAsync(actor, "commerce.reconciliation.imported", "commerce_reconciliation_batches", batch.Id,
|
||||
new { batch.Provider, batch.BillDate, batch.TotalCount }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return batch;
|
||||
}
|
||||
@@ -357,7 +362,8 @@ internal sealed partial class CommerceAdminService
|
||||
command.RunAfter,
|
||||
5),
|
||||
cancellationToken);
|
||||
await AddAuditAsync(actor, "commerce.reconciliation.provider_bill_requested", "background_jobs", job.Id, new { command.Provider, command.BillDate, command.BillType }, cancellationToken);
|
||||
await AddAuditAsync(actor, "commerce.reconciliation.provider_bill_requested", "background_jobs", job.Id,
|
||||
new { command.Provider, command.BillDate, command.BillType }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return job;
|
||||
}
|
||||
@@ -368,7 +374,8 @@ internal sealed partial class CommerceAdminService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
return await backgroundJobOperations.ListAsync(actor.TenantId, "commerce_reconciliation", Math.Clamp(query.Limit ?? 50, 1, 200), cancellationToken);
|
||||
return await backgroundJobOperations.ListAsync(actor.TenantId, "commerce_reconciliation",
|
||||
Math.Clamp(query.Limit ?? 50, 1, 200), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CommerceRefundRequest> ProcessRefundNotificationAsync(
|
||||
@@ -389,10 +396,7 @@ internal sealed partial class CommerceAdminService
|
||||
item.EventType == "refund" &&
|
||||
item.EventId == eventId,
|
||||
cancellationToken);
|
||||
if (duplicate)
|
||||
{
|
||||
return refund;
|
||||
}
|
||||
if (duplicate) return refund;
|
||||
|
||||
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||
{
|
||||
@@ -435,6 +439,4 @@ internal sealed partial class CommerceAdminService
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return refund;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Aop.Api;
|
||||
using Aop.Api.Domain;
|
||||
@@ -20,10 +21,7 @@ internal sealed class AlipayProvider : IPaymentProvider
|
||||
var client = BuildClient(account);
|
||||
var alipayRequest = new AlipayTradeWapPayRequest();
|
||||
alipayRequest.SetNotifyUrl(request.NotifyUrl);
|
||||
if (!string.IsNullOrWhiteSpace(request.ReturnUrl))
|
||||
{
|
||||
alipayRequest.SetReturnUrl(request.ReturnUrl);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ReturnUrl)) alipayRequest.SetReturnUrl(request.ReturnUrl);
|
||||
|
||||
alipayRequest.SetBizModel(new AlipayTradeWapPayModel
|
||||
{
|
||||
@@ -35,9 +33,7 @@ internal sealed class AlipayProvider : IPaymentProvider
|
||||
});
|
||||
var response = client.pageExecute(alipayRequest);
|
||||
if (string.IsNullOrWhiteSpace(response.Body))
|
||||
{
|
||||
throw new PaymentProviderException("Alipay create payment returned an empty body.", "alipay_create_failed");
|
||||
}
|
||||
|
||||
var clientPayload = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
@@ -74,7 +70,8 @@ internal sealed class AlipayProvider : IPaymentProvider
|
||||
false);
|
||||
var eventId = GetValue(values, "notify_id") ?? GetValue(values, "trade_no") ?? Guid.NewGuid().ToString("N");
|
||||
var orderNo = GetValue(values, "out_trade_no")
|
||||
?? throw new PaymentProviderException("Alipay notification order number is missing.", "alipay_notify_order_missing");
|
||||
?? throw new PaymentProviderException("Alipay notification order number is missing.",
|
||||
"alipay_notify_order_missing");
|
||||
var tradeStatus = GetValue(values, "trade_status");
|
||||
var amountCents = YuanToCents(GetValue(values, "total_amount") ?? GetValue(values, "receipt_amount"));
|
||||
DateTimeOffset? paidAt = DateTimeOffset.TryParse(GetValue(values, "gmt_payment"), out var parsedPaidAt)
|
||||
@@ -112,17 +109,11 @@ internal sealed class AlipayProvider : IPaymentProvider
|
||||
private static string Required(JsonElement element, params string[] keys)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (element.TryGetProperty(key, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(property.GetString()))
|
||||
{
|
||||
return property.GetString()!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new PaymentProviderException(
|
||||
"Alipay provider configuration is incomplete.",
|
||||
@@ -132,44 +123,34 @@ internal sealed class AlipayProvider : IPaymentProvider
|
||||
private static Dictionary<string, string> ToDictionary(JsonElement element)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return values;
|
||||
}
|
||||
if (element.ValueKind != JsonValueKind.Object) return values;
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
values[property.Name] = property.Value.ValueKind == JsonValueKind.String
|
||||
? property.Value.GetString() ?? string.Empty
|
||||
: property.Value.GetRawText();
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, params string[] keys)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (element.TryGetProperty(key, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return property.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetValue(IReadOnlyDictionary<string, string> values, string key) =>
|
||||
values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
private static string? GetValue(IReadOnlyDictionary<string, string> values, string key)
|
||||
{
|
||||
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int YuanToCents(string? value)
|
||||
{
|
||||
@@ -178,6 +159,8 @@ internal sealed class AlipayProvider : IPaymentProvider
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static string FormatYuan(int cents) =>
|
||||
(cents / 100m).ToString("0.00", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
private static string FormatYuan(int cents)
|
||||
{
|
||||
return (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -22,5 +14,4 @@ internal sealed partial class CommerceAdminService(
|
||||
IBackgroundJobQueue backgroundJobQueue,
|
||||
IBackgroundJobOperations backgroundJobOperations) : ICommerceAdminService
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
@@ -15,6 +9,4 @@ public sealed partial class CommerceService(
|
||||
IPaymentProviderGateway paymentGateway) : ICommerceService
|
||||
{
|
||||
private sealed record CouponApplication(Coupon Coupon, CouponRedemption Redemption, int DiscountCents);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -73,9 +63,7 @@ internal sealed partial class CommerceAdminService
|
||||
var redemptions = dbContext.CouponRedemptions.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await redemptions
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
@@ -90,16 +78,16 @@ internal sealed partial class CommerceAdminService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var couponCount = await dbContext.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken);
|
||||
var couponCount =
|
||||
await dbContext.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken);
|
||||
var redemptions = dbContext.CouponRedemptions.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
var claimedCount = await redemptions.CountAsync(cancellationToken);
|
||||
var usedCount = await redemptions.CountAsync(item => item.Status == CouponRedemptionStatus.Used, cancellationToken);
|
||||
var usedCount =
|
||||
await redemptions.CountAsync(item => item.Status == CouponRedemptionStatus.Used, cancellationToken);
|
||||
var discountApplied = await redemptions
|
||||
.Where(item => item.Status == CouponRedemptionStatus.Used)
|
||||
.SumAsync(item => item.DiscountAppliedCents, cancellationToken) ?? 0;
|
||||
return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -29,15 +23,10 @@ public sealed partial class CommerceService
|
||||
item.CouponId == coupon.Id)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return ToCouponItem(coupon, existing, null);
|
||||
}
|
||||
if (existing is not null) return ToCouponItem(coupon, existing, null);
|
||||
|
||||
if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
|
||||
{
|
||||
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
|
||||
}
|
||||
|
||||
var redemption = new CouponRedemption
|
||||
{
|
||||
@@ -66,9 +55,7 @@ public sealed partial class CommerceService
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await redemptions
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
@@ -99,18 +86,16 @@ public sealed partial class CommerceService
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
if (command.Quantity is < 1 or > 99)
|
||||
{
|
||||
throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity");
|
||||
}
|
||||
|
||||
var plan = await dbContext.SvipPlans
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.PlanId &&
|
||||
item.IsActive,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found");
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.PlanId &&
|
||||
item.IsActive,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found");
|
||||
var originalAmountCents = checked(plan.PriceCents * command.Quantity);
|
||||
try
|
||||
{
|
||||
@@ -140,6 +125,4 @@ public sealed partial class CommerceService
|
||||
FormatCny(originalAmountCents));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -29,10 +23,7 @@ public sealed partial class CommerceService
|
||||
.OrderByDescending(item => item.ExpiresAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (entitlement is null)
|
||||
{
|
||||
return new CurrentEntitlementItem(false, "svip", null, null, "inactive", null);
|
||||
}
|
||||
if (entitlement is null) return new CurrentEntitlementItem(false, "svip", null, null, "inactive", null);
|
||||
|
||||
return new CurrentEntitlementItem(
|
||||
true,
|
||||
@@ -44,6 +35,4 @@ public sealed partial class CommerceService
|
||||
? Math.Max(0, (int)Math.Ceiling((entitlement.ExpiresAt.Value - now).TotalDays))
|
||||
: null);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using NotificationSeverity = Tiku.Domain.Commerce.NotificationSeverity;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -23,9 +22,7 @@ internal sealed partial class CommerceAdminService
|
||||
access.UserId != actor.UserId ||
|
||||
access.TenantId != actor.TenantId ||
|
||||
!access.HasTenantPermission(BackendPermissions.TenantCommerceOperate))
|
||||
{
|
||||
throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CurrentDataScope> RequireDataScopeAsync(
|
||||
@@ -36,8 +33,9 @@ internal sealed partial class CommerceAdminService
|
||||
return (await currentAccessContext.GetAsync(cancellationToken)).DataScope;
|
||||
}
|
||||
|
||||
private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) =>
|
||||
new(
|
||||
private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item)
|
||||
{
|
||||
return new TenantPaymentProviderItem(
|
||||
item.Id,
|
||||
item.Provider,
|
||||
GetJsonString(item.ConfigPublic, "mode") ?? "TenantCollect",
|
||||
@@ -48,71 +46,118 @@ internal sealed partial class CommerceAdminService
|
||||
item.ConfigPublic,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt);
|
||||
}
|
||||
|
||||
private static TenantSecretItem ToSecretItem(TenantSecret item) =>
|
||||
new(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef, item.Status.ToString(), item.RotatedAt, item.ExpiresAt, item.UpdatedAt);
|
||||
private static TenantSecretItem ToSecretItem(TenantSecret item)
|
||||
{
|
||||
return new TenantSecretItem(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef,
|
||||
item.Status.ToString(),
|
||||
item.RotatedAt, item.ExpiresAt, item.UpdatedAt);
|
||||
}
|
||||
|
||||
private static CodeBatchItem ToCodeBatchItem(CodeBatch item) =>
|
||||
new(item.Id, item.Name, item.TotalCount, item.Days ?? 0, item.RegionId, item.SaleType, item.Channel, item.DefaultUnitPriceCents, item.CostPriceCents, item.IssuedAt, item.Remark, item.CreatedAt);
|
||||
private static CodeBatchItem ToCodeBatchItem(CodeBatch item)
|
||||
{
|
||||
return new CodeBatchItem(item.Id, item.Name, item.TotalCount, item.Days ?? 0, item.RegionId, item.SaleType,
|
||||
item.Channel,
|
||||
item.DefaultUnitPriceCents, item.CostPriceCents, item.IssuedAt, item.Remark, item.CreatedAt);
|
||||
}
|
||||
|
||||
private static ActivationCodeItem ToActivationCodeItem(ActivationCode item) =>
|
||||
new(item.Id, item.BatchId, item.Code, item.Days, item.IsUsed, item.UsedBy, item.UsedAt, item.SaleType, item.SoldTo, item.Remark, item.CreatedAt);
|
||||
private static ActivationCodeItem ToActivationCodeItem(ActivationCode item)
|
||||
{
|
||||
return new ActivationCodeItem(item.Id, item.BatchId, item.Code, item.Days, item.IsUsed, item.UsedBy,
|
||||
item.UsedAt, item.SaleType,
|
||||
item.SoldTo, item.Remark, item.CreatedAt);
|
||||
}
|
||||
|
||||
private static CommerceOrderItem ToOrderItem(Order order) =>
|
||||
new(order.Id, order.OrderNo, order.Status.ToString(), order.PlanId, order.RegionId, order.ProductType, order.ProductName, order.AmountCents, FormatCny(order.AmountCents), order.PayMethod, order.PayProvider, order.TradeNo, order.Days, order.PaidAt, order.CreatedAt, order.RawPayload);
|
||||
private static CommerceOrderItem ToOrderItem(Order order)
|
||||
{
|
||||
return new CommerceOrderItem(order.Id, order.OrderNo, order.Status.ToString(), order.PlanId, order.RegionId,
|
||||
order.ProductType,
|
||||
order.ProductName, order.AmountCents, FormatCny(order.AmountCents), order.PayMethod, order.PayProvider,
|
||||
order.TradeNo, order.Days, order.PaidAt, order.CreatedAt, order.RawPayload);
|
||||
}
|
||||
|
||||
private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo) =>
|
||||
new(payment.Id, payment.OrderId, orderNo, payment.Provider, payment.Method, payment.Status.ToString(), payment.AmountCents, FormatCny(payment.AmountCents), payment.ProviderTradeNo, payment.PaidAt, JsonSerializer.SerializeToElement(new { }), payment.RawPayload);
|
||||
private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo)
|
||||
{
|
||||
return new CommercePaymentItem(payment.Id, payment.OrderId, orderNo, payment.Provider, payment.Method,
|
||||
payment.Status.ToString(),
|
||||
payment.AmountCents, FormatCny(payment.AmountCents), payment.ProviderTradeNo, payment.PaidAt,
|
||||
JsonSerializer.SerializeToElement(new { }), payment.RawPayload);
|
||||
}
|
||||
|
||||
private static OrderStatus ParseOrderStatus(string? status) =>
|
||||
Enum.TryParse<OrderStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
private static OrderStatus ParseOrderStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<OrderStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Order status is invalid.", "invalid_order_status");
|
||||
}
|
||||
|
||||
private static PaymentStatus ParsePaymentStatus(string? status) =>
|
||||
Enum.TryParse<PaymentStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
private static PaymentStatus ParsePaymentStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<PaymentStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Payment status is invalid.", "invalid_payment_status");
|
||||
}
|
||||
|
||||
private static PointActivityTaskStatus ParsePointTaskStatus(string? status) =>
|
||||
Enum.TryParse<PointActivityTaskStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
private static PointActivityTaskStatus ParsePointTaskStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<PointActivityTaskStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Point task status is invalid.", "invalid_point_task_status");
|
||||
}
|
||||
|
||||
private static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status) =>
|
||||
Enum.TryParse<PointExchangeItemStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
private static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<PointExchangeItemStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Point exchange item status is invalid.", "invalid_point_exchange_item_status");
|
||||
: throw new CommerceException("Point exchange item status is invalid.",
|
||||
"invalid_point_exchange_item_status");
|
||||
}
|
||||
|
||||
private static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status) =>
|
||||
Enum.TryParse<PointExchangeOrderStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
private static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<PointExchangeOrderStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Point exchange order status is invalid.", "invalid_point_exchange_order_status");
|
||||
: throw new CommerceException("Point exchange order status is invalid.",
|
||||
"invalid_point_exchange_order_status");
|
||||
}
|
||||
|
||||
private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status) =>
|
||||
Enum.TryParse<CouponRedemptionStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<CouponRedemptionStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status");
|
||||
}
|
||||
|
||||
private static CommerceRefundStatus ParseRefundStatus(string? status) =>
|
||||
Enum.TryParse<CommerceRefundStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
private static CommerceRefundStatus ParseRefundStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<CommerceRefundStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Refund status is invalid.", "invalid_refund_status");
|
||||
}
|
||||
|
||||
private static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status) =>
|
||||
Enum.TryParse<ReconciliationBatchStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
private static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<ReconciliationBatchStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Reconciliation batch status is invalid.", "invalid_reconciliation_batch_status");
|
||||
: throw new CommerceException("Reconciliation batch status is invalid.",
|
||||
"invalid_reconciliation_batch_status");
|
||||
}
|
||||
|
||||
private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status) =>
|
||||
Enum.TryParse<ReconciliationIssueStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<ReconciliationIssueStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status");
|
||||
: throw new CommerceException("Reconciliation issue status is invalid.",
|
||||
"invalid_reconciliation_issue_status");
|
||||
}
|
||||
|
||||
private static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status) =>
|
||||
Enum.TryParse<CommerceAdjustmentVoucherStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
private static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<CommerceAdjustmentVoucherStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Adjustment voucher status is invalid.", "invalid_adjustment_voucher_status");
|
||||
}
|
||||
|
||||
private async Task AssertOptionalReferenceAsync<TEntity>(
|
||||
DbSet<TEntity> set,
|
||||
@@ -122,18 +167,12 @@ internal sealed partial class CommerceAdminService
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : class
|
||||
{
|
||||
if (!id.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!id.HasValue) return;
|
||||
|
||||
var exists = await set.AnyAsync(
|
||||
item => EF.Property<Guid>(item, "TenantId") == tenantId && EF.Property<Guid>(item, "Id") == id.Value,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new CommerceException("Referenced commerce entity was not found.", code);
|
||||
}
|
||||
if (!exists) throw new CommerceException("Referenced commerce entity was not found.", code);
|
||||
}
|
||||
|
||||
private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken)
|
||||
@@ -156,7 +195,8 @@ internal sealed partial class CommerceAdminService
|
||||
cancellationToken);
|
||||
if (payment is not null)
|
||||
{
|
||||
payment.RefundedAmountCents = Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents);
|
||||
payment.RefundedAmountCents =
|
||||
Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents);
|
||||
payment.Status = payment.RefundedAmountCents >= payment.AmountCents
|
||||
? PaymentStatus.Refunded
|
||||
: PaymentStatus.PartiallyRefunded;
|
||||
@@ -168,7 +208,8 @@ internal sealed partial class CommerceAdminService
|
||||
{
|
||||
return from switch
|
||||
{
|
||||
CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected or CommerceRefundStatus.Cancelled,
|
||||
CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected
|
||||
or CommerceRefundStatus.Cancelled,
|
||||
CommerceRefundStatus.Approved => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
|
||||
CommerceRefundStatus.Processing => to is CommerceRefundStatus.Succeeded or CommerceRefundStatus.Failed,
|
||||
CommerceRefundStatus.Failed => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
|
||||
@@ -176,12 +217,15 @@ internal sealed partial class CommerceAdminService
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsAllowedAdjustmentTransition(CommerceAdjustmentVoucherStatus from, CommerceAdjustmentVoucherStatus to)
|
||||
private static bool IsAllowedAdjustmentTransition(CommerceAdjustmentVoucherStatus from,
|
||||
CommerceAdjustmentVoucherStatus to)
|
||||
{
|
||||
return from switch
|
||||
{
|
||||
CommerceAdjustmentVoucherStatus.Draft => to is CommerceAdjustmentVoucherStatus.PendingReview or CommerceAdjustmentVoucherStatus.Void,
|
||||
CommerceAdjustmentVoucherStatus.PendingReview => to is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected or CommerceAdjustmentVoucherStatus.Void,
|
||||
CommerceAdjustmentVoucherStatus.Draft => to is CommerceAdjustmentVoucherStatus.PendingReview
|
||||
or CommerceAdjustmentVoucherStatus.Void,
|
||||
CommerceAdjustmentVoucherStatus.PendingReview => to is CommerceAdjustmentVoucherStatus.Approved
|
||||
or CommerceAdjustmentVoucherStatus.Rejected or CommerceAdjustmentVoucherStatus.Void,
|
||||
CommerceAdjustmentVoucherStatus.Approved => to is CommerceAdjustmentVoucherStatus.Closed,
|
||||
_ => false
|
||||
};
|
||||
@@ -216,7 +260,7 @@ internal sealed partial class CommerceAdminService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
ActorUserId = actor.UserId,
|
||||
@@ -228,12 +272,15 @@ internal sealed partial class CommerceAdminService
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string NormalizeEnum(string? value) =>
|
||||
string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));
|
||||
private static string NormalizeEnum(string? value)
|
||||
{
|
||||
return string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
private static string NormalizeProvider(string? provider)
|
||||
{
|
||||
var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
|
||||
var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant()
|
||||
.Replace("-", "_", StringComparison.Ordinal);
|
||||
return normalized switch
|
||||
{
|
||||
"wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay,
|
||||
@@ -247,12 +294,8 @@ internal sealed partial class CommerceAdminService
|
||||
{
|
||||
var values = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
values[property.Name] = property.Value.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
values["mode"] = JsonSerializer.SerializeToElement(
|
||||
string.IsNullOrWhiteSpace(mode) ? "TenantCollect" : mode.Trim());
|
||||
@@ -261,48 +304,35 @@ internal sealed partial class CommerceAdminService
|
||||
|
||||
private static string? GetJsonString(JsonElement element, params string[] keys)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return value.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement element) =>
|
||||
element.ValueKind == JsonValueKind.Object
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement element)
|
||||
{
|
||||
return element.ValueKind == JsonValueKind.Object
|
||||
? element.Clone()
|
||||
: JsonSerializer.SerializeToElement(new { });
|
||||
}
|
||||
|
||||
private static void AssertNoSecrets(JsonElement element, string path)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (element.ValueKind != JsonValueKind.Object) return;
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
var key = property.Name.ToLowerInvariant();
|
||||
if (key is "secretref" or "secret_ref")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (key is "secretref" or "secret_ref") continue;
|
||||
|
||||
if (key.Contains("secret", StringComparison.Ordinal) ||
|
||||
key.Contains("privatekey", StringComparison.Ordinal) ||
|
||||
key is "appsecret" or "apiv3key" or "api_v3_key" or "accesskeysecret")
|
||||
{
|
||||
throw new CommerceException($"{path} cannot contain secrets.", "public_config_contains_secret");
|
||||
}
|
||||
|
||||
AssertNoSecrets(property.Value, $"{path}.{property.Name}");
|
||||
}
|
||||
@@ -317,7 +347,9 @@ internal sealed partial class CommerceAdminService
|
||||
var invalidCount = parsedRows.Count(row => row.MatchStatus != ReconciliationMatchStatus.Matched);
|
||||
var amountCents = parsedRows.Sum(row => row.AmountCents);
|
||||
var refundAmountCents = parsedRows.Sum(row => row.RefundAmountCents);
|
||||
var sourceHash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"{normalizedProvider}:{rows.GetRawText()}"))).ToLowerInvariant();
|
||||
var sourceHash = Convert
|
||||
.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{normalizedProvider}:{rows.GetRawText()}")))
|
||||
.ToLowerInvariant();
|
||||
return new ReconciliationImportPreview(
|
||||
parsedRows.Length,
|
||||
paymentCount,
|
||||
@@ -328,26 +360,10 @@ internal sealed partial class CommerceAdminService
|
||||
sourceHash);
|
||||
}
|
||||
|
||||
private sealed record ReconciliationImportRow(
|
||||
ReconciliationTransactionType TransactionType,
|
||||
string? ProviderTradeNo,
|
||||
string? ProviderRefundNo,
|
||||
string? OrderNo,
|
||||
string? RefundNo,
|
||||
int AmountCents,
|
||||
int RefundAmountCents,
|
||||
string? ProviderStatus,
|
||||
string? LocalStatus,
|
||||
ReconciliationMatchStatus MatchStatus,
|
||||
string? IssueCode,
|
||||
JsonElement Details);
|
||||
|
||||
private static IEnumerable<ReconciliationImportRow> EnumerateImportRows(JsonElement rows)
|
||||
{
|
||||
if (rows.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new CommerceException("Reconciliation rows must be an array.", "invalid_reconciliation_rows");
|
||||
}
|
||||
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
@@ -401,8 +417,9 @@ internal sealed partial class CommerceAdminService
|
||||
}
|
||||
}
|
||||
|
||||
private static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row) =>
|
||||
new(
|
||||
private static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row)
|
||||
{
|
||||
return new ReconciliationImportRow(
|
||||
ReconciliationTransactionType.Payment,
|
||||
null,
|
||||
null,
|
||||
@@ -415,14 +432,16 @@ internal sealed partial class CommerceAdminService
|
||||
ReconciliationMatchStatus.AmountMismatch,
|
||||
issueCode,
|
||||
row.Clone());
|
||||
}
|
||||
|
||||
private static CommerceReconciliationItem CreateReconciliationItem(
|
||||
Guid tenantId,
|
||||
Guid batchId,
|
||||
int rowNo,
|
||||
string provider,
|
||||
ReconciliationImportRow row) =>
|
||||
new()
|
||||
ReconciliationImportRow row)
|
||||
{
|
||||
return new CommerceReconciliationItem
|
||||
{
|
||||
TenantId = tenantId,
|
||||
BatchId = batchId,
|
||||
@@ -438,29 +457,24 @@ internal sealed partial class CommerceAdminService
|
||||
ProviderStatus = row.ProviderStatus,
|
||||
LocalStatus = row.LocalStatus,
|
||||
MatchStatus = row.MatchStatus,
|
||||
Severity = row.MatchStatus == ReconciliationMatchStatus.Matched ? NotificationSeverity.Info : NotificationSeverity.Warning,
|
||||
Severity = row.MatchStatus == ReconciliationMatchStatus.Matched
|
||||
? NotificationSeverity.Info
|
||||
: NotificationSeverity.Warning,
|
||||
IssueCode = row.IssueCode,
|
||||
Details = row.Details
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetJsonInt(JsonElement element, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!element.TryGetProperty(key, out var value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!element.TryGetProperty(key, out var value)) continue;
|
||||
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number;
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
if (value.ValueKind == JsonValueKind.String &&
|
||||
int.TryParse(value.GetString(), CultureInfo.InvariantCulture, out var parsed)) return parsed;
|
||||
}
|
||||
|
||||
return 0;
|
||||
@@ -473,6 +487,22 @@ internal sealed partial class CommerceAdminService
|
||||
return $"TKU{Convert.ToHexString(bytes)}";
|
||||
}
|
||||
|
||||
private static string FormatCny(int cents) =>
|
||||
(cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
|
||||
}
|
||||
private static string FormatCny(int cents)
|
||||
{
|
||||
return (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private sealed record ReconciliationImportRow(
|
||||
ReconciliationTransactionType TransactionType,
|
||||
string? ProviderTradeNo,
|
||||
string? ProviderRefundNo,
|
||||
string? OrderNo,
|
||||
string? RefundNo,
|
||||
int AmountCents,
|
||||
int RefundAmountCents,
|
||||
string? ProviderStatus,
|
||||
string? LocalStatus,
|
||||
ReconciliationMatchStatus MatchStatus,
|
||||
string? IssueCode,
|
||||
JsonElement Details);
|
||||
}
|
||||
@@ -4,9 +4,7 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -86,10 +84,7 @@ public sealed partial class CommerceService
|
||||
int originalAmountCents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode)) return null;
|
||||
|
||||
var coupon = command.CouponRedemptionId.HasValue
|
||||
? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken)
|
||||
@@ -106,9 +101,7 @@ public sealed partial class CommerceService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode))
|
||||
{
|
||||
throw new CommerceException("Coupon code or redemption id is required.", "coupon_required");
|
||||
}
|
||||
|
||||
var coupon = command.CouponRedemptionId.HasValue
|
||||
? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken)
|
||||
@@ -131,15 +124,10 @@ public sealed partial class CommerceService
|
||||
item.CouponId == coupon.Id)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return new CouponApplication(coupon, existing, 0);
|
||||
}
|
||||
if (existing is not null) return new CouponApplication(coupon, existing, 0);
|
||||
|
||||
if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
|
||||
{
|
||||
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
|
||||
}
|
||||
|
||||
var redemption = new CouponRedemption
|
||||
{
|
||||
@@ -171,10 +159,7 @@ public sealed partial class CommerceService
|
||||
item.CouponId == coupon.Id)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (redemption is not null)
|
||||
{
|
||||
return new CouponApplication(coupon, redemption, 0);
|
||||
}
|
||||
if (redemption is not null) return new CouponApplication(coupon, redemption, 0);
|
||||
|
||||
ValidateCouponClaimable(coupon, null);
|
||||
return new CouponApplication(
|
||||
@@ -198,20 +183,21 @@ public sealed partial class CommerceService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var redemption = await dbContext.CouponRedemptions
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.Id == couponRedemptionId,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Coupon redemption was not found.", "coupon_redemption_not_found");
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.Id == couponRedemptionId,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Coupon redemption was not found.",
|
||||
"coupon_redemption_not_found");
|
||||
if (redemption.CouponId is null)
|
||||
{
|
||||
throw new CommerceException("Coupon redemption is not linked to a coupon.", "coupon_redemption_invalid");
|
||||
}
|
||||
|
||||
var coupon = await dbContext.Coupons
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == redemption.CouponId.Value, cancellationToken)
|
||||
?? throw new CommerceException("Coupon was not found.", "coupon_not_found");
|
||||
.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == redemption.CouponId.Value,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Coupon was not found.", "coupon_not_found");
|
||||
return new CouponApplication(coupon, redemption, 0);
|
||||
}
|
||||
|
||||
@@ -222,23 +208,19 @@ public sealed partial class CommerceService
|
||||
{
|
||||
var code = NormalizeRequired(couponCode, "coupon_code_required");
|
||||
return await dbContext.Coupons
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Code == code, cancellationToken)
|
||||
?? throw new CommerceException("Coupon was not found.", "coupon_not_found");
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Code == code, cancellationToken)
|
||||
?? throw new CommerceException("Coupon was not found.", "coupon_not_found");
|
||||
}
|
||||
|
||||
private static void ValidateCouponClaimable(Coupon coupon, CouponRedemption? redemption)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (coupon.ValidFrom is not null && coupon.ValidFrom > now ||
|
||||
coupon.ValidTo is not null && coupon.ValidTo <= now)
|
||||
{
|
||||
if ((coupon.ValidFrom is not null && coupon.ValidFrom > now) ||
|
||||
(coupon.ValidTo is not null && coupon.ValidTo <= now))
|
||||
throw new CommerceException("Coupon is expired or not started.", "coupon_inactive");
|
||||
}
|
||||
|
||||
if (redemption is null && coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
|
||||
{
|
||||
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateCouponUsable(
|
||||
@@ -249,22 +231,16 @@ public sealed partial class CommerceService
|
||||
{
|
||||
ValidateCouponClaimable(coupon, redemption);
|
||||
if (redemption.Status != CouponRedemptionStatus.Claimed)
|
||||
{
|
||||
throw new CommerceException("Coupon redemption is not claimable.", "coupon_redemption_status_invalid");
|
||||
}
|
||||
|
||||
if (coupon.PlanId.HasValue && coupon.PlanId != plan.Id ||
|
||||
redemption.PlanId.HasValue && redemption.PlanId != plan.Id)
|
||||
{
|
||||
if ((coupon.PlanId.HasValue && coupon.PlanId != plan.Id) ||
|
||||
(redemption.PlanId.HasValue && redemption.PlanId != plan.Id))
|
||||
throw new CommerceException("Coupon is not applicable to this plan.", "coupon_plan_not_applicable");
|
||||
}
|
||||
|
||||
if (redemption.RegionId.HasValue &&
|
||||
regionId.HasValue &&
|
||||
redemption.RegionId != regionId)
|
||||
{
|
||||
throw new CommerceException("Coupon is not applicable to this region.", "coupon_region_not_applicable");
|
||||
}
|
||||
}
|
||||
|
||||
private static int CalculateDiscountCents(Coupon coupon, int originalAmountCents)
|
||||
@@ -282,10 +258,7 @@ public sealed partial class CommerceService
|
||||
|
||||
private static decimal PercentFactor(decimal value)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (value <= 0) return 0;
|
||||
|
||||
return value <= 1 ? value : value / 100;
|
||||
}
|
||||
@@ -293,14 +266,11 @@ public sealed partial class CommerceService
|
||||
private async Task AssertActiveMemberAsync(CommerceActor actor, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.TenantMemberships.AnyAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.Status == MembershipStatus.Active,
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new CommerceException("Current user is not a member of the tenant.", "tenant_access_denied");
|
||||
}
|
||||
if (!exists) throw new CommerceException("Current user is not a member of the tenant.", "tenant_access_denied");
|
||||
}
|
||||
|
||||
private async Task<Order> FindActorOrderAsync(
|
||||
@@ -310,12 +280,12 @@ public sealed partial class CommerceService
|
||||
{
|
||||
var trimmed = orderNo.Trim();
|
||||
return await dbContext.Orders
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.OrderNo == trimmed,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Order was not found.", "order_not_found");
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.OrderNo == trimmed,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Order was not found.", "order_not_found");
|
||||
}
|
||||
|
||||
private static CommerceOrderItem ToOrderItem(Order order)
|
||||
@@ -392,10 +362,12 @@ public sealed partial class CommerceService
|
||||
: throw new CommerceException("Coupon status is invalid.", "invalid_coupon_status");
|
||||
}
|
||||
|
||||
private static string NormalizeEnum(string? value) =>
|
||||
string.Concat((value ?? string.Empty).Split(
|
||||
private static string NormalizeEnum(string? value)
|
||||
{
|
||||
return string.Concat((value ?? string.Empty).Split(
|
||||
['_', '-', ' '],
|
||||
StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string code)
|
||||
{
|
||||
@@ -428,10 +400,12 @@ public sealed partial class CommerceService
|
||||
return string.IsNullOrWhiteSpace(normalized) ? "manual" : normalized;
|
||||
}
|
||||
|
||||
private static bool IsPaid(string status) =>
|
||||
string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(status, "succeeded", StringComparison.OrdinalIgnoreCase);
|
||||
private static bool IsPaid(string status)
|
||||
{
|
||||
return string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(status, "succeeded", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GenerateOrderNo()
|
||||
{
|
||||
@@ -440,6 +414,8 @@ public sealed partial class CommerceService
|
||||
return $"TK{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Convert.ToHexString(bytes)}";
|
||||
}
|
||||
|
||||
private static string FormatCny(int cents) =>
|
||||
(cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
|
||||
}
|
||||
private static string FormatCny(int cents)
|
||||
{
|
||||
return (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -58,4 +58,4 @@ internal sealed class ManualPaymentProvider : IPaymentProvider
|
||||
$"manual-{request.RefundNo}",
|
||||
payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
@@ -31,9 +21,7 @@ internal sealed partial class CommerceAdminService
|
||||
item => item.UserId == actor.UserId,
|
||||
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await orders
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
@@ -57,10 +45,10 @@ internal sealed partial class CommerceAdminService
|
||||
order => order.UserId == actor.UserId,
|
||||
order => order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value));
|
||||
var payments = from payment in dbContext.Payments.AsNoTracking()
|
||||
join order in scopedOrders
|
||||
on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id }
|
||||
where payment.TenantId == actor.TenantId
|
||||
select new { payment, order.OrderNo };
|
||||
join order in scopedOrders
|
||||
on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id }
|
||||
where payment.TenantId == actor.TenantId
|
||||
select new { payment, order.OrderNo };
|
||||
if (!string.IsNullOrWhiteSpace(query.Provider))
|
||||
{
|
||||
var provider = NormalizeProvider(query.Provider);
|
||||
@@ -68,9 +56,7 @@ internal sealed partial class CommerceAdminService
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
payments = payments.Where(item => item.payment.Status == ParsePaymentStatus(query.Status));
|
||||
}
|
||||
|
||||
var rows = await payments
|
||||
.OrderByDescending(item => item.payment.CreatedAt)
|
||||
@@ -78,6 +64,4 @@ internal sealed partial class CommerceAdminService
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new AdminPaymentList(rows.Select(item => ToPaymentItem(item.payment, item.OrderNo)).ToArray());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -18,35 +13,29 @@ public sealed partial class CommerceService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (command.Quantity is < 1 or > 99)
|
||||
{
|
||||
throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity");
|
||||
}
|
||||
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var plan = await dbContext.SvipPlans
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.PlanId &&
|
||||
item.IsActive,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found");
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.PlanId &&
|
||||
item.IsActive,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found");
|
||||
|
||||
if (plan.CouponOnly &&
|
||||
string.IsNullOrWhiteSpace(command.CouponCode) &&
|
||||
!command.CouponRedemptionId.HasValue)
|
||||
{
|
||||
throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required");
|
||||
}
|
||||
|
||||
if (command.RegionId.HasValue)
|
||||
{
|
||||
var regionExists = await dbContext.Regions
|
||||
.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken);
|
||||
if (!regionExists)
|
||||
{
|
||||
throw new CommerceException("Region was not found.", "region_not_found");
|
||||
}
|
||||
.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value,
|
||||
cancellationToken);
|
||||
if (!regionExists) throw new CommerceException("Region was not found.", "region_not_found");
|
||||
}
|
||||
|
||||
await using var transaction = dbContext.Database.IsRelational()
|
||||
@@ -60,9 +49,7 @@ public sealed partial class CommerceService
|
||||
originalAmountCents,
|
||||
cancellationToken);
|
||||
if (plan.CouponOnly && coupon is null)
|
||||
{
|
||||
throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required");
|
||||
}
|
||||
|
||||
var amountCents = Math.Max(0, originalAmountCents - (coupon?.DiscountCents ?? 0));
|
||||
var order = new Order
|
||||
@@ -147,10 +134,7 @@ public sealed partial class CommerceService
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
return ToOrderItem(order);
|
||||
}
|
||||
@@ -164,9 +148,7 @@ public sealed partial class CommerceService
|
||||
var orders = dbContext.Orders.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await orders
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
@@ -185,6 +167,4 @@ public sealed partial class CommerceService
|
||||
var order = await FindActorOrderAsync(actor, orderNo, cancellationToken);
|
||||
return ToOrderItem(order);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -32,9 +27,7 @@ public sealed partial class CommerceService
|
||||
cancellationToken);
|
||||
|
||||
if (!notification.SignatureValid)
|
||||
{
|
||||
throw new CommerceException("Payment notification signature is invalid.", "payment_signature_invalid");
|
||||
}
|
||||
|
||||
var alreadyProcessed = await dbContext.PaymentEvents.AnyAsync(
|
||||
item =>
|
||||
@@ -43,26 +36,21 @@ public sealed partial class CommerceService
|
||||
item.ProcessedAt != null,
|
||||
cancellationToken);
|
||||
if (alreadyProcessed)
|
||||
{
|
||||
return new PaymentNotificationProcessResult(
|
||||
normalizedProvider,
|
||||
notification.EventId,
|
||||
notification.OrderNo,
|
||||
"processed",
|
||||
true);
|
||||
}
|
||||
|
||||
var order = await dbContext.Orders
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.OrderNo == notification.OrderNo,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Order was not found.", "order_not_found");
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.OrderNo == notification.OrderNo,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Order was not found.", "order_not_found");
|
||||
|
||||
if (order.UserId is null)
|
||||
{
|
||||
throw new CommerceException("Order does not belong to a user.", "order_user_missing");
|
||||
}
|
||||
if (order.UserId is null) throw new CommerceException("Order does not belong to a user.", "order_user_missing");
|
||||
|
||||
if (order.AmountCents != notification.AmountCents)
|
||||
{
|
||||
@@ -102,7 +90,6 @@ public sealed partial class CommerceService
|
||||
}
|
||||
|
||||
if (notification.Paid && order.Status == OrderStatus.Pending)
|
||||
{
|
||||
await MarkPaidAsync(
|
||||
new CommerceActor(tenantId, order.UserId.Value),
|
||||
order,
|
||||
@@ -114,9 +101,7 @@ public sealed partial class CommerceService
|
||||
notification.SignatureValid,
|
||||
notification.PaidAt,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
@@ -128,7 +113,6 @@ public sealed partial class CommerceService
|
||||
Payload = notification.RawPayload,
|
||||
ProcessedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new PaymentNotificationProcessResult(
|
||||
@@ -138,6 +122,4 @@ public sealed partial class CommerceService
|
||||
"processed",
|
||||
false);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -65,7 +56,8 @@ internal sealed partial class CommerceAdminService
|
||||
: command.SecretRef.Trim();
|
||||
var provider = NormalizeProvider(command.Provider);
|
||||
var secret = await dbContext.TenantSecrets
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.SecretRef == secretRef, cancellationToken);
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.SecretRef == secretRef,
|
||||
cancellationToken);
|
||||
if (secret is null)
|
||||
{
|
||||
secret = new TenantSecret
|
||||
@@ -98,6 +90,4 @@ internal sealed partial class CommerceAdminService
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToSecretItem(secret);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -53,20 +50,13 @@ internal sealed class PaymentProviderConfigService(
|
||||
|
||||
private static string? GetString(JsonElement element, params string[] keys)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (element.TryGetProperty(key, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return property.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,4 +42,4 @@ internal sealed class PaymentProviderGateway(
|
||||
"Payment provider is not supported.",
|
||||
"payment_provider_not_supported");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -20,9 +15,7 @@ public sealed partial class CommerceService
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var order = await FindActorOrderAsync(actor, command.OrderNo, cancellationToken);
|
||||
if (order.Status != OrderStatus.Pending)
|
||||
{
|
||||
throw new CommerceException("Only pending orders can create payments.", "order_status_invalid");
|
||||
}
|
||||
|
||||
var provider = NormalizeProvider(command.Provider);
|
||||
var method = NormalizeMethod(command.Method);
|
||||
@@ -66,13 +59,9 @@ public sealed partial class CommerceService
|
||||
|
||||
payment.Method = result.Method;
|
||||
payment.RawPayload = result.RawPayload;
|
||||
if (!string.IsNullOrWhiteSpace(result.ProviderTradeNo))
|
||||
{
|
||||
payment.ProviderTradeNo = result.ProviderTradeNo;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(result.ProviderTradeNo)) payment.ProviderTradeNo = result.ProviderTradeNo;
|
||||
|
||||
if (IsPaid(result.Status))
|
||||
{
|
||||
await MarkPaidAsync(
|
||||
actor,
|
||||
order,
|
||||
@@ -84,9 +73,7 @@ public sealed partial class CommerceService
|
||||
true,
|
||||
null,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
@@ -95,11 +82,8 @@ public sealed partial class CommerceService
|
||||
EventType = "payment_created",
|
||||
Payload = result.RawPayload
|
||||
});
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToPaymentItem(payment, order.OrderNo, result.ClientPayload);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -25,9 +15,7 @@ internal sealed partial class CommerceAdminService
|
||||
var tasks = dbContext.PointActivityTasks.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
tasks = tasks.Where(item => item.Status == ParsePointTaskStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await tasks
|
||||
.OrderBy(item => item.SortOrder)
|
||||
@@ -44,9 +32,7 @@ internal sealed partial class CommerceAdminService
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
if (command.Points <= 0 || command.MaxClaimsPerUser <= 0)
|
||||
{
|
||||
throw new CommerceException("Point task points and claim limit must be positive.", "invalid_point_task");
|
||||
}
|
||||
|
||||
var task = command.Id.HasValue
|
||||
? await dbContext.PointActivityTasks.SingleOrDefaultAsync(
|
||||
@@ -86,10 +72,7 @@ internal sealed partial class CommerceAdminService
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var claims = dbContext.PointActivityClaims.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (query.UserId.HasValue)
|
||||
{
|
||||
claims = claims.Where(item => item.UserId == query.UserId.Value);
|
||||
}
|
||||
if (query.UserId.HasValue) claims = claims.Where(item => item.UserId == query.UserId.Value);
|
||||
|
||||
var items = await claims
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
@@ -107,14 +90,10 @@ internal sealed partial class CommerceAdminService
|
||||
var items = dbContext.PointExchangeItems.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
items = items.Where(item => item.Status == ParsePointExchangeItemStatus(query.Status));
|
||||
}
|
||||
|
||||
if (query.RegionId.HasValue)
|
||||
{
|
||||
items = items.Where(item => item.RegionId == null || item.RegionId == query.RegionId.Value);
|
||||
}
|
||||
|
||||
var result = await items
|
||||
.OrderBy(item => item.SortOrder)
|
||||
@@ -131,9 +110,7 @@ internal sealed partial class CommerceAdminService
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
if (command.PointsCost <= 0)
|
||||
{
|
||||
throw new CommerceException("Point exchange item cost must be positive.", "invalid_point_exchange_item");
|
||||
}
|
||||
|
||||
var item = command.Id.HasValue
|
||||
? await dbContext.PointExchangeItems.SingleOrDefaultAsync(
|
||||
@@ -175,15 +152,10 @@ internal sealed partial class CommerceAdminService
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var orders = dbContext.PointExchangeOrders.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (query.UserId.HasValue)
|
||||
{
|
||||
orders = orders.Where(item => item.UserId == query.UserId.Value);
|
||||
}
|
||||
if (query.UserId.HasValue) orders = orders.Where(item => item.UserId == query.UserId.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
orders = orders.Where(item => item.Status == ParsePointExchangeOrderStatus(query.Status));
|
||||
}
|
||||
|
||||
var result = await orders
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
@@ -199,8 +171,10 @@ internal sealed partial class CommerceAdminService
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var order = await dbContext.PointExchangeOrders
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.OrderId, cancellationToken)
|
||||
?? throw new CommerceException("Point exchange order was not found.", "point_exchange_order_not_found");
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.OrderId,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Point exchange order was not found.",
|
||||
"point_exchange_order_not_found");
|
||||
order.Status = command.Status;
|
||||
if (command.Status == PointExchangeOrderStatus.Completed)
|
||||
{
|
||||
@@ -215,6 +189,4 @@ internal sealed partial class CommerceAdminService
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return order;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -31,9 +22,7 @@ internal sealed partial class CommerceAdminService
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
batches = batches.Where(item => item.Status == ParseReconciliationBatchStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await batches.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
@@ -61,7 +50,8 @@ internal sealed partial class CommerceAdminService
|
||||
Metadata = JsonObjectOrDefault(command.Metadata)
|
||||
};
|
||||
dbContext.CommerceReconciliationBatches.Add(batch);
|
||||
await AddAuditAsync(actor, "commerce.reconciliation_batch.created", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate }, cancellationToken);
|
||||
await AddAuditAsync(actor, "commerce.reconciliation_batch.created", "commerce_reconciliation_batches", batch.Id,
|
||||
new { batch.Provider, batch.BillDate }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return batch;
|
||||
}
|
||||
@@ -81,9 +71,7 @@ internal sealed partial class CommerceAdminService
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
issues = issues.Where(item => item.Status == ParseReconciliationIssueStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await issues.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
@@ -98,8 +86,10 @@ internal sealed partial class CommerceAdminService
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var issue = await dbContext.CommerceReconciliationIssues.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.IssueId,
|
||||
cancellationToken) ?? throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found");
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.IssueId,
|
||||
cancellationToken) ??
|
||||
throw new CommerceException("Reconciliation issue was not found.",
|
||||
"reconciliation_issue_not_found");
|
||||
var fromStatus = issue.Status;
|
||||
issue.Status = command.Status;
|
||||
issue.ResolutionType = command.ResolutionType;
|
||||
@@ -122,10 +112,9 @@ internal sealed partial class CommerceAdminService
|
||||
Note = command.Note,
|
||||
Details = JsonSerializer.SerializeToElement(new { command.ResolutionType, command.AssignedTo })
|
||||
});
|
||||
await AddAuditAsync(actor, "commerce.reconciliation_issue.status_changed", "commerce_reconciliation_issues", issue.Id, new { issue.IssueNo, From = fromStatus, To = command.Status }, cancellationToken);
|
||||
await AddAuditAsync(actor, "commerce.reconciliation_issue.status_changed", "commerce_reconciliation_issues",
|
||||
issue.Id, new { issue.IssueNo, From = fromStatus, To = command.Status }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return issue;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
@@ -36,9 +28,7 @@ internal sealed partial class CommerceAdminService
|
||||
order.RegionId.HasValue &&
|
||||
regionIds.Contains(order.RegionId.Value)));
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await refunds
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
@@ -56,32 +46,26 @@ internal sealed partial class CommerceAdminService
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var order = await dbContext.Orders
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.UserId == actor.UserId,
|
||||
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
?? throw new CommerceException("Order was not found.", "order_not_found");
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.UserId == actor.UserId,
|
||||
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
?? throw new CommerceException("Order was not found.", "order_not_found");
|
||||
if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded))
|
||||
{
|
||||
throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable");
|
||||
}
|
||||
|
||||
if (command.AmountCents <= 0 || command.AmountCents > order.AmountCents - order.RefundedAmountCents)
|
||||
{
|
||||
throw new CommerceException("Refund amount is invalid.", "invalid_refund_amount");
|
||||
}
|
||||
|
||||
if (command.PaymentId.HasValue)
|
||||
{
|
||||
var paymentExists = await dbContext.Payments.AnyAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value && item.OrderId == order.Id,
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value &&
|
||||
item.OrderId == order.Id,
|
||||
cancellationToken);
|
||||
if (!paymentExists)
|
||||
{
|
||||
throw new CommerceException("Payment was not found.", "payment_not_found");
|
||||
}
|
||||
if (!paymentExists) throw new CommerceException("Payment was not found.", "payment_not_found");
|
||||
}
|
||||
|
||||
var refund = new CommerceRefundRequest
|
||||
@@ -99,8 +83,10 @@ internal sealed partial class CommerceAdminService
|
||||
Metadata = JsonObjectOrDefault(command.Metadata)
|
||||
};
|
||||
dbContext.CommerceRefundRequests.Add(refund);
|
||||
AddRefundEvent(refund, null, CommerceRefundStatus.Requested, "created", actor.UserId, new { refund.AmountCents, refund.Reason });
|
||||
await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id, new { refund.RefundNo, refund.AmountCents }, cancellationToken);
|
||||
AddRefundEvent(refund, null, CommerceRefundStatus.Requested, "created", actor.UserId,
|
||||
new { refund.AmountCents, refund.Reason });
|
||||
await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id,
|
||||
new { refund.RefundNo, refund.AmountCents }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return refund;
|
||||
}
|
||||
@@ -114,23 +100,22 @@ internal sealed partial class CommerceAdminService
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var refund = await dbContext.CommerceRefundRequests
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
|
||||
order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
|
||||
item => dbContext.Orders.Any(order =>
|
||||
order.TenantId == actor.TenantId &&
|
||||
order.Id == item.OrderId &&
|
||||
order.RegionId.HasValue &&
|
||||
regionIds.Contains(order.RegionId.Value)))
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
?? throw new CommerceException("Refund request was not found.", "refund_not_found");
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
|
||||
order.TenantId == actor.TenantId && order.Id == item.OrderId &&
|
||||
order.UserId == actor.UserId),
|
||||
item => dbContext.Orders.Any(order =>
|
||||
order.TenantId == actor.TenantId &&
|
||||
order.Id == item.OrderId &&
|
||||
order.RegionId.HasValue &&
|
||||
regionIds.Contains(order.RegionId.Value)))
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
?? throw new CommerceException("Refund request was not found.", "refund_not_found");
|
||||
var fromStatus = refund.Status;
|
||||
if (!IsAllowedRefundTransition(fromStatus, command.Status))
|
||||
{
|
||||
throw new CommerceException("Refund status transition is invalid.", "invalid_refund_transition");
|
||||
}
|
||||
|
||||
refund.Status = command.Status;
|
||||
refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo)
|
||||
@@ -159,8 +144,10 @@ internal sealed partial class CommerceAdminService
|
||||
break;
|
||||
}
|
||||
|
||||
AddRefundEvent(refund, fromStatus, command.Status, "status_changed", actor.UserId, new { command.Reason, command.ProviderRefundNo });
|
||||
await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id, new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken);
|
||||
AddRefundEvent(refund, fromStatus, command.Status, "status_changed", actor.UserId,
|
||||
new { command.Reason, command.ProviderRefundNo });
|
||||
await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id,
|
||||
new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return refund;
|
||||
}
|
||||
@@ -185,10 +172,7 @@ internal sealed partial class CommerceAdminService
|
||||
order.RegionId.HasValue &&
|
||||
regionIds.Contains(order.RegionId.Value)))
|
||||
.AnyAsync(cancellationToken);
|
||||
if (!refundExists)
|
||||
{
|
||||
throw new CommerceException("Refund request was not found.", "refund_not_found");
|
||||
}
|
||||
if (!refundExists) throw new CommerceException("Refund request was not found.", "refund_not_found");
|
||||
|
||||
var items = await dbContext.CommerceRefundEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId)
|
||||
@@ -196,6 +180,4 @@ internal sealed partial class CommerceAdminService
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantRefundEventList(items);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
@@ -15,10 +16,7 @@ public sealed class TenantSecretEncryptionOptions
|
||||
|
||||
public static bool BeValid(TenantSecretEncryptionOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.KeyId) || string.IsNullOrWhiteSpace(options.MasterKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(options.KeyId) || string.IsNullOrWhiteSpace(options.MasterKey)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -30,8 +28,10 @@ public sealed class TenantSecretEncryptionOptions
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsDevelopmentDefault(TenantSecretEncryptionOptions options) =>
|
||||
string.Equals(options.MasterKey, DevelopmentMasterKey, StringComparison.Ordinal);
|
||||
public static bool IsDevelopmentDefault(TenantSecretEncryptionOptions options)
|
||||
{
|
||||
return string.Equals(options.MasterKey, DevelopmentMasterKey, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
public interface ITenantSecretProtector
|
||||
@@ -54,7 +54,7 @@ public sealed record ProtectedTenantSecret(
|
||||
byte[] Tag);
|
||||
|
||||
public sealed class TenantSecretProtector(
|
||||
Microsoft.Extensions.Options.IOptions<TenantSecretEncryptionOptions> options) : ITenantSecretProtector
|
||||
IOptions<TenantSecretEncryptionOptions> options) : ITenantSecretProtector
|
||||
{
|
||||
private readonly TenantSecretEncryptionOptions options = options.Value;
|
||||
|
||||
@@ -89,10 +89,8 @@ public sealed class TenantSecretProtector(
|
||||
byte[] tag)
|
||||
{
|
||||
if (!string.Equals(keyId, options.KeyId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Tenant secret uses unknown encryption key '{keyId}'.");
|
||||
}
|
||||
|
||||
var key = Convert.FromBase64String(options.MasterKey);
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
@@ -111,6 +109,8 @@ public sealed class TenantSecretProtector(
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] GetAssociatedData(Guid tenantId, string secretRef, string keyId) =>
|
||||
Encoding.UTF8.GetBytes($"{tenantId:N}\n{secretRef}\n{keyId}");
|
||||
}
|
||||
private static byte[] GetAssociatedData(Guid tenantId, string secretRef, string keyId)
|
||||
{
|
||||
return Encoding.UTF8.GetBytes($"{tenantId:N}\n{secretRef}\n{keyId}");
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,9 @@ internal sealed class TenantSecretService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(secretRef))
|
||||
{
|
||||
throw new PaymentProviderException(
|
||||
"Payment provider secret is not configured.",
|
||||
"payment_secret_not_configured");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var secret = await dbContext.TenantSecrets
|
||||
@@ -33,14 +31,11 @@ internal sealed class TenantSecretService(
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (secret is null)
|
||||
{
|
||||
throw new PaymentProviderException(
|
||||
"Payment provider secret is not configured.",
|
||||
"payment_secret_not_configured");
|
||||
}
|
||||
|
||||
if (secret.EncryptedPayload.Length > 0)
|
||||
{
|
||||
return tenantSecretProtector.Unprotect(
|
||||
tenantId,
|
||||
secretRef,
|
||||
@@ -48,10 +43,9 @@ internal sealed class TenantSecretService(
|
||||
secret.EncryptedPayload,
|
||||
secret.EncryptionNonce,
|
||||
secret.EncryptionTag);
|
||||
}
|
||||
|
||||
throw new PaymentProviderException(
|
||||
"Payment provider secret is not configured.",
|
||||
"payment_secret_not_configured");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Senparc.Weixin;
|
||||
using Senparc.Weixin.Entities;
|
||||
using Senparc.Weixin.TenPayV3;
|
||||
using Senparc.Weixin.TenPayV3.Apis;
|
||||
using Senparc.Weixin.TenPayV3.Apis.BasePay;
|
||||
using Senparc.Weixin.TenPayV3.Helpers;
|
||||
@@ -30,9 +29,11 @@ internal sealed class WechatPayProvider : IPaymentProvider
|
||||
_ = BuildTenPaySettings(account);
|
||||
var signatureValid = HasWechatPaySignatureHeaders(request.Headers);
|
||||
var payload = request.Body;
|
||||
var eventId = GetString(request.Body, "id") ?? GetString(payload, "transaction_id") ?? Guid.NewGuid().ToString("N");
|
||||
var eventId = GetString(request.Body, "id") ??
|
||||
GetString(payload, "transaction_id") ?? Guid.NewGuid().ToString("N");
|
||||
var orderNo = GetString(payload, "out_trade_no", "outTradeNo")
|
||||
?? throw new PaymentProviderException("WeChat Pay notification order number is missing.", "wechat_pay_notify_order_missing");
|
||||
?? throw new PaymentProviderException("WeChat Pay notification order number is missing.",
|
||||
"wechat_pay_notify_order_missing");
|
||||
var tradeNo = GetString(payload, "transaction_id", "transactionId");
|
||||
var tradeState = GetString(payload, "trade_state", "tradeState") ?? GetString(request.Body, "event_type");
|
||||
var amount = GetInt(payload, "amount", "total") ?? GetInt(payload, "amountCents") ?? 0;
|
||||
@@ -80,9 +81,7 @@ internal sealed class WechatPayProvider : IPaymentProvider
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.OpenId))
|
||||
{
|
||||
throw new PaymentProviderException("WeChat Pay JSAPI requires openId.", "wechat_pay_openid_required");
|
||||
}
|
||||
|
||||
var tenPaySettings = BuildTenPaySettings(account);
|
||||
var appId = Required(account.ConfigPublic, "appId");
|
||||
@@ -98,11 +97,7 @@ internal sealed class WechatPayProvider : IPaymentProvider
|
||||
request.NotifyUrl,
|
||||
null,
|
||||
new TransactionsRequestData.Amount(request.AmountCents, "CNY"),
|
||||
new TransactionsRequestData.Payer(request.OpenId),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false);
|
||||
new TransactionsRequestData.Payer(request.OpenId));
|
||||
object response = string.Equals(request.Method, "h5", StringComparison.OrdinalIgnoreCase)
|
||||
? await payApis.H5Async(wxRequest).WaitAsync(cancellationToken)
|
||||
: await payApis.JsApiAsync(wxRequest).WaitAsync(cancellationToken);
|
||||
@@ -110,11 +105,9 @@ internal sealed class WechatPayProvider : IPaymentProvider
|
||||
var prepayId = GetPropertyValue<string?>(response, "prepay_id", "PrepayId");
|
||||
var h5Url = GetPropertyValue<string?>(response, "h5_url", "H5Url");
|
||||
if (string.IsNullOrWhiteSpace(prepayId) && string.IsNullOrWhiteSpace(h5Url))
|
||||
{
|
||||
throw new PaymentProviderException(
|
||||
"WeChat Pay create transaction failed.",
|
||||
"wechat_pay_create_failed");
|
||||
}
|
||||
|
||||
var clientPayload = string.IsNullOrWhiteSpace(prepayId)
|
||||
? JsonSerializer.SerializeToElement(new { h5Url })
|
||||
@@ -139,17 +132,11 @@ internal sealed class WechatPayProvider : IPaymentProvider
|
||||
private static string Required(JsonElement element, params string[] keys)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (element.TryGetProperty(key, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(property.GetString()))
|
||||
{
|
||||
return property.GetString()!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new PaymentProviderException(
|
||||
"WeChat Pay provider configuration is incomplete.",
|
||||
@@ -159,17 +146,11 @@ internal sealed class WechatPayProvider : IPaymentProvider
|
||||
private static string? Optional(JsonElement element, params string[] keys)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (element.TryGetProperty(key, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(property.GetString()))
|
||||
{
|
||||
return property.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -193,10 +174,7 @@ internal sealed class WechatPayProvider : IPaymentProvider
|
||||
foreach (var name in names)
|
||||
{
|
||||
var property = type.GetProperty(name);
|
||||
if (property?.GetValue(value) is T typed)
|
||||
{
|
||||
return typed;
|
||||
}
|
||||
if (property?.GetValue(value) is T typed) return typed;
|
||||
}
|
||||
|
||||
return default;
|
||||
@@ -211,6 +189,45 @@ internal sealed class WechatPayProvider : IPaymentProvider
|
||||
});
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, params string[] keys)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var key in keys)
|
||||
if (element.TryGetProperty(key, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String)
|
||||
return property.GetString();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? GetInt(JsonElement element, string parentKey, string childKey)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty(parentKey, out var parent) &&
|
||||
parent.ValueKind == JsonValueKind.Object &&
|
||||
parent.TryGetProperty(childKey, out var child) &&
|
||||
child.TryGetInt32(out var value))
|
||||
return value;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? GetInt(JsonElement element, string key)
|
||||
{
|
||||
return element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty(key, out var property) &&
|
||||
property.TryGetInt32(out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetDateTimeOffset(JsonElement element, params string[] keys)
|
||||
{
|
||||
var value = GetString(element, keys);
|
||||
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private sealed class WechatPaySettings : ISenparcWeixinSettingForTenpayV3
|
||||
{
|
||||
public string ItemKey { get; set; } = string.Empty;
|
||||
@@ -233,52 +250,4 @@ internal sealed class WechatPayProvider : IPaymentProvider
|
||||
public string TenPayV3_WxOpenTenpayNotify { get; set; } = string.Empty;
|
||||
public CertType? EncryptionType { get; set; }
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, params string[] keys)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (element.TryGetProperty(key, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return property.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? GetInt(JsonElement element, string parentKey, string childKey)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty(parentKey, out var parent) &&
|
||||
parent.ValueKind == JsonValueKind.Object &&
|
||||
parent.TryGetProperty(childKey, out var child) &&
|
||||
child.TryGetInt32(out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? GetInt(JsonElement element, string key)
|
||||
{
|
||||
return element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty(key, out var property) &&
|
||||
property.TryGetInt32(out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetDateTimeOffset(JsonElement element, params string[] keys)
|
||||
{
|
||||
var value = GetString(element, keys);
|
||||
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
@@ -31,30 +25,16 @@ public sealed partial class ContentManagementService
|
||||
collection => collection.CreatedBy == actor.UserId,
|
||||
collection => collection.RegionId.HasValue && regionIds.Contains(collection.RegionId.Value));
|
||||
|
||||
if (!filter.IncludeInactive)
|
||||
{
|
||||
query = query.Where(collection => collection.Status == ContentStatus.Active);
|
||||
}
|
||||
if (!filter.IncludeInactive) query = query.Where(collection => collection.Status == ContentStatus.Active);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(collection => collection.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(collection => collection.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (filter.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(collection => collection.EntryId == filter.EntryId.Value);
|
||||
}
|
||||
if (filter.EntryId.HasValue) query = query.Where(collection => collection.EntryId == filter.EntryId.Value);
|
||||
|
||||
if (filter.NodeId.HasValue)
|
||||
{
|
||||
query = query.Where(collection => collection.NodeId == filter.NodeId.Value);
|
||||
}
|
||||
if (filter.NodeId.HasValue) query = query.Where(collection => collection.NodeId == filter.NodeId.Value);
|
||||
|
||||
if (TryParse(filter.CollectionType, out QuestionCollectionType collectionType))
|
||||
{
|
||||
query = query.Where(collection => collection.CollectionType == collectionType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -83,8 +63,10 @@ public sealed partial class ContentManagementService
|
||||
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
|
||||
await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken);
|
||||
await AssertReferenceAsync<Subject>(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Category>(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<QuestionBank>(actor.TenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Category>(actor.TenantId, command.CategoryId, "category_not_found",
|
||||
cancellationToken);
|
||||
await AssertReferenceAsync<QuestionBank>(actor.TenantId, command.QuestionBankId, "question_bank_not_found",
|
||||
cancellationToken);
|
||||
|
||||
var collection = await ResolveEntityByIdOrLegacyAsync(
|
||||
dbContext.QuestionCollections,
|
||||
@@ -95,19 +77,13 @@ public sealed partial class ContentManagementService
|
||||
|
||||
var isNew = collection is null;
|
||||
if (command.Id.HasValue && (collection is null || collection.Id != command.Id.Value))
|
||||
{
|
||||
throw new ContentManagementException("Collection was not found.", "collection_not_found");
|
||||
}
|
||||
|
||||
if (collection is not null && !scope.AllowsResource(actor.UserId, collection.CreatedBy, collection.RegionId))
|
||||
{
|
||||
throw new ContentManagementException("Collection was not found.", "collection_not_found");
|
||||
}
|
||||
|
||||
if (collection is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
|
||||
{
|
||||
throw new ContentManagementException("Collection was not found.", "collection_not_found");
|
||||
}
|
||||
|
||||
collection ??= new QuestionCollection
|
||||
{
|
||||
@@ -124,8 +100,10 @@ public sealed partial class ContentManagementService
|
||||
collection.QuestionBankId = command.QuestionBankId;
|
||||
collection.LegacyId = Normalize(command.LegacyId);
|
||||
collection.Name = command.Name.Trim();
|
||||
collection.CollectionType = Parse(command.CollectionType, QuestionCollectionType.Dynamic, "collection_type_invalid");
|
||||
collection.SourceType = Parse(command.SourceType, QuestionCollectionSourceType.Filters, "collection_source_type_invalid");
|
||||
collection.CollectionType =
|
||||
Parse(command.CollectionType, QuestionCollectionType.Dynamic, "collection_type_invalid");
|
||||
collection.SourceType = Parse(command.SourceType, QuestionCollectionSourceType.Filters,
|
||||
"collection_source_type_invalid");
|
||||
collection.Filters = JsonObjectOrDefault(command.Filters);
|
||||
collection.TotalScore = command.TotalScore;
|
||||
collection.DurationMinutes = command.DurationMinutes;
|
||||
@@ -135,10 +113,7 @@ public sealed partial class ContentManagementService
|
||||
collection.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
collection.UpdatedBy = actor.UserId;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.QuestionCollections.Add(collection);
|
||||
}
|
||||
if (isNew) dbContext.QuestionCollections.Add(collection);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<QuestionCollectionManagementItem>(ToCollectionItem(collection));
|
||||
@@ -160,9 +135,7 @@ public sealed partial class ContentManagementService
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (collection is null)
|
||||
{
|
||||
throw new ContentManagementException("Collection was not found.", "collection_not_found");
|
||||
}
|
||||
|
||||
var resolvedQuestions = new List<(CollectionQuestionCommand Command, TenantQuestionReference Reference)>();
|
||||
foreach (var question in command.Questions)
|
||||
@@ -206,6 +179,4 @@ public sealed partial class ContentManagementService
|
||||
collection.QuestionCount,
|
||||
items.Select(ToCollectionItemItem).ToArray());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
@@ -21,6 +12,4 @@ public sealed partial class ContentManagementService(
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 1000;
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -23,20 +23,13 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
entry.TenantId == filter.TenantId &&
|
||||
entry.IsActive);
|
||||
|
||||
if (!filter.IncludeHidden)
|
||||
{
|
||||
query = query.Where(entry => entry.Visibility != ContentVisibility.Hidden);
|
||||
}
|
||||
if (!filter.IncludeHidden) query = query.Where(entry => entry.Visibility != ContentVisibility.Hidden);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(entry => entry.RegionId == filter.RegionId.Value || entry.RegionId == null);
|
||||
}
|
||||
|
||||
if (TryParseEntryType(filter.EntryType, out var entryType))
|
||||
{
|
||||
query = query.Where(entry => entry.EntryType == entryType);
|
||||
}
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
@@ -67,10 +60,7 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
ContentNavigationFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!filter.EntryId.HasValue)
|
||||
{
|
||||
throw new RequiredFieldException("entryId is required.");
|
||||
}
|
||||
if (!filter.EntryId.HasValue) throw new RequiredFieldException("entryId is required.");
|
||||
|
||||
var query = dbContext.ContentNodes
|
||||
.AsNoTracking()
|
||||
@@ -78,27 +68,18 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
node.TenantId == filter.TenantId &&
|
||||
node.EntryId == filter.EntryId.Value);
|
||||
|
||||
if (!filter.IncludeInactive)
|
||||
{
|
||||
query = query.Where(node => node.IsActive);
|
||||
}
|
||||
if (!filter.IncludeInactive) query = query.Where(node => node.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(node => node.RegionId == filter.RegionId.Value || node.RegionId == null);
|
||||
}
|
||||
|
||||
if (filter.ParentWasSpecified)
|
||||
{
|
||||
query = filter.ParentIsRoot
|
||||
? query.Where(node => node.ParentId == null)
|
||||
: query.Where(node => node.ParentId == filter.ParentId);
|
||||
}
|
||||
|
||||
if (TryParseMarkerType(filter.MarkerType, out var markerType))
|
||||
{
|
||||
query = query.Where(node => node.MarkerType == markerType);
|
||||
}
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
@@ -142,24 +123,15 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
collection.Status == ContentStatus.Active);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(collection => collection.RegionId == filter.RegionId.Value || collection.RegionId == null);
|
||||
}
|
||||
query = query.Where(collection =>
|
||||
collection.RegionId == filter.RegionId.Value || collection.RegionId == null);
|
||||
|
||||
if (filter.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(collection => collection.EntryId == filter.EntryId.Value);
|
||||
}
|
||||
if (filter.EntryId.HasValue) query = query.Where(collection => collection.EntryId == filter.EntryId.Value);
|
||||
|
||||
if (filter.NodeId.HasValue)
|
||||
{
|
||||
query = query.Where(collection => collection.NodeId == filter.NodeId.Value);
|
||||
}
|
||||
if (filter.NodeId.HasValue) query = query.Where(collection => collection.NodeId == filter.NodeId.Value);
|
||||
|
||||
if (TryParseCollectionType(filter.CollectionType, out var collectionType))
|
||||
{
|
||||
query = query.Where(collection => collection.CollectionType == collectionType);
|
||||
}
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
@@ -203,29 +175,16 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
blueprint.Status == ContentStatus.Active);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value || blueprint.RegionId == null);
|
||||
}
|
||||
|
||||
if (filter.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value);
|
||||
}
|
||||
if (filter.EntryId.HasValue) query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value);
|
||||
|
||||
if (filter.NodeId.HasValue)
|
||||
{
|
||||
query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value);
|
||||
}
|
||||
if (filter.NodeId.HasValue) query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value);
|
||||
|
||||
if (filter.CollectionId.HasValue)
|
||||
{
|
||||
query = query.Where(blueprint => blueprint.CollectionId == filter.CollectionId.Value);
|
||||
}
|
||||
|
||||
if (TryParsePracticeMode(filter.Mode, out var mode))
|
||||
{
|
||||
query = query.Where(blueprint => blueprint.Mode == mode);
|
||||
}
|
||||
if (TryParsePracticeMode(filter.Mode, out var mode)) query = query.Where(blueprint => blueprint.Mode == mode);
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
@@ -261,10 +220,7 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
ContentNavigationFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!filter.CollectionId.HasValue)
|
||||
{
|
||||
throw new RequiredFieldException("collectionId is required.");
|
||||
}
|
||||
if (!filter.CollectionId.HasValue) throw new RequiredFieldException("collectionId is required.");
|
||||
|
||||
var collectionExists = await dbContext.QuestionCollections
|
||||
.AsNoTracking()
|
||||
@@ -275,10 +231,7 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
collection.Status == ContentStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
if (!collectionExists)
|
||||
{
|
||||
throw new ContentNavigationNotFoundException("Question collection was not found.");
|
||||
}
|
||||
if (!collectionExists) throw new ContentNavigationNotFoundException("Question collection was not found.");
|
||||
|
||||
var emptyOptions = JsonDefaults.Array();
|
||||
var emptyCorrectOptionIndices = JsonDefaults.Array();
|
||||
@@ -286,7 +239,7 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
var query =
|
||||
from item in dbContext.QuestionCollectionItems.AsNoTracking()
|
||||
join question in dbContext.Questions.AsNoTracking()
|
||||
on new { item.TenantId, QuestionId = item.QuestionId } equals new { question.TenantId, QuestionId = question.Id }
|
||||
on new { item.TenantId, item.QuestionId } equals new { question.TenantId, QuestionId = question.Id }
|
||||
join version in dbContext.QuestionVersions.AsNoTracking()
|
||||
on new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId }
|
||||
equals new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id }
|
||||
@@ -336,10 +289,7 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
private static IQueryable<T> ApplyKeyword<T>(IQueryable<T> query, string? keyword)
|
||||
where T : class
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
return query;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(keyword)) return query;
|
||||
|
||||
var trimmed = keyword.Trim();
|
||||
return query.Where(entity => EF.Property<string>(entity, nameof(ContentEntry.Name)).Contains(trimmed));
|
||||
@@ -352,22 +302,22 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
|
||||
private static bool TryParseEntryType(string? value, out ContentEntryType type)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
|
||||
return Enum.TryParse(NormalizeEnumValue(value), true, out type);
|
||||
}
|
||||
|
||||
private static bool TryParseCollectionType(string? value, out QuestionCollectionType type)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
|
||||
return Enum.TryParse(NormalizeEnumValue(value), true, out type);
|
||||
}
|
||||
|
||||
private static bool TryParsePracticeMode(string? value, out PracticeMode mode)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out mode);
|
||||
return Enum.TryParse(NormalizeEnumValue(value), true, out mode);
|
||||
}
|
||||
|
||||
private static bool TryParseMarkerType(string? value, out ContentMarkerType type)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
|
||||
return Enum.TryParse(NormalizeEnumValue(value), true, out type);
|
||||
}
|
||||
|
||||
private static string? NormalizeEnumValue(string? value)
|
||||
@@ -377,4 +327,4 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
: value.Replace("_", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("-", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
@@ -24,6 +12,9 @@ public sealed partial class DirectContentService(
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
IFeatureAccessService featureAccessService) : IDirectContentService
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 1000;
|
||||
|
||||
private static readonly string[] ContentPermissions =
|
||||
[
|
||||
BackendPermissions.TenantContentManage,
|
||||
@@ -34,9 +25,9 @@ public sealed partial class DirectContentService(
|
||||
BackendPermissions.TenantSiteContentManage,
|
||||
BackendPermissions.TenantJobManage
|
||||
];
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 1000;
|
||||
|
||||
private static readonly Regex ScorelineFieldKeyRegex = new("^[A-Za-z][A-Za-z0-9_]{0,63}$", RegexOptions.Compiled);
|
||||
|
||||
private static readonly HashSet<string> SupportedImportTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"questions",
|
||||
@@ -45,6 +36,4 @@ public sealed partial class DirectContentService(
|
||||
"scoreline",
|
||||
"videos"
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
@@ -30,10 +18,7 @@ public sealed partial class DirectContentService
|
||||
var query = dbContext.Schools.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -55,7 +40,8 @@ public sealed partial class DirectContentService
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId,
|
||||
cancellationToken);
|
||||
var isNew = item is null;
|
||||
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "school_not_found");
|
||||
item ??= new School { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
@@ -64,10 +50,7 @@ public sealed partial class DirectContentService
|
||||
item.Name = command.Name.Trim();
|
||||
item.ProfessionalExamDate = Normalize(command.ProfessionalExamDate);
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.Schools.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.Schools.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<School>(item);
|
||||
@@ -83,25 +66,18 @@ public sealed partial class DirectContentService
|
||||
var query = dbContext.Majors.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (filter.SchoolId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.SchoolId == filter.SchoolId.Value);
|
||||
}
|
||||
if (filter.SchoolId.HasValue) query = query.Where(item => item.SchoolId == filter.SchoolId.Value);
|
||||
|
||||
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => item.IsActive);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword)));
|
||||
query = query.Where(item =>
|
||||
item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword)));
|
||||
}
|
||||
|
||||
return new CatalogList<Major>(await query
|
||||
@@ -120,7 +96,8 @@ public sealed partial class DirectContentService
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId,
|
||||
cancellationToken);
|
||||
var isNew = item is null;
|
||||
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "major_not_found");
|
||||
item ??= new Major { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
@@ -132,14 +109,9 @@ public sealed partial class DirectContentService
|
||||
item.StudyTips = Normalize(command.StudyTips);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.Majors.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.Majors.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<Major>(item);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
@@ -31,20 +23,12 @@ public sealed partial class ContentManagementService
|
||||
entry => entry.CreatedBy == actor.UserId,
|
||||
entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value));
|
||||
|
||||
if (!filter.IncludeInactive)
|
||||
{
|
||||
query = query.Where(entry => entry.IsActive);
|
||||
}
|
||||
if (!filter.IncludeInactive) query = query.Where(entry => entry.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(entry => entry.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(entry => entry.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (TryParse(filter.EntryType, out ContentEntryType entryType))
|
||||
{
|
||||
query = query.Where(entry => entry.EntryType == entryType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -86,19 +70,13 @@ public sealed partial class ContentManagementService
|
||||
|
||||
var isNew = entry is null;
|
||||
if (command.Id.HasValue && (entry is null || entry.Id != command.Id.Value))
|
||||
{
|
||||
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
|
||||
}
|
||||
|
||||
if (entry is not null && !scope.AllowsResource(actor.UserId, entry.CreatedBy, entry.RegionId))
|
||||
{
|
||||
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
|
||||
}
|
||||
|
||||
if (entry is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
|
||||
{
|
||||
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
|
||||
}
|
||||
|
||||
entry ??= new ContentEntry
|
||||
{
|
||||
@@ -122,14 +100,9 @@ public sealed partial class ContentManagementService
|
||||
entry.IsActive = command.IsActive ?? true;
|
||||
entry.UpdatedBy = actor.UserId;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.ContentEntries.Add(entry);
|
||||
}
|
||||
if (isNew) dbContext.ContentEntries.Add(entry);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<ContentEntryManagementItem>(ToEntryItem(entry));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,148 @@
|
||||
using System.Text;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
public sealed partial class ContentManagementService
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, ImportSpec> Specs =
|
||||
new Dictionary<string, ImportSpec>(StringComparer.Ordinal)
|
||||
{
|
||||
["questions"] = new(
|
||||
"questions",
|
||||
"题目导入模板",
|
||||
"用于导入刷题题库,后端会校验题型、答案、目标科目、分类、题集和租户隔离。",
|
||||
[
|
||||
Field("legacyId", "旧系统 ID", false, ["legacy_id", "externalId", "id"], "用于幂等更新。",
|
||||
"tj-english-2026-001"),
|
||||
Field("type", "题型", true, ["题型", "questionType"], "choice、multi、judge、reading、short_answer 等。",
|
||||
"choice"),
|
||||
Field("content", "题干", true, ["题干", "stem", "question"], "支持 Markdown、图片 URL 和公式。",
|
||||
"多租户 SaaS 最重要的安全边界是什么?"),
|
||||
Field("options", "选项", false, ["选项", "choices"], "客观题选项。", new[] { "前端隐藏", "后端权限" }),
|
||||
Field("correctOptionIndices", "正确选项索引", false, ["答案", "answer"], "从 0 开始;CSV 可用 A/B/C/D。",
|
||||
new[] { 1 }),
|
||||
Field("answerText", "文字答案", false, ["主观题答案"], "主观题答案。", "以后端权限和数据库约束为准。"),
|
||||
Field("explanation", "解析", false, ["解析", "analysis"], "题目解析内容。", "最终权限以后端强制为准。"),
|
||||
Field("difficulty", "难度", false, ["难度"], "建议 1-5。", 2),
|
||||
Field("tags", "标签", false, ["标签", "tag"], "JSON 数组或 CSV 中用 | 分隔。", new[] { "安全", "多租户" })
|
||||
],
|
||||
[
|
||||
["legacyId", "type", "content", "选项A", "选项B", "答案", "explanation", "difficulty", "tags"],
|
||||
[
|
||||
"tj-english-2026-001", "choice", "多租户 SaaS 最重要的安全边界是什么?", "前端隐藏", "后端权限", "B", "最终权限以后端强制为准。",
|
||||
"2", "安全|多租户"
|
||||
]
|
||||
],
|
||||
new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
legacyId = "tj-english-2026-001",
|
||||
type = "choice",
|
||||
content = "多租户 SaaS 最重要的安全边界是什么?",
|
||||
options = new[] { "前端隐藏", "后端权限" },
|
||||
correctOptionIndices = new[] { 1 },
|
||||
explanation = "最终权限以后端强制为准。",
|
||||
difficulty = 2,
|
||||
tags = new[] { "安全", "多租户" }
|
||||
}
|
||||
}
|
||||
}),
|
||||
["vocabulary"] = new(
|
||||
"vocabulary",
|
||||
"单词导入模板",
|
||||
"用于导入词汇单元和单词,后端会按单元归组并幂等写入。",
|
||||
[
|
||||
Field("unitName", "单元名称", true, ["unit", "单元"], "单词所属单元。", "核心词汇 Unit 1"),
|
||||
Field("word", "单词", true, ["单词"], "英文单词或词组。", "scale"),
|
||||
Field("meaning", "释义", true, ["释义", "中文"], "中文释义。", "n. 规模;等级"),
|
||||
Field("phonetic", "音标", false, ["音标"], "音标展示文本。", "/skeil/"),
|
||||
Field("example", "例句", false, ["例句"], "英文例句。", "The platform must scale safely.")
|
||||
],
|
||||
[
|
||||
["unitName", "word", "phonetic", "meaning", "example", "difficulty", "tags"],
|
||||
["核心词汇 Unit 1", "scale", "/skeil/", "n. 规模;等级", "The platform must scale safely.", "2", "高频|SaaS"]
|
||||
],
|
||||
new
|
||||
{
|
||||
units = new[]
|
||||
{ new { name = "核心词汇 Unit 1", words = new[] { new { word = "scale", meaning = "n. 规模;等级" } } } }
|
||||
}),
|
||||
["handbook"] = new(
|
||||
"handbook",
|
||||
"知识手册导入模板",
|
||||
"用于导入手册科目、章节、小节和知识点。",
|
||||
[
|
||||
Field("subjectName", "手册科目", true, ["subject", "手册"], "知识手册顶层名称。", "专升本英语知识手册"),
|
||||
Field("chapterName", "章节", true, ["chapter", "章节"], "章节名称。", "第一章 语法基础"),
|
||||
Field("title", "知识点标题", true, ["entryTitle", "标题"], "知识点条目标题。", "that 引导的主语从句"),
|
||||
Field("content", "正文", true, ["正文", "markdown"], "Markdown 正文。", "主语从句可放在句首。")
|
||||
],
|
||||
[
|
||||
["subjectName", "chapterName", "title", "content", "tags"],
|
||||
["专升本英语知识手册", "第一章 语法基础", "that 引导的主语从句", "主语从句可放在句首。", "语法"]
|
||||
],
|
||||
new
|
||||
{
|
||||
subjects = new[] { new { name = "专升本英语知识手册", chapters = new[] { new { name = "第一章 语法基础" } } } }
|
||||
}),
|
||||
["scoreline"] = new(
|
||||
"scoreline",
|
||||
"分数线导入模板",
|
||||
"用于导入动态字段、院校、专业和年份分数线记录。",
|
||||
[
|
||||
Field("kind", "数据类型", true, ["type", "类型"], "field、school、major、record。", "record"),
|
||||
Field("schoolName", "院校名称", false, ["school", "院校"], "院校名称。", "天津职业技术师范大学"),
|
||||
Field("majorName", "专业名称", false, ["major", "专业"], "专业名称。", "软件工程"),
|
||||
Field("year", "年份", false, ["年份"], "record 常用。", 2026),
|
||||
Field("fieldValues", "字段值", false, ["values", "分数字段"], "record 的动态字段 JSON。", new { minScore = 188 })
|
||||
],
|
||||
[
|
||||
["kind", "schoolName", "majorName", "year", "minScore"],
|
||||
["record", "天津职业技术师范大学", "软件工程", "2026", "188"]
|
||||
],
|
||||
new
|
||||
{
|
||||
records = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
schoolName = "天津职业技术师范大学", majorName = "软件工程", year = 2026,
|
||||
fieldValues = new { minScore = 188 }
|
||||
}
|
||||
}
|
||||
}),
|
||||
["videos"] = new(
|
||||
"videos",
|
||||
"视频解析导入模板",
|
||||
"用于导入视频解析元数据并绑定到题目。",
|
||||
[
|
||||
Field("title", "标题", true, ["视频标题", "name"], "视频标题。", "多租户隔离题解析"),
|
||||
Field("videoUrl", "视频 URL", false, ["video_url", "url"], "外部视频 URL。",
|
||||
"https://cdn.example.test/video.mp4"),
|
||||
Field("assetId", "资源 ID", false, ["asset_id"], "对象存储资源台账 ID。",
|
||||
"00000000-0000-0000-0000-000000000000"),
|
||||
Field("legacyQuestionId", "题目外部 ID", false, ["legacy_question_id"], "按旧题目 ID 绑定。",
|
||||
"tj-english-2026-001")
|
||||
],
|
||||
[
|
||||
["title", "videoUrl", "legacyQuestionId", "videoType"],
|
||||
["多租户隔离题解析", "https://cdn.example.test/video.mp4", "tj-english-2026-001", "specific"]
|
||||
],
|
||||
new { videos = new[] { new { title = "多租户隔离题解析", videoUrl = "https://cdn.example.test/video.mp4" } } })
|
||||
};
|
||||
|
||||
private async Task<(string Path, int Depth)> BuildNodePathAsync(
|
||||
Guid tenantId,
|
||||
Guid entryId,
|
||||
@@ -24,10 +151,7 @@ public sealed partial class ContentManagementService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var label = $"n_{nodeId:N}";
|
||||
if (!parentId.HasValue)
|
||||
{
|
||||
return (label, 0);
|
||||
}
|
||||
if (!parentId.HasValue) return (label, 0);
|
||||
|
||||
var parent = await dbContext.ContentNodes
|
||||
.AsNoTracking()
|
||||
@@ -36,9 +160,7 @@ public sealed partial class ContentManagementService
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (parent is null)
|
||||
{
|
||||
throw new ContentManagementException("Parent node was not found in this entry.", "parent_node_not_found");
|
||||
}
|
||||
|
||||
return ($"{parent.Path}.{label}", parent.Depth + 1);
|
||||
}
|
||||
@@ -59,10 +181,7 @@ public sealed partial class ContentManagementService
|
||||
Guid? entryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!entryId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!entryId.HasValue) return;
|
||||
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var exists = await dbContext.ContentEntries
|
||||
@@ -72,10 +191,7 @@ public sealed partial class ContentManagementService
|
||||
entry => entry.CreatedBy == actor.UserId,
|
||||
entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value))
|
||||
.AnyAsync(cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
|
||||
}
|
||||
if (!exists) throw new ContentManagementException("Content entry was not found.", "entry_not_found");
|
||||
}
|
||||
|
||||
private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken)
|
||||
@@ -89,10 +205,7 @@ public sealed partial class ContentManagementService
|
||||
Guid? nodeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!nodeId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!nodeId.HasValue) return;
|
||||
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var exists = await dbContext.ContentNodes
|
||||
@@ -102,10 +215,7 @@ public sealed partial class ContentManagementService
|
||||
node => node.CreatedBy == actor.UserId,
|
||||
node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value))
|
||||
.AnyAsync(cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new ContentManagementException("Content node was not found.", "node_not_found");
|
||||
}
|
||||
if (!exists) throw new ContentManagementException("Content node was not found.", "node_not_found");
|
||||
}
|
||||
|
||||
private async Task<CurrentDataScope> RequireDataScopeAsync(
|
||||
@@ -114,9 +224,7 @@ public sealed partial class ContentManagementService
|
||||
{
|
||||
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||
if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId)
|
||||
{
|
||||
throw new ContentManagementException("Content resource was not found.", "content_not_found");
|
||||
}
|
||||
|
||||
return access.DataScope;
|
||||
}
|
||||
@@ -128,41 +236,32 @@ public sealed partial class ContentManagementService
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : class
|
||||
{
|
||||
if (!id.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!id.HasValue) return;
|
||||
|
||||
var exists = await dbContext.Set<TEntity>()
|
||||
.AnyAsync(entity =>
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
|
||||
cancellationToken);
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
throw new ContentManagementException("Referenced entity was not found in this tenant.", code);
|
||||
}
|
||||
if (!exists) throw new ContentManagementException("Referenced entity was not found in this tenant.", code);
|
||||
}
|
||||
|
||||
private static async Task<TEntity?> ResolveEntityAsync<TEntity>(
|
||||
DbSet<TEntity> set,
|
||||
Guid tenantId,
|
||||
Guid? id,
|
||||
System.Linq.Expressions.Expression<Func<TEntity, bool>> alternatePredicate,
|
||||
Expression<Func<TEntity, bool>> alternatePredicate,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : class
|
||||
{
|
||||
if (id.HasValue)
|
||||
{
|
||||
var byId = await set.SingleOrDefaultAsync(entity =>
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
|
||||
cancellationToken);
|
||||
if (byId is not null)
|
||||
{
|
||||
return byId;
|
||||
}
|
||||
if (byId is not null) return byId;
|
||||
}
|
||||
|
||||
return await set
|
||||
@@ -181,24 +280,18 @@ public sealed partial class ContentManagementService
|
||||
if (id.HasValue)
|
||||
{
|
||||
var byId = await set.SingleOrDefaultAsync(entity =>
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
|
||||
cancellationToken);
|
||||
if (byId is not null)
|
||||
{
|
||||
return byId;
|
||||
}
|
||||
if (byId is not null) return byId;
|
||||
}
|
||||
|
||||
var normalizedLegacyId = Normalize(legacyId);
|
||||
if (normalizedLegacyId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (normalizedLegacyId is null) return null;
|
||||
|
||||
return await set.SingleOrDefaultAsync(entity =>
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
|
||||
EF.Property<string?>(entity, nameof(ContentEntry.LegacyId)) == normalizedLegacyId,
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
|
||||
EF.Property<string?>(entity, nameof(ContentEntry.LegacyId)) == normalizedLegacyId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
@@ -342,15 +435,9 @@ public sealed partial class ContentManagementService
|
||||
private static TEnum Parse<TEnum>(string? value, TEnum fallback, string code)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(value)) return fallback;
|
||||
|
||||
if (Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
if (Enum.TryParse<TEnum>(value, true, out var parsed)) return parsed;
|
||||
|
||||
throw new ContentManagementException("Invalid enum value.", code);
|
||||
}
|
||||
@@ -358,15 +445,9 @@ public sealed partial class ContentManagementService
|
||||
private static TEnum? ParseNullable<TEnum>(string? value, string code)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
if (Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
if (Enum.TryParse<TEnum>(value, true, out var parsed)) return parsed;
|
||||
|
||||
throw new ContentManagementException("Invalid enum value.", code);
|
||||
}
|
||||
@@ -374,7 +455,7 @@ public sealed partial class ContentManagementService
|
||||
private static bool TryParse<TEnum>(string? value, out TEnum parsed)
|
||||
where TEnum : struct
|
||||
{
|
||||
return Enum.TryParse(value, ignoreCase: true, out parsed);
|
||||
return Enum.TryParse(value, true, out parsed);
|
||||
}
|
||||
|
||||
private static string EscapeCsv(string value)
|
||||
@@ -392,14 +473,6 @@ public sealed partial class ContentManagementService
|
||||
: throw new ContentManagementException("Import type is not supported.", "import_type_invalid");
|
||||
}
|
||||
|
||||
private sealed record ImportSpec(
|
||||
string ImportType,
|
||||
string Title,
|
||||
string Description,
|
||||
IReadOnlyCollection<ImportFieldSpec> Fields,
|
||||
string[][] CsvRows,
|
||||
object JsonExample);
|
||||
|
||||
private static ImportFieldSpec Field(
|
||||
string field,
|
||||
string label,
|
||||
@@ -417,106 +490,11 @@ public sealed partial class ContentManagementService
|
||||
JsonSerializer.SerializeToElement(example));
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, ImportSpec> Specs =
|
||||
new Dictionary<string, ImportSpec>(StringComparer.Ordinal)
|
||||
{
|
||||
["questions"] = new(
|
||||
"questions",
|
||||
"题目导入模板",
|
||||
"用于导入刷题题库,后端会校验题型、答案、目标科目、分类、题集和租户隔离。",
|
||||
[
|
||||
Field("legacyId", "旧系统 ID", false, ["legacy_id", "externalId", "id"], "用于幂等更新。", "tj-english-2026-001"),
|
||||
Field("type", "题型", true, ["题型", "questionType"], "choice、multi、judge、reading、short_answer 等。", "choice"),
|
||||
Field("content", "题干", true, ["题干", "stem", "question"], "支持 Markdown、图片 URL 和公式。", "多租户 SaaS 最重要的安全边界是什么?"),
|
||||
Field("options", "选项", false, ["选项", "choices"], "客观题选项。", new[] { "前端隐藏", "后端权限" }),
|
||||
Field("correctOptionIndices", "正确选项索引", false, ["答案", "answer"], "从 0 开始;CSV 可用 A/B/C/D。", new[] { 1 }),
|
||||
Field("answerText", "文字答案", false, ["主观题答案"], "主观题答案。", "以后端权限和数据库约束为准。"),
|
||||
Field("explanation", "解析", false, ["解析", "analysis"], "题目解析内容。", "最终权限以后端强制为准。"),
|
||||
Field("difficulty", "难度", false, ["难度"], "建议 1-5。", 2),
|
||||
Field("tags", "标签", false, ["标签", "tag"], "JSON 数组或 CSV 中用 | 分隔。", new[] { "安全", "多租户" })
|
||||
],
|
||||
[
|
||||
["legacyId", "type", "content", "选项A", "选项B", "答案", "explanation", "difficulty", "tags"],
|
||||
["tj-english-2026-001", "choice", "多租户 SaaS 最重要的安全边界是什么?", "前端隐藏", "后端权限", "B", "最终权限以后端强制为准。", "2", "安全|多租户"]
|
||||
],
|
||||
new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
legacyId = "tj-english-2026-001",
|
||||
type = "choice",
|
||||
content = "多租户 SaaS 最重要的安全边界是什么?",
|
||||
options = new[] { "前端隐藏", "后端权限" },
|
||||
correctOptionIndices = new[] { 1 },
|
||||
explanation = "最终权限以后端强制为准。",
|
||||
difficulty = 2,
|
||||
tags = new[] { "安全", "多租户" }
|
||||
}
|
||||
}
|
||||
}),
|
||||
["vocabulary"] = new(
|
||||
"vocabulary",
|
||||
"单词导入模板",
|
||||
"用于导入词汇单元和单词,后端会按单元归组并幂等写入。",
|
||||
[
|
||||
Field("unitName", "单元名称", true, ["unit", "单元"], "单词所属单元。", "核心词汇 Unit 1"),
|
||||
Field("word", "单词", true, ["单词"], "英文单词或词组。", "scale"),
|
||||
Field("meaning", "释义", true, ["释义", "中文"], "中文释义。", "n. 规模;等级"),
|
||||
Field("phonetic", "音标", false, ["音标"], "音标展示文本。", "/skeil/"),
|
||||
Field("example", "例句", false, ["例句"], "英文例句。", "The platform must scale safely.")
|
||||
],
|
||||
[
|
||||
["unitName", "word", "phonetic", "meaning", "example", "difficulty", "tags"],
|
||||
["核心词汇 Unit 1", "scale", "/skeil/", "n. 规模;等级", "The platform must scale safely.", "2", "高频|SaaS"]
|
||||
],
|
||||
new { units = new[] { new { name = "核心词汇 Unit 1", words = new[] { new { word = "scale", meaning = "n. 规模;等级" } } } } }),
|
||||
["handbook"] = new(
|
||||
"handbook",
|
||||
"知识手册导入模板",
|
||||
"用于导入手册科目、章节、小节和知识点。",
|
||||
[
|
||||
Field("subjectName", "手册科目", true, ["subject", "手册"], "知识手册顶层名称。", "专升本英语知识手册"),
|
||||
Field("chapterName", "章节", true, ["chapter", "章节"], "章节名称。", "第一章 语法基础"),
|
||||
Field("title", "知识点标题", true, ["entryTitle", "标题"], "知识点条目标题。", "that 引导的主语从句"),
|
||||
Field("content", "正文", true, ["正文", "markdown"], "Markdown 正文。", "主语从句可放在句首。")
|
||||
],
|
||||
[
|
||||
["subjectName", "chapterName", "title", "content", "tags"],
|
||||
["专升本英语知识手册", "第一章 语法基础", "that 引导的主语从句", "主语从句可放在句首。", "语法"]
|
||||
],
|
||||
new { subjects = new[] { new { name = "专升本英语知识手册", chapters = new[] { new { name = "第一章 语法基础" } } } } }),
|
||||
["scoreline"] = new(
|
||||
"scoreline",
|
||||
"分数线导入模板",
|
||||
"用于导入动态字段、院校、专业和年份分数线记录。",
|
||||
[
|
||||
Field("kind", "数据类型", true, ["type", "类型"], "field、school、major、record。", "record"),
|
||||
Field("schoolName", "院校名称", false, ["school", "院校"], "院校名称。", "天津职业技术师范大学"),
|
||||
Field("majorName", "专业名称", false, ["major", "专业"], "专业名称。", "软件工程"),
|
||||
Field("year", "年份", false, ["年份"], "record 常用。", 2026),
|
||||
Field("fieldValues", "字段值", false, ["values", "分数字段"], "record 的动态字段 JSON。", new { minScore = 188 })
|
||||
],
|
||||
[
|
||||
["kind", "schoolName", "majorName", "year", "minScore"],
|
||||
["record", "天津职业技术师范大学", "软件工程", "2026", "188"]
|
||||
],
|
||||
new { records = new[] { new { schoolName = "天津职业技术师范大学", majorName = "软件工程", year = 2026, fieldValues = new { minScore = 188 } } } }),
|
||||
["videos"] = new(
|
||||
"videos",
|
||||
"视频解析导入模板",
|
||||
"用于导入视频解析元数据并绑定到题目。",
|
||||
[
|
||||
Field("title", "标题", true, ["视频标题", "name"], "视频标题。", "多租户隔离题解析"),
|
||||
Field("videoUrl", "视频 URL", false, ["video_url", "url"], "外部视频 URL。", "https://cdn.example.test/video.mp4"),
|
||||
Field("assetId", "资源 ID", false, ["asset_id"], "对象存储资源台账 ID。", "00000000-0000-0000-0000-000000000000"),
|
||||
Field("legacyQuestionId", "题目外部 ID", false, ["legacy_question_id"], "按旧题目 ID 绑定。", "tj-english-2026-001")
|
||||
],
|
||||
[
|
||||
["title", "videoUrl", "legacyQuestionId", "videoType"],
|
||||
["多租户隔离题解析", "https://cdn.example.test/video.mp4", "tj-english-2026-001", "specific"]
|
||||
],
|
||||
new { videos = new[] { new { title = "多租户隔离题解析", videoUrl = "https://cdn.example.test/video.mp4" } } })
|
||||
};
|
||||
}
|
||||
private sealed record ImportSpec(
|
||||
string ImportType,
|
||||
string Title,
|
||||
string Description,
|
||||
IReadOnlyCollection<ImportFieldSpec> Fields,
|
||||
string[][] CsvRows,
|
||||
object JsonExample);
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
@@ -224,9 +217,7 @@ public sealed partial class DirectContentService
|
||||
access.UserId != actor.UserId ||
|
||||
access.TenantId != actor.TenantId ||
|
||||
!ContentPermissions.Any(access.HasTenantPermission))
|
||||
{
|
||||
throw new ContentManagementException("Tenant content access was denied.", "content_access_denied");
|
||||
}
|
||||
|
||||
return access.DataScope;
|
||||
}
|
||||
@@ -242,9 +233,7 @@ public sealed partial class DirectContentService
|
||||
var canAccessCurrent = isNew || scope.AllowsResource(actor.UserId, regionId: currentRegionId);
|
||||
var canAccessTarget = scope.AllowsResource(actor.UserId, regionId: targetRegionId);
|
||||
if (!canAccessCurrent || !canAccessTarget)
|
||||
{
|
||||
throw new ContentManagementException("Content resource was not found.", notFoundCode);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TEntity?> ResolveByIdOrLegacyAsync<TEntity>(
|
||||
@@ -256,9 +245,8 @@ public sealed partial class DirectContentService
|
||||
where TEntity : AuditableTenantEntity
|
||||
{
|
||||
if (id.HasValue)
|
||||
{
|
||||
return await set.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == id.Value, cancellationToken);
|
||||
}
|
||||
return await set.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == id.Value,
|
||||
cancellationToken);
|
||||
|
||||
var normalizedLegacyId = Normalize(legacyId);
|
||||
return normalizedLegacyId is null
|
||||
@@ -275,17 +263,13 @@ public sealed partial class DirectContentService
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : class
|
||||
{
|
||||
if (!id.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!id.HasValue) return;
|
||||
|
||||
var exists = await dbContext.Set<TEntity>()
|
||||
.AnyAsync(item => EF.Property<Guid>(item, "TenantId") == tenantId && EF.Property<Guid>(item, "Id") == id.Value, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new ContentManagementException("Referenced entity was not found.", code);
|
||||
}
|
||||
.AnyAsync(
|
||||
item => EF.Property<Guid>(item, "TenantId") == tenantId && EF.Property<Guid>(item, "Id") == id.Value,
|
||||
cancellationToken);
|
||||
if (!exists) throw new ContentManagementException("Referenced entity was not found.", code);
|
||||
}
|
||||
|
||||
private async Task AssertImportJobAsync(Guid tenantId, Guid jobId, CancellationToken cancellationToken)
|
||||
@@ -293,10 +277,7 @@ public sealed partial class DirectContentService
|
||||
var exists = await dbContext.ContentImportJobs.AnyAsync(
|
||||
item => item.TenantId == tenantId && item.Id == jobId,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
||||
}
|
||||
if (!exists) throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
||||
}
|
||||
|
||||
private static string NormalizeOperationKind(string kind)
|
||||
@@ -328,15 +309,9 @@ public sealed partial class DirectContentService
|
||||
private static TEnum Parse<TEnum>(string? value, TEnum fallback, string code)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(value)) return fallback;
|
||||
|
||||
if (Enum.TryParse<TEnum>(value.Trim(), ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
if (Enum.TryParse<TEnum>(value.Trim(), true, out var parsed)) return parsed;
|
||||
|
||||
throw new ContentManagementException("Enum value is invalid.", code);
|
||||
}
|
||||
@@ -344,15 +319,9 @@ public sealed partial class DirectContentService
|
||||
private static TEnum? ParseNullable<TEnum>(string? value, string code)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
if (Enum.TryParse<TEnum>(value.Trim(), ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
if (Enum.TryParse<TEnum>(value.Trim(), true, out var parsed)) return parsed;
|
||||
|
||||
throw new ContentManagementException("Enum value is invalid.", code);
|
||||
}
|
||||
@@ -386,20 +355,14 @@ public sealed partial class DirectContentService
|
||||
|
||||
private static string? GetString(JsonElement payload, string name)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null;
|
||||
|
||||
return value.ValueKind == JsonValueKind.String ? Normalize(value.GetString()) : value.ToString();
|
||||
}
|
||||
|
||||
private static int? GetInt(JsonElement payload, string name)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null;
|
||||
|
||||
return value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)
|
||||
? number
|
||||
@@ -410,10 +373,7 @@ public sealed partial class DirectContentService
|
||||
|
||||
private static Guid? GetGuid(JsonElement payload, string name)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null;
|
||||
|
||||
return value.ValueKind == JsonValueKind.String &&
|
||||
Guid.TryParse(value.GetString(), out var guid)
|
||||
@@ -423,10 +383,7 @@ public sealed partial class DirectContentService
|
||||
|
||||
private static bool? GetBool(JsonElement payload, string name)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null;
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
@@ -436,4 +393,4 @@ public sealed partial class DirectContentService
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,14 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
@@ -27,13 +21,12 @@ public sealed partial class DirectContentService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!SupportedImportTypes.Contains(command.ImportType))
|
||||
{
|
||||
throw new ContentManagementException("Import type is invalid.", "import_type_invalid");
|
||||
}
|
||||
|
||||
var importType = ParseImportType(command.ImportType);
|
||||
var sourceFormat = Parse(command.SourceFormat, ImportSourceFormat.Json, "import_source_format_invalid");
|
||||
var items = command.Items.Select(item => item.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : item).ToArray();
|
||||
var items = command.Items
|
||||
.Select(item => item.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : item).ToArray();
|
||||
var job = new ContentImportJob
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
@@ -203,10 +196,12 @@ public sealed partial class DirectContentService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Banner> UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken)
|
||||
private async Task<Banner> UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Banners, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Banners, actor.TenantId, command.Id, command.LegacyId,
|
||||
cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new Banner { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.RegionId = command.RegionId;
|
||||
@@ -220,19 +215,18 @@ public sealed partial class DirectContentService
|
||||
item.BorderColor = Normalize(command.BorderColor);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.Banners.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.Banners.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
private async Task<Faq> UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken)
|
||||
private async Task<Faq> UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Faqs, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Faqs, actor.TenantId, command.Id, command.LegacyId,
|
||||
cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new Faq { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.RegionId = command.RegionId;
|
||||
@@ -241,18 +235,17 @@ public sealed partial class DirectContentService
|
||||
item.Answer = Normalize(command.Answer) ?? Normalize(command.Content);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.Faqs.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.Faqs.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
private async Task<Announcement> UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken)
|
||||
private async Task<Announcement> UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Announcements, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Announcements, actor.TenantId, command.Id, command.LegacyId,
|
||||
cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new Announcement { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.LegacyId = Normalize(command.LegacyId);
|
||||
@@ -261,21 +254,20 @@ public sealed partial class DirectContentService
|
||||
item.BackgroundColor = Normalize(command.BackgroundColor);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.Announcements.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.Announcements.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
private async Task<ExamDate> UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken)
|
||||
private async Task<ExamDate> UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.ExamName);
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.ExamDates, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.ExamDates, actor.TenantId, command.Id, command.LegacyId,
|
||||
cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new ExamDate { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.RegionId = command.RegionId;
|
||||
@@ -288,10 +280,7 @@ public sealed partial class DirectContentService
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.ExamDates.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.ExamDates.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
@@ -319,10 +308,7 @@ public sealed partial class DirectContentService
|
||||
private static void ValidateQuestionForPublication(QuestionWriteCommand command)
|
||||
{
|
||||
var status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid");
|
||||
if (status != QuestionStatus.Published)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (status != QuestionStatus.Published) return;
|
||||
|
||||
var type = Normalize(command.Type) ?? "choice";
|
||||
if (!QuestionGrader.HasValidAuthoritativeAnswer(
|
||||
@@ -330,11 +316,9 @@ public sealed partial class DirectContentService
|
||||
command.CorrectOptionIndex,
|
||||
command.CorrectOptionIndices,
|
||||
command.AnswerText))
|
||||
{
|
||||
throw new ContentManagementException(
|
||||
"Published questions require a valid authoritative answer.",
|
||||
"question_grading_rule_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static QuestionVersion BuildQuestionVersion(
|
||||
@@ -368,15 +352,18 @@ public sealed partial class DirectContentService
|
||||
version.SourceHash = Normalize(command.SourceHash);
|
||||
}
|
||||
|
||||
private async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command, CancellationToken cancellationToken)
|
||||
private async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await AssertReferenceAsync<QuestionBank>(tenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<QuestionBank>(tenantId, command.QuestionBankId, "question_bank_not_found",
|
||||
cancellationToken);
|
||||
await AssertReferenceAsync<Subject>(tenantId, command.SubjectId, "subject_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Category>(tenantId, command.CategoryId, "category_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ModuleNode>(tenantId, command.NodeId, "module_node_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentEntry>(tenantId, command.EntryId, "entry_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(tenantId, command.ContentNodeId, "node_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<QuestionCollection>(tenantId, command.PrimaryCollectionId, "collection_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<QuestionCollection>(tenantId, command.PrimaryCollectionId, "collection_not_found",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SyncPrimaryCollectionItemAsync(
|
||||
@@ -384,10 +371,7 @@ public sealed partial class DirectContentService
|
||||
Question question,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!question.PrimaryCollectionId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!question.PrimaryCollectionId.HasValue) return;
|
||||
|
||||
var existing = await dbContext.QuestionCollectionItems.SingleOrDefaultAsync(
|
||||
item =>
|
||||
@@ -403,7 +387,8 @@ public sealed partial class DirectContentService
|
||||
new QuestionLocator(QuestionSource.Tenant, question.Id),
|
||||
cancellationToken);
|
||||
var nextOrder = await dbContext.QuestionCollectionItems
|
||||
.Where(item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value)
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value)
|
||||
.Select(item => (int?)item.SortOrder)
|
||||
.MaxAsync(cancellationToken) ?? -1;
|
||||
dbContext.QuestionCollectionItems.Add(new QuestionCollectionItem
|
||||
@@ -433,18 +418,13 @@ public sealed partial class DirectContentService
|
||||
Guid? contentNodeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!unitId.HasValue)
|
||||
{
|
||||
return (entryId, contentNodeId);
|
||||
}
|
||||
if (!unitId.HasValue) return (entryId, contentNodeId);
|
||||
|
||||
var unit = await dbContext.VocabularyUnits.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.Id == unitId.Value,
|
||||
cancellationToken);
|
||||
if (unit is null)
|
||||
{
|
||||
throw new ContentManagementException("Vocabulary unit was not found.", "vocabulary_unit_not_found");
|
||||
}
|
||||
|
||||
return (entryId ?? unit.EntryId, contentNodeId ?? unit.ContentNodeId);
|
||||
}
|
||||
@@ -456,18 +436,13 @@ public sealed partial class DirectContentService
|
||||
Guid? contentNodeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!subjectId.HasValue)
|
||||
{
|
||||
return (entryId, contentNodeId);
|
||||
}
|
||||
if (!subjectId.HasValue) return (entryId, contentNodeId);
|
||||
|
||||
var subject = await dbContext.HandbookSubjects.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.Id == subjectId.Value,
|
||||
cancellationToken);
|
||||
if (subject is null)
|
||||
{
|
||||
throw new ContentManagementException("Handbook subject was not found.", "handbook_subject_not_found");
|
||||
}
|
||||
|
||||
return (entryId ?? subject.EntryId, contentNodeId ?? subject.ContentNodeId);
|
||||
}
|
||||
@@ -479,21 +454,14 @@ public sealed partial class DirectContentService
|
||||
Guid? contentNodeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!chapterId.HasValue)
|
||||
{
|
||||
return (entryId, contentNodeId);
|
||||
}
|
||||
if (!chapterId.HasValue) return (entryId, contentNodeId);
|
||||
|
||||
var chapter = await dbContext.HandbookChapters.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.Id == chapterId.Value,
|
||||
cancellationToken);
|
||||
if (chapter is null)
|
||||
{
|
||||
throw new ContentManagementException("Handbook chapter was not found.", "handbook_chapter_not_found");
|
||||
}
|
||||
|
||||
return (entryId ?? chapter.EntryId, contentNodeId ?? chapter.ContentNodeId);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
@@ -30,40 +19,25 @@ public sealed partial class DirectContentService
|
||||
var query = dbContext.HandbookSubjects.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (filter.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.EntryId == filter.EntryId.Value);
|
||||
}
|
||||
if (filter.EntryId.HasValue) query = query.Where(item => item.EntryId == filter.EntryId.Value);
|
||||
|
||||
if (filter.ContentNodeId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value);
|
||||
}
|
||||
|
||||
if (filter.SchoolId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.SchoolId == filter.SchoolId.Value);
|
||||
}
|
||||
if (filter.SchoolId.HasValue) query = query.Where(item => item.SchoolId == filter.SchoolId.Value);
|
||||
|
||||
if (filter.MajorId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.MajorId == filter.MajorId.Value);
|
||||
}
|
||||
if (filter.MajorId.HasValue) query = query.Where(item => item.MajorId == filter.MajorId.Value);
|
||||
|
||||
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => item.IsActive);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword)));
|
||||
query = query.Where(item =>
|
||||
item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword)));
|
||||
}
|
||||
|
||||
return new CatalogList<HandbookSubject>(await query
|
||||
@@ -84,9 +58,11 @@ public sealed partial class DirectContentService
|
||||
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Major>(actor.TenantId, command.MajorId, "major_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(actor.TenantId, command.ContentNodeId, "node_not_found",
|
||||
cancellationToken);
|
||||
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id,
|
||||
command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "handbook_subject_not_found");
|
||||
item ??= new HandbookSubject { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
@@ -104,10 +80,7 @@ public sealed partial class DirectContentService
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.HandbookSubjects.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.HandbookSubjects.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<HandbookSubject>(item);
|
||||
@@ -119,30 +92,21 @@ public sealed partial class DirectContentService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.HandbookChapters.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.SubjectId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.SubjectId == filter.SubjectId.Value);
|
||||
}
|
||||
if (filter.SubjectId.HasValue) query = query.Where(item => item.SubjectId == filter.SubjectId.Value);
|
||||
|
||||
if (filter.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.EntryId == filter.EntryId.Value);
|
||||
}
|
||||
if (filter.EntryId.HasValue) query = query.Where(item => item.EntryId == filter.EntryId.Value);
|
||||
|
||||
if (filter.ContentNodeId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value);
|
||||
}
|
||||
|
||||
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => item.IsActive);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword)));
|
||||
query = query.Where(item =>
|
||||
item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword)));
|
||||
}
|
||||
|
||||
return new CatalogList<HandbookChapter>(await query
|
||||
@@ -158,11 +122,14 @@ public sealed partial class DirectContentService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||
await AssertReferenceAsync<HandbookSubject>(actor.TenantId, command.SubjectId, "handbook_subject_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<HandbookSubject>(actor.TenantId, command.SubjectId, "handbook_subject_not_found",
|
||||
cancellationToken);
|
||||
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(actor.TenantId, command.ContentNodeId, "node_not_found",
|
||||
cancellationToken);
|
||||
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookChapters, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookChapters, actor.TenantId, command.Id,
|
||||
command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new HandbookChapter { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
var chapterNavigation = await ResolveHandbookSubjectNavigationAsync(
|
||||
@@ -180,10 +147,7 @@ public sealed partial class DirectContentService
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.HandbookChapters.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.HandbookChapters.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<HandbookChapter>(item);
|
||||
@@ -195,30 +159,21 @@ public sealed partial class DirectContentService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.HandbookEntries.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.ChapterId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ChapterId == filter.ChapterId.Value);
|
||||
}
|
||||
if (filter.ChapterId.HasValue) query = query.Where(item => item.ChapterId == filter.ChapterId.Value);
|
||||
|
||||
if (filter.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.EntryId == filter.EntryId.Value);
|
||||
}
|
||||
if (filter.EntryId.HasValue) query = query.Where(item => item.EntryId == filter.EntryId.Value);
|
||||
|
||||
if (filter.ContentNodeId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value);
|
||||
}
|
||||
|
||||
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => item.IsActive);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => item.Title.Contains(keyword) || (item.Content != null && item.Content.Contains(keyword)));
|
||||
query = query.Where(item =>
|
||||
item.Title.Contains(keyword) || (item.Content != null && item.Content.Contains(keyword)));
|
||||
}
|
||||
|
||||
return new CatalogList<HandbookEntry>(await query
|
||||
@@ -234,11 +189,14 @@ public sealed partial class DirectContentService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
|
||||
await AssertReferenceAsync<HandbookChapter>(actor.TenantId, command.ChapterId, "handbook_chapter_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<HandbookChapter>(actor.TenantId, command.ChapterId, "handbook_chapter_not_found",
|
||||
cancellationToken);
|
||||
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(actor.TenantId, command.ContentNodeId, "node_not_found",
|
||||
cancellationToken);
|
||||
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookEntries, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookEntries, actor.TenantId, command.Id,
|
||||
command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new HandbookEntry { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
var entryNavigation = await ResolveHandbookChapterNavigationAsync(
|
||||
@@ -258,14 +216,9 @@ public sealed partial class DirectContentService
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.HandbookEntries.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.HandbookEntries.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<HandbookEntry>(item);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
@@ -25,7 +13,7 @@ public sealed partial class DirectContentService
|
||||
SimpleImportCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return CreateImportJobAsync(actor, command with { DryRun = true }, execute: false, cancellationToken);
|
||||
return CreateImportJobAsync(actor, command with { DryRun = true }, false, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<SimpleImportResult> ExecuteImportAsync(
|
||||
@@ -33,7 +21,7 @@ public sealed partial class DirectContentService
|
||||
SimpleImportCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return CreateImportJobAsync(actor, command with { DryRun = false }, execute: true, cancellationToken);
|
||||
return CreateImportJobAsync(actor, command with { DryRun = false }, true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ContentImportJobDetail> GetImportJobAsync(
|
||||
@@ -45,10 +33,7 @@ public sealed partial class DirectContentService
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == jobId)
|
||||
.Select(item => ToJobItem(item))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
||||
}
|
||||
if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
||||
|
||||
var items = await dbContext.ContentImportItems.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.JobId == jobId)
|
||||
@@ -109,10 +94,7 @@ public sealed partial class DirectContentService
|
||||
var job = await dbContext.ContentImportJobs.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == jobId,
|
||||
cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
||||
}
|
||||
if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
||||
|
||||
var counts = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
@@ -145,10 +127,7 @@ public sealed partial class DirectContentService
|
||||
var job = await dbContext.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == jobId,
|
||||
cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
||||
}
|
||||
if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
||||
|
||||
var issues = await GetImportIssuesAsync(actor, jobId, cancellationToken);
|
||||
var counts = JsonSerializer.SerializeToElement(new
|
||||
@@ -163,6 +142,4 @@ public sealed partial class DirectContentService
|
||||
});
|
||||
return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, issues.Items);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
@@ -22,10 +14,7 @@ public sealed partial class ContentManagementService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
if (!filter.EntryId.HasValue)
|
||||
{
|
||||
throw new ContentManagementException("entryId is required.", "entry_id_required");
|
||||
}
|
||||
if (!filter.EntryId.HasValue) throw new ContentManagementException("entryId is required.", "entry_id_required");
|
||||
|
||||
await AssertEntryAsync(actor, scope, filter.EntryId, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
@@ -37,32 +26,20 @@ public sealed partial class ContentManagementService
|
||||
node => node.CreatedBy == actor.UserId,
|
||||
node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value));
|
||||
|
||||
if (!filter.IncludeInactive)
|
||||
{
|
||||
query = query.Where(node => node.IsActive);
|
||||
}
|
||||
if (!filter.IncludeInactive) query = query.Where(node => node.IsActive);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(node => node.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(node => node.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (filter.ParentId is not null)
|
||||
{
|
||||
if (string.Equals(filter.ParentId, "root", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(node => node.ParentId == null);
|
||||
}
|
||||
else if (Guid.TryParse(filter.ParentId, out var parentId))
|
||||
{
|
||||
query = query.Where(node => node.ParentId == parentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (TryParse(filter.MarkerType, out ContentMarkerType markerType))
|
||||
{
|
||||
query = query.Where(node => node.MarkerType == markerType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -107,19 +84,13 @@ public sealed partial class ContentManagementService
|
||||
|
||||
var isNew = node is null;
|
||||
if (command.Id.HasValue && (node is null || node.Id != command.Id.Value))
|
||||
{
|
||||
throw new ContentManagementException("Content node was not found.", "node_not_found");
|
||||
}
|
||||
|
||||
if (node is not null && !scope.AllowsResource(actor.UserId, node.CreatedBy, node.RegionId))
|
||||
{
|
||||
throw new ContentManagementException("Content node was not found.", "node_not_found");
|
||||
}
|
||||
|
||||
if (node is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
|
||||
{
|
||||
throw new ContentManagementException("Content node was not found.", "node_not_found");
|
||||
}
|
||||
|
||||
node ??= new ContentNode
|
||||
{
|
||||
@@ -130,7 +101,8 @@ public sealed partial class ContentManagementService
|
||||
CreatedBy = actor.UserId
|
||||
};
|
||||
|
||||
var path = await BuildNodePathAsync(actor.TenantId, command.EntryId, node.Id, command.ParentId, cancellationToken);
|
||||
var path = await BuildNodePathAsync(actor.TenantId, command.EntryId, node.Id, command.ParentId,
|
||||
cancellationToken);
|
||||
node.EntryId = command.EntryId;
|
||||
node.RegionId = command.RegionId;
|
||||
node.ParentId = command.ParentId;
|
||||
@@ -149,25 +121,17 @@ public sealed partial class ContentManagementService
|
||||
node.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
node.UpdatedBy = actor.UserId;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.ContentNodes.Add(node);
|
||||
}
|
||||
if (isNew) dbContext.ContentNodes.Add(node);
|
||||
|
||||
if (command.ParentId.HasValue)
|
||||
{
|
||||
var parent = await dbContext.ContentNodes.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.ParentId.Value,
|
||||
cancellationToken);
|
||||
if (parent is not null)
|
||||
{
|
||||
parent.IsLeaf = false;
|
||||
}
|
||||
if (parent is not null) parent.IsLeaf = false;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<ContentNodeManagementItem>(ToNodeItem(node));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
@@ -61,7 +47,8 @@ public sealed partial class DirectContentService
|
||||
.ThenBy(item => item.SortOrder)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(),
|
||||
_ => throw new ContentManagementException("Operation content kind is invalid.", "operation_content_kind_invalid")
|
||||
_ => throw new ContentManagementException("Operation content kind is invalid.",
|
||||
"operation_content_kind_invalid")
|
||||
};
|
||||
|
||||
return new CatalogList<OperationContentItem>(items);
|
||||
@@ -73,17 +60,16 @@ public sealed partial class DirectContentService
|
||||
OperationContentCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
OperationContentItem item = NormalizeOperationKind(kind) switch
|
||||
var item = NormalizeOperationKind(kind) switch
|
||||
{
|
||||
"banners" => ToOperationItem(await UpsertBannerAsync(actor, command, cancellationToken)),
|
||||
"faqs" => ToOperationItem(await UpsertFaqAsync(actor, command, cancellationToken)),
|
||||
"announcements" => ToOperationItem(await UpsertAnnouncementAsync(actor, command, cancellationToken)),
|
||||
"exam-dates" => ToOperationItem(await UpsertExamDateAsync(actor, command, cancellationToken)),
|
||||
_ => throw new ContentManagementException("Operation content kind is invalid.", "operation_content_kind_invalid")
|
||||
_ => throw new ContentManagementException("Operation content kind is invalid.",
|
||||
"operation_content_kind_invalid")
|
||||
};
|
||||
|
||||
return new ContentManagementResult<OperationContentItem>(item);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,7 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
@@ -31,35 +25,18 @@ public sealed partial class ContentManagementService
|
||||
blueprint => blueprint.CreatedBy == actor.UserId,
|
||||
blueprint => blueprint.RegionId.HasValue && regionIds.Contains(blueprint.RegionId.Value));
|
||||
|
||||
if (!filter.IncludeInactive)
|
||||
{
|
||||
query = query.Where(blueprint => blueprint.Status == ContentStatus.Active);
|
||||
}
|
||||
if (!filter.IncludeInactive) query = query.Where(blueprint => blueprint.Status == ContentStatus.Active);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (filter.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value);
|
||||
}
|
||||
if (filter.EntryId.HasValue) query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value);
|
||||
|
||||
if (filter.NodeId.HasValue)
|
||||
{
|
||||
query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value);
|
||||
}
|
||||
if (filter.NodeId.HasValue) query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value);
|
||||
|
||||
if (filter.CollectionId.HasValue)
|
||||
{
|
||||
query = query.Where(blueprint => blueprint.CollectionId == filter.CollectionId.Value);
|
||||
}
|
||||
|
||||
if (TryParse(filter.Mode, out PracticeMode mode))
|
||||
{
|
||||
query = query.Where(blueprint => blueprint.Mode == mode);
|
||||
}
|
||||
if (TryParse(filter.Mode, out PracticeMode mode)) query = query.Where(blueprint => blueprint.Mode == mode);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -87,7 +64,8 @@ public sealed partial class ContentManagementService
|
||||
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
|
||||
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
|
||||
await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken);
|
||||
await AssertReferenceAsync<QuestionCollection>(actor.TenantId, command.CollectionId, "collection_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<QuestionCollection>(actor.TenantId, command.CollectionId, "collection_not_found",
|
||||
cancellationToken);
|
||||
|
||||
var blueprint = await ResolveEntityByIdOrLegacyAsync(
|
||||
dbContext.PracticeBlueprints,
|
||||
@@ -98,19 +76,13 @@ public sealed partial class ContentManagementService
|
||||
|
||||
var isNew = blueprint is null;
|
||||
if (command.Id.HasValue && (blueprint is null || blueprint.Id != command.Id.Value))
|
||||
{
|
||||
throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
|
||||
}
|
||||
|
||||
if (blueprint is not null && !scope.AllowsResource(actor.UserId, blueprint.CreatedBy, blueprint.RegionId))
|
||||
{
|
||||
throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
|
||||
}
|
||||
|
||||
if (blueprint is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
|
||||
{
|
||||
throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
|
||||
}
|
||||
|
||||
blueprint ??= new PracticeBlueprint
|
||||
{
|
||||
@@ -126,7 +98,8 @@ public sealed partial class ContentManagementService
|
||||
blueprint.LegacyId = Normalize(command.LegacyId);
|
||||
blueprint.Name = command.Name.Trim();
|
||||
blueprint.Mode = Parse(command.Mode, PracticeMode.Sequential, "practice_mode_invalid");
|
||||
blueprint.AssemblyType = Parse(command.AssemblyType, PracticeAssemblyType.Collection, "practice_assembly_type_invalid");
|
||||
blueprint.AssemblyType = Parse(command.AssemblyType, PracticeAssemblyType.Collection,
|
||||
"practice_assembly_type_invalid");
|
||||
blueprint.QuestionLimit = command.QuestionLimit;
|
||||
blueprint.DurationMinutes = command.DurationMinutes;
|
||||
blueprint.TotalScore = command.TotalScore;
|
||||
@@ -138,10 +111,7 @@ public sealed partial class ContentManagementService
|
||||
blueprint.SortOrder = command.Order ?? 0;
|
||||
blueprint.UpdatedBy = actor.UserId;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.PracticeBlueprints.Add(blueprint);
|
||||
}
|
||||
if (isNew) dbContext.PracticeBlueprints.Add(blueprint);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<PracticeBlueprintManagementItem>(ToBlueprintItem(blueprint));
|
||||
@@ -168,7 +138,8 @@ public sealed partial class ContentManagementService
|
||||
"csv" => string.Join(
|
||||
"\n",
|
||||
spec.CsvRows.Select(row => string.Join(",", row.Select(EscapeCsv)))),
|
||||
_ => throw new ContentManagementException("Import template format is not supported.", "import_template_format_invalid")
|
||||
_ => throw new ContentManagementException("Import template format is not supported.",
|
||||
"import_template_format_invalid")
|
||||
};
|
||||
|
||||
return new ImportTemplateItem(
|
||||
@@ -180,6 +151,4 @@ public sealed partial class ContentManagementService
|
||||
content,
|
||||
spec.Fields);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
@@ -38,12 +25,10 @@ public sealed partial class DirectContentService
|
||||
};
|
||||
ApplyQuestion(question, command);
|
||||
if (question.Status != QuestionStatus.Archived)
|
||||
{
|
||||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.PrivateQuestionCount,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
dbContext.Questions.Add(question);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -53,10 +38,7 @@ public sealed partial class DirectContentService
|
||||
await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
return new ContentManagementResult<QuestionManagementItem>(ToQuestionItem(question, version));
|
||||
}
|
||||
|
||||
@@ -66,19 +48,14 @@ public sealed partial class DirectContentService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!command.QuestionId.HasValue)
|
||||
{
|
||||
throw new ContentManagementException("questionId is required.", "question_id_required");
|
||||
}
|
||||
|
||||
ValidateQuestionForPublication(command);
|
||||
|
||||
var question = await dbContext.Questions.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.QuestionId.Value,
|
||||
cancellationToken);
|
||||
if (question is null)
|
||||
{
|
||||
throw new ContentManagementException("Question was not found.", "question_not_found");
|
||||
}
|
||||
if (question is null) throw new ContentManagementException("Question was not found.", "question_not_found");
|
||||
|
||||
await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken);
|
||||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||
@@ -88,12 +65,10 @@ public sealed partial class DirectContentService
|
||||
ApplyQuestion(question, command);
|
||||
var isCounted = question.Status != QuestionStatus.Archived;
|
||||
if (!wasCounted && isCounted)
|
||||
{
|
||||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.PrivateQuestionCount,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
QuestionVersion? version;
|
||||
if (command.CreateVersion || !question.CurrentVersionId.HasValue)
|
||||
{
|
||||
@@ -129,19 +104,12 @@ public sealed partial class DirectContentService
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (wasCounted && !isCounted)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.PrivateQuestionCount,
|
||||
1,
|
||||
cancellationToken);
|
||||
}
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
return new ContentManagementResult<QuestionManagementItem>(ToQuestionItem(question, version));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
@@ -31,9 +19,7 @@ public sealed partial class DirectContentService
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -57,12 +43,11 @@ public sealed partial class DirectContentService
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName);
|
||||
if (!ScorelineFieldKeyRegex.IsMatch(command.FieldKey.Trim()))
|
||||
{
|
||||
throw new ContentManagementException("Scoreline field key is invalid.", "scoreline_field_key_invalid");
|
||||
}
|
||||
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id,
|
||||
command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_field_not_found");
|
||||
item ??= new ScorelineField { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
@@ -80,10 +65,7 @@ public sealed partial class DirectContentService
|
||||
item.Placeholder = Normalize(command.Placeholder);
|
||||
item.Description = Normalize(command.Description);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.ScorelineFields.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.ScorelineFields.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<ScorelineField>(item);
|
||||
@@ -99,25 +81,13 @@ public sealed partial class DirectContentService
|
||||
var query = dbContext.ScorelineRecords.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (filter.SchoolId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.SchoolId == filter.SchoolId.Value);
|
||||
}
|
||||
if (filter.SchoolId.HasValue) query = query.Where(item => item.SchoolId == filter.SchoolId.Value);
|
||||
|
||||
if (filter.MajorId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.MajorId == filter.MajorId.Value);
|
||||
}
|
||||
if (filter.MajorId.HasValue) query = query.Where(item => item.MajorId == filter.MajorId.Value);
|
||||
|
||||
if (filter.Year.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.Year == filter.Year.Value);
|
||||
}
|
||||
if (filter.Year.HasValue) query = query.Where(item => item.Year == filter.Year.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -142,14 +112,13 @@ public sealed partial class DirectContentService
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
if (command.Year is < 1900 or > 3000)
|
||||
{
|
||||
throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid");
|
||||
}
|
||||
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Major>(actor.TenantId, command.MajorId, "major_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id,
|
||||
command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_record_not_found");
|
||||
item ??= new ScorelineRecord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
@@ -161,10 +130,7 @@ public sealed partial class DirectContentService
|
||||
item.SchoolName = Normalize(command.SchoolName);
|
||||
item.MajorName = Normalize(command.MajorName);
|
||||
item.FieldValues = JsonObjectOrDefault(command.FieldValues);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.ScorelineRecords.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.ScorelineRecords.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<ScorelineRecord>(item);
|
||||
@@ -180,15 +146,9 @@ public sealed partial class DirectContentService
|
||||
var query = dbContext.ScorelineRecords.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (filter.SchoolId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.SchoolId == filter.SchoolId.Value);
|
||||
}
|
||||
if (filter.SchoolId.HasValue) query = query.Where(item => item.SchoolId == filter.SchoolId.Value);
|
||||
|
||||
var years = await query
|
||||
.Select(item => item.Year)
|
||||
@@ -225,6 +185,4 @@ public sealed partial class DirectContentService
|
||||
|
||||
return new CatalogList<ScorelineTrendItem>(items);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,9 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
@@ -26,20 +15,16 @@ public sealed partial class DirectContentService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.VideoExplanations.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.SubjectId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.SubjectId == filter.SubjectId.Value);
|
||||
}
|
||||
if (filter.SubjectId.HasValue) query = query.Where(item => item.SubjectId == filter.SubjectId.Value);
|
||||
|
||||
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => item.IsActive);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => item.Title.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword)));
|
||||
query = query.Where(item =>
|
||||
item.Title.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword)));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
@@ -58,7 +43,8 @@ public sealed partial class DirectContentService
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
|
||||
await AssertReferenceAsync<Subject>(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.VideoExplanations, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.VideoExplanations, actor.TenantId, command.Id,
|
||||
command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new VideoExplanation { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.SubjectId = command.SubjectId;
|
||||
@@ -74,10 +60,7 @@ public sealed partial class DirectContentService
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.VideoExplanations.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.VideoExplanations.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<VideoManagementItem>(ToVideoItem(item));
|
||||
@@ -88,10 +71,13 @@ public sealed partial class DirectContentService
|
||||
QuestionVideoCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertReferenceAsync<Question>(actor.TenantId, command.QuestionId, "question_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<VideoExplanation>(actor.TenantId, command.VideoId, "video_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Question>(actor.TenantId, command.QuestionId, "question_not_found",
|
||||
cancellationToken);
|
||||
await AssertReferenceAsync<VideoExplanation>(actor.TenantId, command.VideoId, "video_not_found",
|
||||
cancellationToken);
|
||||
var item = await dbContext.QuestionVideos.SingleOrDefaultAsync(
|
||||
link => link.TenantId == actor.TenantId && link.QuestionId == command.QuestionId && link.VideoId == command.VideoId,
|
||||
link => link.TenantId == actor.TenantId && link.QuestionId == command.QuestionId &&
|
||||
link.VideoId == command.VideoId,
|
||||
cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new QuestionVideo { TenantId = actor.TenantId };
|
||||
@@ -101,10 +87,7 @@ public sealed partial class DirectContentService
|
||||
item.VideoType = Parse(command.VideoType, QuestionVideoType.Specific, "question_video_type_invalid");
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.QuestionVideos.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.QuestionVideos.Add(item);
|
||||
|
||||
var question = await dbContext.Questions.SingleAsync(
|
||||
question => question.TenantId == actor.TenantId && question.Id == command.QuestionId,
|
||||
@@ -113,6 +96,4 @@ public sealed partial class DirectContentService
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<QuestionVideoManagementItem>(ToQuestionVideoItem(item));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
@@ -30,30 +19,21 @@ public sealed partial class DirectContentService
|
||||
var query = dbContext.VocabularyUnits.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
|
||||
if (filter.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.EntryId == filter.EntryId.Value);
|
||||
}
|
||||
if (filter.EntryId.HasValue) query = query.Where(item => item.EntryId == filter.EntryId.Value);
|
||||
|
||||
if (filter.ContentNodeId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value);
|
||||
}
|
||||
|
||||
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => item.IsActive);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword)));
|
||||
query = query.Where(item =>
|
||||
item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword)));
|
||||
}
|
||||
|
||||
return new CatalogList<VocabularyUnit>(await query
|
||||
@@ -72,9 +52,11 @@ public sealed partial class DirectContentService
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(actor.TenantId, command.ContentNodeId, "node_not_found",
|
||||
cancellationToken);
|
||||
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id,
|
||||
command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "vocabulary_unit_not_found");
|
||||
item ??= new VocabularyUnit { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
@@ -88,10 +70,7 @@ public sealed partial class DirectContentService
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.VocabularyUnits.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.VocabularyUnits.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<VocabularyUnit>(item);
|
||||
@@ -103,30 +82,21 @@ public sealed partial class DirectContentService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.VocabularyWords.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.UnitId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.UnitId == filter.UnitId.Value);
|
||||
}
|
||||
if (filter.UnitId.HasValue) query = query.Where(item => item.UnitId == filter.UnitId.Value);
|
||||
|
||||
if (filter.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.EntryId == filter.EntryId.Value);
|
||||
}
|
||||
if (filter.EntryId.HasValue) query = query.Where(item => item.EntryId == filter.EntryId.Value);
|
||||
|
||||
if (filter.ContentNodeId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value);
|
||||
}
|
||||
|
||||
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => item.IsActive);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => item.Word.Contains(keyword) || (item.Meaning != null && item.Meaning.Contains(keyword)));
|
||||
query = query.Where(item =>
|
||||
item.Word.Contains(keyword) || (item.Meaning != null && item.Meaning.Contains(keyword)));
|
||||
}
|
||||
|
||||
return new CatalogList<VocabularyWord>(await query
|
||||
@@ -142,11 +112,14 @@ public sealed partial class DirectContentService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Word);
|
||||
await AssertReferenceAsync<VocabularyUnit>(actor.TenantId, command.UnitId, "vocabulary_unit_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<VocabularyUnit>(actor.TenantId, command.UnitId, "vocabulary_unit_not_found",
|
||||
cancellationToken);
|
||||
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(actor.TenantId, command.ContentNodeId, "node_not_found",
|
||||
cancellationToken);
|
||||
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyWords, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyWords, actor.TenantId, command.Id,
|
||||
command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new VocabularyWord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
var vocabularyNavigation = await ResolveVocabularyNavigationAsync(
|
||||
@@ -169,14 +142,9 @@ public sealed partial class DirectContentService
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.VocabularyWords.Add(item);
|
||||
}
|
||||
if (isNew) dbContext.VocabularyWords.Add(item);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<VocabularyWord>(item);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StackExchange.Redis;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using Tiku.Domain.Identity;
|
||||
using StackExchange.Redis;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
|
||||
namespace Tiku.Infrastructure;
|
||||
|
||||
@@ -75,16 +77,16 @@ public static class DependencyInjection
|
||||
services.AddSingleton(provider => new RedisSecurityStore(
|
||||
provider.GetRequiredService<IConnectionMultiplexer>(),
|
||||
environmentName,
|
||||
provider.GetRequiredService<Microsoft.Extensions.Logging.ILogger<RedisSecurityStore>>()));
|
||||
provider.GetRequiredService<ILogger<RedisSecurityStore>>()));
|
||||
services.AddSingleton<IRedisSecurityStore>(provider => provider.GetRequiredService<RedisSecurityStore>());
|
||||
services.AddSingleton(provider => new RedisAuthorizationCache(
|
||||
provider.GetRequiredService<IConnectionMultiplexer>(),
|
||||
provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<AuthorizationCacheOptions>>(),
|
||||
provider.GetRequiredService<IOptions<AuthorizationCacheOptions>>(),
|
||||
environmentName));
|
||||
services.AddSingleton<IAccessSecurityCache>(provider => provider.GetRequiredService<RedisAuthorizationCache>());
|
||||
services.AddSingleton<IAuthorizationSnapshotCache>(provider => provider.GetRequiredService<RedisAuthorizationCache>());
|
||||
services.AddSingleton<IAuthorizationSnapshotCache>(provider =>
|
||||
provider.GetRequiredService<RedisAuthorizationCache>());
|
||||
services.AddStackExchangeRedisCache(cache => cache.ConfigurationOptions = options);
|
||||
return services;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Growth;
|
||||
|
||||
@@ -37,9 +28,7 @@ public sealed partial class ReferralService
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.ReferrerUserId != null);
|
||||
if (query.ReferrerUserId.HasValue)
|
||||
{
|
||||
referrerIdsQuery = referrerIdsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value);
|
||||
}
|
||||
|
||||
var referrerIds = await referrerIdsQuery
|
||||
.Select(item => item.ReferrerUserId!.Value)
|
||||
@@ -48,9 +37,7 @@ public sealed partial class ReferralService
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var stats = new List<ReferralStatsItem>(referrerIds.Length);
|
||||
foreach (var referrerId in referrerIds)
|
||||
{
|
||||
stats.Add(await BuildStatsAsync(actor.TenantId, referrerId, cancellationToken));
|
||||
}
|
||||
|
||||
return new ReferralList<ReferralStatsItem>(
|
||||
stats
|
||||
@@ -69,10 +56,7 @@ public sealed partial class ReferralService
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var endDate = query.EndDate ?? today;
|
||||
var startDate = query.StartDate ?? endDate.AddDays(-Math.Clamp(query.Days ?? 30, 1, 365) + 1);
|
||||
if (endDate < startDate)
|
||||
{
|
||||
throw new ReferralException("Referral date range was invalid.", "invalid_date_range");
|
||||
}
|
||||
if (endDate < startDate) throw new ReferralException("Referral date range was invalid.", "invalid_date_range");
|
||||
|
||||
var start = startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
var endExclusive = endDate.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
@@ -83,9 +67,7 @@ public sealed partial class ReferralService
|
||||
item.BoundAt >= start &&
|
||||
item.BoundAt < endExclusive);
|
||||
if (query.ReferrerUserId.HasValue)
|
||||
{
|
||||
leadsQuery = leadsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value);
|
||||
}
|
||||
|
||||
var leads = await leadsQuery
|
||||
.OrderByDescending(item => item.BoundAt)
|
||||
@@ -145,6 +127,4 @@ public sealed partial class ReferralService
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new ReferralList<ReferralLeadItem>(leads.Select(item => ToLeadItem(item, false)).ToArray());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Growth;
|
||||
@@ -18,36 +17,43 @@ public sealed class CommissionService(
|
||||
TikuDbContext dbContext,
|
||||
ICurrentAccessContext currentAccessContext) : ICommissionService
|
||||
{
|
||||
public async Task<CommissionSettingsItem> GetSettingsAsync(CommissionAdminActor actor, CancellationToken cancellationToken = default)
|
||||
public async Task<CommissionSettingsItem> GetSettingsAsync(CommissionAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
return ToSettingsItem(await GetSettingsCoreAsync(actor.TenantId, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<CommissionSettingsItem> UpdateSettingsAsync(CommissionAdminActor actor, UpdateCommissionSettingsCommand command, CancellationToken cancellationToken = default)
|
||||
public async Task<CommissionSettingsItem> UpdateSettingsAsync(CommissionAdminActor actor,
|
||||
UpdateCommissionSettingsCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var settings = await GetSettingsCoreAsync(actor.TenantId, cancellationToken);
|
||||
settings.DefaultRate = command.DefaultRate ?? settings.DefaultRate;
|
||||
settings.MinSettlementCents = command.MinSettlementCents ?? settings.MinSettlementCents;
|
||||
settings.SettlementCycle = ParseEnum(command.SettlementCycle, settings.SettlementCycle, "invalid_commission_cycle");
|
||||
settings.SettlementCycle =
|
||||
ParseEnum(command.SettlementCycle, settings.SettlementCycle, "invalid_commission_cycle");
|
||||
settings.Config = command.Config ?? settings.Config;
|
||||
settings.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToSettingsItem(settings);
|
||||
}
|
||||
|
||||
public async Task<object> UpdateMemberRateAsync(CommissionAdminActor actor, UpdateMemberCommissionRateCommand command, CancellationToken cancellationToken = default)
|
||||
public async Task<object> UpdateMemberRateAsync(CommissionAdminActor actor,
|
||||
UpdateMemberCommissionRateCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var member = await dbContext.TenantMemberships
|
||||
.FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == command.UserId, cancellationToken)
|
||||
?? throw new CommissionException("Commission member was not found.", "commission_member_not_found");
|
||||
.FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == command.UserId,
|
||||
cancellationToken)
|
||||
?? throw new CommissionException("Commission member was not found.",
|
||||
"commission_member_not_found");
|
||||
var settings = await GetSettingsCoreAsync(actor.TenantId, cancellationToken);
|
||||
var config = settings.Config.ValueKind == JsonValueKind.Object
|
||||
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(settings.Config.GetRawText()) ?? []
|
||||
: [];
|
||||
var memberRates = config.TryGetValue("memberRates", out var existingRates) && existingRates.ValueKind == JsonValueKind.Object
|
||||
var memberRates = config.TryGetValue("memberRates", out var existingRates) &&
|
||||
existingRates.ValueKind == JsonValueKind.Object
|
||||
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(existingRates.GetRawText()) ?? []
|
||||
: [];
|
||||
memberRates[member.UserId.ToString("N")] = JsonSerializer.SerializeToElement(new
|
||||
@@ -58,10 +64,14 @@ public sealed class CommissionService(
|
||||
config["memberRates"] = JsonSerializer.SerializeToElement(memberRates);
|
||||
settings.Config = JsonSerializer.SerializeToElement(config);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new { member.UserId, commissionRate = command.CommissionRate, commissionConfig = command.CommissionConfig };
|
||||
return new
|
||||
{
|
||||
member.UserId, commissionRate = command.CommissionRate, commissionConfig = command.CommissionConfig
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<CommissionSummaryItem> GetSummaryAsync(CommissionAdminActor actor, CommissionPeriodQuery query, CancellationToken cancellationToken = default)
|
||||
public async Task<CommissionSummaryItem> GetSummaryAsync(CommissionAdminActor actor, CommissionPeriodQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var rows = await BuildSourcesAsync(actor.TenantId, query, cancellationToken);
|
||||
@@ -72,24 +82,32 @@ public sealed class CommissionService(
|
||||
rows.Sum(item => item.CommissionAmountCents));
|
||||
}
|
||||
|
||||
public async Task<CommissionList<CommissionSourceItem>> GetOrdersAsync(CommissionAdminActor actor, CommissionPeriodQuery query, CancellationToken cancellationToken = default)
|
||||
public async Task<CommissionList<CommissionSourceItem>> GetOrdersAsync(CommissionAdminActor actor,
|
||||
CommissionPeriodQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var rows = await BuildSourcesAsync(actor.TenantId, query, cancellationToken);
|
||||
return new CommissionList<CommissionSourceItem>(rows.Take(Math.Clamp(query.Limit ?? 100, 1, 500)).Select(ToSourceItem).ToArray());
|
||||
return new CommissionList<CommissionSourceItem>(rows.Take(Math.Clamp(query.Limit ?? 100, 1, 500))
|
||||
.Select(ToSourceItem).ToArray());
|
||||
}
|
||||
|
||||
public async Task<CommissionList<CommissionSettlementItemDto>> GetSettlementsAsync(CommissionAdminActor actor, CommissionSettlementQuery query, CancellationToken cancellationToken = default)
|
||||
public async Task<CommissionList<CommissionSettlementItemDto>> GetSettlementsAsync(CommissionAdminActor actor,
|
||||
CommissionSettlementQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var items = dbContext.CommissionSettlements.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||||
if (query.ReferrerUserId.HasValue) items = items.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status)) items = items.Where(item => item.Status == ParseEnum(query.Status, CommissionSettlementStatus.Draft, "invalid_commission_status"));
|
||||
var result = await items.OrderByDescending(item => item.CreatedAt).Take(Math.Clamp(query.Limit ?? 100, 1, 500)).ToArrayAsync(cancellationToken);
|
||||
if (query.ReferrerUserId.HasValue)
|
||||
items = items.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
items = items.Where(item =>
|
||||
item.Status == ParseEnum(query.Status, CommissionSettlementStatus.Draft, "invalid_commission_status"));
|
||||
var result = await items.OrderByDescending(item => item.CreatedAt).Take(Math.Clamp(query.Limit ?? 100, 1, 500))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CommissionList<CommissionSettlementItemDto>(result.Select(ToSettlementItem).ToArray());
|
||||
}
|
||||
|
||||
public async Task<CommissionExportItem> ExportSettlementAsync(CommissionAdminActor actor, Guid settlementId, string? format, CancellationToken cancellationToken = default)
|
||||
public async Task<CommissionExportItem> ExportSettlementAsync(CommissionAdminActor actor, Guid settlementId,
|
||||
string? format, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var settlement = await GetSettlementAsync(actor.TenantId, settlementId, cancellationToken);
|
||||
@@ -115,21 +133,29 @@ public sealed class CommissionService(
|
||||
ContentSha256 = sha,
|
||||
Metadata = JsonSerializer.SerializeToElement(new { sizeBytes = bytes.Length })
|
||||
});
|
||||
await AddAuditAsync(actor, "commission.settlement.exported", "commission_settlements", settlementId, new { format = resolvedFormat, filename, rowCount = items.Length, sha }, cancellationToken);
|
||||
await AddAuditAsync(actor, "commission.settlement.exported", "commission_settlements", settlementId,
|
||||
new { format = resolvedFormat, filename, rowCount = items.Length, sha }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new CommissionExportItem(settlementId, filename, resolvedFormat, resolvedFormat == "json" ? "application/json" : "text/csv", items.Length, Convert.ToBase64String(bytes), sha, bytes.Length);
|
||||
return new CommissionExportItem(settlementId, filename, resolvedFormat,
|
||||
resolvedFormat == "json" ? "application/json" : "text/csv", items.Length, Convert.ToBase64String(bytes),
|
||||
sha, bytes.Length);
|
||||
}
|
||||
|
||||
public async Task<CommissionSettlementItemDto> GenerateSettlementAsync(CommissionAdminActor actor, GenerateCommissionSettlementCommand command, CancellationToken cancellationToken = default)
|
||||
public async Task<CommissionSettlementItemDto> GenerateSettlementAsync(CommissionAdminActor actor,
|
||||
GenerateCommissionSettlementCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
if (command.EndDate < command.StartDate) throw new CommissionException("Commission period was invalid.", "invalid_commission_period");
|
||||
if (command.EndDate < command.StartDate)
|
||||
throw new CommissionException("Commission period was invalid.", "invalid_commission_period");
|
||||
var query = new CommissionPeriodQuery(command.StartDate, command.EndDate, command.ReferrerUserId, 5000);
|
||||
var candidates = (await BuildSourcesAsync(actor.TenantId, query, cancellationToken)).Where(item => item.SettlementId is null).ToArray();
|
||||
if (candidates.Length == 0) throw new CommissionException("No unsettled commission sources found.", "commission_no_unsettled_sources");
|
||||
var candidates = (await BuildSourcesAsync(actor.TenantId, query, cancellationToken))
|
||||
.Where(item => item.SettlementId is null).ToArray();
|
||||
if (candidates.Length == 0)
|
||||
throw new CommissionException("No unsettled commission sources found.", "commission_no_unsettled_sources");
|
||||
var settings = await GetSettingsCoreAsync(actor.TenantId, cancellationToken);
|
||||
var amount = candidates.Sum(item => item.CommissionAmountCents);
|
||||
if (amount < settings.MinSettlementCents) throw new CommissionException("Commission amount is below settlement minimum.", "commission_below_minimum");
|
||||
if (amount < settings.MinSettlementCents)
|
||||
throw new CommissionException("Commission amount is below settlement minimum.", "commission_below_minimum");
|
||||
var settlement = new CommissionSettlement
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
@@ -149,7 +175,6 @@ public sealed class CommissionService(
|
||||
};
|
||||
dbContext.CommissionSettlements.Add(settlement);
|
||||
foreach (var source in candidates)
|
||||
{
|
||||
dbContext.CommissionSettlementItems.Add(new CommissionSettlementItem
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
@@ -167,12 +192,12 @@ public sealed class CommissionService(
|
||||
AttributionType = source.AttributionType,
|
||||
Metadata = JsonSerializer.SerializeToElement(new { source = "commission_generate" })
|
||||
});
|
||||
}
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToSettlementItem(settlement);
|
||||
}
|
||||
|
||||
public async Task<CommissionSettlementItemDto> UpdateSettlementStatusAsync(CommissionAdminActor actor, UpdateCommissionSettlementStatusCommand command, CancellationToken cancellationToken = default)
|
||||
public async Task<CommissionSettlementItemDto> UpdateSettlementStatusAsync(CommissionAdminActor actor,
|
||||
UpdateCommissionSettlementStatusCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var item = await GetSettlementAsync(actor.TenantId, command.SettlementId, cancellationToken);
|
||||
@@ -182,11 +207,13 @@ public sealed class CommissionService(
|
||||
item.Status = status;
|
||||
item.Remark = command.ReviewNote ?? item.Remark;
|
||||
item.Metadata = command.Metadata ?? item.Metadata;
|
||||
if (status is CommissionSettlementStatus.Approved or CommissionSettlementStatus.Rejected or CommissionSettlementStatus.Cancelled)
|
||||
if (status is CommissionSettlementStatus.Approved or CommissionSettlementStatus.Rejected
|
||||
or CommissionSettlementStatus.Cancelled)
|
||||
{
|
||||
item.ReviewedBy = actor.UserId;
|
||||
item.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
if (status == CommissionSettlementStatus.Paid)
|
||||
{
|
||||
item.PaidBy = actor.UserId;
|
||||
@@ -194,11 +221,13 @@ public sealed class CommissionService(
|
||||
item.PaymentMethod = command.PaymentMethod ?? item.PaymentMethod;
|
||||
item.PaymentAccount = command.PaymentAccount ?? item.PaymentAccount;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToSettlementItem(item);
|
||||
}
|
||||
|
||||
public async Task<CommissionList<CommissionProofItem>> GetProofsAsync(CommissionAdminActor actor, Guid settlementId, CancellationToken cancellationToken = default)
|
||||
public async Task<CommissionList<CommissionProofItem>> GetProofsAsync(CommissionAdminActor actor, Guid settlementId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
await GetSettlementAsync(actor.TenantId, settlementId, cancellationToken);
|
||||
@@ -209,7 +238,8 @@ public sealed class CommissionService(
|
||||
return new CommissionList<CommissionProofItem>(items.Select(ToProofItem).ToArray());
|
||||
}
|
||||
|
||||
public async Task<CommissionProofItem> CreateProofAsync(CommissionAdminActor actor, CreateCommissionProofCommand command, CancellationToken cancellationToken = default)
|
||||
public async Task<CommissionProofItem> CreateProofAsync(CommissionAdminActor actor,
|
||||
CreateCommissionProofCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var settlement = await GetSettlementAsync(actor.TenantId, command.SettlementId, cancellationToken);
|
||||
@@ -235,14 +265,18 @@ public sealed class CommissionService(
|
||||
return ToProofItem(proof);
|
||||
}
|
||||
|
||||
public async Task<CommissionProofItem> UpdateProofStatusAsync(CommissionAdminActor actor, UpdateCommissionProofStatusCommand command, CancellationToken cancellationToken = default)
|
||||
public async Task<CommissionProofItem> UpdateProofStatusAsync(CommissionAdminActor actor,
|
||||
UpdateCommissionProofStatusCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var proof = await dbContext.CommissionSettlementProofs.FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.ProofId, cancellationToken)
|
||||
?? throw new CommissionException("Commission proof was not found.", "commission_proof_not_found");
|
||||
var proof = await dbContext.CommissionSettlementProofs.FirstOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.ProofId, cancellationToken)
|
||||
?? throw new CommissionException("Commission proof was not found.", "commission_proof_not_found");
|
||||
var status = ParseEnum(command.Status, CommissionProofStatus.Approved, "invalid_commission_proof_status");
|
||||
if (status == CommissionProofStatus.Submitted) throw new CommissionException("Cannot move proof back to submitted.", "invalid_commission_proof_status");
|
||||
if (proof.Status is CommissionProofStatus.Approved or CommissionProofStatus.Rejected or CommissionProofStatus.Voided && proof.Status != status)
|
||||
if (status == CommissionProofStatus.Submitted)
|
||||
throw new CommissionException("Cannot move proof back to submitted.", "invalid_commission_proof_status");
|
||||
if (proof.Status is CommissionProofStatus.Approved or CommissionProofStatus.Rejected
|
||||
or CommissionProofStatus.Voided && proof.Status != status)
|
||||
throw new CommissionException("Closed proof cannot change status.", "commission_proof_closed");
|
||||
proof.Status = status;
|
||||
proof.ReviewedBy = actor.UserId;
|
||||
@@ -253,7 +287,8 @@ public sealed class CommissionService(
|
||||
return ToProofItem(proof);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<SourceCandidate>> BuildSourcesAsync(Guid tenantId, CommissionPeriodQuery query, CancellationToken cancellationToken)
|
||||
private async Task<IReadOnlyCollection<SourceCandidate>> BuildSourcesAsync(Guid tenantId,
|
||||
CommissionPeriodQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var startDate = query.StartDate ?? today.AddDays(-29);
|
||||
@@ -267,27 +302,36 @@ public sealed class CommissionService(
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var result = new List<SourceCandidate>();
|
||||
var orders = await (
|
||||
from order in dbContext.Orders.AsNoTracking()
|
||||
join lead in dbContext.ReferralLeads.AsNoTracking()
|
||||
on new { order.TenantId, StudentUserId = order.UserId!.Value } equals new { lead.TenantId, lead.StudentUserId }
|
||||
where order.TenantId == tenantId && order.UserId != null && lead.ReferrerUserId != null &&
|
||||
order.Status == OrderStatus.Paid && order.PaidAt >= start && order.PaidAt < end
|
||||
select new { order, lead })
|
||||
from order in dbContext.Orders.AsNoTracking()
|
||||
join lead in dbContext.ReferralLeads.AsNoTracking()
|
||||
on new { order.TenantId, StudentUserId = order.UserId!.Value } equals new
|
||||
{ lead.TenantId, lead.StudentUserId }
|
||||
where order.TenantId == tenantId && order.UserId != null && lead.ReferrerUserId != null &&
|
||||
order.Status == OrderStatus.Paid && order.PaidAt >= start && order.PaidAt < end
|
||||
select new { order, lead })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
foreach (var row in orders)
|
||||
{
|
||||
if (query.ReferrerUserId.HasValue && row.lead.ReferrerUserId != query.ReferrerUserId) continue;
|
||||
var rate = GetMemberRate(settings.Config, row.lead.ReferrerUserId!.Value) ?? settings.DefaultRate;
|
||||
var settled = existing.FirstOrDefault(item => item.SourceType == CommissionSourceType.Order && item.SourceId == row.order.Id)?.SettlementId;
|
||||
result.Add(new SourceCandidate(CommissionSourceType.Order, row.order.Id, row.order.OrderNo, row.lead.ReferrerUserId.Value, row.order.UserId, row.order.AmountCents, rate, (int)Math.Round(row.order.AmountCents * rate), CommissionRateSource.Member, settled, row.order.PaidAt, "protected_lead"));
|
||||
var settled = existing
|
||||
.FirstOrDefault(item => item.SourceType == CommissionSourceType.Order && item.SourceId == row.order.Id)
|
||||
?.SettlementId;
|
||||
result.Add(new SourceCandidate(CommissionSourceType.Order, row.order.Id, row.order.OrderNo,
|
||||
row.lead.ReferrerUserId.Value, row.order.UserId, row.order.AmountCents, rate,
|
||||
(int)Math.Round(row.order.AmountCents * rate), CommissionRateSource.Member, settled, row.order.PaidAt,
|
||||
"protected_lead"));
|
||||
}
|
||||
|
||||
var codes = await (
|
||||
from code in dbContext.ActivationCodes.AsNoTracking()
|
||||
join batch in dbContext.CodeBatches.AsNoTracking()
|
||||
on new { code.TenantId, BatchId = code.BatchId } equals new { batch.TenantId, BatchId = (Guid?)batch.Id } into batches
|
||||
from batch in batches.DefaultIfEmpty()
|
||||
where code.TenantId == tenantId && code.IsUsed && code.AgentUserId != null && code.UsedAt >= start && code.UsedAt < end
|
||||
select new { code, batch })
|
||||
from code in dbContext.ActivationCodes.AsNoTracking()
|
||||
join batch in dbContext.CodeBatches.AsNoTracking()
|
||||
on new { code.TenantId, code.BatchId } equals new { batch.TenantId, BatchId = (Guid?)batch.Id } into
|
||||
batches
|
||||
from batch in batches.DefaultIfEmpty()
|
||||
where code.TenantId == tenantId && code.IsUsed && code.AgentUserId != null && code.UsedAt >= start &&
|
||||
code.UsedAt < end
|
||||
select new { code, batch })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
foreach (var row in codes)
|
||||
{
|
||||
@@ -296,9 +340,14 @@ public sealed class CommissionService(
|
||||
if (query.ReferrerUserId.HasValue && agentUserId != query.ReferrerUserId) continue;
|
||||
var rate = row.batch?.CommissionRate ?? GetMemberRate(settings.Config, agentUserId) ?? settings.DefaultRate;
|
||||
var sourceAmount = row.code.UnitPriceCents ?? row.batch?.DefaultUnitPriceCents ?? 0;
|
||||
var settled = existing.FirstOrDefault(item => item.SourceType == CommissionSourceType.ActivationCode && item.SourceId == row.code.Id)?.SettlementId;
|
||||
result.Add(new SourceCandidate(CommissionSourceType.ActivationCode, row.code.Id, row.code.Code, agentUserId, row.code.UsedBy, sourceAmount, rate, (int)Math.Round(sourceAmount * rate), row.batch?.CommissionRate is null ? CommissionRateSource.Member : CommissionRateSource.Batch, settled, row.code.UsedAt, "activation_code_agent"));
|
||||
var settled = existing.FirstOrDefault(item =>
|
||||
item.SourceType == CommissionSourceType.ActivationCode && item.SourceId == row.code.Id)?.SettlementId;
|
||||
result.Add(new SourceCandidate(CommissionSourceType.ActivationCode, row.code.Id, row.code.Code, agentUserId,
|
||||
row.code.UsedBy, sourceAmount, rate, (int)Math.Round(sourceAmount * rate),
|
||||
row.batch?.CommissionRate is null ? CommissionRateSource.Member : CommissionRateSource.Batch, settled,
|
||||
row.code.UsedAt, "activation_code_agent"));
|
||||
}
|
||||
|
||||
return result.OrderBy(item => item.SourcePaidAt).ToArray();
|
||||
}
|
||||
|
||||
@@ -312,25 +361,30 @@ public sealed class CommissionService(
|
||||
!memberRate.TryGetProperty("commissionRate", out var value) ||
|
||||
value.ValueKind != JsonValueKind.Number ||
|
||||
!value.TryGetDecimal(out var rate))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return rate;
|
||||
}
|
||||
|
||||
private async Task<TenantCommissionSetting> GetSettingsCoreAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await dbContext.TenantCommissionSettings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||
var settings =
|
||||
await dbContext.TenantCommissionSettings.FirstOrDefaultAsync(item => item.TenantId == tenantId,
|
||||
cancellationToken);
|
||||
if (settings is not null) return settings;
|
||||
settings = new TenantCommissionSetting { TenantId = tenantId };
|
||||
dbContext.TenantCommissionSettings.Add(settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
private async Task<CommissionSettlement> GetSettlementAsync(Guid tenantId, Guid settlementId, CancellationToken cancellationToken) =>
|
||||
await dbContext.CommissionSettlements.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Id == settlementId, cancellationToken)
|
||||
?? throw new CommissionException("Commission settlement was not found.", "commission_settlement_not_found");
|
||||
private async Task<CommissionSettlement> GetSettlementAsync(Guid tenantId, Guid settlementId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.CommissionSettlements.FirstOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.Id == settlementId, cancellationToken)
|
||||
?? throw new CommissionException("Commission settlement was not found.",
|
||||
"commission_settlement_not_found");
|
||||
}
|
||||
|
||||
private async Task AssertAdminAsync(CommissionAdminActor actor, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -339,39 +393,91 @@ public sealed class CommissionService(
|
||||
access.TenantId != actor.TenantId ||
|
||||
access.UserId != actor.UserId ||
|
||||
!access.HasTenantPermission(BackendPermissions.TenantCommissionManage))
|
||||
{
|
||||
throw new CommissionException("Commission admin access was denied.", "commission_access_denied");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddAuditAsync(CommissionAdminActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken)
|
||||
private async Task AddAuditAsync(CommissionAdminActor actor, string action, string targetType, Guid targetId,
|
||||
object details, CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.AuditLogs.Add(new AuditLog { TenantId = actor.TenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) });
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
TenantId = actor.TenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType,
|
||||
TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details)
|
||||
});
|
||||
await Task.CompletedTask.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildCsv(IEnumerable<CommissionSettlementItem> items)
|
||||
{
|
||||
var builder = new StringBuilder("sourceType,sourceNo,referrerUserId,studentUserId,grossAmountCents,commissionRate,commissionAmountCents\n");
|
||||
var builder =
|
||||
new StringBuilder(
|
||||
"sourceType,sourceNo,referrerUserId,studentUserId,grossAmountCents,commissionRate,commissionAmountCents\n");
|
||||
foreach (var item in items)
|
||||
builder.Append(CultureInfo.InvariantCulture, $"{item.SourceType},{item.SourceNo},{item.ReferrerUserId},{item.StudentUserId},{item.GrossAmountCents},{item.CommissionRate},{item.CommissionAmountCents}\n");
|
||||
builder.Append(CultureInfo.InvariantCulture,
|
||||
$"{item.SourceType},{item.SourceNo},{item.ReferrerUserId},{item.StudentUserId},{item.GrossAmountCents},{item.CommissionRate},{item.CommissionAmountCents}\n");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static CommissionSettingsItem ToSettingsItem(TenantCommissionSetting item) => new(item.DefaultRate, item.MinSettlementCents, item.SettlementCycle.ToString(), item.Config);
|
||||
private static CommissionSourceItem ToSourceItem(SourceCandidate item) => new(item.SourceType.ToString(), item.SourceId, item.SourceNo, item.ReferrerUserId, item.StudentUserId, item.GrossAmountCents, item.CommissionRate, item.CommissionAmountCents, item.RateSource.ToString(), item.SettlementId);
|
||||
private static CommissionSourceItem ToSourceItem(CommissionSettlementItem item) => new(item.SourceType.ToString(), item.SourceId, item.SourceNo, item.ReferrerUserId, item.StudentUserId, item.GrossAmountCents, item.CommissionRate, item.CommissionAmountCents, item.RateSource.ToString(), item.SettlementId);
|
||||
private static CommissionSettlementItemDto ToSettlementItem(CommissionSettlement item) => new(item.Id, item.SettlementNo, item.ReferrerUserId, item.Status.ToString(), item.PeriodStart, item.PeriodEnd, item.SourceCount, item.PaidUserCount, item.GrossAmountCents, item.CommissionAmountCents, item.ReviewedAt, item.PaidAt);
|
||||
private static CommissionProofItem ToProofItem(CommissionSettlementProof item) => new(item.Id, item.SettlementId, item.ProofType.ToString(), item.Status.ToString(), item.Title, item.AssetId, item.ExternalUrl, item.AmountCents, item.PaymentMethod, item.PaymentAccount, item.PaidAt, item.ReviewedAt);
|
||||
private static CommissionSettingsItem ToSettingsItem(TenantCommissionSetting item)
|
||||
{
|
||||
return new CommissionSettingsItem(item.DefaultRate, item.MinSettlementCents, item.SettlementCycle.ToString(),
|
||||
item.Config);
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum defaultValue, string errorCode) where TEnum : struct, Enum
|
||||
private static CommissionSourceItem ToSourceItem(SourceCandidate item)
|
||||
{
|
||||
return new CommissionSourceItem(item.SourceType.ToString(), item.SourceId, item.SourceNo, item.ReferrerUserId,
|
||||
item.StudentUserId,
|
||||
item.GrossAmountCents, item.CommissionRate, item.CommissionAmountCents, item.RateSource.ToString(),
|
||||
item.SettlementId);
|
||||
}
|
||||
|
||||
private static CommissionSourceItem ToSourceItem(CommissionSettlementItem item)
|
||||
{
|
||||
return new CommissionSourceItem(item.SourceType.ToString(), item.SourceId, item.SourceNo, item.ReferrerUserId,
|
||||
item.StudentUserId,
|
||||
item.GrossAmountCents, item.CommissionRate, item.CommissionAmountCents, item.RateSource.ToString(),
|
||||
item.SettlementId);
|
||||
}
|
||||
|
||||
private static CommissionSettlementItemDto ToSettlementItem(CommissionSettlement item)
|
||||
{
|
||||
return new CommissionSettlementItemDto(item.Id, item.SettlementNo, item.ReferrerUserId, item.Status.ToString(),
|
||||
item.PeriodStart,
|
||||
item.PeriodEnd, item.SourceCount, item.PaidUserCount, item.GrossAmountCents, item.CommissionAmountCents,
|
||||
item.ReviewedAt, item.PaidAt);
|
||||
}
|
||||
|
||||
private static CommissionProofItem ToProofItem(CommissionSettlementProof item)
|
||||
{
|
||||
return new CommissionProofItem(item.Id, item.SettlementId, item.ProofType.ToString(), item.Status.ToString(),
|
||||
item.Title,
|
||||
item.AssetId, item.ExternalUrl, item.AmountCents, item.PaymentMethod, item.PaymentAccount, item.PaidAt,
|
||||
item.ReviewedAt);
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum defaultValue, string errorCode)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return defaultValue;
|
||||
var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal);
|
||||
foreach (var enumValue in Enum.GetValues<TEnum>())
|
||||
if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) return enumValue;
|
||||
if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase))
|
||||
return enumValue;
|
||||
throw new CommissionException("Commission enum value was invalid.", errorCode);
|
||||
}
|
||||
|
||||
private sealed record SourceCandidate(CommissionSourceType SourceType, Guid SourceId, string? SourceNo, Guid ReferrerUserId, Guid? StudentUserId, int GrossAmountCents, decimal CommissionRate, int CommissionAmountCents, CommissionRateSource RateSource, Guid? SettlementId, DateTimeOffset? SourcePaidAt, string AttributionType);
|
||||
}
|
||||
private sealed record SourceCandidate(
|
||||
CommissionSourceType SourceType,
|
||||
Guid SourceId,
|
||||
string? SourceNo,
|
||||
Guid ReferrerUserId,
|
||||
Guid? StudentUserId,
|
||||
int GrossAmountCents,
|
||||
decimal CommissionRate,
|
||||
int CommissionAmountCents,
|
||||
CommissionRateSource RateSource,
|
||||
Guid? SettlementId,
|
||||
DateTimeOffset? SourcePaidAt,
|
||||
string AttributionType);
|
||||
}
|
||||
@@ -66,7 +66,8 @@ internal sealed class CrmService(
|
||||
config.ExamType = NormalizeOptional(command.ExamType);
|
||||
config.TimeoutSeconds = command.TimeoutSeconds;
|
||||
config.DelaySeconds = command.DelaySeconds;
|
||||
config.AssignmentMode = ParseEnum(command.AssignmentMode, ReferralAssignmentMode.None, "invalid_assignment_mode");
|
||||
config.AssignmentMode =
|
||||
ParseEnum(command.AssignmentMode, ReferralAssignmentMode.None, "invalid_assignment_mode");
|
||||
config.AssignmentPool = EnsureArray(command.AssignmentPool);
|
||||
config.AssignmentConfig = EnsureObject(command.AssignmentConfig);
|
||||
|
||||
@@ -107,9 +108,8 @@ internal sealed class CrmService(
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
deadLetters = deadLetters.Where(item => item.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Failed, "invalid_crm_queue_status"));
|
||||
}
|
||||
deadLetters = deadLetters.Where(item =>
|
||||
item.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Failed, "invalid_crm_queue_status"));
|
||||
|
||||
var items = await deadLetters
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
@@ -144,11 +144,11 @@ internal sealed class CrmService(
|
||||
if (query.QueueId.HasValue)
|
||||
{
|
||||
var queue = await dbContext.CrmWebhookQueue
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == query.QueueId.Value)
|
||||
.Select(item => new { item.Id, item.RecordId })
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
?? throw new CrmException("CRM queue item was not found.", "crm_queue_not_found");
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == query.QueueId.Value)
|
||||
.Select(item => new { item.Id, item.RecordId })
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
?? throw new CrmException("CRM queue item was not found.", "crm_queue_not_found");
|
||||
logs = logs.Where(item => item.RecordId == queue.RecordId || item.RecordId == queue.Id.ToString());
|
||||
}
|
||||
|
||||
@@ -166,15 +166,15 @@ internal sealed class CrmService(
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var item = await dbContext.CrmWebhookQueue
|
||||
.FirstOrDefaultAsync(entry => entry.TenantId == actor.TenantId && entry.Id == command.QueueId, cancellationToken)
|
||||
?? throw new CrmException("CRM queue item was not found.", "crm_queue_not_found");
|
||||
.FirstOrDefaultAsync(entry => entry.TenantId == actor.TenantId && entry.Id == command.QueueId,
|
||||
cancellationToken)
|
||||
?? throw new CrmException("CRM queue item was not found.", "crm_queue_not_found");
|
||||
var action = NormalizeOptional(command.Action) ?? "retry";
|
||||
if (action.Equals("retry", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (item.Status is not (CrmWebhookQueueStatus.Failed or CrmWebhookQueueStatus.Discarded or CrmWebhookQueueStatus.Retrying))
|
||||
{
|
||||
if (item.Status is not (CrmWebhookQueueStatus.Failed or CrmWebhookQueueStatus.Discarded
|
||||
or CrmWebhookQueueStatus.Retrying))
|
||||
throw new CrmException("CRM queue item cannot be retried.", "crm_queue_status_invalid");
|
||||
}
|
||||
|
||||
item.Status = CrmWebhookQueueStatus.Pending;
|
||||
item.NextAttemptAt = DateTimeOffset.UtcNow;
|
||||
@@ -183,9 +183,7 @@ internal sealed class CrmService(
|
||||
else if (action.Equals("ignore", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (item.Status is CrmWebhookQueueStatus.Sent)
|
||||
{
|
||||
throw new CrmException("CRM queue item cannot be ignored.", "crm_queue_status_invalid");
|
||||
}
|
||||
|
||||
item.Status = CrmWebhookQueueStatus.Discarded;
|
||||
item.LastError = NormalizeOptional(command.Note) ?? item.LastError;
|
||||
@@ -215,15 +213,11 @@ internal sealed class CrmService(
|
||||
var items = dbContext.CrmWebhookQueue
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId);
|
||||
if (query.QueueId.HasValue)
|
||||
{
|
||||
items = items.Where(item => item.Id == query.QueueId.Value);
|
||||
}
|
||||
if (query.QueueId.HasValue) items = items.Where(item => item.Id == query.QueueId.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
items = items.Where(item => item.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Pending, "invalid_crm_queue_status"));
|
||||
}
|
||||
items = items.Where(item =>
|
||||
item.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Pending, "invalid_crm_queue_status"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Source))
|
||||
{
|
||||
@@ -241,7 +235,8 @@ internal sealed class CrmService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await dbContext.TenantSecrets
|
||||
.FirstOrDefaultAsync(secretItem => secretItem.TenantId == tenantId && secretItem.SecretRef == secretRef, cancellationToken);
|
||||
.FirstOrDefaultAsync(secretItem => secretItem.TenantId == tenantId && secretItem.SecretRef == secretRef,
|
||||
cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
item = new TenantSecret
|
||||
@@ -278,9 +273,7 @@ internal sealed class CrmService(
|
||||
access.TenantId != actor.TenantId ||
|
||||
!access.HasTenantPermission(BackendPermissions.TenantCrmManage) ||
|
||||
access.DataScope.Mode != DataScopeMode.All)
|
||||
{
|
||||
throw new CrmException("CRM admin access was denied.", "crm_access_denied");
|
||||
}
|
||||
}
|
||||
|
||||
private static CrmConfigItem ToConfigItem(CrmConfig item)
|
||||
@@ -334,14 +327,10 @@ internal sealed class CrmService(
|
||||
private static JsonElement EnsureObject(JsonElement? value)
|
||||
{
|
||||
if (value is null || value.Value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||
{
|
||||
return JsonDefaults.Object();
|
||||
}
|
||||
|
||||
if (value.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new CrmException("CRM JSON value must be an object.", "invalid_json_payload");
|
||||
}
|
||||
|
||||
return value.Value.Clone();
|
||||
}
|
||||
@@ -349,21 +338,17 @@ internal sealed class CrmService(
|
||||
private static JsonElement EnsureArray(JsonElement? value)
|
||||
{
|
||||
if (value is null || value.Value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||
{
|
||||
return JsonDefaults.Array();
|
||||
}
|
||||
|
||||
if (value.Value.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new CrmException("CRM JSON value must be an array.", "invalid_json_payload");
|
||||
}
|
||||
|
||||
return value.Value.Clone();
|
||||
}
|
||||
|
||||
private static JsonElement Redact(JsonElement value)
|
||||
{
|
||||
object? converted = RedactValue(value);
|
||||
var converted = RedactValue(value);
|
||||
return JsonSerializer.SerializeToElement(converted);
|
||||
}
|
||||
|
||||
@@ -387,16 +372,10 @@ internal sealed class CrmService(
|
||||
|
||||
private static string? RedactText(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(value)) return value;
|
||||
|
||||
var result = value;
|
||||
foreach (var key in SensitiveKeys)
|
||||
{
|
||||
result = result.Replace(key, "***", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
foreach (var key in SensitiveKeys) result = result.Replace(key, "***", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -404,19 +383,12 @@ internal sealed class CrmService(
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum defaultValue, string errorCode)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(value)) return defaultValue;
|
||||
|
||||
var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal);
|
||||
foreach (var enumValue in Enum.GetValues<TEnum>())
|
||||
{
|
||||
if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return enumValue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new CrmException("CRM enum value was invalid.", errorCode);
|
||||
}
|
||||
@@ -425,4 +397,4 @@ internal sealed class CrmService(
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,7 @@ using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Growth;
|
||||
|
||||
@@ -31,20 +29,17 @@ public sealed partial class ReferralService
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var lead = await dbContext.ReferralLeads
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.StudentUserId == studentUserId,
|
||||
item.TenantId == tenantId &&
|
||||
item.StudentUserId == studentUserId,
|
||||
cancellationToken);
|
||||
if (lead is not null)
|
||||
{
|
||||
if (lead.ReferrerUserId == referrerUserId)
|
||||
{
|
||||
return lead;
|
||||
}
|
||||
if (lead.ReferrerUserId == referrerUserId) return lead;
|
||||
|
||||
if (!force && lead.Status == ReferralLeadStatus.Protected && (lead.ProtectedUntil is null || lead.ProtectedUntil > now))
|
||||
{
|
||||
throw new ReferralException("Referral lead is protected and cannot be rebound.", "referral_lead_protected");
|
||||
}
|
||||
if (!force && lead.Status == ReferralLeadStatus.Protected &&
|
||||
(lead.ProtectedUntil is null || lead.ProtectedUntil > now))
|
||||
throw new ReferralException("Referral lead is protected and cannot be rebound.",
|
||||
"referral_lead_protected");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -76,19 +71,14 @@ public sealed partial class ReferralService
|
||||
var config = await dbContext.CrmConfigs
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Enabled, cancellationToken);
|
||||
if (config is null || string.IsNullOrWhiteSpace(config.Url))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (config is null || string.IsNullOrWhiteSpace(config.Url)) return null;
|
||||
|
||||
var recordId = lead.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||
var idempotencyKey = $"{source}:{recordId}";
|
||||
var existing = await dbContext.CrmWebhookQueue
|
||||
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.IdempotencyKey == idempotencyKey, cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.IdempotencyKey == idempotencyKey,
|
||||
cancellationToken);
|
||||
if (existing is not null) return existing;
|
||||
|
||||
var queue = new CrmWebhookQueueItem
|
||||
{
|
||||
@@ -118,14 +108,15 @@ public sealed partial class ReferralService
|
||||
return queue;
|
||||
}
|
||||
|
||||
private async Task<ReferralCode?> ResolveCodeCoreAsync(Guid tenantId, string code, CancellationToken cancellationToken)
|
||||
private async Task<ReferralCode?> ResolveCodeCoreAsync(Guid tenantId, string code,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.ReferralCodes
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.Code == code &&
|
||||
item.Status == ReferralCodeStatus.Active,
|
||||
item.TenantId == tenantId &&
|
||||
item.Code == code &&
|
||||
item.Status == ReferralCodeStatus.Active,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
@@ -137,10 +128,7 @@ public sealed partial class ReferralService
|
||||
item.UserId == userId &&
|
||||
item.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new ReferralException("Tenant member was not found.", "tenant_access_denied");
|
||||
}
|
||||
if (!exists) throw new ReferralException("Tenant member was not found.", "tenant_access_denied");
|
||||
}
|
||||
|
||||
private async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken)
|
||||
@@ -150,9 +138,7 @@ public sealed partial class ReferralService
|
||||
access.TenantId != actor.TenantId ||
|
||||
access.UserId != actor.UserId ||
|
||||
!access.HasTenantPermission(BackendPermissions.TenantCrmManage))
|
||||
{
|
||||
throw new ReferralException("Referral admin access was denied.", "referral_access_denied");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ReferralStatsItem> BuildStatsAsync(
|
||||
@@ -217,10 +203,7 @@ public sealed partial class ReferralService
|
||||
var exists = await dbContext.ReferralCodes.AnyAsync(
|
||||
item => item.TenantId == tenantId && item.Code == code,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
return code;
|
||||
}
|
||||
if (!exists) return code;
|
||||
}
|
||||
|
||||
throw new ReferralException("Could not generate referral code.", "referral_code_generation_failed");
|
||||
@@ -232,17 +215,15 @@ public sealed partial class ReferralService
|
||||
Span<byte> bytes = stackalloc byte[8];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
Span<char> chars = stackalloc char[8];
|
||||
for (var index = 0; index < chars.Length; index++)
|
||||
{
|
||||
chars[index] = alphabet[bytes[index] % alphabet.Length];
|
||||
}
|
||||
for (var index = 0; index < chars.Length; index++) chars[index] = alphabet[bytes[index] % alphabet.Length];
|
||||
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
private static Guid RequireUser(ReferralActor actor)
|
||||
{
|
||||
return actor.UserId ?? throw new ReferralException("Current referral actor was not resolved.", "referral_access_denied");
|
||||
return actor.UserId ??
|
||||
throw new ReferralException("Current referral actor was not resolved.", "referral_access_denied");
|
||||
}
|
||||
|
||||
private static string? NormalizeCode(string? value)
|
||||
@@ -258,10 +239,7 @@ public sealed partial class ReferralService
|
||||
string defaultValue,
|
||||
string errorCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(value)) return defaultValue;
|
||||
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
return allowed.Contains(normalized)
|
||||
@@ -272,19 +250,12 @@ public sealed partial class ReferralService
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum defaultValue, string errorCode)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(value)) return defaultValue;
|
||||
|
||||
var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal);
|
||||
foreach (var enumValue in Enum.GetValues<TEnum>())
|
||||
{
|
||||
if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return enumValue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ReferralException("Referral enum value was invalid.", errorCode);
|
||||
}
|
||||
@@ -356,4 +327,4 @@ public sealed partial class ReferralService
|
||||
item.Status.ToString(),
|
||||
item.Metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ public sealed class ReferralQrcodeGenerator(
|
||||
var provider = NormalizeProvider(request.Provider);
|
||||
return provider == "wechat-miniapp"
|
||||
? await GenerateWechatMiniappAsync(request with { Provider = provider }, cancellationToken)
|
||||
: throw new ReferralException("Referral qrcode provider is not supported.", "referral_qrcode_provider_not_supported");
|
||||
: throw new ReferralException("Referral qrcode provider is not supported.",
|
||||
"referral_qrcode_provider_not_supported");
|
||||
}
|
||||
|
||||
private async Task<ReferralQrcodeGenerateResult> GenerateWechatMiniappAsync(
|
||||
@@ -39,7 +40,7 @@ public sealed class ReferralQrcodeGenerator(
|
||||
var imageBytes = await GenerateWechatMiniappImageAsync(options, request, cancellationToken);
|
||||
var checksum = Convert.ToHexString(SHA256.HashData(imageBytes)).ToLowerInvariant();
|
||||
var objectKey = BuildObjectKey(request.TenantId, request.UserId, request.RefCode, request.Page, request.Scene);
|
||||
await using var content = new MemoryStream(imageBytes, writable: false);
|
||||
await using var content = new MemoryStream(imageBytes, false);
|
||||
var storage = await objectStorageService.WriteObjectAsync(
|
||||
new ObjectStorageWriteRequest(
|
||||
request.TenantId,
|
||||
@@ -59,11 +60,9 @@ public sealed class ReferralQrcodeGenerator(
|
||||
cancellationToken);
|
||||
|
||||
if (storage.PublicUrl is null)
|
||||
{
|
||||
throw new ReferralException(
|
||||
"Referral qrcode storage public base URL is not configured.",
|
||||
"referral_qrcode_public_url_not_configured");
|
||||
}
|
||||
|
||||
return new ReferralQrcodeGenerateResult(
|
||||
storage.PublicUrl.ToString(),
|
||||
@@ -85,7 +84,6 @@ public sealed class ReferralQrcodeGenerator(
|
||||
{
|
||||
TenantExternalProviderAccount? provider = null;
|
||||
foreach (var alias in WechatMiniappProviderAliases)
|
||||
{
|
||||
try
|
||||
{
|
||||
provider = await providerConfigService.GetActiveProviderAsync(
|
||||
@@ -98,23 +96,18 @@ public sealed class ReferralQrcodeGenerator(
|
||||
catch (TenantExternalProviderException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (provider is null)
|
||||
{
|
||||
throw new ReferralException(
|
||||
"Wechat miniapp auth provider is not configured.",
|
||||
"referral_qrcode_provider_not_configured");
|
||||
}
|
||||
|
||||
var appId = GetJsonString(provider.ConfigPublic, "appId", "clientId");
|
||||
var appSecret = GetJsonString(provider.SecretPayload, "appSecret", "clientSecret", "secret");
|
||||
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret))
|
||||
{
|
||||
throw new ReferralException(
|
||||
"Wechat miniapp auth provider configuration is incomplete.",
|
||||
"referral_qrcode_provider_not_configured");
|
||||
}
|
||||
|
||||
return new WechatMiniappOptions(appId, appSecret);
|
||||
}
|
||||
@@ -129,8 +122,7 @@ public sealed class ReferralQrcodeGenerator(
|
||||
{
|
||||
var accessToken = await AccessTokenContainer.TryGetAccessTokenAsync(
|
||||
options.AppId,
|
||||
options.AppSecret,
|
||||
false);
|
||||
options.AppSecret);
|
||||
await using var imageStream = new MemoryStream();
|
||||
var result = await WxAppApi.GetWxaCodeUnlimitAsync(
|
||||
accessToken,
|
||||
@@ -147,19 +139,15 @@ public sealed class ReferralQrcodeGenerator(
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (result.errcode != 0)
|
||||
{
|
||||
throw new ReferralException(
|
||||
$"Wechat miniapp qrcode generation failed: {result.errcode}.",
|
||||
"referral_qrcode_wechat_failed");
|
||||
}
|
||||
|
||||
var imageBytes = imageStream.ToArray();
|
||||
if (imageBytes.Length == 0)
|
||||
{
|
||||
throw new ReferralException(
|
||||
"Wechat miniapp qrcode response was empty.",
|
||||
"referral_qrcode_wechat_empty");
|
||||
}
|
||||
|
||||
return imageBytes;
|
||||
}
|
||||
@@ -212,23 +200,16 @@ public sealed class ReferralQrcodeGenerator(
|
||||
|
||||
private static string? GetJsonString(JsonElement element, params string[] names)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (element.TryGetProperty(name, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(property.GetString()))
|
||||
{
|
||||
return property.GetString()!.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record WechatMiniappOptions(string AppId, string AppSecret);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Growth;
|
||||
@@ -38,6 +29,4 @@ public sealed partial class ReferralService(
|
||||
"manual",
|
||||
"unknown"
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Growth;
|
||||
|
||||
@@ -25,20 +19,14 @@ public sealed partial class ReferralService
|
||||
|
||||
var existing = await dbContext.ReferralCodes
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == userId,
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == userId,
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(command.Channel))
|
||||
{
|
||||
existing.Channel = command.Channel.Trim();
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(command.Channel)) existing.Channel = command.Channel.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(command.LandingPath))
|
||||
{
|
||||
existing.LandingPath = command.LandingPath.Trim();
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(command.LandingPath)) existing.LandingPath = command.LandingPath.Trim();
|
||||
|
||||
existing.Status = ReferralCodeStatus.Active;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
@@ -66,30 +54,28 @@ public sealed partial class ReferralService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var code = NormalizeCode(command.Code);
|
||||
if (code is null)
|
||||
{
|
||||
return new ReferralResolutionItem(false, null, null, null, null);
|
||||
}
|
||||
if (code is null) return new ReferralResolutionItem(false, null, null, null, null);
|
||||
|
||||
var row = await (
|
||||
from referralCode in dbContext.ReferralCodes.AsNoTracking()
|
||||
join membership in dbContext.TenantMemberships.AsNoTracking()
|
||||
on new { referralCode.TenantId, referralCode.UserId } equals new { membership.TenantId, membership.UserId }
|
||||
join user in dbContext.Users.AsNoTracking()
|
||||
on referralCode.UserId equals user.Id
|
||||
where referralCode.TenantId == actor.TenantId &&
|
||||
referralCode.Code == code &&
|
||||
referralCode.Status == ReferralCodeStatus.Active &&
|
||||
membership.Status == MembershipStatus.Active
|
||||
select new
|
||||
{
|
||||
referralCode.Code,
|
||||
referralCode.UserId,
|
||||
membership.Role,
|
||||
user.Name,
|
||||
user.UserName,
|
||||
user.Phone
|
||||
})
|
||||
from referralCode in dbContext.ReferralCodes.AsNoTracking()
|
||||
join membership in dbContext.TenantMemberships.AsNoTracking()
|
||||
on new { referralCode.TenantId, referralCode.UserId } equals new
|
||||
{ membership.TenantId, membership.UserId }
|
||||
join user in dbContext.Users.AsNoTracking()
|
||||
on referralCode.UserId equals user.Id
|
||||
where referralCode.TenantId == actor.TenantId &&
|
||||
referralCode.Code == code &&
|
||||
referralCode.Status == ReferralCodeStatus.Active &&
|
||||
membership.Status == MembershipStatus.Active
|
||||
select new
|
||||
{
|
||||
referralCode.Code,
|
||||
referralCode.UserId,
|
||||
membership.Role,
|
||||
user.Name,
|
||||
user.UserName,
|
||||
user.Phone
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return row is null
|
||||
@@ -143,18 +129,12 @@ public sealed partial class ReferralService
|
||||
command.Metadata,
|
||||
cancellationToken);
|
||||
var setFirstTrack = lead.FirstTrackId is null;
|
||||
if (setFirstTrack)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
if (setFirstTrack) await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
dbContext.ReferralTracks.Add(track);
|
||||
track.LeadId = lead.Id;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (setFirstTrack)
|
||||
{
|
||||
lead.FirstTrackId = track.Id;
|
||||
}
|
||||
if (setFirstTrack) lead.FirstTrackId = track.Id;
|
||||
|
||||
crmQueue = await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.track_event", cancellationToken);
|
||||
}
|
||||
@@ -165,7 +145,8 @@ public sealed partial class ReferralService
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new ReferralTrackResult(ToTrackItem(track), lead is null ? null : ToLeadItem(lead, true), ToQueuePreview(crmQueue));
|
||||
return new ReferralTrackResult(ToTrackItem(track), lead is null ? null : ToLeadItem(lead, true),
|
||||
ToQueuePreview(crmQueue));
|
||||
}
|
||||
|
||||
public async Task<ReferralBindResult> BindAsync(
|
||||
@@ -181,14 +162,12 @@ public sealed partial class ReferralService
|
||||
var resolution = await ResolveCodeCoreAsync(actor.TenantId, code, cancellationToken)
|
||||
?? throw new ReferralException("Referral code was not found.", "referral_code_not_found");
|
||||
if (resolution.UserId == userId)
|
||||
{
|
||||
throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed");
|
||||
}
|
||||
|
||||
var existing = await dbContext.ReferralLeads
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.StudentUserId == userId,
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.StudentUserId == userId,
|
||||
cancellationToken);
|
||||
var beforeReferrerId = existing?.ReferrerUserId;
|
||||
var lead = await BindLeadCoreAsync(
|
||||
@@ -206,7 +185,8 @@ public sealed partial class ReferralService
|
||||
: await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.bind", cancellationToken);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ReferralBindResult(ToLeadItem(lead, beforeReferrerId != lead.ReferrerUserId), ToQueuePreview(crmQueue));
|
||||
return new ReferralBindResult(ToLeadItem(lead, beforeReferrerId != lead.ReferrerUserId),
|
||||
ToQueuePreview(crmQueue));
|
||||
}
|
||||
|
||||
public async Task<ReferralQrcodeItem> GetOrCreateQrcodeAsync(
|
||||
@@ -216,7 +196,8 @@ public sealed partial class ReferralService
|
||||
{
|
||||
var userId = RequireUser(actor);
|
||||
await AssertActiveMemberAsync(actor.TenantId, userId, cancellationToken);
|
||||
var refCode = (await GetOrCreateInviteCodeAsync(actor, new ReferralInviteCommand("qrcode"), cancellationToken)).InviteCode;
|
||||
var refCode = (await GetOrCreateInviteCodeAsync(actor, new ReferralInviteCommand("qrcode"), cancellationToken))
|
||||
.InviteCode;
|
||||
var page = NormalizeOptional(command.Page) ?? "pages/index/index";
|
||||
var provider = NormalizeOptional(command.Provider) ?? "wechat-miniapp";
|
||||
var scene = NormalizeOptional(command.Scene) ?? $"ref={refCode}";
|
||||
@@ -233,10 +214,10 @@ public sealed partial class ReferralService
|
||||
|
||||
var item = await dbContext.ReferralQrcodes
|
||||
.FirstOrDefaultAsync(entry =>
|
||||
entry.TenantId == actor.TenantId &&
|
||||
entry.Provider == generated.Provider &&
|
||||
entry.Scene == scene &&
|
||||
entry.Page == page,
|
||||
entry.TenantId == actor.TenantId &&
|
||||
entry.Provider == generated.Provider &&
|
||||
entry.Scene == scene &&
|
||||
entry.Page == page,
|
||||
cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
@@ -261,6 +242,4 @@ public sealed partial class ReferralService
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToQrcodeItem(item);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Growth;
|
||||
|
||||
@@ -22,9 +14,7 @@ public sealed partial class ReferralService
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
if (command.StudentUserId == command.ReferrerUserId)
|
||||
{
|
||||
throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed");
|
||||
}
|
||||
|
||||
var refCode = await dbContext.ReferralCodes
|
||||
.Where(item =>
|
||||
@@ -34,18 +24,16 @@ public sealed partial class ReferralService
|
||||
.Select(item => item.Code)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (refCode is null)
|
||||
{
|
||||
refCode = (await GetOrCreateInviteCodeAsync(
|
||||
new ReferralActor(actor.TenantId, command.ReferrerUserId),
|
||||
new ReferralInviteCommand("manual"),
|
||||
cancellationToken)).InviteCode;
|
||||
}
|
||||
|
||||
var before = await dbContext.ReferralLeads
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.StudentUserId == command.StudentUserId,
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.StudentUserId == command.StudentUserId,
|
||||
cancellationToken);
|
||||
var lead = await BindLeadCoreAsync(
|
||||
actor.TenantId,
|
||||
@@ -63,7 +51,8 @@ public sealed partial class ReferralService
|
||||
? null
|
||||
: await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.manual_bind", cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ReferralBindResult(ToLeadItem(lead, before?.ReferrerUserId != lead.ReferrerUserId), ToQueuePreview(crmQueue));
|
||||
return new ReferralBindResult(ToLeadItem(lead, before?.ReferrerUserId != lead.ReferrerUserId),
|
||||
ToQueuePreview(crmQueue));
|
||||
}
|
||||
|
||||
public async Task<ReferralList<ReferralTeamItem>> GetTeamAsync(
|
||||
@@ -75,10 +64,7 @@ public sealed partial class ReferralService
|
||||
var edges = dbContext.ReferralTeamEdges
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (query.LeaderUserId.HasValue)
|
||||
{
|
||||
edges = edges.Where(item => item.LeaderUserId == query.LeaderUserId.Value);
|
||||
}
|
||||
if (query.LeaderUserId.HasValue) edges = edges.Where(item => item.LeaderUserId == query.LeaderUserId.Value);
|
||||
|
||||
var items = await edges
|
||||
.OrderBy(item => item.RelationType)
|
||||
@@ -95,17 +81,16 @@ public sealed partial class ReferralService
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
await AssertActiveMemberAsync(actor.TenantId, command.MemberUserId, cancellationToken);
|
||||
if (command.LeaderUserId.HasValue)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor.TenantId, command.LeaderUserId.Value, cancellationToken);
|
||||
}
|
||||
|
||||
var relationType = ParseEnum(command.RelationType, ReferralTeamRelationType.SalesTeam, "invalid_referral_team_relation");
|
||||
var relationType = ParseEnum(command.RelationType, ReferralTeamRelationType.SalesTeam,
|
||||
"invalid_referral_team_relation");
|
||||
var status = ParseEnum(command.Status, ReferralTeamEdgeStatus.Active, "invalid_referral_team_status");
|
||||
var edge = await dbContext.ReferralTeamEdges
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.MemberUserId == command.MemberUserId &&
|
||||
item.RelationType == relationType,
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.MemberUserId == command.MemberUserId &&
|
||||
item.RelationType == relationType,
|
||||
cancellationToken);
|
||||
if (edge is null)
|
||||
{
|
||||
@@ -124,6 +109,4 @@ public sealed partial class ReferralService
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToTeamItem(edge);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Tiku.Infrastructure.Jobs;
|
||||
|
||||
@@ -24,6 +10,4 @@ internal sealed partial class BackgroundJobService(
|
||||
IFeatureAccessService featureAccessService) : IBackgroundJobService
|
||||
{
|
||||
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Tiku.Infrastructure.Jobs;
|
||||
|
||||
@@ -30,21 +17,24 @@ internal sealed partial class BackgroundJobService
|
||||
var normalized = value?.Trim();
|
||||
if (string.IsNullOrEmpty(normalized)) return null;
|
||||
if (normalized.Length > 200)
|
||||
{
|
||||
throw new BackgroundJobException("background_job_idempotency_key_too_long", "Idempotency key cannot exceed 200 characters.");
|
||||
}
|
||||
throw new BackgroundJobException("background_job_idempotency_key_too_long",
|
||||
"Idempotency key cannot exceed 200 characters.");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string ResolveRequiredFeature(string jobType, JsonElement payload) => jobType switch
|
||||
private static string ResolveRequiredFeature(string jobType, JsonElement payload)
|
||||
{
|
||||
"content_import" => SaasFeatureCatalog.ResolveContentImportFeature(GetJsonString(payload, "importType"))
|
||||
?? throw new InvalidOperationException("Background content import type is not supported."),
|
||||
"content_export" or "asset_security_scan" => SaasFeatureCatalog.PrivateQuestionBank,
|
||||
"commerce_reconciliation" => SaasFeatureCatalog.StudentStore,
|
||||
"statistics_aggregation" or "tenant_domain_recheck" => SaasFeatureCatalog.CoreBackoffice,
|
||||
_ => SaasFeatureCatalog.CoreBackoffice
|
||||
};
|
||||
return jobType switch
|
||||
{
|
||||
"content_import" => SaasFeatureCatalog.ResolveContentImportFeature(GetJsonString(payload, "importType"))
|
||||
?? throw new InvalidOperationException(
|
||||
"Background content import type is not supported."),
|
||||
"content_export" or "asset_security_scan" => SaasFeatureCatalog.PrivateQuestionBank,
|
||||
"commerce_reconciliation" => SaasFeatureCatalog.StudentStore,
|
||||
"statistics_aggregation" or "tenant_domain_recheck" => SaasFeatureCatalog.CoreBackoffice,
|
||||
_ => SaasFeatureCatalog.CoreBackoffice
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeProvider(string? provider)
|
||||
{
|
||||
@@ -67,9 +57,7 @@ internal sealed partial class BackgroundJobService
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object ||
|
||||
!element.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.ValueKind == JsonValueKind.String && Guid.TryParse(property.GetString(), out var value)
|
||||
? value
|
||||
@@ -81,9 +69,7 @@ internal sealed partial class BackgroundJobService
|
||||
if (element.ValueKind != JsonValueKind.Object ||
|
||||
!element.TryGetProperty(propertyName, out var property) ||
|
||||
property.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return property.EnumerateArray().Select(item => item.Clone()).ToArray();
|
||||
}
|
||||
@@ -121,4 +107,4 @@ internal sealed partial class BackgroundJobService
|
||||
job.OutputAssetId,
|
||||
job.Result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Tiku.Infrastructure.Jobs;
|
||||
|
||||
@@ -31,10 +30,13 @@ internal sealed partial class BackgroundJobService
|
||||
{
|
||||
["content_export"] = () => ProcessContentExportAsync(scopedDbContext, job, cancellationToken),
|
||||
["content_import"] = () => ProcessContentImportAsync(scopedProvider, job, cancellationToken),
|
||||
["asset_security_scan"] = () => ProcessAssetSecurityScanAsync(scopedProvider, scopedDbContext, job, cancellationToken),
|
||||
["asset_security_scan"] = () =>
|
||||
ProcessAssetSecurityScanAsync(scopedProvider, scopedDbContext, job, cancellationToken),
|
||||
["tenant_export"] = () => ProcessTenantExportAsync(scopedProvider, scopedDbContext, job, cancellationToken),
|
||||
["statistics_aggregation"] = () => ProcessStatisticsAggregationAsync(scopedProvider, scopedDbContext, job, cancellationToken),
|
||||
["commerce_reconciliation"] = () => ProcessCommerceReconciliationAsync(scopedDbContext, job, cancellationToken),
|
||||
["statistics_aggregation"] = () =>
|
||||
ProcessStatisticsAggregationAsync(scopedProvider, scopedDbContext, job, cancellationToken),
|
||||
["commerce_reconciliation"] =
|
||||
() => ProcessCommerceReconciliationAsync(scopedDbContext, job, cancellationToken),
|
||||
["tenant_domain_recheck"] = () => ProcessTenantDomainRecheckAsync(scopedProvider, cancellationToken)
|
||||
};
|
||||
return handlers.TryGetValue(job.JobType, out var handler)
|
||||
@@ -49,13 +51,11 @@ internal sealed partial class BackgroundJobService
|
||||
{
|
||||
var directContentService = scopedProvider.GetRequiredService<IDirectContentService>();
|
||||
var createdBy = GetJsonGuid(job.Payload, "createdBy") ?? Guid.Empty;
|
||||
if (createdBy == Guid.Empty)
|
||||
{
|
||||
throw new InvalidOperationException("content_import job requires createdBy.");
|
||||
}
|
||||
if (createdBy == Guid.Empty) throw new InvalidOperationException("content_import job requires createdBy.");
|
||||
|
||||
var command = new SimpleImportCommand(
|
||||
GetJsonString(job.Payload, "importType") ?? throw new InvalidOperationException("content_import job requires importType."),
|
||||
GetJsonString(job.Payload, "importType") ??
|
||||
throw new InvalidOperationException("content_import job requires importType."),
|
||||
GetJsonString(job.Payload, "sourceFormat"),
|
||||
GetJsonString(job.Payload, "sourceName"),
|
||||
GetJsonGuid(job.Payload, "regionId"),
|
||||
@@ -97,27 +97,27 @@ internal sealed partial class BackgroundJobService
|
||||
if (asset.UploadStatus != AssetUploadStatus.Verified ||
|
||||
string.IsNullOrWhiteSpace(asset.Bucket) ||
|
||||
string.IsNullOrWhiteSpace(asset.ObjectKey))
|
||||
{
|
||||
throw new InvalidOperationException("Asset must have a verified object location before security scanning.");
|
||||
}
|
||||
|
||||
asset.SecurityScanStatus = AssetSecurityScanStatus.Scanning;
|
||||
await scopedDbContext.SaveChangesAsync(cancellationToken);
|
||||
var scanner = scopedProvider.GetRequiredService<IAssetSecurityScanner>();
|
||||
var storage = scopedProvider.GetRequiredService<Tiku.Application.Storage.IObjectStorageService>();
|
||||
var storage = scopedProvider.GetRequiredService<IObjectStorageService>();
|
||||
await using var content = await storage.OpenReadAsync(
|
||||
new Tiku.Application.Storage.ObjectStorageReadRequest(
|
||||
new ObjectStorageReadRequest(
|
||||
job.TenantId,
|
||||
asset.StorageProvider switch
|
||||
{
|
||||
AssetStorageProvider.AliyunOss => Tiku.Application.Storage.ObjectStorageProviders.AliyunOss,
|
||||
AssetStorageProvider.LocalDev => Tiku.Application.Storage.ObjectStorageProviders.LocalDev,
|
||||
_ => throw new InvalidOperationException("Asset storage provider does not support security scanning.")
|
||||
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
|
||||
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
|
||||
_ => throw new InvalidOperationException(
|
||||
"Asset storage provider does not support security scanning.")
|
||||
},
|
||||
asset.Bucket,
|
||||
asset.ObjectKey),
|
||||
cancellationToken);
|
||||
var result = await scanner.ScanAsync(content, asset.VerifiedSizeBytes ?? asset.FileSizeBytes, cancellationToken);
|
||||
var result =
|
||||
await scanner.ScanAsync(content, asset.VerifiedSizeBytes ?? asset.FileSizeBytes, cancellationToken);
|
||||
var infected = result.Verdict == AssetSecurityScanVerdict.Infected;
|
||||
asset.SecurityScanStatus = infected ? AssetSecurityScanStatus.Failed : AssetSecurityScanStatus.Passed;
|
||||
asset.SecurityScannedAt = DateTimeOffset.UtcNow;
|
||||
@@ -154,18 +154,12 @@ internal sealed partial class BackgroundJobService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var assetId = GetJsonGuid(job.Payload, "assetId");
|
||||
if (assetId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (assetId is null) return;
|
||||
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == job.TenantId && item.Id == assetId,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (asset is null) return;
|
||||
|
||||
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
|
||||
asset.SecurityScanProvider = "clamav";
|
||||
@@ -192,8 +186,8 @@ internal sealed partial class BackgroundJobService
|
||||
var operationId = GetJsonGuid(job.Payload, "operationId") ??
|
||||
throw new InvalidOperationException("tenant_export job requires operationId.");
|
||||
var operation = await scopedDbContext.TenantLifecycleOperations.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == job.TenantId && item.Id == operationId &&
|
||||
item.OperationType == TenantLifecycleOperationType.Export,
|
||||
item.TenantId == job.TenantId && item.Id == operationId &&
|
||||
item.OperationType == TenantLifecycleOperationType.Export,
|
||||
cancellationToken) ?? throw new InvalidOperationException("Tenant export operation was not found.");
|
||||
operation.Status = TenantLifecycleOperationStatus.Processing;
|
||||
operation.StartedAt ??= DateTimeOffset.UtcNow;
|
||||
@@ -203,23 +197,28 @@ internal sealed partial class BackgroundJobService
|
||||
var temporaryPath = Path.Combine(Path.GetTempPath(), $"tiku-tenant-export-{operation.Id:N}.tar.gz");
|
||||
try
|
||||
{
|
||||
var tenant = await scopedDbContext.Tenants.AsNoTracking().SingleAsync(item => item.Id == job.TenantId, cancellationToken);
|
||||
var tenant = await scopedDbContext.Tenants.AsNoTracking()
|
||||
.SingleAsync(item => item.Id == job.TenantId, cancellationToken);
|
||||
var memberships = await scopedDbContext.TenantMemberships.AsNoTracking()
|
||||
.Where(item => item.TenantId == job.TenantId)
|
||||
.Select(item => new { item.UserId, item.Role, item.Status, item.CreatedAt, item.UpdatedAt })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var domains = await scopedDbContext.TenantDomains.AsNoTracking()
|
||||
.Where(item => item.TenantId == job.TenantId)
|
||||
.Select(item => new { item.Id, item.Host, item.DomainType, item.Status, item.IsPrimary, item.CreatedAt, item.UpdatedAt })
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id, item.Host, item.DomainType, item.Status, item.IsPrimary, item.CreatedAt, item.UpdatedAt
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var assets = await scopedDbContext.ContentAssets.AsNoTracking()
|
||||
.Where(item => item.TenantId == job.TenantId && item.Status == ContentStatus.Active)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var storage = scopedProvider.GetRequiredService<Tiku.Application.Storage.IObjectStorageService>();
|
||||
var storage = scopedProvider.GetRequiredService<IObjectStorageService>();
|
||||
|
||||
await using (var file = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, FileOptions.Asynchronous))
|
||||
await using (var gzip = new GZipStream(file, CompressionLevel.Fastest, leaveOpen: false))
|
||||
await using (var archive = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: false))
|
||||
await using (var file = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
128 * 1024, FileOptions.Asynchronous))
|
||||
await using (var gzip = new GZipStream(file, CompressionLevel.Fastest, false))
|
||||
await using (var archive = new TarWriter(gzip, TarEntryFormat.Pax, false))
|
||||
{
|
||||
await WriteJsonEntryAsync(archive, "manifest.json", new
|
||||
{
|
||||
@@ -228,14 +227,23 @@ internal sealed partial class BackgroundJobService
|
||||
tenantId = job.TenantId,
|
||||
operationId,
|
||||
generatedAt = DateTimeOffset.UtcNow,
|
||||
exclusions = new[] { "password_hashes", "auth_tokens", "refresh_tokens", "secret_plaintext", "data_protection_keys", "global_platform_data" },
|
||||
exclusions = new[]
|
||||
{
|
||||
"password_hashes", "auth_tokens", "refresh_tokens", "secret_plaintext", "data_protection_keys",
|
||||
"global_platform_data"
|
||||
},
|
||||
tables = new[] { "tenant", "tenant_memberships", "tenant_domains", "content_assets" }
|
||||
}, cancellationToken);
|
||||
await WriteJsonLinesEntryAsync(archive, "data/tenant.jsonl", new[]
|
||||
{
|
||||
new { tenant.Id, tenant.Slug, tenant.Name, tenant.LegalName, tenant.Status, tenant.Mode, tenant.BillingStatus, tenant.OwnerUserId, tenant.CreatedAt, tenant.UpdatedAt }
|
||||
new
|
||||
{
|
||||
tenant.Id, tenant.Slug, tenant.Name, tenant.LegalName, tenant.Status, tenant.Mode,
|
||||
tenant.BillingStatus, tenant.OwnerUserId, tenant.CreatedAt, tenant.UpdatedAt
|
||||
}
|
||||
}, cancellationToken);
|
||||
await WriteJsonLinesEntryAsync(archive, "data/tenant_memberships.jsonl", memberships, cancellationToken);
|
||||
await WriteJsonLinesEntryAsync(archive, "data/tenant_memberships.jsonl", memberships,
|
||||
cancellationToken);
|
||||
await WriteJsonLinesEntryAsync(archive, "data/tenant_domains.jsonl", domains, cancellationToken);
|
||||
await WriteJsonLinesEntryAsync(archive, "data/content_assets.jsonl", assets.Select(item => new
|
||||
{
|
||||
@@ -257,19 +265,20 @@ internal sealed partial class BackgroundJobService
|
||||
|
||||
foreach (var asset in assets.Where(item =>
|
||||
item.UploadStatus == AssetUploadStatus.Verified &&
|
||||
item.SecurityScanStatus is AssetSecurityScanStatus.Passed or AssetSecurityScanStatus.NotRequired &&
|
||||
item.SecurityScanStatus is AssetSecurityScanStatus.Passed
|
||||
or AssetSecurityScanStatus.NotRequired &&
|
||||
!string.IsNullOrWhiteSpace(item.Bucket) &&
|
||||
!string.IsNullOrWhiteSpace(item.ObjectKey)))
|
||||
{
|
||||
var provider = asset.StorageProvider switch
|
||||
{
|
||||
AssetStorageProvider.AliyunOss => Tiku.Application.Storage.ObjectStorageProviders.AliyunOss,
|
||||
AssetStorageProvider.LocalDev => Tiku.Application.Storage.ObjectStorageProviders.LocalDev,
|
||||
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
|
||||
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
|
||||
_ => null
|
||||
};
|
||||
if (provider is null) continue;
|
||||
await using var content = await storage.OpenReadAsync(
|
||||
new Tiku.Application.Storage.ObjectStorageReadRequest(
|
||||
new ObjectStorageReadRequest(
|
||||
job.TenantId, provider, asset.Bucket!, asset.ObjectKey!),
|
||||
cancellationToken);
|
||||
var name = SanitizeTarPath(asset.FileName ?? asset.Id.ToString("N"));
|
||||
@@ -280,12 +289,14 @@ internal sealed partial class BackgroundJobService
|
||||
}
|
||||
}
|
||||
|
||||
await using var upload = new FileStream(temporaryPath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, FileOptions.Asynchronous);
|
||||
await using var upload = new FileStream(temporaryPath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
128 * 1024, FileOptions.Asynchronous);
|
||||
var providerName = storage.ConfiguredDefaultProvider();
|
||||
var bucket = storage.ConfiguredDefaultBucket();
|
||||
var objectKey = storage.ValidateObjectKey(job.TenantId, $"{job.TenantId:N}/tenant-exports/{operation.Id:N}.tar.gz");
|
||||
var objectKey =
|
||||
storage.ValidateObjectKey(job.TenantId, $"{job.TenantId:N}/tenant-exports/{operation.Id:N}.tar.gz");
|
||||
var written = await storage.WriteObjectAsync(
|
||||
new Tiku.Application.Storage.ObjectStorageWriteRequest(
|
||||
new ObjectStorageWriteRequest(
|
||||
job.TenantId,
|
||||
providerName,
|
||||
bucket,
|
||||
@@ -303,8 +314,8 @@ internal sealed partial class BackgroundJobService
|
||||
AssetType = ContentAssetType.Document,
|
||||
StorageProvider = providerName switch
|
||||
{
|
||||
Tiku.Application.Storage.ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss,
|
||||
Tiku.Application.Storage.ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev,
|
||||
ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss,
|
||||
ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev,
|
||||
_ => AssetStorageProvider.ExternalUrl
|
||||
},
|
||||
Bucket = written.Bucket,
|
||||
@@ -366,7 +377,7 @@ internal sealed partial class BackgroundJobService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
await using (var writer = new StreamWriter(stream, new System.Text.UTF8Encoding(false), leaveOpen: true))
|
||||
await using (var writer = new StreamWriter(stream, new UTF8Encoding(false), leaveOpen: true))
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
@@ -374,6 +385,7 @@ internal sealed partial class BackgroundJobService
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(value));
|
||||
}
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
archive.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = stream });
|
||||
await stream.DisposeAsync();
|
||||
@@ -410,9 +422,12 @@ internal sealed partial class BackgroundJobService
|
||||
scopedDbContext.ContentAssets.Add(asset);
|
||||
}
|
||||
|
||||
var questionBankCount = await scopedDbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
var questionCount = await scopedDbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
var studentCount = await scopedDbContext.StudentProfiles.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
var questionBankCount =
|
||||
await scopedDbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
var questionCount =
|
||||
await scopedDbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
var studentCount =
|
||||
await scopedDbContext.StudentProfiles.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
asset.FileName = $"content-export-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
|
||||
asset.Title = "Content export manifest";
|
||||
asset.Description = $"Generated content export manifest for {exportType}.";
|
||||
@@ -448,14 +463,13 @@ internal sealed partial class BackgroundJobService
|
||||
var hasProviderConfig = await scopedDbContext.TenantExternalProviders.AnyAsync(
|
||||
item =>
|
||||
item.TenantId == job.TenantId &&
|
||||
item.Capability == Tiku.Domain.Tenancy.TenantExternalProviderCapability.Payment &&
|
||||
item.Capability == TenantExternalProviderCapability.Payment &&
|
||||
item.Provider == provider &&
|
||||
item.Status == Tiku.Domain.Tenancy.TenantExternalProviderStatus.Active,
|
||||
item.Status == TenantExternalProviderStatus.Active,
|
||||
cancellationToken);
|
||||
if (!hasProviderConfig)
|
||||
{
|
||||
throw new InvalidOperationException($"Active payment provider '{provider}' is required for commerce reconciliation job.");
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
$"Active payment provider '{provider}' is required for commerce reconciliation job.");
|
||||
|
||||
var billDate = GetJsonDateOnly(job.Payload, "billDate") ?? DateOnly.FromDateTime(DateTime.UtcNow.Date);
|
||||
var billType = GetJsonEnum(job.Payload, "billType", ReconciliationBillType.Combined);
|
||||
@@ -482,7 +496,8 @@ internal sealed partial class BackgroundJobService
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
jobId = job.Id,
|
||||
note = "Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor."
|
||||
note =
|
||||
"Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor."
|
||||
})
|
||||
};
|
||||
scopedDbContext.CommerceReconciliationBatches.Add(batch);
|
||||
@@ -549,6 +564,4 @@ internal sealed partial class BackgroundJobService
|
||||
quotaUsage
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Content;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Tiku.Infrastructure.Jobs;
|
||||
|
||||
@@ -47,10 +34,7 @@ internal sealed partial class BackgroundJobService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.BackgroundJobs.AsNoTracking().Where(item => item.Id == jobId);
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.TenantId == tenantId.Value);
|
||||
}
|
||||
if (tenantId.HasValue) query = query.Where(item => item.TenantId == tenantId.Value);
|
||||
var job = await query.SingleOrDefaultAsync(cancellationToken);
|
||||
return job is null ? null : ToItem(job);
|
||||
}
|
||||
@@ -69,6 +53,7 @@ internal sealed partial class BackgroundJobService
|
||||
var normalized = NormalizeJobType(jobType);
|
||||
query = query.Where(item => item.JobType == normalized);
|
||||
}
|
||||
|
||||
if (status.HasValue) query = query.Where(item => item.Status == status.Value);
|
||||
return (await query.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(limit, 1, 500))
|
||||
@@ -86,14 +71,12 @@ internal sealed partial class BackgroundJobService
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
throw new BackgroundJobException("background_job_cancel_reason_required", "Cancellation reason is required.");
|
||||
}
|
||||
throw new BackgroundJobException("background_job_cancel_reason_required",
|
||||
"Cancellation reason is required.");
|
||||
var job = await FindMutableAsync(jobId, tenantId, cancellationToken);
|
||||
if (job.Status is BackgroundJobStatus.Succeeded or BackgroundJobStatus.Failed or BackgroundJobStatus.Cancelled)
|
||||
{
|
||||
throw new BackgroundJobException("background_job_not_cancellable", "Only pending or processing jobs can be cancelled.");
|
||||
}
|
||||
throw new BackgroundJobException("background_job_not_cancellable",
|
||||
"Only pending or processing jobs can be cancelled.");
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
job.CancellationRequestedAt = now;
|
||||
@@ -104,6 +87,7 @@ internal sealed partial class BackgroundJobService
|
||||
job.Status = BackgroundJobStatus.Cancelled;
|
||||
job.CompletedAt = now;
|
||||
}
|
||||
|
||||
AddMutationAudit(job, actorUserId, "background_job.cancel_requested");
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToItem(job);
|
||||
@@ -118,9 +102,8 @@ internal sealed partial class BackgroundJobService
|
||||
dbContext.ChangeTracker.Clear();
|
||||
var job = await FindMutableAsync(jobId, tenantId, cancellationToken);
|
||||
if (job.Status is not (BackgroundJobStatus.Failed or BackgroundJobStatus.Cancelled))
|
||||
{
|
||||
throw new BackgroundJobException("background_job_not_retryable", "Only failed or cancelled jobs can be retried.");
|
||||
}
|
||||
throw new BackgroundJobException("background_job_not_retryable",
|
||||
"Only failed or cancelled jobs can be retried.");
|
||||
|
||||
job.Status = BackgroundJobStatus.Pending;
|
||||
job.RunAfter = DateTimeOffset.UtcNow;
|
||||
@@ -160,6 +143,4 @@ internal sealed partial class BackgroundJobService
|
||||
Details = JsonSerializer.SerializeToElement(new { job.JobType, job.Status })
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,11 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
|
||||
namespace Tiku.Infrastructure.Jobs;
|
||||
|
||||
@@ -29,26 +20,26 @@ internal sealed partial class BackgroundJobService
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var leaseExpiresAt = now.Add(LeaseDuration);
|
||||
var claimedIds = await dbContext.Database.SqlQuery<Guid>($"""
|
||||
UPDATE background_jobs AS job
|
||||
SET status = 'processing',
|
||||
locked_by = {workerId},
|
||||
lock_expires_at = {leaseExpiresAt},
|
||||
started_at = COALESCE(started_at, {now}),
|
||||
updated_at = {now}
|
||||
WHERE job.id IN (
|
||||
SELECT candidate.id
|
||||
FROM background_jobs AS candidate
|
||||
WHERE (
|
||||
(candidate.status = 'pending' AND ({includeImmediateJobs} OR candidate.run_after IS NOT NULL) AND
|
||||
(candidate.run_after IS NULL OR candidate.run_after <= {now})) OR
|
||||
(candidate.status = 'processing' AND candidate.lock_expires_at <= {now})
|
||||
)
|
||||
ORDER BY candidate.created_at, candidate.id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT {Math.Clamp(batchSize, 1, 100)}
|
||||
)
|
||||
RETURNING job.id AS "Value"
|
||||
""")
|
||||
UPDATE background_jobs AS job
|
||||
SET status = 'processing',
|
||||
locked_by = {workerId},
|
||||
lock_expires_at = {leaseExpiresAt},
|
||||
started_at = COALESCE(started_at, {now}),
|
||||
updated_at = {now}
|
||||
WHERE job.id IN (
|
||||
SELECT candidate.id
|
||||
FROM background_jobs AS candidate
|
||||
WHERE (
|
||||
(candidate.status = 'pending' AND ({includeImmediateJobs} OR candidate.run_after IS NOT NULL) AND
|
||||
(candidate.run_after IS NULL OR candidate.run_after <= {now})) OR
|
||||
(candidate.status = 'processing' AND candidate.lock_expires_at <= {now})
|
||||
)
|
||||
ORDER BY candidate.created_at, candidate.id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT {Math.Clamp(batchSize, 1, 100)}
|
||||
)
|
||||
RETURNING job.id AS "Value"
|
||||
""")
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var processed = 0;
|
||||
@@ -57,7 +48,7 @@ internal sealed partial class BackgroundJobService
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var job = await dbContext.BackgroundJobs.SingleAsync(value => value.Id == jobId, cancellationToken);
|
||||
if (await ProcessJobAsync(job, workerId, alreadyClaimed: true, cancellationToken)) processed++;
|
||||
if (await ProcessJobAsync(job, workerId, true, cancellationToken)) processed++;
|
||||
dbContext.ChangeTracker.Clear();
|
||||
}
|
||||
|
||||
@@ -81,24 +72,17 @@ internal sealed partial class BackgroundJobService
|
||||
.SetProperty(item => item.LockedBy, workerId)
|
||||
.SetProperty(item => item.LockExpiresAt, DateTimeOffset.UtcNow.Add(LeaseDuration))
|
||||
.SetProperty(item => item.StartedAt, DateTimeOffset.UtcNow), cancellationToken);
|
||||
if (claimed == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (claimed == 0) return false;
|
||||
|
||||
dbContext.ChangeTracker.Clear();
|
||||
var job = await dbContext.BackgroundJobs.SingleOrDefaultAsync(
|
||||
item => item.Id == jobId && item.TenantId == tenantId,
|
||||
cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
throw new InvalidOperationException("The requested background job does not exist in the target tenant.");
|
||||
}
|
||||
if (!string.Equals(job.JobType, normalizedJobType, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("The requested background job type does not match the persisted job.");
|
||||
}
|
||||
return await ProcessJobAsync(job, workerId, alreadyClaimed: true, cancellationToken);
|
||||
return await ProcessJobAsync(job, workerId, true, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessJobAsync(
|
||||
@@ -110,9 +94,7 @@ internal sealed partial class BackgroundJobService
|
||||
var startedTimestamp = Stopwatch.GetTimestamp();
|
||||
if ((!alreadyClaimed && job.Status != BackgroundJobStatus.Pending) ||
|
||||
(alreadyClaimed && (job.Status != BackgroundJobStatus.Processing || job.LockedBy != workerId)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
await dbContext.Entry(job).ReloadAsync(cancellationToken);
|
||||
if (job.CancellationRequestedAt.HasValue)
|
||||
{
|
||||
@@ -126,6 +108,7 @@ internal sealed partial class BackgroundJobService
|
||||
cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (job.JobType is not ("asset_security_scan" or "tenant_export") && !(await featureAccessService.EvaluateAsync(
|
||||
job.TenantId,
|
||||
ResolveRequiredFeature(job.JobType, job.Payload),
|
||||
@@ -176,9 +159,7 @@ internal sealed partial class BackgroundJobService
|
||||
? $"{scannerException.Code}: {scannerException.Message}"
|
||||
: exception.Message;
|
||||
if (exception is AssetSecurityScannerException assetScanException)
|
||||
{
|
||||
await RecordAssetScanRetryAsync(job, assetScanException, cancellationToken);
|
||||
}
|
||||
job.Status = job.RetryCount > job.MaxRetries
|
||||
? BackgroundJobStatus.Failed
|
||||
: BackgroundJobStatus.Pending;
|
||||
@@ -189,7 +170,8 @@ internal sealed partial class BackgroundJobService
|
||||
finally
|
||||
{
|
||||
await CompleteAsync(job, workerId, job.Status, job.Result, job.LastError, cancellationToken);
|
||||
WorkerTelemetry.RecordJob(job.JobType, job.Status.ToString(), Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds);
|
||||
WorkerTelemetry.RecordJob(job.JobType, job.Status.ToString(),
|
||||
Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -206,17 +188,15 @@ internal sealed partial class BackgroundJobService
|
||||
await dbContext.BackgroundJobs
|
||||
.Where(value => value.Id == job.Id && value.LockedBy == workerId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(value => value.Status, status)
|
||||
.SetProperty(value => value.RetryCount, job.RetryCount)
|
||||
.SetProperty(value => value.RunAfter, job.RunAfter)
|
||||
.SetProperty(value => value.CompletedAt, job.CompletedAt)
|
||||
.SetProperty(value => value.LastError, lastError)
|
||||
.SetProperty(value => value.OutputAssetId, job.OutputAssetId)
|
||||
.SetProperty(value => value.Result, result)
|
||||
.SetProperty(value => value.LockedBy, (string?)null)
|
||||
.SetProperty(value => value.LockExpiresAt, (DateTimeOffset?)null),
|
||||
.SetProperty(value => value.Status, status)
|
||||
.SetProperty(value => value.RetryCount, job.RetryCount)
|
||||
.SetProperty(value => value.RunAfter, job.RunAfter)
|
||||
.SetProperty(value => value.CompletedAt, job.CompletedAt)
|
||||
.SetProperty(value => value.LastError, lastError)
|
||||
.SetProperty(value => value.OutputAssetId, job.OutputAssetId)
|
||||
.SetProperty(value => value.Result, result)
|
||||
.SetProperty(value => value.LockedBy, (string?)null)
|
||||
.SetProperty(value => value.LockExpiresAt, (DateTimeOffset?)null),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Tiku.Infrastructure.Jobs;
|
||||
|
||||
@@ -32,10 +19,7 @@ internal sealed partial class BackgroundJobService
|
||||
item => item.TenantId == command.TenantId && item.JobType == normalizedJobType &&
|
||||
item.IdempotencyKey == idempotencyKey,
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return ToItem(existing);
|
||||
}
|
||||
if (existing is not null) return ToItem(existing);
|
||||
}
|
||||
|
||||
if (!command.IsSystemJob && !(await featureAccessService.EvaluateAsync(
|
||||
@@ -43,9 +27,7 @@ internal sealed partial class BackgroundJobService
|
||||
ResolveRequiredFeature(normalizedJobType, command.Payload),
|
||||
FeatureAccessOperation.Write,
|
||||
cancellationToken)).Allowed)
|
||||
{
|
||||
throw new InvalidOperationException("Tenant feature entitlement does not allow this background job.");
|
||||
}
|
||||
var quotaMetric = command.IsSystemJob ? null : ResolveQuotaMetric(normalizedJobType);
|
||||
var quotaConsumed = false;
|
||||
if (quotaMetric is not null)
|
||||
@@ -57,11 +39,11 @@ internal sealed partial class BackgroundJobService
|
||||
quotaConsumed = await featureAccessService.TryConsumeQuotaAsync(
|
||||
command.TenantId, quotaMetric, 1, cancellationToken);
|
||||
if (!quotaConsumed)
|
||||
{
|
||||
throw new FeatureAccessException("The background job quota has been exhausted.", "feature_quota_exhausted");
|
||||
}
|
||||
throw new FeatureAccessException("The background job quota has been exhausted.",
|
||||
"feature_quota_exhausted");
|
||||
}
|
||||
}
|
||||
|
||||
var job = new BackgroundJob
|
||||
{
|
||||
TenantId = command.TenantId,
|
||||
@@ -86,30 +68,30 @@ internal sealed partial class BackgroundJobService
|
||||
if (existing is not null)
|
||||
{
|
||||
if (quotaConsumed && quotaMetric is not null)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(command.TenantId, quotaMetric, 1, CancellationToken.None);
|
||||
}
|
||||
await featureAccessService.ReleaseQuotaAsync(command.TenantId, quotaMetric, 1,
|
||||
CancellationToken.None);
|
||||
return ToItem(existing);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (quotaConsumed && quotaMetric is not null)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(command.TenantId, quotaMetric, 1, CancellationToken.None);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
|
||||
return ToItem(job);
|
||||
}
|
||||
|
||||
private static string? ResolveQuotaMetric(string jobType) => jobType switch
|
||||
private static string? ResolveQuotaMetric(string jobType)
|
||||
{
|
||||
"content_import" => SaasQuotaMetricCatalog.ImportCount,
|
||||
"content_export" => SaasQuotaMetricCatalog.ExportCount,
|
||||
_ => null
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
return jobType switch
|
||||
{
|
||||
"content_import" => SaasQuotaMetricCatalog.ImportCount,
|
||||
"content_export" => SaasQuotaMetricCatalog.ExportCount,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using ZLinq;
|
||||
|
||||
namespace Tiku.Infrastructure.Learning;
|
||||
@@ -125,6 +113,4 @@ public sealed partial class LearningActivityService
|
||||
items.FirstOrDefault(item => item.UserId == actor.UserId),
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,9 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using ZLinq;
|
||||
|
||||
namespace Tiku.Infrastructure.Learning;
|
||||
|
||||
@@ -25,47 +15,43 @@ public sealed partial class LearningActivityService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command.IdempotencyKey))
|
||||
{
|
||||
throw new LearningValidationException("idempotency_key_required", "An idempotency key is required.");
|
||||
}
|
||||
if (command.SelectedOptionIndices?.Any(index => index < 0) == true)
|
||||
{
|
||||
throw new LearningValidationException("selected_option_index_invalid", "Selected option indices must be zero-based non-negative values.");
|
||||
}
|
||||
throw new LearningValidationException("selected_option_index_invalid",
|
||||
"Selected option indices must be zero-based non-negative values.");
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessionQuestion = await dbContext.PracticeSessionQuestions.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId && item.Id == command.SessionQuestionId, cancellationToken);
|
||||
|
||||
if (sessionQuestion is null)
|
||||
{
|
||||
throw new LearningResourceNotFoundException(
|
||||
"session_question_not_found",
|
||||
"An active practice session question was not found.");
|
||||
}
|
||||
|
||||
var session = await dbContext.PracticeSessions.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.Id == sessionQuestion.PracticeSessionId, cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found.");
|
||||
}
|
||||
throw new LearningResourceNotFoundException("practice_session_not_found",
|
||||
"Practice session was not found.");
|
||||
|
||||
var requestHash = HashAnswer(command);
|
||||
var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.PracticeSessionId == session.Id &&
|
||||
item.OperationType == "answer" &&
|
||||
item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
|
||||
var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.PracticeSessionId == session.Id &&
|
||||
item.OperationType == "answer" &&
|
||||
item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
|
||||
if (existingOperation is not null)
|
||||
{
|
||||
if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal))
|
||||
{
|
||||
AnswerConflicts.Add(1);
|
||||
throw new LearningValidationException("idempotency_conflict", "The idempotency key was used with a different request.");
|
||||
throw new LearningValidationException("idempotency_conflict",
|
||||
"The idempotency key was used with a different request.");
|
||||
}
|
||||
|
||||
IdempotencyReplays.Add(1);
|
||||
@@ -80,6 +66,7 @@ public sealed partial class LearningActivityService
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new LearningValidationException("practice_session_expired", "The practice session has expired.");
|
||||
}
|
||||
|
||||
EnsureAnswerSessionState(session, command);
|
||||
var current = await dbContext.AnswerRecords.SingleOrDefaultAsync(answer =>
|
||||
answer.TenantId == actor.TenantId &&
|
||||
@@ -87,10 +74,7 @@ public sealed partial class LearningActivityService
|
||||
answer.PracticeSessionId == session.Id &&
|
||||
answer.SessionQuestionId == sessionQuestion.Id &&
|
||||
answer.IsCurrent, cancellationToken);
|
||||
if (current is not null)
|
||||
{
|
||||
current.IsCurrent = false;
|
||||
}
|
||||
if (current is not null) current.IsCurrent = false;
|
||||
|
||||
var selectedIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [];
|
||||
var score = sessionQuestion.Score ?? 1;
|
||||
@@ -157,11 +141,12 @@ public sealed partial class LearningActivityService
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before answering.");
|
||||
throw new LearningValidationException("practice_session_version_conflict",
|
||||
"The practice session changed. Reload it before answering.");
|
||||
}
|
||||
catch (DbUpdateException exception) when (
|
||||
exception.InnerException is Npgsql.PostgresException postgresException &&
|
||||
postgresException.SqlState == Npgsql.PostgresErrorCodes.UniqueViolation)
|
||||
exception.InnerException is PostgresException postgresException &&
|
||||
postgresException.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
var replay = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item =>
|
||||
@@ -176,12 +161,12 @@ public sealed partial class LearningActivityService
|
||||
return replay.ResponseSnapshot.Deserialize<AnswerRecordItem>()
|
||||
?? throw new InvalidOperationException("The stored answer response is invalid.");
|
||||
}
|
||||
|
||||
AnswerConflicts.Add(1);
|
||||
throw new LearningValidationException("practice_answer_conflict", "The answer conflicted with another client operation.");
|
||||
throw new LearningValidationException("practice_answer_conflict",
|
||||
"The answer conflicted with another client operation.");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
@@ -13,7 +11,6 @@ using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using ZLinq;
|
||||
|
||||
namespace Tiku.Infrastructure.Learning;
|
||||
|
||||
@@ -37,10 +34,7 @@ public sealed partial class LearningActivityService
|
||||
command.DurationMinutes,
|
||||
command.TotalScore);
|
||||
|
||||
if (!command.BlueprintId.HasValue)
|
||||
{
|
||||
return assembly;
|
||||
}
|
||||
if (!command.BlueprintId.HasValue) return assembly;
|
||||
|
||||
var blueprint = await dbContext.PracticeBlueprints
|
||||
.AsNoTracking()
|
||||
@@ -52,9 +46,8 @@ public sealed partial class LearningActivityService
|
||||
cancellationToken);
|
||||
|
||||
if (blueprint is null)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("practice_blueprint_not_found", "Practice blueprint was not found.");
|
||||
}
|
||||
throw new LearningResourceNotFoundException("practice_blueprint_not_found",
|
||||
"Practice blueprint was not found.");
|
||||
|
||||
return assembly with
|
||||
{
|
||||
@@ -76,7 +69,6 @@ public sealed partial class LearningActivityService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (assembly.Mode == "wrong_review")
|
||||
{
|
||||
return await dbContext.WrongQuestions
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
@@ -88,10 +80,8 @@ public sealed partial class LearningActivityService
|
||||
.Take(assembly.QuestionLimit)
|
||||
.Select(item => item.QuestionReferenceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (assembly.Mode == "favorite_review")
|
||||
{
|
||||
return await dbContext.FavoriteQuestions
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
@@ -101,10 +91,8 @@ public sealed partial class LearningActivityService
|
||||
.Take(assembly.QuestionLimit)
|
||||
.Select(item => item.QuestionReferenceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (assembly.CollectionId.HasValue)
|
||||
{
|
||||
return await dbContext.QuestionCollectionItems
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
@@ -114,7 +102,6 @@ public sealed partial class LearningActivityService
|
||||
.Take(assembly.QuestionLimit)
|
||||
.Select(item => item.QuestionReferenceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var query = dbContext.Questions
|
||||
.AsNoTracking()
|
||||
@@ -123,21 +110,13 @@ public sealed partial class LearningActivityService
|
||||
question.Status == QuestionStatus.Published);
|
||||
|
||||
if (assembly.ContentNodeId.HasValue)
|
||||
{
|
||||
query = query.Where(question => question.ContentNodeId == assembly.ContentNodeId.Value);
|
||||
}
|
||||
else if (assembly.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(question => question.EntryId == assembly.EntryId.Value);
|
||||
}
|
||||
else if (assembly.TargetId.HasValue && !string.IsNullOrWhiteSpace(assembly.TargetType))
|
||||
{
|
||||
query = ApplyLegacyTargetFilter(query, assembly.TargetType, assembly.TargetId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new LearningValidationException("practice_target_required", "Practice target is required.");
|
||||
}
|
||||
|
||||
var questionIds = await query
|
||||
.OrderBy(question => question.CreatedAt)
|
||||
@@ -188,52 +167,50 @@ public sealed partial class LearningActivityService
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
return await (
|
||||
from reference in systemDbContext.TenantQuestionReferences.AsNoTracking()
|
||||
join question in systemDbContext.Questions.AsNoTracking()
|
||||
on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId }
|
||||
equals new { question.TenantId, question.Id }
|
||||
join version in systemDbContext.QuestionVersions.AsNoTracking()
|
||||
on new
|
||||
{
|
||||
TenantId = reference.QuestionOwnerTenantId,
|
||||
from reference in systemDbContext.TenantQuestionReferences.AsNoTracking()
|
||||
join question in systemDbContext.Questions.AsNoTracking()
|
||||
on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId }
|
||||
equals new { question.TenantId, question.Id }
|
||||
join version in systemDbContext.QuestionVersions.AsNoTracking()
|
||||
on new
|
||||
{
|
||||
TenantId = reference.QuestionOwnerTenantId,
|
||||
reference.QuestionId,
|
||||
Id = question.CurrentVersionId
|
||||
}
|
||||
equals new
|
||||
{
|
||||
version.TenantId,
|
||||
version.QuestionId,
|
||||
Id = (Guid?)version.Id
|
||||
}
|
||||
where reference.TenantId == tenantId &&
|
||||
questionReferenceIds.Contains(reference.Id) &&
|
||||
question.Status == QuestionStatus.Published
|
||||
select new QuestionSelection(
|
||||
reference.Id,
|
||||
reference.QuestionOwnerTenantId,
|
||||
reference.QuestionId,
|
||||
Id = question.CurrentVersionId
|
||||
}
|
||||
equals new
|
||||
{
|
||||
version.TenantId,
|
||||
version.QuestionId,
|
||||
Id = (Guid?)version.Id
|
||||
}
|
||||
where reference.TenantId == tenantId &&
|
||||
questionReferenceIds.Contains(reference.Id) &&
|
||||
question.Status == QuestionStatus.Published
|
||||
select new QuestionSelection(
|
||||
reference.Id,
|
||||
reference.QuestionOwnerTenantId,
|
||||
reference.QuestionId,
|
||||
version.Id,
|
||||
question.Type,
|
||||
question.TypeLabel,
|
||||
question.Difficulty,
|
||||
question.Tags,
|
||||
version.Content,
|
||||
version.Options,
|
||||
version.CorrectOptionIndex,
|
||||
version.CorrectOptionIndices,
|
||||
version.AnswerText,
|
||||
version.Explanation))
|
||||
version.Id,
|
||||
question.Type,
|
||||
question.TypeLabel,
|
||||
question.Difficulty,
|
||||
question.Tags,
|
||||
version.Content,
|
||||
version.Options,
|
||||
version.CorrectOptionIndex,
|
||||
version.CorrectOptionIndices,
|
||||
version.AnswerText,
|
||||
version.Explanation))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
var byReference = rows.ToDictionary(row => row.QuestionReferenceId);
|
||||
if (byReference.Count != questionReferenceIds.Distinct().Count())
|
||||
{
|
||||
throw new LearningValidationException(
|
||||
"practice_question_unavailable",
|
||||
"One or more practice questions have no published version.");
|
||||
}
|
||||
|
||||
return questionReferenceIds.Select(referenceId => byReference[referenceId]).ToArray();
|
||||
}
|
||||
@@ -251,26 +228,26 @@ public sealed partial class LearningActivityService
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
return await (
|
||||
from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking()
|
||||
where sessionQuestion.TenantId == tenantId &&
|
||||
sessionQuestion.PracticeSessionId == practiceSessionId
|
||||
orderby sessionQuestion.Position
|
||||
select new PracticeSessionQuestionItem(
|
||||
sessionQuestion.Id,
|
||||
sessionQuestion.QuestionReferenceId,
|
||||
new QuestionLocator(
|
||||
sessionQuestion.QuestionOwnerTenantId == tenantId
|
||||
? QuestionSource.Tenant
|
||||
: QuestionSource.Platform,
|
||||
sessionQuestion.QuestionId),
|
||||
sessionQuestion.QuestionId,
|
||||
sessionQuestion.QuestionType,
|
||||
sessionQuestion.TypeLabelSnapshot,
|
||||
sessionQuestion.DifficultySnapshot,
|
||||
sessionQuestion.TagsSnapshot,
|
||||
sessionQuestion.QuestionVersionId,
|
||||
sessionQuestion.ContentSnapshot,
|
||||
sessionQuestion.OptionsSnapshot))
|
||||
from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking()
|
||||
where sessionQuestion.TenantId == tenantId &&
|
||||
sessionQuestion.PracticeSessionId == practiceSessionId
|
||||
orderby sessionQuestion.Position
|
||||
select new PracticeSessionQuestionItem(
|
||||
sessionQuestion.Id,
|
||||
sessionQuestion.QuestionReferenceId,
|
||||
new QuestionLocator(
|
||||
sessionQuestion.QuestionOwnerTenantId == tenantId
|
||||
? QuestionSource.Tenant
|
||||
: QuestionSource.Platform,
|
||||
sessionQuestion.QuestionId),
|
||||
sessionQuestion.QuestionId,
|
||||
sessionQuestion.QuestionType,
|
||||
sessionQuestion.TypeLabelSnapshot,
|
||||
sessionQuestion.DifficultySnapshot,
|
||||
sessionQuestion.TagsSnapshot,
|
||||
sessionQuestion.QuestionVersionId,
|
||||
sessionQuestion.ContentSnapshot,
|
||||
sessionQuestion.OptionsSnapshot))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
@@ -282,9 +259,7 @@ public sealed partial class LearningActivityService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!practiceSessionId.HasValue)
|
||||
{
|
||||
throw new LearningValidationException("practice_session_id_required", "Practice session id is required.");
|
||||
}
|
||||
|
||||
var session = await dbContext.PracticeSessions
|
||||
.SingleOrDefaultAsync(
|
||||
@@ -295,9 +270,8 @@ public sealed partial class LearningActivityService
|
||||
cancellationToken);
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found.");
|
||||
}
|
||||
throw new LearningResourceNotFoundException("practice_session_not_found",
|
||||
"Practice session was not found.");
|
||||
|
||||
return session;
|
||||
}
|
||||
@@ -314,9 +288,8 @@ public sealed partial class LearningActivityService
|
||||
.OrderBy(item => item.Position)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
if (sessionQuestions.Length == 0)
|
||||
{
|
||||
throw new LearningValidationException("practice_session_empty", "Practice session has no question snapshot.");
|
||||
}
|
||||
throw new LearningValidationException("practice_session_empty",
|
||||
"Practice session has no question snapshot.");
|
||||
|
||||
var answers = await dbContext.AnswerRecords
|
||||
.AsNoTracking()
|
||||
@@ -489,10 +462,7 @@ public sealed partial class LearningActivityService
|
||||
question.Status == QuestionStatus.Published,
|
||||
cancellationToken);
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("question_not_found", "Question was not found.");
|
||||
}
|
||||
if (!exists) throw new LearningResourceNotFoundException("question_not_found", "Question was not found.");
|
||||
}
|
||||
|
||||
private async Task EnsureWordExistsAsync(
|
||||
@@ -507,10 +477,7 @@ public sealed partial class LearningActivityService
|
||||
word.IsActive,
|
||||
cancellationToken);
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("word_not_found", "Word was not found.");
|
||||
}
|
||||
if (!exists) throw new LearningResourceNotFoundException("word_not_found", "Word was not found.");
|
||||
}
|
||||
|
||||
private static AnswerRecordItem ToItem(AnswerRecord record, long sessionVersion)
|
||||
@@ -603,10 +570,7 @@ public sealed partial class LearningActivityService
|
||||
|
||||
private static List<Guid> ReadGuidArray(JsonElement value)
|
||||
{
|
||||
if (value.ValueKind is not JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
if (value.ValueKind is not JsonValueKind.Array) return [];
|
||||
|
||||
return value.EnumerateArray()
|
||||
.Select(item => item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out var id)
|
||||
@@ -622,58 +586,60 @@ public sealed partial class LearningActivityService
|
||||
SubmitAnswerCommand command)
|
||||
{
|
||||
if (session.Status != PracticeSessionStatus.Active)
|
||||
{
|
||||
throw new LearningValidationException("practice_session_not_active", "Only an active practice session accepts answers.");
|
||||
}
|
||||
throw new LearningValidationException("practice_session_not_active",
|
||||
"Only an active practice session accepts answers.");
|
||||
if (session.Version != command.ExpectedSessionVersion)
|
||||
{
|
||||
throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before answering.");
|
||||
}
|
||||
throw new LearningValidationException("practice_session_version_conflict",
|
||||
"The practice session changed. Reload it before answering.");
|
||||
if (command.ClientSequence <= session.LastClientSequence)
|
||||
{
|
||||
throw new LearningValidationException("practice_client_sequence_conflict", "Client sequence must increase within a practice session.");
|
||||
}
|
||||
throw new LearningValidationException("practice_client_sequence_conflict",
|
||||
"Client sequence must increase within a practice session.");
|
||||
}
|
||||
|
||||
private static JsonElement BuildGradingRules(QuestionSelection selection) =>
|
||||
JsonSerializer.SerializeToElement(new
|
||||
private static JsonElement BuildGradingRules(QuestionSelection selection)
|
||||
{
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
version = 1,
|
||||
normalization = selection.QuestionType.Equals("fill_blank", StringComparison.OrdinalIgnoreCase)
|
||||
? "nfkc_trim_casefold_whitespace"
|
||||
: "exact"
|
||||
});
|
||||
}
|
||||
|
||||
private static string HashAnswer(SubmitAnswerCommand command) => Hash(JsonSerializer.Serialize(new
|
||||
private static string HashAnswer(SubmitAnswerCommand command)
|
||||
{
|
||||
command.SessionQuestionId,
|
||||
command.ExpectedSessionVersion,
|
||||
command.ClientSequence,
|
||||
selectedOptionIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [],
|
||||
answerText = command.AnswerText?.Trim()
|
||||
}));
|
||||
return Hash(JsonSerializer.Serialize(new
|
||||
{
|
||||
command.SessionQuestionId,
|
||||
command.ExpectedSessionVersion,
|
||||
command.ClientSequence,
|
||||
selectedOptionIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [],
|
||||
answerText = command.AnswerText?.Trim()
|
||||
}));
|
||||
}
|
||||
|
||||
private static string HashSubmission(SubmitPracticeSessionCommand command) => Hash(JsonSerializer.Serialize(new
|
||||
private static string HashSubmission(SubmitPracticeSessionCommand command)
|
||||
{
|
||||
command.PracticeSessionId,
|
||||
command.ExpectedSessionVersion
|
||||
}));
|
||||
return Hash(JsonSerializer.Serialize(new
|
||||
{
|
||||
command.PracticeSessionId,
|
||||
command.ExpectedSessionVersion
|
||||
}));
|
||||
}
|
||||
|
||||
private static string Hash(string value) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
private static string Hash(string value)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ResolvePracticeSessionHistoryStatus(PracticeSession session, DateTimeOffset now)
|
||||
{
|
||||
if (session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview)
|
||||
{
|
||||
return "finished";
|
||||
}
|
||||
if (session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview) return "finished";
|
||||
|
||||
if (session.Status == PracticeSessionStatus.Expired ||
|
||||
session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now)
|
||||
{
|
||||
(session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now))
|
||||
return "expired";
|
||||
}
|
||||
|
||||
return "active";
|
||||
}
|
||||
@@ -694,7 +660,7 @@ public sealed partial class LearningActivityService
|
||||
|
||||
private static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out status);
|
||||
return Enum.TryParse(NormalizeEnumValue(value), true, out status);
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int? limit)
|
||||
@@ -737,4 +703,4 @@ public sealed partial class LearningActivityService
|
||||
JsonElement CorrectOptionIndices,
|
||||
string? AnswerText,
|
||||
string? Explanation);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,9 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using ZLinq;
|
||||
|
||||
namespace Tiku.Infrastructure.Learning;
|
||||
|
||||
@@ -27,10 +17,16 @@ public sealed partial class LearningActivityService(
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 500;
|
||||
private static readonly Meter LearningMeter = new("Tiku.Learning");
|
||||
private static readonly Counter<long> IdempotencyReplays = LearningMeter.CreateCounter<long>("tiku.learning.idempotency.replays");
|
||||
private static readonly Counter<long> AnswerConflicts = LearningMeter.CreateCounter<long>("tiku.learning.answer.conflicts");
|
||||
private static readonly Counter<long> SubmissionConflicts = LearningMeter.CreateCounter<long>("tiku.learning.submission.conflicts");
|
||||
private static readonly Counter<long> ScoringFailures = LearningMeter.CreateCounter<long>("tiku.learning.scoring.failures");
|
||||
|
||||
private static readonly Counter<long> IdempotencyReplays =
|
||||
LearningMeter.CreateCounter<long>("tiku.learning.idempotency.replays");
|
||||
|
||||
}
|
||||
private static readonly Counter<long> AnswerConflicts =
|
||||
LearningMeter.CreateCounter<long>("tiku.learning.answer.conflicts");
|
||||
|
||||
private static readonly Counter<long> SubmissionConflicts =
|
||||
LearningMeter.CreateCounter<long>("tiku.learning.submission.conflicts");
|
||||
|
||||
private static readonly Counter<long> ScoringFailures =
|
||||
LearningMeter.CreateCounter<long>("tiku.learning.scoring.failures");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user