feat: complete phase six backoffice operations

This commit is contained in:
2026-07-28 14:31:43 +08:00
parent 747ff59d76
commit 99e4e43122
31 changed files with 23504 additions and 26 deletions

View File

@@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Catalog;
@@ -17,7 +18,8 @@ internal sealed class CommerceAdminService(
TikuDbContext dbContext,
ITenantSecretProtector tenantSecretProtector,
ITenantExternalProviderConfigService providerConfigService,
ICurrentAccessContext currentAccessContext) : ICommerceAdminService
ICurrentAccessContext currentAccessContext,
IBackgroundJobService backgroundJobService) : ICommerceAdminService
{
public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
CommerceAdminActor actor,
@@ -871,6 +873,426 @@ internal sealed class CommerceAdminService(
return issue;
}
public async Task<TenantAdjustmentVoucherList> GetAdjustmentVouchersAsync(
CommerceAdminActor actor,
CommerceAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var vouchers = dbContext.CommerceAdjustmentVouchers.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status))
{
vouchers = vouchers.Where(item => item.Status == ParseAdjustmentVoucherStatus(query.Status));
}
var items = await vouchers
.OrderByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.ToArrayAsync(cancellationToken);
return new TenantAdjustmentVoucherList(items);
}
public async Task<CommerceAdjustmentVoucher> GetAdjustmentVoucherAsync(
CommerceAdminActor actor,
Guid voucherId,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
return await dbContext.CommerceAdjustmentVouchers.AsNoTracking()
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == voucherId, cancellationToken)
?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
}
public async Task<CommerceAdjustmentVoucher> CreateAdjustmentVoucherAsync(
CommerceAdminActor actor,
CreateAdjustmentVoucherCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Reason);
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationIssues, actor.TenantId, command.IssueId, "reconciliation_issue_not_found", cancellationToken);
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationBatches, actor.TenantId, command.BatchId, "reconciliation_batch_not_found", cancellationToken);
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationItems, actor.TenantId, command.ItemId, "reconciliation_item_not_found", cancellationToken);
await AssertOptionalReferenceAsync(dbContext.Orders, actor.TenantId, command.OrderId, "order_not_found", cancellationToken);
await AssertOptionalReferenceAsync(dbContext.Payments, actor.TenantId, command.PaymentId, "payment_not_found", cancellationToken);
await AssertOptionalReferenceAsync(dbContext.CommerceRefundRequests, actor.TenantId, command.RefundRequestId, "refund_not_found", cancellationToken);
var voucher = new CommerceAdjustmentVoucher
{
TenantId = actor.TenantId,
IssueId = command.IssueId,
BatchId = command.BatchId,
ItemId = command.ItemId,
OrderId = command.OrderId,
PaymentId = command.PaymentId,
RefundRequestId = command.RefundRequestId,
CreatedBy = actor.UserId,
VoucherNo = $"ADJ{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}",
Status = CommerceAdjustmentVoucherStatus.Draft,
Direction = command.Direction,
AmountCents = command.AmountCents,
Currency = string.IsNullOrWhiteSpace(command.Currency) ? "CNY" : command.Currency.Trim().ToUpperInvariant(),
Reason = command.Reason.Trim(),
ProofAssetKey = string.IsNullOrWhiteSpace(command.ProofAssetKey) ? null : command.ProofAssetKey.Trim(),
Metadata = JsonObjectOrDefault(command.Metadata)
};
dbContext.CommerceAdjustmentVouchers.Add(voucher);
dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent
{
TenantId = actor.TenantId,
VoucherId = voucher.Id,
ToStatus = voucher.Status,
ActorUserId = actor.UserId,
Note = voucher.Reason,
Details = JsonSerializer.SerializeToElement(new { voucher.Direction, voucher.AmountCents })
});
await AddAuditAsync(actor, "commerce.adjustment_voucher.created", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, voucher.Direction, voucher.AmountCents }, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return voucher;
}
public async Task<CommerceAdjustmentVoucher> UpdateAdjustmentVoucherStatusAsync(
CommerceAdminActor actor,
UpdateAdjustmentVoucherStatusCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var voucher = await dbContext.CommerceAdjustmentVouchers.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.VoucherId,
cancellationToken) ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
var fromStatus = voucher.Status;
if (fromStatus != command.Status && !IsAllowedAdjustmentTransition(fromStatus, command.Status))
{
throw new CommerceException("Adjustment voucher status transition is invalid.", "invalid_adjustment_status_transition");
}
voucher.Status = command.Status;
if (command.Status is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected)
{
voucher.ReviewedBy = actor.UserId;
voucher.ReviewedAt ??= DateTimeOffset.UtcNow;
}
else if (command.Status is CommerceAdjustmentVoucherStatus.Closed or CommerceAdjustmentVoucherStatus.Void)
{
voucher.ClosedAt ??= DateTimeOffset.UtcNow;
}
dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent
{
TenantId = actor.TenantId,
VoucherId = voucher.Id,
FromStatus = fromStatus,
ToStatus = command.Status,
ActorUserId = actor.UserId,
Note = command.Note,
Details = JsonSerializer.SerializeToElement(new { })
});
await AddAuditAsync(actor, "commerce.adjustment_voucher.status_changed", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, From = fromStatus, To = command.Status }, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return voucher;
}
public async Task<TenantAdjustmentVoucherEventList> GetAdjustmentVoucherEventsAsync(
CommerceAdminActor actor,
Guid voucherId,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var exists = await dbContext.CommerceAdjustmentVouchers.AnyAsync(
item => item.TenantId == actor.TenantId && item.Id == voucherId,
cancellationToken);
if (!exists)
{
throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
}
var events = await dbContext.CommerceAdjustmentVoucherEvents.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.VoucherId == voucherId)
.OrderBy(item => item.CreatedAt)
.ToArrayAsync(cancellationToken);
return new TenantAdjustmentVoucherEventList(events);
}
public async Task<TenantAdjustmentReport> GetAdjustmentReportAsync(
CommerceAdminActor actor,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
return new TenantAdjustmentReport(
await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft, cancellationToken),
await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview, cancellationToken),
await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved, cancellationToken),
await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed, cancellationToken),
await dbContext.CommerceAdjustmentVouchers
.Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.IncreaseRevenue)
.SumAsync(item => item.AmountCents, cancellationToken),
await dbContext.CommerceAdjustmentVouchers
.Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.DecreaseRevenue)
.SumAsync(item => item.AmountCents, cancellationToken));
}
public async Task<TenantReconciliationItemList> GetReconciliationItemsAsync(
CommerceAdminActor actor,
Guid batchId,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var batchExists = await dbContext.CommerceReconciliationBatches.AnyAsync(
item => item.TenantId == actor.TenantId && item.Id == batchId,
cancellationToken);
if (!batchExists)
{
throw new CommerceException("Reconciliation batch was not found.", "reconciliation_batch_not_found");
}
var items = await dbContext.CommerceReconciliationItems.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.BatchId == batchId)
.OrderBy(item => item.RowNo)
.Take(500)
.ToArrayAsync(cancellationToken);
return new TenantReconciliationItemList(items);
}
public async Task<TenantReconciliationIssueEventList> GetReconciliationIssueEventsAsync(
CommerceAdminActor actor,
Guid issueId,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var issueExists = await dbContext.CommerceReconciliationIssues.AnyAsync(
item => item.TenantId == actor.TenantId && item.Id == issueId,
cancellationToken);
if (!issueExists)
{
throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found");
}
var events = await dbContext.CommerceReconciliationIssueEvents.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.IssueId == issueId)
.OrderBy(item => item.CreatedAt)
.ToArrayAsync(cancellationToken);
return new TenantReconciliationIssueEventList(events);
}
public async Task<TenantCommerceAnomalySummary> GetAnomalySummaryAsync(
CommerceAdminActor actor,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var openRefunds = await dbContext.CommerceRefundRequests.CountAsync(
item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Requested,
cancellationToken);
var processingRefunds = await dbContext.CommerceRefundRequests.CountAsync(
item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Processing,
cancellationToken);
var openIssues = await dbContext.CommerceReconciliationIssues.CountAsync(
item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved && item.Status != ReconciliationIssueStatus.Ignored,
cancellationToken);
var failedBatches = await dbContext.CommerceReconciliationBatches.CountAsync(
item => item.TenantId == actor.TenantId && item.Status == ReconciliationBatchStatus.Failed,
cancellationToken);
var pendingPayments = await dbContext.Payments.CountAsync(
item => item.TenantId == actor.TenantId && item.Status == PaymentStatus.Pending,
cancellationToken);
var mismatchCount = await dbContext.CommerceReconciliationItems.CountAsync(
item => item.TenantId == actor.TenantId &&
(item.MatchStatus == ReconciliationMatchStatus.AmountMismatch ||
item.MatchStatus == ReconciliationMatchStatus.StatusMismatch),
cancellationToken);
return new TenantCommerceAnomalySummary(
openRefunds,
processingRefunds,
openIssues,
failedBatches,
pendingPayments,
mismatchCount);
}
public async Task<ReconciliationImportPreview> PreviewReconciliationImportAsync(
CommerceAdminActor actor,
PreviewReconciliationImportCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
return BuildImportPreview(command.Provider, command.Rows);
}
public async Task<CommerceReconciliationBatch> ImportReconciliationAsync(
CommerceAdminActor actor,
ImportReconciliationCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var preview = BuildImportPreview(command.Provider, command.Rows);
var batch = new CommerceReconciliationBatch
{
TenantId = actor.TenantId,
CreatedBy = actor.UserId,
Provider = NormalizeProvider(command.Provider),
BillDate = command.BillDate,
BillType = command.BillType,
Source = ReconciliationSource.ManualUpload,
SourceName = command.SourceName.Trim(),
SourceHash = preview.SourceHash,
Status = preview.InvalidCount == 0
? ReconciliationBatchStatus.Completed
: ReconciliationBatchStatus.CompletedWithIssues,
TotalCount = preview.TotalCount,
MatchedCount = preview.TotalCount - preview.InvalidCount,
MismatchCount = preview.InvalidCount,
AmountCents = preview.AmountCents,
RefundAmountCents = preview.RefundAmountCents,
CompletedAt = DateTimeOffset.UtcNow,
Metadata = JsonSerializer.SerializeToElement(new
{
preview.PaymentCount,
preview.RefundCount,
preview.InvalidCount
})
};
dbContext.CommerceReconciliationBatches.Add(batch);
var rowNo = 0;
foreach (var row in EnumerateImportRows(command.Rows))
{
rowNo++;
var item = CreateReconciliationItem(actor.TenantId, batch.Id, rowNo, NormalizeProvider(command.Provider), row);
dbContext.CommerceReconciliationItems.Add(item);
if (item.MatchStatus != ReconciliationMatchStatus.Matched)
{
dbContext.CommerceReconciliationIssues.Add(new CommerceReconciliationIssue
{
TenantId = actor.TenantId,
BatchId = batch.Id,
Provider = item.Provider,
TransactionType = item.TransactionType,
IssueNo = $"RC{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{rowNo:0000}",
MatchStatus = item.MatchStatus switch
{
ReconciliationMatchStatus.MissingLocal => ReconciliationIssueMatchStatus.MissingLocal,
ReconciliationMatchStatus.MissingProvider => ReconciliationIssueMatchStatus.MissingProvider,
ReconciliationMatchStatus.Duplicate => ReconciliationIssueMatchStatus.Duplicate,
ReconciliationMatchStatus.StatusMismatch => ReconciliationIssueMatchStatus.StatusMismatch,
_ => ReconciliationIssueMatchStatus.AmountMismatch
},
Severity = item.Severity,
Status = ReconciliationIssueStatus.Open,
OrderNo = item.OrderNo,
RefundNo = item.RefundNo,
ProviderTradeNo = item.ProviderTradeNo,
ProviderRefundNo = item.ProviderRefundNo,
AmountCents = item.AmountCents,
RefundAmountCents = item.RefundAmountCents,
Summary = item.IssueCode,
CreatedBy = actor.UserId,
Metadata = item.Details
});
}
}
await AddAuditAsync(actor, "commerce.reconciliation.imported", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate, batch.TotalCount }, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return batch;
}
public async Task<BackgroundJobItem> RequestProviderBillJobAsync(
CommerceAdminActor actor,
RequestProviderBillJobCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var job = await backgroundJobService.EnqueueAsync(
new CreateBackgroundJobCommand(
actor.TenantId,
"commerce_reconciliation",
JsonSerializer.SerializeToElement(new
{
provider = NormalizeProvider(command.Provider),
command.BillDate,
billType = command.BillType.ToString()
}),
command.RunAfter,
5),
cancellationToken);
await AddAuditAsync(actor, "commerce.reconciliation.provider_bill_requested", "background_jobs", job.Id, new { command.Provider, command.BillDate, command.BillType }, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return job;
}
public async Task<IReadOnlyCollection<BackgroundJobItem>> GetProviderBillJobsAsync(
CommerceAdminActor actor,
CommerceAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
return await backgroundJobService.ListAsync(actor.TenantId, "commerce_reconciliation", Math.Clamp(query.Limit ?? 50, 1, 200), cancellationToken);
}
public async Task<CommerceRefundRequest> ProcessRefundNotificationAsync(
Guid tenantId,
RefundNotificationCommand command,
CancellationToken cancellationToken = default)
{
var provider = NormalizeProvider(command.Provider);
var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.RefundNo == command.RefundNo,
cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found");
var eventId = string.IsNullOrWhiteSpace(command.EventId)
? $"{provider}:{command.RefundNo}:{command.Status}"
: command.EventId.Trim();
var duplicate = await dbContext.PaymentEvents.AnyAsync(
item => item.TenantId == tenantId &&
item.Provider == provider &&
item.EventType == "refund" &&
item.EventId == eventId,
cancellationToken);
if (duplicate)
{
return refund;
}
dbContext.PaymentEvents.Add(new PaymentEvent
{
TenantId = tenantId,
Provider = provider,
EventType = "refund",
EventId = eventId,
SignatureValid = true,
Payload = JsonObjectOrDefault(command.Payload),
ProcessedAt = DateTimeOffset.UtcNow
});
var fromStatus = refund.Status;
if (fromStatus != command.Status && IsAllowedRefundTransition(fromStatus, command.Status))
{
refund.Status = command.Status;
refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo)
? refund.ProviderRefundNo
: command.ProviderRefundNo.Trim();
if (command.Status == CommerceRefundStatus.Succeeded)
{
refund.SucceededAt = DateTimeOffset.UtcNow;
await ApplyRefundToOrderAsync(refund, cancellationToken);
}
else if (command.Status == CommerceRefundStatus.Failed)
{
refund.FailedAt = DateTimeOffset.UtcNow;
}
dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent
{
TenantId = tenantId,
RefundRequestId = refund.Id,
FromStatus = fromStatus,
ToStatus = command.Status,
EventType = "provider_notify",
Details = JsonObjectOrDefault(command.Payload)
});
}
await dbContext.SaveChangesAsync(cancellationToken);
return refund;
}
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
@@ -964,6 +1386,33 @@ internal sealed class CommerceAdminService(
? parsed
: throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status");
private static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status) =>
Enum.TryParse<CommerceAdjustmentVoucherStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Adjustment voucher status is invalid.", "invalid_adjustment_voucher_status");
private async Task AssertOptionalReferenceAsync<TEntity>(
DbSet<TEntity> set,
Guid tenantId,
Guid? id,
string code,
CancellationToken cancellationToken)
where TEntity : class
{
if (!id.HasValue)
{
return;
}
var exists = await set.AnyAsync(
item => EF.Property<Guid>(item, "TenantId") == tenantId && EF.Property<Guid>(item, "Id") == id.Value,
cancellationToken);
if (!exists)
{
throw new CommerceException("Referenced commerce entity was not found.", code);
}
}
private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken)
{
var order = await dbContext.Orders.SingleAsync(
@@ -1004,6 +1453,17 @@ internal sealed class CommerceAdminService(
};
}
private static bool IsAllowedAdjustmentTransition(CommerceAdjustmentVoucherStatus from, CommerceAdjustmentVoucherStatus to)
{
return from switch
{
CommerceAdjustmentVoucherStatus.Draft => to is CommerceAdjustmentVoucherStatus.PendingReview or CommerceAdjustmentVoucherStatus.Void,
CommerceAdjustmentVoucherStatus.PendingReview => to is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected or CommerceAdjustmentVoucherStatus.Void,
CommerceAdjustmentVoucherStatus.Approved => to is CommerceAdjustmentVoucherStatus.Closed,
_ => false
};
}
private void AddRefundEvent(
CommerceRefundRequest refund,
CommerceRefundStatus? fromStatus,
@@ -1125,6 +1585,164 @@ internal sealed class CommerceAdminService(
}
}
private static ReconciliationImportPreview BuildImportPreview(string provider, JsonElement rows)
{
var normalizedProvider = NormalizeProvider(provider);
var parsedRows = EnumerateImportRows(rows).ToArray();
var paymentCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Payment);
var refundCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Refund);
var invalidCount = parsedRows.Count(row => row.MatchStatus != ReconciliationMatchStatus.Matched);
var amountCents = parsedRows.Sum(row => row.AmountCents);
var refundAmountCents = parsedRows.Sum(row => row.RefundAmountCents);
var sourceHash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"{normalizedProvider}:{rows.GetRawText()}"))).ToLowerInvariant();
return new ReconciliationImportPreview(
parsedRows.Length,
paymentCount,
refundCount,
invalidCount,
amountCents,
refundAmountCents,
sourceHash);
}
private sealed record ReconciliationImportRow(
ReconciliationTransactionType TransactionType,
string? ProviderTradeNo,
string? ProviderRefundNo,
string? OrderNo,
string? RefundNo,
int AmountCents,
int RefundAmountCents,
string? ProviderStatus,
string? LocalStatus,
ReconciliationMatchStatus MatchStatus,
string? IssueCode,
JsonElement Details);
private static IEnumerable<ReconciliationImportRow> EnumerateImportRows(JsonElement rows)
{
if (rows.ValueKind != JsonValueKind.Array)
{
throw new CommerceException("Reconciliation rows must be an array.", "invalid_reconciliation_rows");
}
foreach (var row in rows.EnumerateArray())
{
if (row.ValueKind != JsonValueKind.Object)
{
yield return InvalidImportRow("row_not_object", row);
continue;
}
var transactionType = Enum.TryParse<ReconciliationTransactionType>(
NormalizeEnum(GetJsonString(row, "transactionType", "transaction_type") ?? "payment"),
true,
out var parsedTransactionType)
? parsedTransactionType
: ReconciliationTransactionType.Payment;
var amountCents = GetJsonInt(row, "amountCents", "amount_cents", "amount");
var refundAmountCents = GetJsonInt(row, "refundAmountCents", "refund_amount_cents", "refundAmount");
var providerTradeNo = GetJsonString(row, "providerTradeNo", "provider_trade_no", "tradeNo");
var providerRefundNo = GetJsonString(row, "providerRefundNo", "provider_refund_no");
var orderNo = GetJsonString(row, "orderNo", "order_no");
var refundNo = GetJsonString(row, "refundNo", "refund_no");
var issueCode = GetJsonString(row, "issueCode", "issue_code");
var matchStatus = Enum.TryParse<ReconciliationMatchStatus>(
NormalizeEnum(GetJsonString(row, "matchStatus", "match_status") ?? "matched"),
true,
out var parsedMatchStatus)
? parsedMatchStatus
: ReconciliationMatchStatus.AmountMismatch;
if (string.IsNullOrWhiteSpace(providerTradeNo) &&
string.IsNullOrWhiteSpace(providerRefundNo) &&
string.IsNullOrWhiteSpace(orderNo) &&
string.IsNullOrWhiteSpace(refundNo))
{
matchStatus = ReconciliationMatchStatus.MissingLocal;
issueCode ??= "missing_business_identifier";
}
yield return new ReconciliationImportRow(
transactionType,
providerTradeNo,
providerRefundNo,
orderNo,
refundNo,
Math.Max(0, amountCents),
Math.Max(0, refundAmountCents),
GetJsonString(row, "providerStatus", "provider_status"),
GetJsonString(row, "localStatus", "local_status"),
matchStatus,
issueCode,
row.Clone());
}
}
private static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row) =>
new(
ReconciliationTransactionType.Payment,
null,
null,
null,
null,
0,
0,
null,
null,
ReconciliationMatchStatus.AmountMismatch,
issueCode,
row.Clone());
private static CommerceReconciliationItem CreateReconciliationItem(
Guid tenantId,
Guid batchId,
int rowNo,
string provider,
ReconciliationImportRow row) =>
new()
{
TenantId = tenantId,
BatchId = batchId,
RowNo = rowNo,
Provider = provider,
TransactionType = row.TransactionType,
ProviderTradeNo = row.ProviderTradeNo,
ProviderRefundNo = row.ProviderRefundNo,
OrderNo = row.OrderNo,
RefundNo = row.RefundNo,
AmountCents = row.AmountCents,
RefundAmountCents = row.RefundAmountCents,
ProviderStatus = row.ProviderStatus,
LocalStatus = row.LocalStatus,
MatchStatus = row.MatchStatus,
Severity = row.MatchStatus == ReconciliationMatchStatus.Matched ? NotificationSeverity.Info : NotificationSeverity.Warning,
IssueCode = row.IssueCode,
Details = row.Details
};
private static int GetJsonInt(JsonElement element, params string[] keys)
{
foreach (var key in keys)
{
if (!element.TryGetProperty(key, out var value))
{
continue;
}
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
{
return number;
}
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
}
return 0;
}
private static string GenerateActivationCode()
{
Span<byte> bytes = stackalloc byte[8];

View File

@@ -15,6 +15,7 @@ using Tiku.Application.Notifications;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Profile;
using Tiku.Application.Points;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Scoreline;
using Tiku.Application.Storage;
using Tiku.Application.StudyContent;
@@ -34,6 +35,7 @@ using Tiku.Infrastructure.Notifications;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Profile;
using Tiku.Infrastructure.Points;
using Tiku.Infrastructure.PlatformAdmin;
using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
using Tiku.Infrastructure.Security;
@@ -113,6 +115,7 @@ public static class DependencyInjection
services.AddScoped<ILearningActivityService, LearningActivityService>();
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
services.AddScoped<IBackofficeService, BackofficeService>();
services.AddScoped<IPlatformAdminService, PlatformAdminService>();
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
services.AddScoped<IOperationAuditService, OperationAuditService>();
services.AddScoped<IBackgroundJobService, BackgroundJobService>();

View File

@@ -1,7 +1,12 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Commerce;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Operations;
using Tiku.Infrastructure.Persistence;
@@ -9,6 +14,7 @@ namespace Tiku.Infrastructure.Jobs;
internal sealed class BackgroundJobService(
TikuDbContext dbContext,
IServiceProvider serviceProvider,
ITenantExecutionScope tenantExecutionScope) : IBackgroundJobService
{
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
@@ -56,7 +62,7 @@ internal sealed class BackgroundJobService(
try
{
await tenantExecutionScope.ExecuteAsync(
var result = await tenantExecutionScope.ExecuteAsync(
job.TenantId,
$"Background job {job.JobType}",
(_, token) => ProcessCoreAsync(job, token),
@@ -64,7 +70,7 @@ internal sealed class BackgroundJobService(
job.Status = BackgroundJobStatus.Succeeded;
job.CompletedAt = DateTimeOffset.UtcNow;
job.LastError = null;
job.Result = JsonDefaults.Object();
job.Result = result;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
@@ -108,26 +114,205 @@ internal sealed class BackgroundJobService(
return jobs.Select(ToItem).ToArray();
}
private static Task ProcessCoreAsync(BackgroundJob job, CancellationToken cancellationToken)
private async Task<JsonElement> ProcessCoreAsync(BackgroundJob job, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return job.JobType switch
{
"content_export" => Task.CompletedTask,
"content_import" => Task.CompletedTask,
"asset_security_scan" => Task.CompletedTask,
"statistics_aggregation" => Task.CompletedTask,
"commerce_reconciliation" => Task.CompletedTask,
"tenant_domain_recheck" => Task.CompletedTask,
"content_export" => await ProcessContentExportAsync(job, cancellationToken),
"content_import" => throw new NotSupportedException("content_import requires the module-specific importer before it can mutate content."),
"asset_security_scan" => throw new NotSupportedException("asset_security_scan requires a configured scanner provider before it can write scan results."),
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(job, cancellationToken),
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(job, cancellationToken),
"tenant_domain_recheck" => await ProcessTenantDomainRecheckAsync(cancellationToken),
_ => throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'.")
};
}
private async Task<JsonElement> ProcessContentExportAsync(
BackgroundJob job,
CancellationToken cancellationToken)
{
var exportType = GetJsonString(job.Payload, "exportType") ?? "summary";
var assetKey = $"background-jobs/{job.Id:N}/content-export.json";
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
item => item.TenantId == job.TenantId && item.AssetKey == assetKey,
cancellationToken);
if (asset is null)
{
asset = new ContentAsset
{
TenantId = job.TenantId,
AssetKey = assetKey,
AssetType = ContentAssetType.Document,
StorageProvider = AssetStorageProvider.ExternalUrl,
UploadStatus = AssetUploadStatus.Verified,
SecurityScanStatus = AssetSecurityScanStatus.NotRequired,
Source = "background_job"
};
dbContext.ContentAssets.Add(asset);
}
var questionBankCount = await dbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
var questionCount = await dbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
var studentCount = await dbContext.StudentProfiles.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
asset.FileName = $"content-export-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
asset.Title = "Content export manifest";
asset.Description = $"Generated content export manifest for {exportType}.";
asset.ObjectKey = assetKey;
asset.MimeType = "application/json";
asset.Metadata = JsonSerializer.SerializeToElement(new
{
exportType,
generatedAt = DateTimeOffset.UtcNow,
questionBankCount,
questionCount,
studentCount,
payload = job.Payload
});
await dbContext.SaveChangesAsync(cancellationToken);
job.OutputAssetId = asset.Id;
return JsonSerializer.SerializeToElement(new
{
outputAssetId = asset.Id,
asset.AssetKey,
questionBankCount,
questionCount,
studentCount
});
}
private async Task<JsonElement> ProcessCommerceReconciliationAsync(
BackgroundJob job,
CancellationToken cancellationToken)
{
var provider = NormalizeProvider(GetJsonString(job.Payload, "provider"));
var hasProviderConfig = await dbContext.TenantExternalProviders.AnyAsync(
item =>
item.TenantId == job.TenantId &&
item.Capability == Tiku.Domain.Tenancy.TenantExternalProviderCapability.Payment &&
item.Provider == provider &&
item.Status == Tiku.Domain.Tenancy.TenantExternalProviderStatus.Active,
cancellationToken);
if (!hasProviderConfig)
{
throw new InvalidOperationException($"Active payment provider '{provider}' is required for commerce reconciliation job.");
}
var billDate = GetJsonDateOnly(job.Payload, "billDate") ?? DateOnly.FromDateTime(DateTime.UtcNow.Date);
var billType = GetJsonEnum(job.Payload, "billType", ReconciliationBillType.Combined);
var sourceHash = $"background-job:{job.Id:N}";
var batch = await dbContext.CommerceReconciliationBatches.SingleOrDefaultAsync(
item =>
item.TenantId == job.TenantId &&
item.Provider == provider &&
item.Source == ReconciliationSource.ProviderDownload &&
item.SourceHash == sourceHash,
cancellationToken);
if (batch is null)
{
batch = new CommerceReconciliationBatch
{
TenantId = job.TenantId,
Provider = provider,
BillDate = billDate,
BillType = billType,
Source = ReconciliationSource.ProviderDownload,
SourceName = $"provider-bill:{provider}:{billDate:yyyyMMdd}",
SourceHash = sourceHash,
Status = ReconciliationBatchStatus.Pending,
Metadata = JsonSerializer.SerializeToElement(new
{
jobId = job.Id,
note = "Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor."
})
};
dbContext.CommerceReconciliationBatches.Add(batch);
await dbContext.SaveChangesAsync(cancellationToken);
}
return JsonSerializer.SerializeToElement(new
{
batchId = batch.Id,
provider,
billDate,
billType = billType.ToString(),
status = batch.Status.ToString()
});
}
private async Task<JsonElement> ProcessTenantDomainRecheckAsync(CancellationToken cancellationToken)
{
var lifecycleService = serviceProvider.GetRequiredService<ITenantDomainLifecycleService>();
var processed = await lifecycleService.ProcessPendingAsync(cancellationToken);
return JsonSerializer.SerializeToElement(new
{
processed
});
}
private async Task<JsonElement> ProcessStatisticsAggregationAsync(
BackgroundJob job,
CancellationToken cancellationToken)
{
var since = DateTimeOffset.UtcNow.AddDays(-7);
var activeLearnerCount = await dbContext.PracticeSessions
.Where(item => item.TenantId == job.TenantId && item.StartedAt >= since)
.Select(item => item.UserId)
.Distinct()
.CountAsync(cancellationToken);
var paidOrderCount = await dbContext.Orders
.CountAsync(item => item.TenantId == job.TenantId && item.Status == OrderStatus.Paid, cancellationToken);
var revenueCents = await dbContext.Orders
.Where(item => item.TenantId == job.TenantId &&
(item.Status == OrderStatus.Paid ||
item.Status == OrderStatus.PartiallyRefunded ||
item.Status == OrderStatus.Refunded))
.SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken);
return JsonSerializer.SerializeToElement(new
{
since,
activeLearnerCount,
paidOrderCount,
revenueCents
});
}
private static string NormalizeJobType(string jobType)
{
return jobType.Trim().ToLowerInvariant();
}
private static string NormalizeProvider(string? provider)
{
var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant();
return string.IsNullOrWhiteSpace(normalized)
? throw new InvalidOperationException("Background job provider is required.")
: normalized;
}
private static string? GetJsonString(JsonElement element, string propertyName)
{
return element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static DateOnly? GetJsonDateOnly(JsonElement element, string propertyName)
{
var value = GetJsonString(element, propertyName);
return DateOnly.TryParse(value, out var parsed) ? parsed : null;
}
private static TEnum GetJsonEnum<TEnum>(JsonElement element, string propertyName, TEnum fallback)
where TEnum : struct
{
var value = GetJsonString(element, propertyName);
return Enum.TryParse<TEnum>(value, true, out var parsed) ? parsed : fallback;
}
private static BackgroundJobItem ToItem(BackgroundJob job)
{
return new BackgroundJobItem(

View File

@@ -529,3 +529,70 @@ internal sealed class CommerceReconciliationIssueEventConfiguration : IEntityTyp
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.ActorUserId).OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class CommerceAdjustmentVoucherConfiguration : IEntityTypeConfiguration<CommerceAdjustmentVoucher>
{
public void Configure(EntityTypeBuilder<CommerceAdjustmentVoucher> builder)
{
builder.ConfigureTenantEntity("commerce_adjustment_vouchers");
builder.ConfigureTimestamps();
builder.Property(entity => entity.VoucherNo).HasMaxLength(100);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Direction).HasSnakeCaseEnum();
builder.Property(entity => entity.Currency).HasMaxLength(10);
builder.Property(entity => entity.Reason).HasMaxLength(1000);
builder.Property(entity => entity.ProofAssetKey).HasMaxLength(500);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.VoucherNo }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.CreatedAt });
builder.HasIndex(entity => new { entity.TenantId, entity.IssueId, entity.CreatedAt });
builder.ToTable(table => table.HasCheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0"));
builder.HasOne<CommerceReconciliationIssue>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.IssueId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<CommerceReconciliationBatch>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.BatchId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<CommerceReconciliationItem>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ItemId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<Order>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.OrderId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<Payment>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.PaymentId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<CommerceRefundRequest>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RefundRequestId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.ReviewedBy).OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class CommerceAdjustmentVoucherEventConfiguration : IEntityTypeConfiguration<CommerceAdjustmentVoucherEvent>
{
public void Configure(EntityTypeBuilder<CommerceAdjustmentVoucherEvent> builder)
{
builder.ConfigureEntity("commerce_adjustment_voucher_events");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.FromStatus).HasNullableSnakeCaseEnum();
builder.Property(entity => entity.ToStatus).HasSnakeCaseEnum();
builder.Property(entity => entity.Note).HasMaxLength(1000);
builder.Property(entity => entity.Details).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.VoucherId, entity.CreatedAt });
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<CommerceAdjustmentVoucher>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.VoucherId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.ActorUserId).OnDelete(DeleteBehavior.SetNull);
}
}

View File

@@ -82,7 +82,6 @@ internal sealed class AuthLoginEventConfiguration : IEntityTypeConfiguration<Aut
public void Configure(EntityTypeBuilder<AuthLoginEvent> builder)
{
builder.ConfigureEntity("auth_login_events");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.Provider).HasMaxLength(50);
builder.Property(entity => entity.Identifier).HasMaxLength(320);
builder.Property(entity => entity.Result).HasSnakeCaseEnum();

View File

@@ -182,7 +182,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("integer")
.HasColumnName("svip_question_limit");
b.Property<Guid>("TenantId")
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
@@ -12956,9 +12956,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasKey("Id")
.HasName("pk_auth_login_events");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_auth_login_events_tenant_id_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_auth_login_events_user_id");

View File

@@ -415,7 +415,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
background_color = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
sort_order = table.Column<int>(type: "integer", nullable: false),
is_active = table.Column<bool>(type: "boolean", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
@@ -441,7 +441,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
file_name = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
@@ -530,7 +530,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
user_id = table.Column<Guid>(type: "uuid", nullable: true),
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
identifier = table.Column<string>(type: "character varying(320)", maxLength: 320, nullable: true),
@@ -544,7 +544,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
constraints: table =>
{
table.PrimaryKey("pk_auth_login_events", x => x.id);
table.UniqueConstraint("ak_auth_login_events_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_auth_login_events_tenants_tenant_id",
column: x => x.tenant_id,

View File

@@ -0,0 +1,213 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddCommerceAdjustmentVouchers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "commerce_adjustment_vouchers",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
issue_id = table.Column<Guid>(type: "uuid", nullable: true),
batch_id = table.Column<Guid>(type: "uuid", nullable: true),
item_id = table.Column<Guid>(type: "uuid", nullable: true),
order_id = table.Column<Guid>(type: "uuid", nullable: true),
payment_id = table.Column<Guid>(type: "uuid", nullable: true),
refund_request_id = table.Column<Guid>(type: "uuid", nullable: true),
created_by = table.Column<Guid>(type: "uuid", nullable: true),
reviewed_by = table.Column<Guid>(type: "uuid", nullable: true),
voucher_no = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
direction = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
amount_cents = table.Column<int>(type: "integer", nullable: false),
currency = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
proof_asset_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
reviewed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
closed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_commerce_adjustment_vouchers", x => x.id);
table.UniqueConstraint("ak_commerce_adjustment_vouchers_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0");
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_batche~",
columns: x => new { x.tenant_id, x.batch_id },
principalTable: "commerce_reconciliation_batches",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_issues~",
columns: x => new { x.tenant_id, x.issue_id },
principalTable: "commerce_reconciliation_issues",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_items_~",
columns: x => new { x.tenant_id, x.item_id },
principalTable: "commerce_reconciliation_items",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_commerce_refund_requests_tenan~",
columns: x => new { x.tenant_id, x.refund_request_id },
principalTable: "commerce_refund_requests",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_orders_tenant_id_order_id",
columns: x => new { x.tenant_id, x.order_id },
principalTable: "orders",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_payments_tenant_id_payment_id",
columns: x => new { x.tenant_id, x.payment_id },
principalTable: "payments",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_users_created_by",
column: x => x.created_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commerce_adjustment_vouchers_users_reviewed_by",
column: x => x.reviewed_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "commerce_adjustment_voucher_events",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
voucher_id = table.Column<Guid>(type: "uuid", nullable: false),
from_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
to_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
actor_user_id = table.Column<Guid>(type: "uuid", nullable: true),
note = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
details = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_commerce_adjustment_voucher_events", x => x.id);
table.UniqueConstraint("ak_commerce_adjustment_voucher_events_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_commerce_adjustment_voucher_events_commerce_adjustment_vouc~",
columns: x => new { x.tenant_id, x.voucher_id },
principalTable: "commerce_adjustment_vouchers",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commerce_adjustment_voucher_events_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commerce_adjustment_voucher_events_users_actor_user_id",
column: x => x.actor_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_voucher_events_actor_user_id",
table: "commerce_adjustment_voucher_events",
column: "actor_user_id");
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_voucher_events_tenant_id_voucher_id_cre~",
table: "commerce_adjustment_voucher_events",
columns: new[] { "tenant_id", "voucher_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_created_by",
table: "commerce_adjustment_vouchers",
column: "created_by");
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_reviewed_by",
table: "commerce_adjustment_vouchers",
column: "reviewed_by");
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_batch_id",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "batch_id" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_issue_id_created_at",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "issue_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_item_id",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "item_id" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_order_id",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "order_id" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_payment_id",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "payment_id" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_refund_request_id",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "refund_request_id" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_status_created_at",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "status", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_commerce_adjustment_vouchers_tenant_id_voucher_no",
table: "commerce_adjustment_vouchers",
columns: new[] { "tenant_id", "voucher_no" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "commerce_adjustment_voucher_events");
migrationBuilder.DropTable(
name: "commerce_adjustment_vouchers");
}
}
}

