Add server-authoritative RZ Coin rewards
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user