90ac0b81d1
Add full V2 architecture: identity, content, studio (.NET 10) and file, render, notification, gateway (Go) services with vendored deps, plus DB migrations, event/API contracts, and an init-db script. Wire the Next.js frontend to the gateway: server-side JWT auth routes (login/register/refresh/logout/me), gateway fetch helper, and session/ cookie/jwt helpers under src/lib. Containerize the stack via docker-compose.v2.yml and per-service Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via next/font/local to avoid Google Fonts (geo-blocked). Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
152 lines
5.8 KiB
C#
152 lines
5.8 KiB
C#
using FlatRender.IdentitySvc.Application.Services.Interfaces;
|
|
using FlatRender.IdentitySvc.Domain.Entities;
|
|
using FlatRender.IdentitySvc.Domain.Enums;
|
|
using FlatRender.IdentitySvc.Infrastructure.Data;
|
|
using FlatRender.IdentitySvc.Models.Responses;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace FlatRender.IdentitySvc.Application.Services;
|
|
|
|
public class GamificationService(IdentityDbContext db) : IGamificationService
|
|
{
|
|
public async Task<List<QuestResponse>> GetActiveQuestsAsync(Guid userId, Guid tenantId)
|
|
{
|
|
var quests = await db.Quests
|
|
.Where(q => q.IsActive &&
|
|
(q.TenantId == null || q.TenantId == tenantId) &&
|
|
(q.StartsAt == null || q.StartsAt <= DateTime.UtcNow) &&
|
|
(q.ExpiresAt == null || q.ExpiresAt > DateTime.UtcNow))
|
|
.OrderBy(q => q.OrderValue)
|
|
.ToListAsync();
|
|
|
|
var progressMap = await db.UserQuestProgresses
|
|
.Where(p => p.UserId == userId && quests.Select(q => q.Id).Contains(p.QuestId))
|
|
.ToDictionaryAsync(p => p.QuestId, p => p);
|
|
|
|
return quests.Select(q =>
|
|
{
|
|
progressMap.TryGetValue(q.Id, out var progress);
|
|
return new QuestResponse(
|
|
q.Id, q.Title, q.Challenge, q.Why, q.Hint, q.Icon,
|
|
q.QuestType.ToString(), q.TargetCount,
|
|
progress?.CurrentCount ?? 0,
|
|
progress?.IsCompleted ?? false,
|
|
progress?.PrizeClaimed ?? false,
|
|
q.PrizeType.ToString(), q.PrizeAmount, q.ExpiresAt
|
|
);
|
|
}).ToList();
|
|
}
|
|
|
|
public async Task ClaimQuestPrizeAsync(Guid userId, Guid questId)
|
|
{
|
|
var progress = await db.UserQuestProgresses
|
|
.FirstOrDefaultAsync(p => p.UserId == userId && p.QuestId == questId && p.IsCompleted && !p.PrizeClaimed)
|
|
?? throw new InvalidOperationException("Quest not completed or prize already claimed");
|
|
|
|
var quest = await db.Quests.FindAsync(questId)
|
|
?? throw new KeyNotFoundException("Quest not found");
|
|
|
|
await ApplyPrizeAsync(userId, quest.PrizeType, quest.PrizeAmount);
|
|
|
|
progress.PrizeClaimed = true;
|
|
progress.PrizeClaimedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task<List<EarnedGiftResponse>> GetEarnedGiftsAsync(Guid userId)
|
|
{
|
|
var gifts = await db.EarnedGifts
|
|
.Include(eg => eg.Gift)
|
|
.Where(eg => eg.UserId == userId && !eg.IsUsed && (eg.ExpiresAt == null || eg.ExpiresAt > DateTime.UtcNow))
|
|
.OrderByDescending(eg => eg.EarnedAt)
|
|
.ToListAsync();
|
|
|
|
return gifts.Select(eg => new EarnedGiftResponse(
|
|
eg.Id, eg.GiftId, eg.Gift.Name, eg.Gift.Description,
|
|
eg.Gift.PrizeType.ToString(), eg.Gift.Value, eg.Gift.Unit,
|
|
eg.EarnedAt, eg.ExpiresAt, eg.IsUsed
|
|
)).ToList();
|
|
}
|
|
|
|
public async Task UseEarnedGiftAsync(Guid userId, Guid earnedGiftId)
|
|
{
|
|
var earned = await db.EarnedGifts
|
|
.Include(eg => eg.Gift)
|
|
.FirstOrDefaultAsync(eg => eg.Id == earnedGiftId && eg.UserId == userId && !eg.IsUsed &&
|
|
(eg.ExpiresAt == null || eg.ExpiresAt > DateTime.UtcNow))
|
|
?? throw new InvalidOperationException("Earned gift not found or already used");
|
|
|
|
await ApplyPrizeAsync(userId, earned.Gift.PrizeType, earned.Gift.Value);
|
|
|
|
earned.IsUsed = true;
|
|
earned.UsedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task IncrementQuestProgressAsync(Guid userId, Guid tenantId, string targetEvent)
|
|
{
|
|
var matchingQuests = await db.Quests
|
|
.Where(q => q.IsActive && q.TargetEvent == targetEvent &&
|
|
(q.TenantId == null || q.TenantId == tenantId) &&
|
|
(q.ExpiresAt == null || q.ExpiresAt > DateTime.UtcNow))
|
|
.ToListAsync();
|
|
|
|
foreach (var quest in matchingQuests)
|
|
{
|
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
|
DateOnly? periodStart = quest.QuestType switch
|
|
{
|
|
QuestType.Daily => today,
|
|
QuestType.Weekly => today.AddDays(-(int)DateTime.UtcNow.DayOfWeek),
|
|
_ => null
|
|
};
|
|
|
|
var progress = await db.UserQuestProgresses
|
|
.FirstOrDefaultAsync(p => p.UserId == userId && p.QuestId == quest.Id && p.PeriodStart == periodStart);
|
|
|
|
if (progress == null)
|
|
{
|
|
progress = new UserQuestProgress
|
|
{
|
|
UserId = userId,
|
|
QuestId = quest.Id,
|
|
PeriodStart = periodStart,
|
|
};
|
|
db.UserQuestProgresses.Add(progress);
|
|
}
|
|
|
|
if (progress.IsCompleted) continue;
|
|
|
|
progress.CurrentCount++;
|
|
if (progress.CurrentCount >= quest.TargetCount)
|
|
{
|
|
progress.IsCompleted = true;
|
|
progress.CompletedAt = DateTime.UtcNow;
|
|
}
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task ApplyPrizeAsync(Guid userId, PrizeType prizeType, long amount)
|
|
{
|
|
var user = await db.Users.FindAsync(userId) ?? throw new KeyNotFoundException("User not found");
|
|
|
|
switch (prizeType)
|
|
{
|
|
case PrizeType.Balance:
|
|
user.BalanceMinor += amount;
|
|
break;
|
|
case PrizeType.RenderSeconds:
|
|
user.UserDailyFreeChargeSec += (int)amount;
|
|
break;
|
|
case PrizeType.LoyaltyPoints:
|
|
user.LoyaltyScore += (int)amount;
|
|
break;
|
|
// StorageGB, Plan, Discount require more complex handling (not inline)
|
|
}
|
|
|
|
user.UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
}
|