Add Rz Coin utility store

This commit is contained in:
hz
2026-07-29 18:34:54 -05:00
parent c14ac1d65b
commit da641a8d1d
21 changed files with 656 additions and 21 deletions
+138 -7
View File
@@ -51,15 +51,108 @@ public sealed class CreditStore
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
return new CreditStatusResponse
{
Credits = account.Credits,
EarnedInRollingWindow = account.RecentEarnings.Count,
RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count)
};
return ToStatus(account);
}
public async Task<int> GetBankCycleAsync(string userId)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
}
public async Task<CreditOperationResponse> PurchaseAsync(
string userId, RzStoreProduct product, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
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);
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, product.CoinPrice) &
Builders<UserCreditAccount>.Filter.Not(
Builders<UserCreditAccount>.Filter.ElemMatch(
value => value.Purchases, purchase => purchase.OperationId == operationId));
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, -product.CoinPrice)
.Push(value => value.Purchases, new CreditPurchaseEvent
{
OperationId = operationId,
Sku = product.Sku,
CoinCost = product.CoinPrice,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now);
update = product.Grant == RzStoreGrant.ExamToken
? update.Inc(value => value.ExamTokens, 1)
: update.Inc(value => value.QBankResetTokens, 1);
var account = await _accounts.FindOneAndUpdateAsync(
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
{
ReturnDocument = ReturnDocument.After
});
if (account is not null) 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);
return Response(CreditOperationResult.InsufficientCredits, account);
}
public async Task<CreditOperationResponse> ApplyQBankResetAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gt(value => value.QBankResetTokens, 0) &
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.QBankResetTokens, -1)
.Inc(value => value.BankCycle, 1)
.AddToSet(value => value.AppliedOperations, operationId)
.Set(value => value.UpdatedUtc, now);
var account = await _accounts.FindOneAndUpdateAsync(
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
{
ReturnDocument = ReturnDocument.After
});
if (account is not null) return Response(CreditOperationResult.Succeeded, account);
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
if (account.AppliedOperations.Contains(operationId))
return Response(CreditOperationResult.Succeeded, account);
return Response(CreditOperationResult.InsufficientTokens, account);
}
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gt(value => value.ExamTokens, 0) &
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.ExamTokens, -1)
.AddToSet(value => value.AppliedOperations, operationId)
.Set(value => value.UpdatedUtc, now);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public Task RefundExamTokenAsync(string userId, string operationId) =>
_accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.AnyEq(value => value.AppliedOperations, operationId),
Builders<UserCreditAccount>.Update
.Inc(value => value.ExamTokens, 1)
.Pull(value => value.AppliedOperations, operationId)
.Set(value => value.UpdatedUtc, _clock.GetUtcNow().UtcDateTime));
private async Task EnsureAndCleanAsync(string userId, DateTime now)
{
await _accounts.UpdateOneAsync(
@@ -67,13 +160,33 @@ public sealed class CreditStore
Builders<UserCreditAccount>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.Credits, 0)
.SetOnInsert(value => value.ExamTokens, 1)
.SetOnInsert(value => value.QBankResetTokens, 0)
.SetOnInsert(value => value.BankCycle, 0)
.SetOnInsert(value => value.InitialExamGrantApplied, true)
.SetOnInsert(value => value.RecentEarnings, [])
.SetOnInsert(value => value.Purchases, [])
.SetOnInsert(value => value.AppliedOperations, [])
.SetOnInsert(value => value.UpdatedUtc, now),
new UpdateOptions { IsUpsert = true });
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Ne(value => value.InitialExamGrantApplied, true),
Builders<UserCreditAccount>.Update
.Inc(value => value.ExamTokens, 1)
.Set(value => value.InitialExamGrantApplied, true));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Purchases), false),
Builders<UserCreditAccount>.Update.Set(value => value.Purchases, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false),
Builders<UserCreditAccount>.Update.Set(value => value.AppliedOperations, []));
var cutoff = now - CreditEconomy.EarningWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
@@ -81,4 +194,22 @@ public sealed class CreditStore
value => value.RecentEarnings,
earning => earning.CreatedUtc <= cutoff));
}
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
{
Credits = account.Credits,
EarnedInRollingWindow = account.RecentEarnings.Count,
RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count),
ExamTokens = account.ExamTokens,
QBankResetTokens = account.QBankResetTokens,
BankCycle = account.BankCycle
};
private static CreditOperationResponse Response(
CreditOperationResult result, UserCreditAccount account) => new()
{
Result = result,
Status = ToStatus(account)
};
}
@@ -13,6 +13,7 @@ public class ExamSimulationStore
private readonly IMongoCollection<Step3Outcome> _outcomes;
private readonly QuestionStore _questions;
private readonly QuestionAttemptStore _attempts;
private readonly CreditStore _credits;
private readonly AnswerStatisticsStore _statistics;
private readonly RatingStore _ratings;
private readonly ServiceMetrics _metrics;
@@ -22,6 +23,7 @@ public class ExamSimulationStore
MongoDatabaseProvider mongo,
QuestionStore questions,
QuestionAttemptStore attempts,
CreditStore credits,
AnswerStatisticsStore statistics,
RatingStore ratings,
ServiceMetrics metrics,
@@ -31,6 +33,7 @@ public class ExamSimulationStore
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
_questions = questions;
_attempts = attempts;
_credits = credits;
_statistics = statistics;
_ratings = ratings;
_metrics = metrics;
@@ -68,7 +71,8 @@ public class ExamSimulationStore
var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList();
if (bank.Count < RequiredQuestions)
throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required.");
var seen = (await _attempts.GetUserQuestionIdsAsync(userId)).ToHashSet(StringComparer.Ordinal);
var bankCycle = await _credits.GetBankCycleAsync(userId);
var seen = (await _attempts.GetUserQuestionIdsAsync(userId, bankCycle)).ToHashSet(StringComparer.Ordinal);
var elos = await _ratings.GetQuestionElosAsync(bank.Select(question => question.ExternalId));
var targets = BuildTargets();
var selected = SelectForm(bank, seen, targets);
@@ -443,7 +447,10 @@ public class ExamSimulationStore
if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue;
responses.TryGetValue(item.QuestionExternalId, out var response);
var choiceId = response?.ChoiceId ?? "";
var record = await _attempts.RecordFirstAttemptAsync(item.QuestionExternalId, simulation.UserId, choiceId, choiceId == question.CorrectChoiceId, choiceId == "", item.PresentedChoiceIds);
var record = await _attempts.RecordFirstAttemptAsync(
item.QuestionExternalId, simulation.UserId, choiceId,
choiceId == question.CorrectChoiceId, choiceId == "",
item.PresentedChoiceIds, bankCycle: await _credits.GetBankCycleAsync(simulation.UserId));
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (!record.Inserted) continue;
_metrics.AttemptCreated();
@@ -0,0 +1,66 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public static class QuestionAttemptCycleMigrationCommand
{
public const string Confirmation = "MIGRATE_ATTEMPT_CYCLES";
public const string LegacyIndexName = "QuestionExternalId_1_UserId_1";
public const string CycleIndexName = "uq_question_user_cycle";
public static async Task<int> RunAsync(
IConfiguration configuration, string[] args, TextWriter output)
{
var apply = args.Contains("--apply", StringComparer.Ordinal);
var confirmation = Option(args, "--confirm");
var mongo = new MongoDatabaseProvider(configuration);
var attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
var missingFilter = new BsonDocumentFilterDefinition<QuestionAttempt>(
new BsonDocument(nameof(QuestionAttempt.BankCycle),
new BsonDocument("$exists", false)));
var missing = await attempts.CountDocumentsAsync(missingFilter);
var indexes = (await attempts.Indexes.ListAsync()).ToList();
var names = indexes.Select(value => value["name"].AsString).ToHashSet(StringComparer.Ordinal);
await output.WriteLineAsync(
$"QuestionAttempts missing BankCycle: {missing}; legacy index: {names.Contains(LegacyIndexName)}; cycle index: {names.Contains(CycleIndexName)}.");
if (!apply)
{
await output.WriteLineAsync(
$"Dry run only. Re-run with --apply --confirm {Confirmation}.");
return 0;
}
if (confirmation != Confirmation)
{
await output.WriteLineAsync($"Refusing migration: --confirm {Confirmation} is required.");
return 2;
}
if (missing > 0)
await attempts.UpdateManyAsync(
missingFilter,
Builders<QuestionAttempt>.Update.Set(value => value.BankCycle, 0));
if (!names.Contains(CycleIndexName))
await attempts.Indexes.CreateOneAsync(new CreateIndexModel<QuestionAttempt>(
Builders<QuestionAttempt>.IndexKeys
.Ascending(value => value.QuestionExternalId)
.Ascending(value => value.UserId)
.Ascending(value => value.BankCycle),
new CreateIndexOptions { Unique = true, Name = CycleIndexName }));
if (names.Contains(LegacyIndexName))
await attempts.Indexes.DropOneAsync(LegacyIndexName);
var remaining = await attempts.CountDocumentsAsync(missingFilter);
await output.WriteLineAsync(
$"Migration complete. Backfilled {missing}; missing BankCycle: {remaining}; dropped legacy index: {names.Contains(LegacyIndexName)}.");
return remaining == 0 ? 0 : 1;
}
private static string Option(string[] args, string name)
{
var index = Array.IndexOf(args, name);
return index >= 0 && index + 1 < args.Length ? args[index + 1] : "";
}
}
@@ -17,10 +17,11 @@ public class QuestionAttemptStore
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
.Ascending(a => a.QuestionExternalId)
.Ascending(a => a.UserId);
.Ascending(a => a.UserId)
.Ascending(a => a.BankCycle);
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
firstAttemptIndex,
new CreateIndexOptions { Unique = true }));
new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" }));
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
@@ -38,12 +39,14 @@ public class QuestionAttemptStore
bool isCorrect,
bool isSkip = false,
IReadOnlyCollection<string>? presentedChoiceIds = null,
int durationSeconds = 0)
int durationSeconds = 0,
int bankCycle = 0)
{
var attempt = new QuestionAttempt
{
QuestionExternalId = questionExternalId,
UserId = userId,
BankCycle = bankCycle,
ChoiceId = choiceId,
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
TracksPresentation = presentedChoiceIds is { Count: > 0 },
@@ -63,7 +66,8 @@ public class QuestionAttemptStore
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
var existing = await _col
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId)
.Find(a => a.QuestionExternalId == questionExternalId &&
a.UserId == userId && a.BankCycle == bankCycle)
.FirstAsync();
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
}
@@ -75,11 +79,17 @@ public class QuestionAttemptStore
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
.ToListAsync();
public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null)
{
var filter = Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId);
if (bankCycle.HasValue)
filter &= Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value);
return
_col.Distinct(
attempt => attempt.QuestionExternalId,
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
filter)
.ToListAsync();
}
public IMongoCollection<QuestionAttempt> Collection => _col;