Add server-authoritative RZ Coin rewards

This commit is contained in:
hz
2026-08-01 01:28:38 -05:00
parent 8c439c6294
commit ddc36a8048
24 changed files with 670 additions and 104 deletions
@@ -0,0 +1,17 @@
namespace QuestionsApi.Services;
public sealed class CreditLedgerReconciliationService(
CreditStore credits,
ILogger<CreditLedgerReconciliationService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try { await credits.ReconcilePendingAsync(stoppingToken); }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; }
catch (Exception exception) { logger.LogError(exception, "Credit ledger reconciliation failed."); }
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}
+213 -31
View File
@@ -7,43 +7,167 @@ namespace QuestionsApi.Services;
public sealed class CreditStore
{
private readonly IMongoCollection<UserCreditAccount> _accounts;
private readonly IMongoCollection<CreditLedgerEntry> _ledger;
private readonly TimeProvider _clock;
private readonly ServiceMetrics _metrics;
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock)
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock, ServiceMetrics metrics)
{
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
_ledger = mongo.Database.GetCollection<CreditLedgerEntry>("CreditLedgerEntries");
_clock = clock;
_metrics = metrics;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
_ledger.Indexes.CreateOne(new CreateIndexModel<CreditLedgerEntry>(
Builders<CreditLedgerEntry>.IndexKeys.Ascending(value => value.UserId)
.Descending(value => value.CreatedUtc).Descending(value => value.Id),
new CreateIndexOptions { Name = "credit_ledger_user_time" }));
}
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId)
public async Task<CreditAwardResult> AwardAsync(string userId, CreditAwardRequest request)
{
if (string.IsNullOrWhiteSpace(attemptId)) return false;
if (string.IsNullOrWhiteSpace(request.OperationId) || request.Amount <= 0)
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserCreditAccount>.Filter;
var belowCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
var entryId = LedgerId(userId, request.OperationId);
var entry = await _ledger.Find(value => value.Id == entryId).FirstOrDefaultAsync();
if (entry is not null && (entry.ReasonCode != request.ReasonCode || entry.SourceId != request.SourceId))
throw new InvalidOperationException("The credit operation ID was already used for another event.");
if (entry is null)
{
var alreadyApplied = await _accounts.Find(value => value.UserId == userId
&& value.AppliedOperations.Contains(request.OperationId)).AnyAsync();
if (alreadyApplied) return new CreditAwardResult { Status = await GetStatusAsync(userId) };
entry = new CreditLedgerEntry
{
new BsonDocument("$size", "$RecentEarnings"),
CreditEconomy.RollingEarningLimit
})));
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.ElemMatch(value => value.RecentEarnings,
earning => earning.OperationId == attemptId)) &
belowCap;
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, 1)
.Push(value => value.RecentEarnings, new CreditEvent
{
OperationId = attemptId,
Id = entryId, UserId = userId, OperationId = request.OperationId,
RequestedAmount = request.Amount, Amount = request.SubjectToRollingCap ? 0 : request.Amount,
ReasonCode = request.ReasonCode,
Description = request.Description, SourceType = request.SourceType,
SourceId = request.SourceId, SubjectToRollingCap = request.SubjectToRollingCap,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
};
try { await _ledger.InsertOneAsync(entry); }
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
{ entry = await _ledger.Find(value => value.Id == entryId).FirstAsync(); }
}
if (entry.State == "applied")
{
_metrics.CreditDuplicate(request.ReasonCode);
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
}
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
if (account.AppliedOperations.Contains(request.OperationId))
{
var appliedAmount = AppliedAmount(account, entry);
entry.Amount = appliedAmount;
await MarkLedgerAppliedAsync(entry, appliedAmount, now);
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
}
var amount = request.Amount;
var appliedNow = false;
while (true)
{
var earned = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount));
amount = request.SubjectToRollingCap
? Math.Min(request.Amount, Math.Max(0, CreditEconomy.RollingEarningLimit - earned))
: request.Amount;
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId)
& Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [request.OperationId]);
if (request.SubjectToRollingCap && amount > 0)
filter &= RollingEarnedAtMost(CreditEconomy.RollingEarningLimit - amount);
var update = Builders<UserCreditAccount>.Update
.AddToSet(value => value.AppliedOperations, request.OperationId)
.Set(value => value.UpdatedUtc, now);
if (amount > 0)
{
update = update.Inc(value => value.Credits, amount);
if (request.SubjectToRollingCap)
update = update.Push(value => value.RecentEarnings,
new CreditEvent { OperationId = request.OperationId, CoinAmount = amount, CreatedUtc = now });
}
if ((await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1)
{
appliedNow = true;
break;
}
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
if (account.AppliedOperations.Contains(request.OperationId))
{
amount = AppliedAmount(account, entry);
break;
}
}
entry.Amount = amount;
await MarkLedgerAppliedAsync(entry, amount, now);
if (appliedNow && amount > 0) _metrics.CreditAward(request.ReasonCode, amount);
if (appliedNow && request.SubjectToRollingCap && amount < request.Amount)
_metrics.CreditCapped(request.ReasonCode);
var status = await GetStatusAsync(userId);
return appliedNow ? Result(entry, status) : new CreditAwardResult { Status = status };
}
public async Task InitializeMasteryBaselineAsync(string userId, IEnumerable<string> operationIds)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
var update = Builders<UserCreditAccount>.Update
.Set(value => value.MasteryAwardBaselineApplied, true);
foreach (var operationId in operationIds.Distinct(StringComparer.Ordinal))
update = update.AddToSet(value => value.AppliedOperations, operationId);
await _accounts.UpdateOneAsync(
value => value.UserId == userId && !value.MasteryAwardBaselineApplied, update);
}
public async Task<bool> IsMasteryBaselineAppliedAsync(string userId)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).MasteryAwardBaselineApplied;
}
public async Task<HashSet<string>> GetAppliedOperationsAsync(string userId)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
return account.AppliedOperations.ToHashSet(StringComparer.Ordinal);
}
public async Task<CreditLedgerPage> GetLedgerAsync(string userId, int limit, DateTime? before)
{
limit = Math.Clamp(limit, 1, 50);
var filter = Builders<CreditLedgerEntry>.Filter.Eq(value => value.UserId, userId)
& Builders<CreditLedgerEntry>.Filter.Eq(value => value.State, "applied")
& Builders<CreditLedgerEntry>.Filter.Ne(value => value.Amount, 0);
if (before.HasValue) filter &= Builders<CreditLedgerEntry>.Filter.Lt(value => value.CreatedUtc, before.Value);
var entries = await _ledger.Find(filter).SortByDescending(value => value.CreatedUtc)
.ThenByDescending(value => value.Id).Limit(limit + 1).ToListAsync();
var hasMore = entries.Count > limit;
if (hasMore) entries.RemoveAt(entries.Count - 1);
return new CreditLedgerPage
{
Items = entries.Select(value => new CreditLedgerEntryResponse
{
Amount = value.Amount, ReasonCode = value.ReasonCode,
Description = value.Description, CreatedUtc = value.CreatedUtc
}).ToList(),
NextBefore = hasMore && entries.Count > 0 ? entries[^1].CreatedUtc : null
};
}
public async Task ReconcilePendingAsync(CancellationToken cancellationToken = default)
{
var pending = await _ledger.Find(value => value.State == "pending")
.SortBy(value => value.CreatedUtc).Limit(100).ToListAsync(cancellationToken);
foreach (var entry in pending)
{
cancellationToken.ThrowIfCancellationRequested();
await AwardAsync(entry.UserId, new CreditAwardRequest(
entry.OperationId, entry.RequestedAmount, entry.ReasonCode, entry.Description,
entry.SourceType, entry.SourceId, entry.SubjectToRollingCap));
}
}
public async Task<CreditStatusResponse> GetStatusAsync(string userId)
@@ -68,9 +192,12 @@ public sealed class CreditStore
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
var prior = existing.Purchases.FirstOrDefault(value => value.OperationId == operationId);
if (prior is not null)
return Response(prior.Sku == product.Sku
? CreditOperationResult.Succeeded
: CreditOperationResult.OperationConflict, existing);
{
if (prior.Sku != product.Sku)
return Response(CreditOperationResult.OperationConflict, existing);
await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc);
return Response(CreditOperationResult.Succeeded, existing);
}
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, product.CoinPrice) &
@@ -95,14 +222,21 @@ public sealed class CreditStore
{
ReturnDocument = ReturnDocument.After
});
if (account is not null) return Response(CreditOperationResult.Succeeded, account);
if (account is not null)
{
await RecordPurchaseLedgerAsync(userId, product, operationId, now);
return Response(CreditOperationResult.Succeeded, account);
}
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
prior = account.Purchases.FirstOrDefault(value => value.OperationId == operationId);
if (prior is not null)
return Response(prior.Sku == product.Sku
? CreditOperationResult.Succeeded
: CreditOperationResult.OperationConflict, account);
{
if (prior.Sku != product.Sku)
return Response(CreditOperationResult.OperationConflict, account);
await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc);
return Response(CreditOperationResult.Succeeded, account);
}
return Response(CreditOperationResult.InsufficientCredits, account);
}
@@ -223,6 +357,7 @@ public sealed class CreditStore
.SetOnInsert(value => value.QBankResetTokens, 0)
.SetOnInsert(value => value.BankCycle, 0)
.SetOnInsert(value => value.InitialExamGrantApplied, true)
.SetOnInsert(value => value.MasteryAwardBaselineApplied, false)
.SetOnInsert(value => value.RecentEarnings, [])
.SetOnInsert(value => value.Purchases, [])
.SetOnInsert(value => value.Spins, [])
@@ -273,9 +408,9 @@ public sealed class CreditStore
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
{
Credits = account.Credits,
EarnedInRollingWindow = account.RecentEarnings.Count,
EarnedInRollingWindow = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount)),
RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count),
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount))),
ExamTokens = account.ExamTokens,
QBankResetTokens = account.QBankResetTokens,
BankCycle = account.BankCycle
@@ -297,4 +432,51 @@ public sealed class CreditStore
CoinsWon = spinEvent?.CoinsWon ?? 0,
Status = ToStatus(account)
};
private static string LedgerId(string userId, string operationId) => $"{userId}:{operationId}";
private static int AppliedAmount(UserCreditAccount account, CreditLedgerEntry entry) =>
entry.SubjectToRollingCap
? account.RecentEarnings.FirstOrDefault(value => value.OperationId == entry.OperationId)?.CoinAmount ?? 0
: entry.RequestedAmount;
private static FilterDefinition<UserCreditAccount> RollingEarnedAtMost(int maximum) =>
new BsonDocumentFilterDefinition<UserCreditAccount>(new BsonDocument("$expr",
new BsonDocument("$lte", new BsonArray
{
new BsonDocument("$sum", new BsonDocument("$map", new BsonDocument
{
{ "input", new BsonDocument("$ifNull", new BsonArray { "$RecentEarnings", new BsonArray() }) },
{ "as", "earning" },
{ "in", new BsonDocument("$ifNull", new BsonArray { "$$earning.CoinAmount", 1 }) }
})),
maximum
})));
private Task MarkLedgerAppliedAsync(CreditLedgerEntry entry, int amount, DateTime now) =>
_ledger.UpdateOneAsync(value => value.Id == entry.Id,
Builders<CreditLedgerEntry>.Update.Set(value => value.Amount, amount)
.Set(value => value.State, "applied").Set(value => value.AppliedUtc, now));
private Task RecordPurchaseLedgerAsync(string userId, RzStoreProduct product, string operationId, DateTime createdUtc) =>
_ledger.ReplaceOneAsync(
value => value.Id == LedgerId(userId, $"purchase:{operationId}"),
new CreditLedgerEntry
{
Id = LedgerId(userId, $"purchase:{operationId}"), UserId = userId,
OperationId = $"purchase:{operationId}", RequestedAmount = -product.CoinPrice,
Amount = -product.CoinPrice, ReasonCode = CreditReasonCodes.Purchase,
Description = $"Purchased {product.Name}", SourceType = "store_product",
SourceId = product.Sku, State = "applied", CreatedUtc = createdUtc, AppliedUtc = createdUtc
},
new ReplaceOptions { IsUpsert = true });
private static CreditAwardResult Result(CreditLedgerEntry entry, CreditStatusResponse status) => new()
{
Award = entry.Amount <= 0 ? null : new CreditAward
{
Amount = entry.Amount, ReasonCode = entry.ReasonCode, Description = entry.Description
},
Status = status
};
}
@@ -0,0 +1,86 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class MasteryAwardService
{
private readonly IMongoCollection<QuestionAttempt> _attempts;
private readonly IMongoCollection<QuestionReviewAttempt> _reviews;
private readonly IMongoCollection<ReviewQueueItem> _queue;
private readonly QuestionStore _questions;
private readonly CreditStore _credits;
private readonly TimeProvider _clock;
public MasteryAwardService(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits, TimeProvider clock)
{
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
_reviews = mongo.Database.GetCollection<QuestionReviewAttempt>("QuestionReviewAttempts");
_queue = mongo.Database.GetCollection<ReviewQueueItem>("ReviewQueueItems");
_questions = questions;
_credits = credits;
_clock = clock;
}
public async Task PrepareAsync(string userId)
{
if (await _credits.IsMasteryBaselineAppliedAsync(userId)) return;
var states = await CurrentStatesAsync(userId);
await _credits.InitializeMasteryBaselineAsync(userId, states.SelectMany(BaselineOperations));
}
public async Task<List<CreditAward>> AwardPromotionsAsync(string userId)
{
var awards = new List<CreditAward>();
var applied = await _credits.GetAppliedOperationsAsync(userId);
foreach (var system in await CurrentStatesAsync(userId))
{
foreach (var state in EarnedStates(system.MasteryState))
{
var spec = AwardSpec(state);
if (applied.Contains(Operation(system.System, state))) continue;
var result = await _credits.AwardAsync(userId, new CreditAwardRequest(
Operation(system.System, state), spec.Amount, spec.Reason, spec.Description,
"step3_system", system.System, false));
if (result.Award is not null) awards.Add(result.Award);
applied.Add(Operation(system.System, state));
}
}
return awards;
}
private async Task<List<DashboardSystemPerformance>> CurrentStatesAsync(string userId)
{
var attempts = await _attempts.Find(value => value.UserId == userId).ToListAsync();
var reviews = await _reviews.Find(value => value.UserId == userId).ToListAsync();
var queue = await _queue.Find(value => value.UserId == userId).ToListAsync();
var ids = attempts.Select(value => value.QuestionExternalId)
.Concat(reviews.Select(value => value.QuestionExternalId))
.Concat(queue.Select(value => value.QuestionExternalId));
var questions = await _questions.GetByExternalIdsAsync(ids);
return MasteryCalculator.Calculate(attempts, reviews, queue, questions, _clock.GetUtcNow().UtcDateTime);
}
private static IEnumerable<string> BaselineOperations(DashboardSystemPerformance system) =>
EarnedStates(system.MasteryState).Select(state => Operation(system.System, state));
private static IEnumerable<string> EarnedStates(string state)
{
if (state is MasteryStates.Practicing or MasteryStates.Strong or MasteryStates.Mastered)
yield return MasteryStates.Practicing;
if (state is MasteryStates.Strong or MasteryStates.Mastered)
yield return MasteryStates.Strong;
if (state is MasteryStates.Mastered)
yield return MasteryStates.Mastered;
}
private static string Operation(string system, string state) =>
$"mastery:{system.ToLowerInvariant().Replace(' ', '_')}:{state}";
private static (int Amount, string Reason, string Description) AwardSpec(string state) => state switch
{
MasteryStates.Practicing => (3, CreditReasonCodes.MasteryPracticing, "System advanced to Practicing"),
MasteryStates.Strong => (5, CreditReasonCodes.MasteryStrong, "System advanced to Strong"),
_ => (10, CreditReasonCodes.MasteryMastered, "System advanced to Mastered")
};
}
@@ -17,6 +17,10 @@ public sealed class ServiceMetrics
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicAttempts = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicCorrect = new();
private long _dynamicTargetGapTotal;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditAwards = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCoins = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditDuplicates = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCapped = new();
public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates);
@@ -43,6 +47,13 @@ public sealed class ServiceMetrics
_dynamicAttempts.AddOrUpdate(tier, 1, (_, value) => value + 1);
if (correct) _dynamicCorrect.AddOrUpdate(tier, 1, (_, value) => value + 1);
}
public void CreditAward(string reason, int amount)
{
_creditAwards.AddOrUpdate(reason, 1, (_, value) => value + 1);
_creditCoins.AddOrUpdate(reason, amount, (_, value) => value + amount);
}
public void CreditDuplicate(string reason) => _creditDuplicates.AddOrUpdate(reason, 1, (_, value) => value + 1);
public void CreditCapped(string reason) => _creditCapped.AddOrUpdate(reason, 1, (_, value) => value + 1);
public string RenderPrometheus()
{
@@ -63,6 +74,17 @@ public sealed class ServiceMetrics
AppendLabeledSample(output, "step3_dynamic_correct_total", tier, _dynamicCorrect.GetValueOrDefault(tier));
}
AppendCounter(output, "step3_dynamic_target_gap_points_total", Interlocked.Read(ref _dynamicTargetGapTotal));
output.AppendLine("# TYPE step3_credit_awards_total counter");
output.AppendLine("# TYPE step3_credit_coins_awarded_total counter");
output.AppendLine("# TYPE step3_credit_award_duplicates_total counter");
output.AppendLine("# TYPE step3_credit_award_capped_total counter");
foreach (var reason in _creditAwards.Keys.Concat(_creditDuplicates.Keys).Concat(_creditCapped.Keys).Distinct().Order())
{
AppendReasonSample(output, "step3_credit_awards_total", reason, _creditAwards.GetValueOrDefault(reason));
AppendReasonSample(output, "step3_credit_coins_awarded_total", reason, _creditCoins.GetValueOrDefault(reason));
AppendReasonSample(output, "step3_credit_award_duplicates_total", reason, _creditDuplicates.GetValueOrDefault(reason));
AppendReasonSample(output, "step3_credit_award_capped_total", reason, _creditCapped.GetValueOrDefault(reason));
}
output.AppendLine("# TYPE step3_http_request_duration_seconds summary");
output.Append("step3_http_request_duration_seconds_count ")
.Append(Interlocked.Read(ref _httpRequests))
@@ -91,4 +113,7 @@ public sealed class ServiceMetrics
{
output.Append(name).Append("{tier=\"").Append(tier.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
}
private static void AppendReasonSample(StringBuilder output, string name, string reason, long value) =>
output.Append(name).Append("{reason=\"").Append(reason.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
}
@@ -20,7 +20,7 @@ public sealed class UserProfileStore
"ExternalIdentities", "ExternalAuthTransactions",
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
"UserCreditAccounts"
"UserCreditAccounts", "CreditLedgerEntries"
];
public UserProfileStore(MongoDatabaseProvider mongo)