forked from gongxuegit/tiku-backend.net
feat: add student profile endpoints
This commit is contained in:
273
Tiku.Infrastructure/Profile/ProfileService.cs
Normal file
273
Tiku.Infrastructure/Profile/ProfileService.cs
Normal file
@@ -0,0 +1,273 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Profile;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Profile;
|
||||
|
||||
public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
|
||||
{
|
||||
private static readonly HashSet<string> AllowedAvatarPresets = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"male",
|
||||
"female"
|
||||
};
|
||||
|
||||
public async Task<StudentProfileItem> GetMeAsync(
|
||||
ProfileActor actor,
|
||||
ProfileQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await EnsureProfileAsync(actor, cancellationToken);
|
||||
return await BuildProfileItemAsync(actor, profile, query.RecentLimit, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<StudentProfileItem> UpdateMeAsync(
|
||||
ProfileActor actor,
|
||||
UpdateProfileCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await EnsureProfileAsync(actor, cancellationToken);
|
||||
var user = await dbContext.Users.FindAsync([actor.UserId], cancellationToken)
|
||||
?? throw new ProfileException("Current user was not found.", "profile_user_not_found");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(command.Name))
|
||||
{
|
||||
user.Name = command.Name.Trim();
|
||||
}
|
||||
|
||||
if (command.AvatarPreset is not null)
|
||||
{
|
||||
var avatarPreset = command.AvatarPreset.Trim();
|
||||
if (!AllowedAvatarPresets.Contains(avatarPreset))
|
||||
{
|
||||
throw new ProfileException("Avatar preset must be male or female.", "invalid_avatar_preset");
|
||||
}
|
||||
|
||||
profile.AvatarPreset = avatarPreset.ToLowerInvariant();
|
||||
}
|
||||
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<School>(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Major>(actor.TenantId, command.SelectedMajorId, "major_not_found", cancellationToken);
|
||||
|
||||
profile.RegionId = command.RegionId ?? profile.RegionId;
|
||||
profile.SelectedSchoolId = command.SelectedSchoolId ?? profile.SelectedSchoolId;
|
||||
profile.SelectedMajorId = command.SelectedMajorId ?? profile.SelectedMajorId;
|
||||
if (command.Stats.HasValue)
|
||||
{
|
||||
profile.Stats = JsonObjectOrDefault(command.Stats.Value);
|
||||
}
|
||||
|
||||
if (command.Progress.HasValue)
|
||||
{
|
||||
profile.Progress = JsonObjectOrDefault(command.Progress.Value);
|
||||
}
|
||||
|
||||
if (command.ModuleSelections.HasValue)
|
||||
{
|
||||
profile.ModuleSelections = JsonObjectOrDefault(command.ModuleSelections.Value);
|
||||
}
|
||||
|
||||
if (command.RecentActivities.HasValue)
|
||||
{
|
||||
profile.RecentActivities = JsonArrayOrDefault(command.RecentActivities.Value);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return await BuildProfileItemAsync(actor, profile, null, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ExamCountdownList> GetExamCountdownsAsync(
|
||||
ProfileActor actor,
|
||||
ProfileQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await EnsureProfileAsync(actor, cancellationToken);
|
||||
var limit = Math.Clamp(query.Limit ?? 5, 1, 20);
|
||||
var items = await dbContext.ExamDates.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.IsActive &&
|
||||
(profile.RegionId == null || item.RegionId == null || item.RegionId == profile.RegionId) &&
|
||||
(profile.SelectedSchoolId == null || item.SchoolId == null || item.SchoolId == profile.SelectedSchoolId))
|
||||
.OrderBy(item => item.ExamAt == null)
|
||||
.ThenBy(item => item.ExamAt)
|
||||
.ThenBy(item => item.SortOrder)
|
||||
.Take(limit)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var today = DateTimeOffset.UtcNow.Date;
|
||||
|
||||
return new ExamCountdownList(
|
||||
items.Select(item => new ExamCountdownItem(
|
||||
item.Id,
|
||||
item.LegacyId,
|
||||
item.RegionId,
|
||||
item.SchoolId,
|
||||
item.ExamName,
|
||||
item.ExamAt,
|
||||
item.ExamType,
|
||||
item.Description,
|
||||
item.Metadata,
|
||||
item.SortOrder,
|
||||
item.IsActive,
|
||||
item.ExamAt.HasValue ? (int)Math.Ceiling((item.ExamAt.Value.UtcDateTime.Date - today).TotalDays) : null))
|
||||
.ToArray(),
|
||||
profile.RegionId,
|
||||
profile.SelectedSchoolId);
|
||||
}
|
||||
|
||||
private async Task<StudentProfile> EnsureProfileAsync(
|
||||
ProfileActor actor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = await dbContext.StudentProfiles
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, cancellationToken);
|
||||
if (profile is not null)
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
|
||||
var membershipExists = await dbContext.TenantMemberships.AnyAsync(
|
||||
membership =>
|
||||
membership.TenantId == actor.TenantId &&
|
||||
membership.UserId == actor.UserId &&
|
||||
membership.Status == Domain.Tenancy.MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!membershipExists)
|
||||
{
|
||||
throw new ProfileException("Current user is not a tenant member.", "profile_access_denied");
|
||||
}
|
||||
|
||||
profile = new StudentProfile
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
AvatarPreset = "male"
|
||||
};
|
||||
dbContext.StudentProfiles.Add(profile);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return profile;
|
||||
}
|
||||
|
||||
private async Task<StudentProfileItem> BuildProfileItemAsync(
|
||||
ProfileActor actor,
|
||||
StudentProfile profile,
|
||||
int? recentLimit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await dbContext.Users.AsNoTracking()
|
||||
.SingleAsync(item => item.Id == actor.UserId, cancellationToken);
|
||||
var regionName = profile.RegionId.HasValue
|
||||
? await dbContext.Regions.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == profile.RegionId.Value)
|
||||
.Select(item => item.Name)
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
var schoolName = profile.SelectedSchoolId.HasValue
|
||||
? await dbContext.Schools.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == profile.SelectedSchoolId.Value)
|
||||
.Select(item => item.Name)
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
var majorName = profile.SelectedMajorId.HasValue
|
||||
? await dbContext.Majors.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == profile.SelectedMajorId.Value)
|
||||
.Select(item => item.Name)
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
var limit = Math.Clamp(recentLimit ?? 8, 1, 50);
|
||||
var recentPractices = await dbContext.RecentPractices.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId)
|
||||
.OrderByDescending(item => item.LastPracticeAt)
|
||||
.ThenByDescending(item => item.LastAccessAt)
|
||||
.ThenByDescending(item => item.UpdatedAt)
|
||||
.Take(limit)
|
||||
.Select(item => new RecentPracticeItem(
|
||||
item.Id,
|
||||
item.PracticeType,
|
||||
item.TargetName,
|
||||
item.Progress,
|
||||
item.LastAccessAt,
|
||||
item.LastPracticeAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var entitlement = await dbContext.Entitlements.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.EntitlementType == "svip" &&
|
||||
item.Status == EntitlementStatus.Active &&
|
||||
item.StartsAt <= DateTimeOffset.UtcNow &&
|
||||
(item.ExpiresAt == null || item.ExpiresAt > DateTimeOffset.UtcNow))
|
||||
.OrderByDescending(item => item.ExpiresAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return new StudentProfileItem(
|
||||
profile.Id,
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.Phone,
|
||||
user.Email,
|
||||
user.Name,
|
||||
profile.AvatarPreset,
|
||||
$"/assets/avatars/default-{profile.AvatarPreset}.svg",
|
||||
user.PrimaryRole,
|
||||
user.Score,
|
||||
profile.RegionId,
|
||||
regionName,
|
||||
profile.SelectedSchoolId,
|
||||
schoolName,
|
||||
profile.SelectedMajorId,
|
||||
majorName,
|
||||
profile.QuestionsAnsweredToday,
|
||||
profile.MasteredWordsCount,
|
||||
profile.LastCheckInDate,
|
||||
profile.Stats,
|
||||
profile.Progress,
|
||||
profile.ModuleSelections,
|
||||
profile.RecentActivities,
|
||||
recentPractices,
|
||||
new StudentMembershipItem(entitlement is not null, entitlement?.ExpiresAt));
|
||||
}
|
||||
|
||||
private async Task AssertReferenceAsync<TEntity>(
|
||||
Guid tenantId,
|
||||
Guid? id,
|
||||
string code,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : Tiku.Domain.Common.TenantEntity
|
||||
{
|
||||
if (!id.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var exists = await dbContext.Set<TEntity>().AnyAsync(
|
||||
entity => entity.TenantId == tenantId && entity.Id == id.Value,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new ProfileException("Profile reference was not found.", code);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement value)
|
||||
{
|
||||
return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object();
|
||||
}
|
||||
|
||||
private static JsonElement JsonArrayOrDefault(JsonElement value)
|
||||
{
|
||||
return value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ProfileException(string message, string code) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
Reference in New Issue
Block a user