forked from gongxuegit/tiku-backend.net
feat: migrate direct tenant content and learning endpoints
This commit is contained in:
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
@@ -91,6 +92,46 @@ public sealed class AssetManagementService(
|
||||
return new CatalogList<ContentAssetManagementItem>(items);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<ContentAssetManagementItem>> UpsertAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
UpsertAssetCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var asset = await ResolveManagementAssetAsync(actor, command, cancellationToken);
|
||||
asset.RegionId = command.RegionId;
|
||||
asset.SubjectId = command.SubjectId;
|
||||
asset.CategoryId = command.CategoryId;
|
||||
asset.ContentNodeId = command.ContentNodeId;
|
||||
asset.LegacyId = NormalizeOptional(command.LegacyId);
|
||||
asset.AssetKey = NormalizeOptional(command.AssetKey);
|
||||
asset.Title = NormalizeOptional(command.Title) ?? NormalizeOptional(command.FileName) ?? asset.Title ?? "未命名资源";
|
||||
asset.Category = NormalizeOptional(command.Category);
|
||||
asset.Description = NormalizeOptional(command.Description);
|
||||
asset.FileName = NormalizeOptional(command.FileName);
|
||||
asset.CdnUrl = NormalizeOptional(command.CdnUrl);
|
||||
asset.IsPublic = command.IsPublic ?? asset.IsPublic;
|
||||
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.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.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.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? asset.Metadata : command.Metadata;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
public async Task<AssetUploadSignResult> SignUploadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetUploadSignCommand command,
|
||||
@@ -227,6 +268,92 @@ public sealed class AssetManagementService(
|
||||
return new AssetUploadConfirmResult(ToItem(asset), metadata);
|
||||
}
|
||||
|
||||
public Task<AssetManagementSignedAccessResult> SignDownloadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SignAssetAccessAsync(actor, command, AssetAccessType.AdminDownload, "attachment", cancellationToken);
|
||||
}
|
||||
|
||||
public Task<AssetManagementSignedAccessResult> SignPreviewAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SignAssetAccessAsync(actor, command, AssetAccessType.AdminPreview, "inline", cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ContentAssetAccessEventItem>> GetAccessEventsAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetEventFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
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.UserId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.UserId == filter.UserId.Value);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new ContentAssetAccessEventItem(
|
||||
item.Id,
|
||||
item.AssetId,
|
||||
item.UserId,
|
||||
item.ActorRole,
|
||||
item.AccessType,
|
||||
item.Visibility,
|
||||
item.AssetType,
|
||||
item.StorageProvider,
|
||||
item.Disposition,
|
||||
item.ExpiresInSeconds,
|
||||
item.SignatureMode,
|
||||
item.Result,
|
||||
item.DenyCode,
|
||||
item.IpAddress,
|
||||
item.UserAgent,
|
||||
item.Metadata,
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<ContentAssetAccessEventItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ContentAssetSecurityScanEventItem>> GetSecurityScanEventsAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetEventFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ContentAssetSecurityScanEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.AssetId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.AssetId == filter.AssetId.Value);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new ContentAssetSecurityScanEventItem(
|
||||
item.Id,
|
||||
item.AssetId,
|
||||
item.Provider,
|
||||
item.ScanStatus,
|
||||
item.RiskLevel,
|
||||
item.IssueCodes,
|
||||
item.Details,
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<ContentAssetSecurityScanEventItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ContentImportJobItem>> GetImportJobsAsync(
|
||||
AssetManagementActor actor,
|
||||
ImportJobFilter filter,
|
||||
@@ -372,6 +499,107 @@ public sealed class AssetManagementService(
|
||||
return asset;
|
||||
}
|
||||
|
||||
private async Task<ContentAsset> ResolveManagementAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
UpsertAssetCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ContentAsset? asset = null;
|
||||
if (command.AssetId.HasValue)
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(command.LegacyId))
|
||||
{
|
||||
var legacyId = command.LegacyId.Trim();
|
||||
asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.LegacyId == legacyId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (asset is not null)
|
||||
{
|
||||
return asset;
|
||||
}
|
||||
|
||||
asset = new ContentAsset
|
||||
{
|
||||
Id = command.AssetId ?? Guid.NewGuid(),
|
||||
TenantId = actor.TenantId,
|
||||
CreatedBy = actor.UserId,
|
||||
UpdatedBy = actor.UserId,
|
||||
Source = "manual",
|
||||
Status = ContentStatus.Active
|
||||
};
|
||||
dbContext.ContentAssets.Add(asset);
|
||||
return asset;
|
||||
}
|
||||
|
||||
private async Task<AssetManagementSignedAccessResult> SignAssetAccessAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
AssetAccessType accessType,
|
||||
string disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
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");
|
||||
}
|
||||
|
||||
var provider = ToObjectStorageProvider(asset.StorageProvider);
|
||||
var objectKey = accessType == AssetAccessType.AdminPreview
|
||||
? asset.PreviewObjectKey ?? asset.ObjectKey
|
||||
: asset.ObjectKey;
|
||||
var cdnUrl = accessType == AssetAccessType.AdminPreview
|
||||
? asset.PreviewUrl ?? asset.CdnUrl
|
||||
: asset.CdnUrl;
|
||||
var expiresIn = TimeSpan.FromSeconds(Math.Clamp(command.ExpiresInSeconds ?? 900, 60, 3600));
|
||||
var url = await objectStorageService.SignDownloadAsync(
|
||||
new ObjectStorageDownloadSignRequest(
|
||||
actor.TenantId,
|
||||
provider,
|
||||
asset.Bucket,
|
||||
objectKey,
|
||||
expiresIn,
|
||||
cdnUrl,
|
||||
asset.FileName,
|
||||
disposition),
|
||||
cancellationToken);
|
||||
dbContext.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
AssetId = asset.Id,
|
||||
UserId = actor.UserId,
|
||||
ActorRole = AssetAccessActorRole.TenantAdmin,
|
||||
AccessType = accessType,
|
||||
Visibility = asset.Visibility.ToString(),
|
||||
AssetType = asset.AssetType.ToString(),
|
||||
StorageProvider = asset.StorageProvider.ToString(),
|
||||
Disposition = disposition == "inline" ? AssetAccessDisposition.Inline : AssetAccessDisposition.Attachment,
|
||||
ExpiresInSeconds = (int)expiresIn.TotalSeconds,
|
||||
SignatureMode = url.SignatureMode,
|
||||
Result = AssetAccessResult.Granted,
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
url.Provider,
|
||||
url.Bucket,
|
||||
url.ObjectKey
|
||||
})
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new AssetManagementSignedAccessResult(ToItem(asset), url);
|
||||
}
|
||||
|
||||
private static ContentAssetManagementItem ToItem(ContentAsset asset)
|
||||
{
|
||||
return new ContentAssetManagementItem(
|
||||
@@ -527,6 +755,19 @@ public sealed class AssetManagementService(
|
||||
return isPublic ? ContentVisibility.Public : ContentVisibility.Members;
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Enum.TryParse<TEnum>(value.Trim(), ignoreCase: true, out var parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static AssetPreviewStatus ResolveInitialPreviewStatus(ContentAssetType assetType, string mimeType)
|
||||
{
|
||||
return assetType is ContentAssetType.Pdf or ContentAssetType.Image ||
|
||||
|
||||
1740
Tiku.Infrastructure/Content/DirectContentService.cs
Normal file
1740
Tiku.Infrastructure/Content/DirectContentService.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
|
||||
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
||||
services.AddScoped<IContentManagementService, ContentManagementService>();
|
||||
services.AddScoped<IDirectContentService, DirectContentService>();
|
||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
||||
|
||||
@@ -14,6 +14,100 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 500;
|
||||
|
||||
public async Task<LearningStatsItem> GetStatsAsync(
|
||||
LearningActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var answers = dbContext.AnswerRecords.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
return new LearningStatsItem(
|
||||
await answers.CountAsync(cancellationToken),
|
||||
await answers.CountAsync(item => item.IsCorrect == true, cancellationToken),
|
||||
await dbContext.WrongQuestions.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.ResolvedAt == null,
|
||||
cancellationToken),
|
||||
await dbContext.FavoriteQuestions.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken),
|
||||
await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken),
|
||||
await dbContext.UserWordProgress.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken),
|
||||
await dbContext.PracticeSessions.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken),
|
||||
await dbContext.PracticeSessionReports.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<LearningList<LearningTrendItem>> GetTrendAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var since = DateTimeOffset.UtcNow.AddDays(-ResolveLimit(filter.Limit));
|
||||
var rows = await dbContext.AnswerRecords.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.AnsweredAt >= since)
|
||||
.Select(item => new { item.AnsweredAt, item.IsCorrect })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var items = rows
|
||||
.GroupBy(item => DateOnly.FromDateTime(item.AnsweredAt.UtcDateTime))
|
||||
.OrderBy(group => group.Key)
|
||||
.Select(group => new LearningTrendItem(
|
||||
group.Key,
|
||||
group.Count(),
|
||||
group.Count(item => item.IsCorrect == true),
|
||||
group.Count(item => item.IsCorrect == false)))
|
||||
.ToArray();
|
||||
return new LearningList<LearningTrendItem>(items);
|
||||
}
|
||||
|
||||
public async Task<LearningLeaderboardResult> GetLeaderboardAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rows = await dbContext.AnswerRecords.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.GroupBy(item => item.UserId)
|
||||
.Select(group => new
|
||||
{
|
||||
UserId = group.Key,
|
||||
AnswerCount = group.Count(),
|
||||
CorrectCount = group.Count(item => item.IsCorrect == true),
|
||||
WrongCount = group.Count(item => item.IsCorrect == false)
|
||||
})
|
||||
.OrderByDescending(item => item.CorrectCount)
|
||||
.ThenByDescending(item => item.AnswerCount)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var userIds = rows.Select(item => item.UserId).ToArray();
|
||||
var names = await dbContext.Users.AsNoTracking()
|
||||
.Where(item => userIds.Contains(item.Id))
|
||||
.ToDictionaryAsync(item => item.Id, item => item.Name ?? item.Phone, cancellationToken);
|
||||
var items = rows
|
||||
.Select(item => new LearningLeaderboardItem(
|
||||
item.UserId,
|
||||
names.GetValueOrDefault(item.UserId),
|
||||
item.AnswerCount,
|
||||
item.CorrectCount,
|
||||
item.WrongCount,
|
||||
item.AnswerCount == 0 ? 0 : decimal.Round((decimal)item.CorrectCount / item.AnswerCount, 4)))
|
||||
.ToArray();
|
||||
return new LearningLeaderboardResult(
|
||||
"correct_count",
|
||||
"all",
|
||||
items,
|
||||
items.FirstOrDefault(item => item.UserId == actor.UserId),
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<AnswerRecordItem> SubmitAnswerAsync(
|
||||
LearningActor actor,
|
||||
SubmitAnswerCommand command,
|
||||
@@ -201,6 +295,31 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
return new LearningActionResult(true);
|
||||
}
|
||||
|
||||
public async Task<WrongQuestionReviewPlan> GetWrongQuestionReviewPlanAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = await dbContext.WrongQuestions.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.ResolvedAt == null)
|
||||
.OrderByDescending(item => item.WrongCount)
|
||||
.ThenBy(item => item.LastWrongAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new WrongQuestionReviewPlanItem(item.QuestionId, item.WrongCount, item.LastWrongAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new WrongQuestionReviewPlan(
|
||||
items,
|
||||
JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
mode = "wrong_review",
|
||||
questionCount = items.Length,
|
||||
recommendedEndpoint = "/api/learning/practice-sessions"
|
||||
}));
|
||||
}
|
||||
|
||||
public async Task<LearningList<WordProgressItem>> GetWordProgressAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
@@ -312,6 +431,91 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
return ToItem(item);
|
||||
}
|
||||
|
||||
public async Task<WordReviewPlan> GetWordReviewPlanAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var query = dbContext.UserWordProgress.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
if (filter.UnitId.HasValue)
|
||||
{
|
||||
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
|
||||
word.TenantId == actor.TenantId &&
|
||||
word.Id == item.WordId &&
|
||||
word.UnitId == filter.UnitId.Value));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.Where(item => item.NextReviewAt == null || item.NextReviewAt <= now)
|
||||
.OrderBy(item => item.NextReviewAt == null)
|
||||
.ThenBy(item => item.NextReviewAt)
|
||||
.ThenByDescending(item => item.WrongCount)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new WordReviewPlanItem(
|
||||
item.WordId,
|
||||
item.Status,
|
||||
item.NextReviewAt,
|
||||
item.CorrectCount,
|
||||
item.WrongCount,
|
||||
item.DueLevel))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new WordReviewPlan(
|
||||
items,
|
||||
JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
mode = "word_review",
|
||||
wordCount = items.Length
|
||||
}));
|
||||
}
|
||||
|
||||
public Task<WordProgressItem> ReviewWordAsync(
|
||||
LearningActor actor,
|
||||
WordReviewCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var correct = string.Equals(command.Result, "correct", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(command.Result, "known", StringComparison.OrdinalIgnoreCase);
|
||||
return UpdateWordProgressAsync(
|
||||
actor,
|
||||
new WordProgressCommand(
|
||||
command.WordId,
|
||||
correct ? "Reviewing" : "Learning",
|
||||
correct ? 1 : 0,
|
||||
correct ? 0 : 1,
|
||||
command.NextReviewAt ?? DateTimeOffset.UtcNow.AddDays(correct ? 2 : 1)),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<WordStatsItem> GetWordStatsAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var query = dbContext.UserWordProgress.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
if (filter.UnitId.HasValue)
|
||||
{
|
||||
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
|
||||
word.TenantId == actor.TenantId &&
|
||||
word.Id == item.WordId &&
|
||||
word.UnitId == filter.UnitId.Value));
|
||||
}
|
||||
|
||||
return new WordStatsItem(
|
||||
await query.CountAsync(cancellationToken),
|
||||
await query.CountAsync(item => item.Status == WordProgressStatus.New, cancellationToken),
|
||||
await query.CountAsync(item => item.Status == WordProgressStatus.Learning, cancellationToken),
|
||||
await query.CountAsync(item => item.Status == WordProgressStatus.Reviewing, cancellationToken),
|
||||
await query.CountAsync(item => item.Status == WordProgressStatus.Mastered, cancellationToken),
|
||||
await query.CountAsync(item => item.NextReviewAt == null || item.NextReviewAt <= now, cancellationToken),
|
||||
await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<LearningList<FavoriteWordItem>> GetFavoriteWordsAsync(
|
||||
LearningActor actor,
|
||||
LearningLimitFilter filter,
|
||||
|
||||
Reference in New Issue
Block a user