View File

@@ -1338,6 +1338,221 @@ namespace Tiku.Infrastructure.Persistence.Migrations
});
});
modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucher", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<int>("AmountCents")
.HasColumnType("integer")
.HasColumnName("amount_cents");
b.Property<Guid?>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset?>("ClosedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("closed_at");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uuid")
.HasColumnName("created_by");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("currency");
b.Property<string>("Direction")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("direction");
b.Property<Guid?>("IssueId")
.HasColumnType("uuid")
.HasColumnName("issue_id");
b.Property<Guid?>("ItemId")
.HasColumnType("uuid")
.HasColumnName("item_id");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid")
.HasColumnName("order_id");
b.Property<Guid?>("PaymentId")
.HasColumnType("uuid")
.HasColumnName("payment_id");
b.Property<string>("ProofAssetKey")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("proof_asset_key");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("reason");
b.Property<Guid?>("RefundRequestId")
.HasColumnType("uuid")
.HasColumnName("refund_request_id");
b.Property<DateTimeOffset?>("ReviewedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("reviewed_at");
b.Property<Guid?>("ReviewedBy")
.HasColumnType("uuid")
.HasColumnName("reviewed_by");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<string>("VoucherNo")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("voucher_no");
b.HasKey("Id")
.HasName("pk_commerce_adjustment_vouchers");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_commerce_adjustment_vouchers_tenant_id_id");
b.HasIndex("CreatedBy")
.HasDatabaseName("ix_commerce_adjustment_vouchers_created_by");
b.HasIndex("ReviewedBy")
.HasDatabaseName("ix_commerce_adjustment_vouchers_reviewed_by");
b.HasIndex("TenantId", "BatchId")
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_batch_id");
b.HasIndex("TenantId", "ItemId")
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_item_id");
b.HasIndex("TenantId", "OrderId")
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_order_id");
b.HasIndex("TenantId", "PaymentId")
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_payment_id");
b.HasIndex("TenantId", "RefundRequestId")
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_refund_request_id");
b.HasIndex("TenantId", "VoucherNo")
.IsUnique()
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_voucher_no");
b.HasIndex("TenantId", "IssueId", "CreatedAt")
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_issue_id_created_at");
b.HasIndex("TenantId", "Status", "CreatedAt")
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_status_created_at");
b.ToTable("commerce_adjustment_vouchers", null, t =>
{
t.HasCheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0");
});
});
modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucherEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid?>("ActorUserId")
.HasColumnType("uuid")
.HasColumnName("actor_user_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("Details")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("details")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("FromStatus")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("from_status");
b.Property<string>("Note")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("note");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<string>("ToStatus")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("to_status");
b.Property<Guid>("VoucherId")
.HasColumnType("uuid")
.HasColumnName("voucher_id");
b.HasKey("Id")
.HasName("pk_commerce_adjustment_voucher_events");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_commerce_adjustment_voucher_events_tenant_id_id");
b.HasIndex("ActorUserId")
.HasDatabaseName("ix_commerce_adjustment_voucher_events_actor_user_id");
b.HasIndex("TenantId", "VoucherId", "CreatedAt")
.HasDatabaseName("ix_commerce_adjustment_voucher_events_tenant_id_voucher_id_cre~");
b.ToTable("commerce_adjustment_voucher_events", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationBatch", b =>
{
b.Property<Guid>("Id")
@@ -12937,7 +13152,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(32)")
.HasColumnName("result");
b.Property<Guid>("TenantId")
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
@@ -12953,9 +13168,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasKey("Id")
.HasName("pk_auth_login_events");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_auth_login_events_tenant_id_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_auth_login_events_user_id");
@@ -14640,6 +14852,94 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_code_batches_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucher", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_commerce_adjustment_vouchers_users_created_by");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("ReviewedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_commerce_adjustment_vouchers_users_reviewed_by");
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_commerce_adjustment_vouchers_tenants_tenant_id");
b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationBatch", null)
.WithMany()
.HasForeignKey("TenantId", "BatchId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_commerce_adjustment_vouchers_commerce_reconciliation_batche~");
b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationIssue", null)
.WithMany()
.HasForeignKey("TenantId", "IssueId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_commerce_adjustment_vouchers_commerce_reconciliation_issues~");
b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationItem", null)
.WithMany()
.HasForeignKey("TenantId", "ItemId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_commerce_adjustment_vouchers_commerce_reconciliation_items_~");
b.HasOne("Tiku.Domain.Commerce.Order", null)
.WithMany()
.HasForeignKey("TenantId", "OrderId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_commerce_adjustment_vouchers_orders_tenant_id_order_id");
b.HasOne("Tiku.Domain.Commerce.Payment", null)
.WithMany()
.HasForeignKey("TenantId", "PaymentId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_commerce_adjustment_vouchers_payments_tenant_id_payment_id");
b.HasOne("Tiku.Domain.Commerce.CommerceRefundRequest", null)
.WithMany()
.HasForeignKey("TenantId", "RefundRequestId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_commerce_adjustment_vouchers_commerce_refund_requests_tenan~");
});
modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucherEvent", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("ActorUserId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_commerce_adjustment_voucher_events_users_actor_user_id");
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_commerce_adjustment_voucher_events_tenants_tenant_id");
b.HasOne("Tiku.Domain.Commerce.CommerceAdjustmentVoucher", null)
.WithMany()
.HasForeignKey("TenantId", "VoucherId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_commerce_adjustment_voucher_events_commerce_adjustment_vouc~");
});
modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationBatch", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
@@ -17401,7 +17701,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.SetNull)
.IsRequired()
.HasConstraintName("fk_auth_login_events_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)

View File

@@ -136,6 +136,8 @@ public sealed class TikuDbContext(
public DbSet<CommerceReconciliationItem> CommerceReconciliationItems => Set<CommerceReconciliationItem>();
public DbSet<CommerceReconciliationIssue> CommerceReconciliationIssues => Set<CommerceReconciliationIssue>();
public DbSet<CommerceReconciliationIssueEvent> CommerceReconciliationIssueEvents => Set<CommerceReconciliationIssueEvent>();
public DbSet<CommerceAdjustmentVoucher> CommerceAdjustmentVouchers => Set<CommerceAdjustmentVoucher>();
public DbSet<CommerceAdjustmentVoucherEvent> CommerceAdjustmentVoucherEvents => Set<CommerceAdjustmentVoucherEvent>();
public DbSet<PointActivityTask> PointActivityTasks => Set<PointActivityTask>();
public DbSet<PointActivityClaim> PointActivityClaims => Set<PointActivityClaim>();
public DbSet<PointExchangeItem> PointExchangeItems => Set<PointExchangeItem>();

View File

@@ -0,0 +1,931 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Domain.Commerce;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformAdmin;
internal sealed class PlatformAdminService(
ICurrentAccessContext currentAccessContext,
ITenantExecutionScope tenantExecutionScope) : IPlatformAdminService
{
public async Task<PlatformOverview> GetOverviewAsync(
PlatformAdminActor actor,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformDashboardView, cancellationToken);
return await ExecuteSystemAsync("platform overview", async dbContext =>
{
var tenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned, cancellationToken);
var activeTenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned && tenant.Status == TenantStatus.Active, cancellationToken);
var suspendedTenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned && tenant.Status == TenantStatus.Suspended, cancellationToken);
var orderCount = await dbContext.Orders.CountAsync(cancellationToken);
var paidOrderCount = await dbContext.Orders.CountAsync(order => order.Status == OrderStatus.Paid, cancellationToken);
var revenueCents = await dbContext.Orders
.Where(order => order.Status == OrderStatus.Paid || order.Status == OrderStatus.PartiallyRefunded)
.SumAsync(order => order.AmountCents - order.RefundedAmountCents, cancellationToken);
var questionBankCount = await dbContext.QuestionBanks.CountAsync(cancellationToken);
var questionCount = await dbContext.Questions.CountAsync(question => question.Status == QuestionStatus.Published, cancellationToken);
var learningActiveUserCount = await dbContext.PracticeSessions
.Where(session => session.StartedAt >= DateTimeOffset.UtcNow.AddDays(-7))
.Select(session => session.UserId)
.Distinct()
.CountAsync(cancellationToken);
return new PlatformOverview(
tenantCount,
activeTenantCount,
suspendedTenantCount,
orderCount,
paidOrderCount,
revenueCents,
questionBankCount,
questionCount,
learningActiveUserCount);
}, cancellationToken);
}
public async Task<PlatformTenantList> GetTenantsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant list", async dbContext =>
{
var tenants = dbContext.Tenants.AsNoTracking()
.Where(tenant => tenant.Mode != TenantMode.PlatformOwned);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var search = query.Search.Trim();
tenants = tenants.Where(tenant =>
tenant.Slug.Contains(search) ||
tenant.Name.Contains(search) ||
(tenant.LegalName != null && tenant.LegalName.Contains(search)));
}
if (!string.IsNullOrWhiteSpace(query.Status))
{
tenants = tenants.Where(tenant => tenant.Status == ParseTenantStatus(query.Status));
}
var rows = await tenants
.OrderByDescending(tenant => tenant.CreatedAt)
.Take(Limit(query.Limit))
.Select(tenant => new
{
Tenant = tenant,
DomainCount = dbContext.TenantDomains.Count(domain => domain.TenantId == tenant.Id),
SubscriptionExpiresAt = dbContext.TenantSubscriptions
.Where(subscription => subscription.TenantId == tenant.Id)
.OrderByDescending(subscription => subscription.ExpiresAt)
.Select(subscription => subscription.ExpiresAt)
.FirstOrDefault()
})
.ToArrayAsync(cancellationToken);
return new PlatformTenantList(rows.Select(row => ToTenantItem(row.Tenant, row.DomainCount, row.SubscriptionExpiresAt)).ToArray());
}, cancellationToken);
}
public async Task<PlatformTenantDetail> GetTenantDetailAsync(
PlatformAdminActor actor,
Guid tenantId,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant detail", async dbContext =>
{
var tenant = await dbContext.Tenants.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == tenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken)
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
var domains = await dbContext.TenantDomains.AsNoTracking()
.Where(domain => domain.TenantId == tenantId)
.OrderByDescending(domain => domain.IsPrimary)
.ThenBy(domain => domain.Host)
.ToArrayAsync(cancellationToken);
var subscriptions = await dbContext.TenantSubscriptions.AsNoTracking()
.Where(subscription => subscription.TenantId == tenantId)
.OrderByDescending(subscription => subscription.CreatedAt)
.ToArrayAsync(cancellationToken);
var billingProfile = await dbContext.TenantBillingProfiles.AsNoTracking()
.SingleOrDefaultAsync(profile => profile.TenantId == tenantId, cancellationToken);
return new PlatformTenantDetail(
ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.ExpiresAt),
domains.Select(ToDomainItem).ToArray(),
subscriptions.Select(ToSubscriptionItem).ToArray(),
billingProfile is null ? null : ToBillingProfileItem(billingProfile));
}, cancellationToken);
}
public async Task<PlatformTenantItem> CreateTenantAsync(
PlatformAdminActor actor,
CreatePlatformTenantCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant create", async dbContext =>
{
var slug = NormalizeCode(command.Slug);
if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken))
{
throw new PlatformAdminException("Tenant slug already exists.", "tenant_slug_exists");
}
var tenant = new Tenant
{
Slug = slug,
Name = command.Name.Trim(),
LegalName = Normalize(command.LegalName),
Status = command.Status,
Mode = TenantMode.Saas,
BillingStatus = command.BillingStatus,
Metadata = JsonObjectOrDefault(command.Metadata)
};
dbContext.Tenants.Add(tenant);
AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus });
await dbContext.SaveChangesAsync(cancellationToken);
return ToTenantItem(tenant, 0, null);
}, cancellationToken);
}
public async Task<PlatformTenantItem> UpdateTenantStatusAsync(
PlatformAdminActor actor,
UpdatePlatformTenantStatusCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant status update", async dbContext =>
{
var tenant = await dbContext.Tenants
.SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken)
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
var fromStatus = tenant.Status;
var fromBilling = tenant.BillingStatus;
tenant.Status = command.Status;
tenant.BillingStatus = command.BillingStatus;
AddAudit(dbContext, actor, "platform.tenant.status_changed", tenant.Id, new
{
FromStatus = fromStatus,
ToStatus = tenant.Status,
FromBillingStatus = fromBilling,
ToBillingStatus = tenant.BillingStatus,
command.Reason
});
await dbContext.SaveChangesAsync(cancellationToken);
var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
var expiresAt = await dbContext.TenantSubscriptions
.Where(subscription => subscription.TenantId == tenant.Id)
.OrderByDescending(subscription => subscription.ExpiresAt)
.Select(subscription => subscription.ExpiresAt)
.FirstOrDefaultAsync(cancellationToken);
return ToTenantItem(tenant, domainCount, expiresAt);
}, cancellationToken);
}
public async Task<TenantBillingProfileItem> UpsertTenantBillingProfileAsync(
PlatformAdminActor actor,
UpsertPlatformTenantBillingProfileCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant billing profile upsert", async dbContext =>
{
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
var profile = await dbContext.TenantBillingProfiles.SingleOrDefaultAsync(
item => item.TenantId == command.TenantId,
cancellationToken);
if (profile is null)
{
profile = new TenantBillingProfile { TenantId = command.TenantId };
dbContext.TenantBillingProfiles.Add(profile);
}
profile.BillingName = Normalize(command.BillingName);
profile.TaxId = Normalize(command.TaxId);
profile.ContactName = Normalize(command.ContactName);
profile.ContactPhone = Normalize(command.ContactPhone);
profile.ContactEmail = Normalize(command.ContactEmail);
profile.BillingAddress = Normalize(command.BillingAddress);
profile.InvoiceTitle = Normalize(command.InvoiceTitle);
profile.InvoiceType = command.InvoiceType;
profile.BankName = Normalize(command.BankName);
profile.BankAccountMasked = MaskBankAccount(command.BankAccountMasked);
profile.Metadata = JsonObjectOrDefault(command.Metadata);
AddAudit(dbContext, actor, "platform.tenant.billing_profile.updated", command.TenantId, new { profile.BillingName, profile.InvoiceType });
await dbContext.SaveChangesAsync(cancellationToken);
return ToBillingProfileItem(profile);
}, cancellationToken);
}
public async Task<PlatformPlanList> GetPlansAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform plan list", async dbContext =>
{
var plans = dbContext.PlatformSaasPlans.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Status))
{
plans = plans.Where(plan => plan.Status == ParsePlanStatus(query.Status));
}
return new PlatformPlanList(await plans
.OrderBy(plan => plan.SortOrder)
.ThenBy(plan => plan.Code)
.Take(Limit(query.Limit))
.ToArrayAsync(cancellationToken));
}, cancellationToken);
}
public async Task<PlatformTenantSubscriptionItem> UpsertSubscriptionAsync(
PlatformAdminActor actor,
UpsertPlatformSubscriptionCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant subscription upsert", async dbContext =>
{
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == NormalizeCode(command.PlanCode), cancellationToken))
{
throw new PlatformAdminException("SaaS plan was not found.", "plan_not_found");
}
var subscription = await dbContext.TenantSubscriptions.SingleOrDefaultAsync(
item => item.TenantId == command.TenantId && item.PlanCode == NormalizeCode(command.PlanCode),
cancellationToken);
if (subscription is null)
{
subscription = new TenantSubscription { TenantId = command.TenantId };
dbContext.TenantSubscriptions.Add(subscription);
}
subscription.PlanCode = NormalizeCode(command.PlanCode);
subscription.Status = command.Status;
subscription.StartsAt = command.StartsAt;
subscription.ExpiresAt = command.ExpiresAt;
subscription.BillingCycle = Normalize(command.BillingCycle);
subscription.AmountCents = command.AmountCents;
subscription.Metadata = JsonObjectOrDefault(command.Metadata);
AddAudit(dbContext, actor, "platform.tenant.subscription.updated", command.TenantId, new
{
subscription.PlanCode,
subscription.Status,
subscription.ExpiresAt
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToSubscriptionItem(subscription);
}, cancellationToken);
}
public async Task<PlatformDomainList> GetDomainsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform domain list", async dbContext =>
{
var domains = dbContext.TenantDomains.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Status))
{
domains = domains.Where(domain => domain.Status == ParseDomainStatus(query.Status));
}
if (!string.IsNullOrWhiteSpace(query.Search))
{
var search = query.Search.Trim();
domains = domains.Where(domain => domain.Host.Contains(search));
}
return new PlatformDomainList(await domains
.OrderByDescending(domain => domain.UpdatedAt)
.Take(Limit(query.Limit))
.Select(domain => ToDomainItem(domain))
.ToArrayAsync(cancellationToken));
}, cancellationToken);
}
public async Task<PlatformDomainRecheckResult> RecheckDomainAsync(
PlatformAdminActor actor,
Guid domainId,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform domain recheck", async dbContext =>
{
var domain = await dbContext.TenantDomains.SingleOrDefaultAsync(domain => domain.Id == domainId, cancellationToken)
?? throw new PlatformAdminException("Tenant domain was not found.", "domain_not_found");
domain.Status = domain.Status == TenantDomainStatus.Disabled ? TenantDomainStatus.Disabled : TenantDomainStatus.Pending;
domain.LastCheckedAt = DateTimeOffset.UtcNow;
domain.LastFailureReason = null;
dbContext.BackgroundJobs.Add(new BackgroundJob
{
TenantId = domain.TenantId,
JobType = "tenant_domain_recheck",
Payload = JsonSerializer.SerializeToElement(new { domain.Id, domain.Host }),
MaxRetries = 3
});
AddAudit(dbContext, actor, "platform.tenant_domain.recheck_requested", domain.TenantId, new { domain.Id, domain.Host });
await dbContext.SaveChangesAsync(cancellationToken);
return new PlatformDomainRecheckResult(domain.Id, domain.Status, domain.LastCheckedAt.Value);
}, cancellationToken);
}
public async Task<PlatformStaffList> GetStaffAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
return await ExecuteSystemAsync("platform staff list", async dbContext =>
{
var roleRows = await (
from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking()
join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id
join user in dbContext.Users.AsNoTracking() on userRole.UserId equals user.Id
select new { user, role.Code })
.ToArrayAsync(cancellationToken);
var items = roleRows
.GroupBy(row => row.user.Id)
.Select(group => ToStaffItem(group.First().user, group.Select(row => row.Code).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray()))
.OrderBy(item => item.Email ?? item.PhoneMasked ?? item.UserId.ToString())
.Take(Limit(query.Limit))
.ToArray();
return new PlatformStaffList(items);
}, cancellationToken);
}
public async Task<PlatformStaffItem> UpsertStaffAsync(
PlatformAdminActor actor,
UpsertPlatformStaffCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
return await ExecuteSystemAsync("platform staff upsert", async dbContext =>
{
var user = command.UserId.HasValue
? await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken)
: await dbContext.Users.SingleOrDefaultAsync(item =>
(!string.IsNullOrWhiteSpace(command.Email) && item.Email == command.Email) ||
(!string.IsNullOrWhiteSpace(command.Phone) && item.Phone == command.Phone),
cancellationToken);
if (user is null)
{
user = new User
{
Email = Normalize(command.Email),
NormalizedEmail = Normalize(command.Email)?.ToUpperInvariant(),
UserName = Normalize(command.Email) ?? Normalize(command.Phone),
NormalizedUserName = (Normalize(command.Email) ?? Normalize(command.Phone))?.ToUpperInvariant(),
Phone = Normalize(command.Phone),
PhoneNumber = Normalize(command.Phone),
Name = Normalize(command.Name),
PrimaryRole = "platform_admin",
Status = command.Status,
ForcePasswordChange = true
};
dbContext.Users.Add(user);
}
else
{
user.Email = Normalize(command.Email) ?? user.Email;
user.NormalizedEmail = user.Email?.ToUpperInvariant();
user.Phone = Normalize(command.Phone) ?? user.Phone;
user.PhoneNumber = user.Phone;
user.Name = Normalize(command.Name) ?? user.Name;
user.Status = command.Status;
user.PrimaryRole = "platform_admin";
}
var roleIds = command.RoleIds.Distinct().ToArray();
var roleCount = await dbContext.PlatformBackendRoles.CountAsync(role => roleIds.Contains(role.Id), cancellationToken);
if (roleCount != roleIds.Length)
{
throw new PlatformAdminException("One or more platform roles were not found.", "role_not_found");
}
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.PlatformBackendUserRoles.Where(binding => binding.UserId == user.Id).ExecuteDeleteAsync(cancellationToken);
dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole
{
UserId = user.Id,
RoleId = roleId
}));
AddAudit(dbContext, actor, "platform.staff.upserted", user.Id, new { user.Email, Phone = MaskPhone(user.Phone), user.Status, RoleIds = roleIds });
await dbContext.SaveChangesAsync(cancellationToken);
var roleCodes = await dbContext.PlatformBackendRoles.AsNoTracking()
.Where(role => roleIds.Contains(role.Id))
.Select(role => role.Code)
.ToArrayAsync(cancellationToken);
return ToStaffItem(user, roleCodes);
}, cancellationToken);
}
public async Task<PlatformStaffItem> UpdateStaffStatusAsync(
PlatformAdminActor actor,
UpdatePlatformStaffStatusCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
return await ExecuteSystemAsync("platform staff status update", async dbContext =>
{
var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId, cancellationToken)
?? throw new PlatformAdminException("Platform staff user was not found.", "staff_not_found");
var from = user.Status;
user.Status = command.Status;
AddAudit(dbContext, actor, "platform.staff.status_changed", user.Id, new { From = from, To = command.Status, command.Reason });
await dbContext.SaveChangesAsync(cancellationToken);
var roleCodes = await (
from binding in dbContext.PlatformBackendUserRoles.AsNoTracking()
join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id
where binding.UserId == user.Id
select role.Code)
.ToArrayAsync(cancellationToken);
return ToStaffItem(user, roleCodes);
}, cancellationToken);
}
public async Task<PlatformAuditLogList> GetAuditLogsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken);
return await ExecuteSystemAsync("platform audit log list", async dbContext =>
{
var logs = dbContext.AuditLogs.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Search))
{
var search = query.Search.Trim();
logs = logs.Where(log => log.Action.Contains(search) || (log.TargetType != null && log.TargetType.Contains(search)));
}
return new PlatformAuditLogList(await logs
.OrderByDescending(log => log.CreatedAt)
.Take(Limit(query.Limit))
.Select(log => new PlatformAuditLogItem(
log.Id,
log.TenantId,
log.ActorUserId,
log.Action,
log.TargetType,
log.TargetId,
log.Details,
log.IpAddress,
log.UserAgent,
log.CreatedAt))
.ToArrayAsync(cancellationToken));
}, cancellationToken);
}
public async Task<PlatformAuditAlertList> GetAuditAlertsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken);
return await ExecuteSystemAsync("platform audit alert list", async dbContext =>
{
var alerts = dbContext.PlatformAuditAlerts.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Status))
{
alerts = alerts.Where(alert => alert.Status == ParseAuditAlertStatus(query.Status));
}
return new PlatformAuditAlertList(await alerts
.OrderByDescending(alert => alert.LastSeenAt)
.Take(Limit(query.Limit))
.ToArrayAsync(cancellationToken));
}, cancellationToken);
}
public async Task<PlatformAuditAlert> UpdateAuditAlertStatusAsync(
PlatformAdminActor actor,
UpdatePlatformAuditAlertStatusCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken);
return await ExecuteSystemAsync("platform audit alert status update", async dbContext =>
{
var alert = await dbContext.PlatformAuditAlerts.SingleOrDefaultAsync(item => item.Id == command.AlertId, cancellationToken)
?? throw new PlatformAdminException("Platform audit alert was not found.", "audit_alert_not_found");
alert.Status = command.Status;
alert.ResolutionNote = Normalize(command.ResolutionNote);
if (command.Status == PlatformAuditAlertStatus.Acknowledged)
{
alert.AcknowledgedBy = actor.UserId;
alert.AcknowledgedAt ??= DateTimeOffset.UtcNow;
}
else if (command.Status is PlatformAuditAlertStatus.Resolved or PlatformAuditAlertStatus.Ignored)
{
alert.ResolvedBy = actor.UserId;
alert.ResolvedAt ??= DateTimeOffset.UtcNow;
}
AddAudit(dbContext, actor, "platform.audit_alert.status_changed", alert.Id, new { alert.Status, command.ResolutionNote });
await dbContext.SaveChangesAsync(cancellationToken);
return alert;
}, cancellationToken);
}
public async Task<PlatformDunningChannelList> GetDunningChannelsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning channel list", async dbContext =>
{
var channels = dbContext.PlatformDunningNotificationChannels.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Search))
{
var search = query.Search.Trim();
channels = channels.Where(channel =>
channel.ChannelCode.Contains(search) ||
channel.Name.Contains(search));
}
if (!string.IsNullOrWhiteSpace(query.Status))
{
var enabled = ParseEnabledStatus(query.Status);
channels = channels.Where(channel => channel.Enabled == enabled);
}
return new PlatformDunningChannelList(await channels
.OrderByDescending(channel => channel.Enabled)
.ThenBy(channel => channel.MinReminderLevel)
.ThenBy(channel => channel.ChannelCode)
.Take(Limit(query.Limit))
.Select(channel => ToDunningChannelItem(channel))
.ToArrayAsync(cancellationToken));
}, cancellationToken);
}
public async Task<PlatformDunningChannelItem> UpsertDunningChannelAsync(
PlatformAdminActor actor,
UpsertPlatformDunningChannelCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning channel upsert", async dbContext =>
{
var code = NormalizeCode(command.ChannelCode);
var channel = command.ChannelId.HasValue
? await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId.Value, cancellationToken)
: await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.ChannelCode == code, cancellationToken);
if (channel is null)
{
channel = new PlatformDunningNotificationChannel { ChannelCode = code };
dbContext.PlatformDunningNotificationChannels.Add(channel);
}
channel.ChannelCode = code;
channel.Name = command.Name.Trim();
channel.Description = Normalize(command.Description);
channel.Enabled = command.Enabled;
channel.Provider = command.Provider;
channel.WebhookUrl = command.WebhookUrl.Trim();
channel.SecretRef = Normalize(command.SecretRef);
channel.ReminderTypes = NormalizeArray(command.ReminderTypes, ["overdue", "final_notice"]);
channel.ReminderChannels = NormalizeArray(command.ReminderChannels, ["internal"]);
channel.MinReminderLevel = Math.Clamp(command.MinReminderLevel, 1, 20);
channel.TenantIds = command.TenantIds.Where(id => id != Guid.Empty).Distinct().ToArray();
channel.TimeoutSeconds = Math.Clamp(command.TimeoutSeconds, 1, 60);
channel.Metadata = JsonObjectOrDefault(command.Metadata);
AddAudit(dbContext, actor, "platform.dunning_channel.upserted", channel.Id, new
{
channel.ChannelCode,
channel.Name,
channel.Enabled,
channel.Provider,
Webhook = MaskWebhook(channel.WebhookUrl),
channel.SecretRef
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToDunningChannelItem(channel);
}, cancellationToken);
}
public async Task<PlatformDunningChannelItem> DisableDunningChannelAsync(
PlatformAdminActor actor,
DisablePlatformDunningChannelCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning channel disable", async dbContext =>
{
var channel = await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId, cancellationToken)
?? throw new PlatformAdminException("Platform dunning channel was not found.", "dunning_channel_not_found");
channel.Enabled = false;
AddAudit(dbContext, actor, "platform.dunning_channel.disabled", channel.Id, new
{
channel.ChannelCode,
command.Reason
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToDunningChannelItem(channel);
}, cancellationToken);
}
public async Task<PlatformDunningEventList> GetDunningEventsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning event list", async dbContext =>
{
var events = dbContext.PlatformDunningNotificationEvents.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Status))
{
events = events.Where(item => item.Status == ParseDunningEventStatus(query.Status));
}
return new PlatformDunningEventList(await events
.OrderByDescending(item => item.CreatedAt)
.Take(Limit(query.Limit))
.Select(item => ToDunningEventItem(item))
.ToArrayAsync(cancellationToken));
}, cancellationToken);
}
public async Task<PlatformDunningEventItem> GetDunningEventDetailAsync(
PlatformAdminActor actor,
Guid eventId,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning event detail", async dbContext =>
{
var item = await dbContext.PlatformDunningNotificationEvents.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == eventId, cancellationToken)
?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found");
return ToDunningEventItem(item);
}, cancellationToken);
}
public async Task<PlatformDunningEventItem> RetryDunningEventAsync(
PlatformAdminActor actor,
RetryPlatformDunningEventCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning event retry", async dbContext =>
{
var item = await dbContext.PlatformDunningNotificationEvents.SingleOrDefaultAsync(item => item.Id == command.EventId, cancellationToken)
?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found");
item.Status = PlatformDunningNotificationStatus.Pending;
item.NextAttemptAt = DateTimeOffset.UtcNow;
item.LastError = null;
AddAudit(dbContext, actor, "platform.dunning_event.retry_requested", item.Id, new
{
item.TenantId,
item.ChannelId,
item.ReminderId,
item.InvoiceId,
command.Reason
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToDunningEventItem(item);
}, cancellationToken);
}
private async Task AssertPlatformPermissionAsync(
PlatformAdminActor actor,
string permissionCode,
CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
if (access.UserId != actor.UserId || !access.HasPlatformPermission(permissionCode))
{
throw new PlatformAdminException("Platform admin access is required.", "platform_access_denied");
}
}
private Task<TResult> ExecuteSystemAsync<TResult>(
string reason,
Func<TikuDbContext, Task<TResult>> operation,
CancellationToken cancellationToken)
{
return tenantExecutionScope.ExecuteAsync(
null,
reason,
async (provider, _) => await operation(provider.GetRequiredService<TikuDbContext>()),
cancellationToken);
}
private static async Task RequireTenantAsync(TikuDbContext dbContext, Guid tenantId, CancellationToken cancellationToken)
{
var exists = await dbContext.Tenants.AnyAsync(tenant => tenant.Id == tenantId && tenant.Mode != TenantMode.PlatformOwned, cancellationToken);
if (!exists)
{
throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
}
}
private static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, object details)
{
dbContext.AuditLogs.Add(new AuditLog
{
ActorUserId = actor.UserId,
Action = action,
TargetType = action.Split('.')[1],
TargetId = targetId.ToString("N"),
Details = JsonSerializer.SerializeToElement(details)
});
}
private static PlatformTenantItem ToTenantItem(Tenant tenant, int domainCount, DateTimeOffset? subscriptionExpiresAt) =>
new(
tenant.Id,
tenant.Slug,
tenant.Name,
tenant.LegalName,
tenant.Status,
tenant.Mode,
tenant.BillingStatus,
subscriptionExpiresAt,
domainCount,
tenant.CreatedAt,
tenant.UpdatedAt);
private static PlatformTenantDomainItem ToDomainItem(TenantDomain domain) =>
new(
domain.Id,
domain.TenantId,
domain.Host,
domain.DomainType,
domain.Status,
domain.IsPrimary,
domain.VerifiedAt,
domain.DnsVerifiedAt,
domain.TlsReadyAt,
domain.LastCheckedAt,
domain.LastFailureReason);
private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSubscription subscription) =>
new(
subscription.Id,
subscription.TenantId,
subscription.PlanCode,
subscription.Status,
subscription.StartsAt,
subscription.ExpiresAt,
subscription.BillingCycle,
subscription.AmountCents,
subscription.Metadata);
private static TenantBillingProfileItem ToBillingProfileItem(TenantBillingProfile profile) =>
new(
profile.TenantId,
profile.BillingName,
profile.TaxId,
profile.ContactName,
MaskPhone(profile.ContactPhone),
profile.ContactEmail,
profile.BillingAddress,
profile.InvoiceTitle,
profile.InvoiceType,
profile.BankName,
profile.BankAccountMasked,
profile.Metadata);
private static PlatformDunningChannelItem ToDunningChannelItem(PlatformDunningNotificationChannel channel) =>
new(
channel.Id,
channel.ChannelCode,
channel.Name,
channel.Description,
channel.Enabled,
channel.Provider,
MaskWebhook(channel.WebhookUrl),
channel.SecretRef,
channel.ReminderTypes,
channel.ReminderChannels,
channel.MinReminderLevel,
channel.TenantIds,
channel.TimeoutSeconds,
channel.Metadata,
channel.CreatedAt,
channel.UpdatedAt);
private static PlatformDunningEventItem ToDunningEventItem(PlatformDunningNotificationEvent item) =>
new(
item.Id,
item.TenantId,
item.ChannelId,
item.ReminderId,
item.InvoiceId,
item.Provider,
item.Status,
item.Attempts,
item.ScheduledAt,
item.NextAttemptAt,
item.LastAttemptAt,
item.SentAt,
item.LastError,
item.LastHttpCode,
item.LastResponseSummary,
item.RequestPayload,
item.Metadata,
item.CreatedAt,
item.UpdatedAt);
private static PlatformStaffItem ToStaffItem(User user, IReadOnlyCollection<string> roleCodes) =>
new(
user.Id,
user.Name,
MaskPhone(user.Phone),
user.Email,
user.Status,
roleCodes,
user.CreatedAt,
user.UpdatedAt);
private static int Limit(int? limit) => Math.Clamp(limit ?? 50, 1, 200);
private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant();
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static JsonElement JsonObjectOrDefault(JsonElement value) =>
value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone();
private static string? MaskPhone(string? phone)
{
var value = Normalize(phone);
return value is { Length: >= 7 }
? $"{value[..3]}****{value[^4..]}"
: value;
}
private static string? MaskBankAccount(string? account)
{
var value = Normalize(account);
return value is { Length: > 8 }
? $"****{value[^4..]}"
: value;
}
private static string MaskWebhook(string webhookUrl)
{
var value = Normalize(webhookUrl) ?? string.Empty;
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
return value.Length <= 16 ? "****" : $"{value[..8]}****{value[^4..]}";
}
return $"{uri.Scheme}://{uri.Host}/****";
}
private static string[] NormalizeArray(IReadOnlyCollection<string> values, string[] fallback)
{
var normalized = values
.Select(Normalize)
.Where(value => value is not null)
.Select(value => value!)
.Distinct(StringComparer.Ordinal)
.ToArray();
return normalized.Length == 0 ? fallback : normalized;
}
private static bool ParseEnabledStatus(string status)
{
return NormalizeCode(status) switch
{
"enabled" or "active" or "true" => true,
"disabled" or "inactive" or "false" => false,
_ => throw InvalidStatus(status)
};
}
private static TenantStatus ParseTenantStatus(string status) =>
Enum.TryParse<TenantStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static TenantDomainStatus ParseDomainStatus(string status) =>
Enum.TryParse<TenantDomainStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static PlatformSaasPlanStatus ParsePlanStatus(string status) =>
Enum.TryParse<PlatformSaasPlanStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static PlatformAuditAlertStatus ParseAuditAlertStatus(string status) =>
Enum.TryParse<PlatformAuditAlertStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static PlatformDunningNotificationStatus ParseDunningEventStatus(string status) =>
Enum.TryParse<PlatformDunningNotificationStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static PlatformAdminException InvalidStatus(string status) =>
new($"Unsupported status '{status}'.", "invalid_status");
}

View File

@@ -15,6 +15,11 @@ using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus;
using OrderStatus = Tiku.Domain.Commerce.OrderStatus;
using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus;
using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus;
using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus;
namespace Tiku.Infrastructure.TenantAdmin;
@@ -25,6 +30,90 @@ public sealed class TenantAdminDirectService(
ICurrentAccessContext currentAccessContext,
IAuthSessionStore sessionStore) : ITenantAdminDirectService
{
public async Task<TenantAdminOverviewItem> GetOverviewAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var classIds = scope.ClassIds.ToArray();
var now = DateTimeOffset.UtcNow;
var today = new DateTimeOffset(now.UtcDateTime.Date, TimeSpan.Zero);
var scopedClasses = dbContext.TenantClasses.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
item => item.CreatedBy == actor.UserId,
item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)));
var scopedStudents = dbContext.StudentProfiles.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
item => item.UserId == actor.UserId,
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
var scopedOrders = dbContext.Orders.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
item => item.UserId == actor.UserId,
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
var studentCount = await scopedStudents.CountAsync(cancellationToken);
var classCount = await scopedClasses.CountAsync(item => item.Status == TenantRecordStatus.Active, cancellationToken);
var staffCount = await dbContext.TenantMemberships.AsNoTracking()
.CountAsync(item =>
item.TenantId == actor.TenantId &&
item.Status == MembershipStatus.Active &&
item.Role != TenantRole.Student,
cancellationToken);
var activePracticeCount = await dbContext.PracticeSessions.AsNoTracking()
.CountAsync(item =>
item.TenantId == actor.TenantId &&
item.FinishedAt == null &&
(!item.ExpiresAt.HasValue || item.ExpiresAt > now),
cancellationToken);
var todayPracticeCount = await dbContext.PracticeSessions.AsNoTracking()
.CountAsync(item => item.TenantId == actor.TenantId && item.StartedAt >= today, cancellationToken);
var pendingFollowupCount = await dbContext.TenantStudentFollowups.AsNoTracking()
.CountAsync(item =>
item.TenantId == actor.TenantId &&
(item.Status == StudentFollowupStatus.Open || item.Status == StudentFollowupStatus.InProgress),
cancellationToken);
var unreadNotificationCount = await dbContext.UserNotifications.AsNoTracking()
.CountAsync(item => item.TenantId == actor.TenantId && item.Status == NotificationStatus.Unread, cancellationToken);
var paidOrderCount = await scopedOrders.CountAsync(item => item.Status == OrderStatus.Paid, cancellationToken);
var revenueCents = await scopedOrders
.Where(item => item.Status == OrderStatus.Paid || item.Status == OrderStatus.PartiallyRefunded || item.Status == OrderStatus.Refunded)
.SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken);
var pendingRefundCount = await dbContext.CommerceRefundRequests.AsNoTracking()
.CountAsync(item =>
item.TenantId == actor.TenantId &&
(item.Status == CommerceRefundStatus.Requested ||
item.Status == CommerceRefundStatus.Approved ||
item.Status == CommerceRefundStatus.Processing),
cancellationToken);
var openReconciliationIssueCount = await dbContext.CommerceReconciliationIssues.AsNoTracking()
.CountAsync(item =>
item.TenantId == actor.TenantId &&
item.Status != ReconciliationIssueStatus.Resolved &&
item.Status != ReconciliationIssueStatus.Ignored,
cancellationToken);
return new TenantAdminOverviewItem(
studentCount,
classCount,
staffCount,
activePracticeCount,
todayPracticeCount,
pendingFollowupCount,
unreadNotificationCount,
paidOrderCount,
revenueCents,
pendingRefundCount,
openReconciliationIssueCount,
now);
}
public async Task<TenantAdminClassList> GetClassesAsync(
TenantAdminActor actor,
TenantAdminClassFilter filter,
@@ -494,6 +583,289 @@ public sealed class TenantAdminDirectService(
new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt));
}
public async Task<TenantAdminStudentImportPreview> PreviewStudentImportAsync(
TenantAdminActor actor,
TenantAdminStudentImportCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var items = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken);
return new TenantAdminStudentImportPreview(
command.Rows.Count,
items.Count(item => item.Valid),
items.Count(item => !item.Valid),
items);
}
public async Task<TenantAdminStudentImportResult> ImportStudentsAsync(
TenantAdminActor actor,
TenantAdminStudentImportCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var previewItems = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken);
var invalidItems = previewItems.Where(item => !item.Valid).ToArray();
var createdOrUpdated = 0;
var classAssigned = 0;
var rowNo = 0;
foreach (var row in command.Rows)
{
rowNo++;
if (invalidItems.Any(item => item.RowNo == rowNo))
{
continue;
}
var user = await ResolveUserAsync(row.User, "student", cancellationToken);
if (row.RawProfile.ValueKind == JsonValueKind.Object)
{
user.RawProfile = row.RawProfile.Clone();
}
await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken);
await EnsureStudentProfileAsync(
actor.TenantId,
user.Id,
row.RegionId,
null,
null,
Normalize(row.AvatarPreset),
JsonDefaults.Object(),
JsonDefaults.Object(),
JsonDefaults.Object(),
cancellationToken);
createdOrUpdated++;
if (row.ClassId.HasValue)
{
await UpsertClassMemberCoreAsync(
actor,
row.ClassId.Value,
user.Id,
TenantClassMemberType.Student,
TenantClassMemberStatus.Active,
JsonSerializer.SerializeToElement(new { source = "student_import" }),
cancellationToken);
classAssigned++;
}
}
await AddAuditAsync(actor, "tenant.student.imported", "student_profiles", actor.TenantId, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return new TenantAdminStudentImportResult(command.Rows.Count, createdOrUpdated, classAssigned, invalidItems);
}
public async Task<TenantAdminBulkOperationResult> BulkAssignClassAsync(
TenantAdminActor actor,
TenantAdminBulkAssignClassCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
await AssertClassAsync(actor, scope, command.ClassId, cancellationToken);
var failed = new List<Guid>();
var succeeded = 0;
foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct())
{
try
{
await AssertStudentAsync(actor, scope, userId, cancellationToken);
await UpsertClassMemberCoreAsync(
actor,
command.ClassId,
userId,
TenantClassMemberType.Student,
TenantClassMemberStatus.Active,
JsonSerializer.SerializeToElement(new { source = "bulk_assign_class" }),
cancellationToken);
succeeded++;
}
catch (TenantAdminDirectException)
{
failed.Add(userId);
}
}
await AddAuditAsync(actor, "tenant.student.bulk_class_assigned", "tenant_classes", command.ClassId, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed);
}
public async Task<TenantAdminBulkOperationResult> BulkUpdateStudentStatusAsync(
TenantAdminActor actor,
TenantAdminBulkStatusCommand command,
CancellationToken cancellationToken = default)
{
var failed = new List<Guid>();
var succeeded = 0;
foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct())
{
try
{
await UpdateStudentStatusAsync(
actor,
new UpdateTenantAdminStudentStatusCommand(userId, command.Status, command.Reason),
cancellationToken);
succeeded++;
}
catch (TenantAdminDirectException)
{
failed.Add(userId);
}
}
await AddAuditAsync(actor, "tenant.student.bulk_status_updated", "tenant_memberships", actor.TenantId, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed);
}
public async Task<CatalogList<TenantSupervisionRuleItem>> GetSupervisionRulesAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default)
{
await RequireDataScopeAsync(actor, cancellationToken);
return new CatalogList<TenantSupervisionRuleItem>(await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken));
}
public async Task<ContentManagementResult<TenantSupervisionRuleItem>> UpsertSupervisionRuleAsync(
TenantAdminActor actor,
UpsertTenantSupervisionRuleCommand command,
CancellationToken cancellationToken = default)
{
await RequireDataScopeAsync(actor, cancellationToken);
var code = NormalizeCode(command.Code);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)).ToList();
var item = new TenantSupervisionRuleItem(
code,
command.Title.Trim(),
command.Enabled,
command.DaysWithoutCheckIn,
command.MaxQuestionsAnsweredToday,
Normalize(command.FollowupType) ?? StudentFollowupType.Risk.ToString(),
Normalize(command.Priority) ?? StudentFollowupPriority.High.ToString(),
JsonObjectOrDefault(command.Metadata));
rules.RemoveAll(rule => string.Equals(rule.Code, code, StringComparison.Ordinal));
rules.Add(item);
await SaveSupervisionRulesCoreAsync(actor.TenantId, rules.OrderBy(rule => rule.Code).ToArray(), cancellationToken);
await AddAuditAsync(actor, "tenant.supervision_rule.upserted", "tenant_settings", actor.TenantId, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<TenantSupervisionRuleItem>(item);
}
public async Task<TenantSupervisionPreview> PreviewSupervisionAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
return new TenantSupervisionPreview(await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken));
}
public async Task<TenantSupervisionGenerateResult> GenerateSupervisionFollowupsAsync(
TenantAdminActor actor,
TenantSupervisionGenerateCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken);
var riskStudents = await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken);
if (command.UserIds is { Count: > 0 })
{
var selected = command.UserIds.Where(id => id != Guid.Empty).Distinct().ToHashSet();
riskStudents = riskStudents.Where(item => selected.Contains(item.UserId)).ToArray();
}
var created = 0;
foreach (var student in riskStudents)
{
if (await dbContext.TenantStudentFollowups.AnyAsync(item =>
item.TenantId == actor.TenantId &&
item.StudentUserId == student.UserId &&
item.Status != StudentFollowupStatus.Done &&
item.FollowupType == StudentFollowupType.Risk,
cancellationToken))
{
continue;
}
dbContext.TenantStudentFollowups.Add(new TenantStudentFollowup
{
TenantId = actor.TenantId,
StudentUserId = student.UserId,
AssignedToUserId = command.AssignedToUserId,
Title = "学习风险督导",
Description = string.Join("", student.Reasons),
FollowupType = StudentFollowupType.Risk,
Priority = StudentFollowupPriority.High,
Status = StudentFollowupStatus.Open,
DueAt = command.DueAt,
CreatedBy = actor.UserId,
UpdatedBy = actor.UserId,
Metadata = JsonSerializer.SerializeToElement(new { ruleCodes = student.RuleCodes })
});
created++;
}
await AddAuditAsync(actor, "tenant.supervision_followups.generated", "tenant_student_followups", actor.TenantId, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return new TenantSupervisionGenerateResult(created);
}
public async Task<TenantFollowupReport> GetFollowupReportAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default)
{
await RequireDataScopeAsync(actor, cancellationToken);
var now = DateTimeOffset.UtcNow;
return new TenantFollowupReport(
await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Open, cancellationToken),
await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.InProgress, cancellationToken),
await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Done, cancellationToken),
await dbContext.TenantStudentFollowups.CountAsync(item =>
item.TenantId == actor.TenantId &&
item.DueAt.HasValue &&
item.DueAt < now &&
item.Status != StudentFollowupStatus.Done &&
item.Status != StudentFollowupStatus.Cancelled,
cancellationToken));
}
public async Task<TenantFeedbackReport> GetFeedbackReportAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default)
{
await RequireDataScopeAsync(actor, cancellationToken);
return new TenantFeedbackReport(
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Pending, cancellationToken),
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Accepted, cancellationToken),
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Rejected, cancellationToken),
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Resolved, cancellationToken),
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Closed, cancellationToken));
}
public async Task<TenantPointRiskReport> GetPointRiskReportAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default)
{
await RequireDataScopeAsync(actor, cancellationToken);
var negativeScoreUsers = await dbContext.Users
.Where(user => user.Score < 0 &&
dbContext.TenantMemberships.Any(member =>
member.TenantId == actor.TenantId &&
member.UserId == user.Id &&
member.Status == MembershipStatus.Active))
.CountAsync(cancellationToken);
var since = DateTimeOffset.UtcNow.AddDays(-7);
var highClaimUsers = await dbContext.PointActivityClaims
.Where(item => item.TenantId == actor.TenantId && item.Status == PointActivityClaimStatus.Claimed && item.ClaimedAt >= since)
.GroupBy(item => item.UserId)
.Where(group => group.Sum(item => item.Points) >= 1000)
.CountAsync(cancellationToken);
var cancelledExchangeOrders = await dbContext.PointExchangeOrders.CountAsync(
item => item.TenantId == actor.TenantId && item.Status == PointExchangeOrderStatus.Cancelled,
cancellationToken);
return new TenantPointRiskReport(negativeScoreUsers, highClaimUsers, cancelledExchangeOrders);
}
public async Task<CatalogList<TenantAdminStudentNoteItem>> GetStudentNotesAsync(
TenantAdminActor actor,
TenantAdminStudentActivityFilter filter,
@@ -1669,6 +2041,197 @@ public sealed class TenantAdminDirectService(
return profile;
}
private async Task<IReadOnlyCollection<TenantAdminStudentImportPreviewItem>> BuildStudentImportPreviewAsync(
TenantAdminActor actor,
CurrentDataScope scope,
TenantAdminStudentImportCommand command,
CancellationToken cancellationToken)
{
var items = new List<TenantAdminStudentImportPreviewItem>();
var rowNo = 0;
foreach (var row in command.Rows.Take(1000))
{
rowNo++;
string? reason = null;
var phone = Normalize(row.User.Phone);
var email = Normalize(row.User.Email);
var name = Normalize(row.User.Name);
if (row.User.UserId is null && phone is null && email is null && name is null)
{
reason = "user_required";
}
else if (!scope.AllowsResource(actor.UserId, actor.UserId, row.RegionId))
{
reason = "data_scope_denied";
}
else if (row.RegionId.HasValue && !await dbContext.Regions.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == row.RegionId.Value, cancellationToken))
{
reason = "region_not_found";
}
else if (row.ClassId.HasValue)
{
try
{
await AssertClassAsync(actor, scope, row.ClassId, cancellationToken);
}
catch (TenantAdminDirectException exception)
{
reason = exception.Code;
}
}
items.Add(new TenantAdminStudentImportPreviewItem(
rowNo,
reason is null,
reason,
phone,
email,
name,
row.RegionId,
row.ClassId));
}
return items;
}
private async Task<TenantClassMember> UpsertClassMemberCoreAsync(
TenantAdminActor actor,
Guid classId,
Guid userId,
TenantClassMemberType memberType,
TenantClassMemberStatus status,
JsonElement metadata,
CancellationToken cancellationToken)
{
var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member =>
member.TenantId == actor.TenantId &&
member.ClassId == classId &&
member.UserId == userId &&
member.MemberType == memberType,
cancellationToken);
if (item is null)
{
item = new TenantClassMember
{
TenantId = actor.TenantId,
ClassId = classId,
UserId = userId,
MemberType = memberType,
JoinedAt = DateTimeOffset.UtcNow
};
dbContext.TenantClassMembers.Add(item);
}
item.Status = status;
item.LeftAt = status == TenantClassMemberStatus.Removed ? DateTimeOffset.UtcNow : null;
item.Metadata = JsonObjectOrDefault(metadata);
return item;
}
private async Task<IReadOnlyCollection<TenantSupervisionRuleItem>> GetSupervisionRulesCoreAsync(
Guid tenantId,
CancellationToken cancellationToken)
{
var settings = await dbContext.TenantSettings.AsNoTracking().SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
if (settings is null ||
settings.AdminFeatureFlags.ValueKind != JsonValueKind.Object ||
!settings.AdminFeatureFlags.TryGetProperty("supervisionRules", out var rulesElement) ||
rulesElement.ValueKind != JsonValueKind.Array)
{
return [];
}
return JsonSerializer.Deserialize<TenantSupervisionRuleItem[]>(rulesElement.GetRawText()) ?? [];
}
private async Task SaveSupervisionRulesCoreAsync(
Guid tenantId,
IReadOnlyCollection<TenantSupervisionRuleItem> rules,
CancellationToken cancellationToken)
{
var settings = await dbContext.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
if (settings is null)
{
settings = new TenantSettings { TenantId = tenantId };
dbContext.TenantSettings.Add(settings);
}
var existing = settings.AdminFeatureFlags.ValueKind == JsonValueKind.Object
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(settings.AdminFeatureFlags.GetRawText()) ?? []
: [];
existing["supervisionRules"] = JsonSerializer.SerializeToElement(rules);
settings.AdminFeatureFlags = JsonSerializer.SerializeToElement(existing);
}
private async Task<IReadOnlyCollection<TenantSupervisionRiskStudentItem>> BuildSupervisionRiskStudentsAsync(
TenantAdminActor actor,
CurrentDataScope scope,
CancellationToken cancellationToken)
{
var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken))
.Where(rule => rule.Enabled)
.ToArray();
if (rules.Length == 0)
{
return [];
}
var regionIds = scope.RegionIds.ToArray();
var students = await dbContext.StudentProfiles.AsNoTracking()
.Where(profile => profile.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
profile => profile.UserId == actor.UserId,
profile => profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value))
.Select(profile => new
{
Profile = profile,
User = dbContext.Users.Where(user => user.Id == profile.UserId).FirstOrDefault()
})
.ToArrayAsync(cancellationToken);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var result = new List<TenantSupervisionRiskStudentItem>();
foreach (var row in students)
{
var hitRules = new List<string>();
var reasons = new List<string>();
foreach (var rule in rules)
{
if (rule.DaysWithoutCheckIn.HasValue)
{
var days = row.Profile.LastCheckInDate.HasValue
? today.DayNumber - row.Profile.LastCheckInDate.Value.DayNumber
: int.MaxValue;
if (days >= rule.DaysWithoutCheckIn.Value)
{
hitRules.Add(rule.Code);
reasons.Add($"{rule.Title}: {days} days without check-in");
}
}
if (rule.MaxQuestionsAnsweredToday.HasValue &&
row.Profile.QuestionsAnsweredToday <= rule.MaxQuestionsAnsweredToday.Value)
{
hitRules.Add(rule.Code);
reasons.Add($"{rule.Title}: questions answered today <= {rule.MaxQuestionsAnsweredToday.Value}");
}
}
if (hitRules.Count > 0)
{
result.Add(new TenantSupervisionRiskStudentItem(
row.Profile.UserId,
row.User?.Name,
MaskPhone(row.User?.Phone),
row.Profile.RegionId,
hitRules.Distinct(StringComparer.Ordinal).ToArray(),
reasons.Distinct(StringComparer.Ordinal).ToArray()));
}
}
return result;
}
private async Task AssertStudentAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken)
{
var exists = await dbContext.TenantMemberships.AnyAsync(
@@ -2261,6 +2824,19 @@ public sealed class TenantAdminDirectService(
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static string NormalizeCode(string value)
{
return value.Trim().ToLowerInvariant();
}
private static string? MaskPhone(string? phone)
{
var value = Normalize(phone);
return value is { Length: >= 7 }
? $"{value[..3]}****{value[^4..]}"
: value;
}
private static JsonElement JsonObjectOrDefault(JsonElement value)
{
return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object();