feat: establish dotnet engineering foundation

This commit is contained in:
2026-07-27 15:00:14 +08:00
parent 60a376ea0b
commit 28e9a9fa41
36 changed files with 17301 additions and 89 deletions

View File

@@ -10,7 +10,9 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Commerce;
public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdminService
internal sealed class CommerceAdminService(
TikuDbContext dbContext,
ITenantSecretProtector tenantSecretProtector) : ICommerceAdminService
{
public async Task<IReadOnlyCollection<TenantPaymentAccountItem>> GetPaymentAccountsAsync(
CommerceAdminActor actor,
@@ -88,11 +90,19 @@ public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdm
secret.RotatedAt = DateTimeOffset.UtcNow;
}
var protectedPayload = tenantSecretProtector.Protect(
actor.TenantId,
secretRef,
JsonObjectOrDefault(command.SecretPayload));
secret.Purpose = command.Purpose.Trim();
secret.Provider = provider;
secret.SecretKey = command.SecretKey.Trim();
secret.Status = command.Status;
secret.SecretPayload = JsonObjectOrDefault(command.SecretPayload);
secret.EncryptionKeyId = protectedPayload.KeyId;
secret.EncryptedPayload = protectedPayload.Ciphertext;
secret.EncryptionNonce = protectedPayload.Nonce;
secret.EncryptionTag = protectedPayload.Tag;
secret.ExpiresAt = command.ExpiresAt;
await dbContext.SaveChangesAsync(cancellationToken);

View File

@@ -0,0 +1,116 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Tiku.Infrastructure.Commerce;
public sealed class TenantSecretEncryptionOptions
{
public const string SectionName = "Security:TenantSecrets";
public const string DevelopmentMasterKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
public string KeyId { get; set; } = string.Empty;
public string MasterKey { get; set; } = string.Empty;
public static bool BeValid(TenantSecretEncryptionOptions options)
{
if (string.IsNullOrWhiteSpace(options.KeyId) || string.IsNullOrWhiteSpace(options.MasterKey))
{
return false;
}
try
{
return Convert.FromBase64String(options.MasterKey).Length == 32;
}
catch (FormatException)
{
return false;
}
}
public static bool IsDevelopmentDefault(TenantSecretEncryptionOptions options) =>
string.Equals(options.MasterKey, DevelopmentMasterKey, StringComparison.Ordinal);
}
internal interface ITenantSecretProtector
{
ProtectedTenantSecret Protect(Guid tenantId, string secretRef, JsonElement payload);
JsonElement Unprotect(
Guid tenantId,
string secretRef,
string keyId,
byte[] ciphertext,
byte[] nonce,
byte[] tag);
}
internal sealed record ProtectedTenantSecret(
string KeyId,
byte[] Ciphertext,
byte[] Nonce,
byte[] Tag);
internal sealed class TenantSecretProtector(
Microsoft.Extensions.Options.IOptions<TenantSecretEncryptionOptions> options) : ITenantSecretProtector
{
private readonly TenantSecretEncryptionOptions options = options.Value;
public ProtectedTenantSecret Protect(Guid tenantId, string secretRef, JsonElement payload)
{
var key = Convert.FromBase64String(options.MasterKey);
var plaintext = JsonSerializer.SerializeToUtf8Bytes(payload);
var nonce = RandomNumberGenerator.GetBytes(12);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[16];
var associatedData = GetAssociatedData(tenantId, secretRef, options.KeyId);
try
{
using var aes = new AesGcm(key, tag.Length);
aes.Encrypt(nonce, plaintext, ciphertext, tag, associatedData);
return new ProtectedTenantSecret(options.KeyId, ciphertext, nonce, tag);
}
finally
{
CryptographicOperations.ZeroMemory(key);
CryptographicOperations.ZeroMemory(plaintext);
}
}
public JsonElement Unprotect(
Guid tenantId,
string secretRef,
string keyId,
byte[] ciphertext,
byte[] nonce,
byte[] tag)
{
if (!string.Equals(keyId, options.KeyId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Tenant secret uses unknown encryption key '{keyId}'.");
}
var key = Convert.FromBase64String(options.MasterKey);
var plaintext = new byte[ciphertext.Length];
var associatedData = GetAssociatedData(tenantId, secretRef, keyId);
try
{
using var aes = new AesGcm(key, tag.Length);
aes.Decrypt(nonce, ciphertext, tag, plaintext, associatedData);
using var document = JsonDocument.Parse(plaintext);
return document.RootElement.Clone();
}
finally
{
CryptographicOperations.ZeroMemory(key);
CryptographicOperations.ZeroMemory(plaintext);
}
}
private static byte[] GetAssociatedData(Guid tenantId, string secretRef, string keyId) =>
Encoding.UTF8.GetBytes($"{tenantId:N}\n{secretRef}\n{keyId}");
}

View File

@@ -6,7 +6,9 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Commerce;
internal sealed class TenantSecretService(TikuDbContext dbContext) : ITenantSecretService
internal sealed class TenantSecretService(
TikuDbContext dbContext,
ITenantSecretProtector tenantSecretProtector) : ITenantSecretService
{
public async Task<JsonElement> GetActiveSecretPayloadAsync(
Guid tenantId,
@@ -28,16 +30,28 @@ internal sealed class TenantSecretService(TikuDbContext dbContext) : ITenantSecr
item.SecretRef == secretRef &&
item.Status == TenantSecretStatus.Active &&
(item.ExpiresAt == null || item.ExpiresAt > now))
.Select(item => item.SecretPayload)
.SingleOrDefaultAsync(cancellationToken);
if (secret.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
if (secret is null)
{
throw new PaymentProviderException(
"Payment provider secret is not configured.",
"payment_secret_not_configured");
}
return secret;
if (secret.EncryptedPayload.Length > 0)
{
return tenantSecretProtector.Unprotect(
tenantId,
secretRef,
secret.EncryptionKeyId,
secret.EncryptedPayload,
secret.EncryptionNonce,
secret.EncryptionTag);
}
throw new PaymentProviderException(
"Payment provider secret is not configured.",
"payment_secret_not_configured");
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
@@ -279,6 +280,9 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
throw new ContentNavigationNotFoundException("Question collection was not found.");
}
var emptyOptions = JsonDefaults.Array();
var emptyCorrectOptionIndices = JsonDefaults.Array();
var emptySubQuestions = JsonDefaults.Array();
var query =
from item in dbContext.QuestionCollectionItems.AsNoTracking()
join question in dbContext.Questions.AsNoTracking()
@@ -313,12 +317,12 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
item.SortOrder,
version == null ? null : version.Id,
version == null ? null : version.Content,
version == null ? default : version.Options,
version == null ? emptyOptions : version.Options,
version == null ? null : version.CorrectOptionIndex,
version == null ? default : version.CorrectOptionIndices,
version == null ? emptyCorrectOptionIndices : version.CorrectOptionIndices,
version == null ? null : version.AnswerText,
version == null ? null : version.Explanation,
version == null ? default : version.SubQuestions,
version == null ? emptySubQuestions : version.SubQuestions,
version == null ? null : version.CodeLang,
version == null ? null : version.CodeTemplate);

View File

@@ -34,6 +34,7 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
CancellationToken cancellationToken = default)
{
await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
var question = new Question
{
@@ -42,6 +43,7 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
};
ApplyQuestion(question, command);
dbContext.Questions.Add(question);
await dbContext.SaveChangesAsync(cancellationToken);
var version = BuildQuestionVersion(actor, question.Id, 1, command);
dbContext.QuestionVersions.Add(version);
@@ -49,6 +51,7 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new ContentManagementResult<QuestionManagementItem>(ToQuestionItem(question, version));
}

View File

@@ -74,6 +74,7 @@ public static class DependencyInjection
services.AddScoped<IReferralService, ReferralService>();
services.AddScoped<ICrmService, CrmService>();
services.AddScoped<ICommissionService, CommissionService>();
services.AddSingleton<ITenantSecretProtector, TenantSecretProtector>();
services.AddScoped<ITenantSecretService, TenantSecretService>();
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();
services.AddScoped<IPaymentProviderGateway, PaymentProviderGateway>();

View File

@@ -4,11 +4,14 @@ using Tiku.Application.Growth;
using Tiku.Domain.Common;
using Tiku.Domain.Growth;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Commerce;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Growth;
public sealed class CrmService(TikuDbContext dbContext) : ICrmService
internal sealed class CrmService(
TikuDbContext dbContext,
ITenantSecretProtector tenantSecretProtector) : ICrmService
{
private static readonly HashSet<string> SensitiveKeys = new(StringComparer.OrdinalIgnoreCase)
{
@@ -254,8 +257,15 @@ public sealed class CrmService(TikuDbContext dbContext) : ICrmService
item.RotatedAt = DateTimeOffset.UtcNow;
}
var protectedPayload = tenantSecretProtector.Protect(
tenantId,
secretRef,
JsonSerializer.SerializeToElement(new { webhookSecret = secret }));
item.Status = TenantSecretStatus.Active;
item.SecretPayload = JsonSerializer.SerializeToElement(new { webhookSecret = secret });
item.EncryptionKeyId = protectedPayload.KeyId;
item.EncryptedPayload = protectedPayload.Ciphertext;
item.EncryptionNonce = protectedPayload.Nonce;
item.EncryptionTag = protectedPayload.Tag;
}
private async Task AssertAdminAsync(CrmAdminActor actor, CancellationToken cancellationToken)

View File

@@ -136,6 +136,7 @@ public sealed class ReferralService(
var eventType = NormalizeChoice(command.EventType, AllowedEventTypes, "enter", "invalid_referral_event_type");
var source = NormalizeChoice(command.Source, AllowedSources, "unknown", "invalid_referral_source");
var resolution = await ResolveCodeCoreAsync(actor.TenantId, code, cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
var track = new ReferralTrack
{
TenantId = actor.TenantId,
@@ -148,7 +149,6 @@ public sealed class ReferralService(
UserAgent = Truncate(NormalizeOptional(userAgent), 1024),
Metadata = command.Metadata ?? JsonDefaults.Object()
};
dbContext.ReferralTracks.Add(track);
ReferralLead? lead = null;
CrmWebhookQueueItem? crmQueue = null;
@@ -164,16 +164,29 @@ public sealed class ReferralService(
false,
command.Metadata,
cancellationToken);
if (lead.FirstTrackId is null)
var setFirstTrack = lead.FirstTrackId is null;
if (setFirstTrack)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
dbContext.ReferralTracks.Add(track);
track.LeadId = lead.Id;
await dbContext.SaveChangesAsync(cancellationToken);
if (setFirstTrack)
{
lead.FirstTrackId = track.Id;
}
track.LeadId = lead.Id;
crmQueue = await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.track_event", cancellationToken);
}
else
{
dbContext.ReferralTracks.Add(track);
}
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new ReferralTrackResult(ToTrackItem(track), lead is null ? null : ToLeadItem(lead, true), ToQueuePreview(crmQueue));
}

View File

@@ -32,10 +32,14 @@ internal sealed class TenantSecretConfiguration : IEntityTypeConfiguration<Tenan
builder.Property(entity => entity.SecretKey).HasMaxLength(120);
builder.Property(entity => entity.SecretRef).HasMaxLength(300);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.SecretPayload).IsJson("{}");
builder.Property(entity => entity.EncryptionKeyId).HasMaxLength(100);
builder.HasIndex(entity => new { entity.TenantId, entity.SecretRef }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Purpose, entity.Provider, entity.SecretKey }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Purpose, entity.Provider, entity.Status });
builder.ToTable(table => table.HasCheckConstraint(
"ck_tenant_secrets_encryption_envelope",
"octet_length(encrypted_payload) > 0 and octet_length(encryption_nonce) = 12 and " +
"octet_length(encryption_tag) = 16 and encryption_key_id <> ''"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class EncryptTenantSecretPayloads : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "secret_payload",
table: "tenant_secrets");
migrationBuilder.AddColumn<byte[]>(
name: "encrypted_payload",
table: "tenant_secrets",
type: "bytea",
nullable: false,
defaultValue: new byte[0]);
migrationBuilder.AddColumn<string>(
name: "encryption_key_id",
table: "tenant_secrets",
type: "character varying(100)",
maxLength: 100,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<byte[]>(
name: "encryption_nonce",
table: "tenant_secrets",
type: "bytea",
nullable: false,
defaultValue: new byte[0]);
migrationBuilder.AddColumn<byte[]>(
name: "encryption_tag",
table: "tenant_secrets",
type: "bytea",
nullable: false,
defaultValue: new byte[0]);
migrationBuilder.AddCheckConstraint(
name: "ck_tenant_secrets_encryption_envelope",
table: "tenant_secrets",
sql: "octet_length(encrypted_payload) > 0 and octet_length(encryption_nonce) = 12 and octet_length(encryption_tag) = 16 and encryption_key_id <> ''");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(
name: "ck_tenant_secrets_encryption_envelope",
table: "tenant_secrets");
migrationBuilder.DropColumn(
name: "encrypted_payload",
table: "tenant_secrets");
migrationBuilder.DropColumn(
name: "encryption_key_id",
table: "tenant_secrets");
migrationBuilder.DropColumn(
name: "encryption_nonce",
table: "tenant_secrets");
migrationBuilder.DropColumn(
name: "encryption_tag",
table: "tenant_secrets");
migrationBuilder.AddColumn<JsonElement>(
name: "secret_payload",
table: "tenant_secrets",
type: "jsonb",
nullable: false,
defaultValueSql: "'{}'::jsonb");
}
}
}

View File

@@ -12958,6 +12958,27 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<byte[]>("EncryptedPayload")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("encrypted_payload");
b.Property<string>("EncryptionKeyId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("encryption_key_id");
b.Property<byte[]>("EncryptionNonce")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("encryption_nonce");
b.Property<byte[]>("EncryptionTag")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("encryption_tag");
b.Property<DateTimeOffset?>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
@@ -12984,12 +13005,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(120)")
.HasColumnName("secret_key");
b.Property<JsonElement>("SecretPayload")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("secret_payload")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("SecretRef")
.IsRequired()
.HasMaxLength(300)
@@ -13029,7 +13044,10 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasIndex("TenantId", "Purpose", "Provider", "Status")
.HasDatabaseName("ix_tenant_secrets_tenant_id_purpose_provider_status");
b.ToTable("tenant_secrets", (string)null);
b.ToTable("tenant_secrets", null, t =>
{
t.HasCheckConstraint("ck_tenant_secrets_encryption_envelope", "octet_length(encrypted_payload) > 0 and octet_length(encryption_nonce) = 12 and octet_length(encryption_tag) = 16 and encryption_key_id <> ''");
});
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSettings", b =>

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.QuestionBanks;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
@@ -222,6 +223,9 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
private IQueryable<QuestionCatalogItem> ProjectQuestions(IQueryable<Question> questions)
{
var emptyOptions = JsonDefaults.Array();
var emptyCorrectOptionIndices = JsonDefaults.Array();
var emptySubQuestions = JsonDefaults.Array();
return
from question in questions
join version in dbContext.QuestionVersions.AsNoTracking()
@@ -253,12 +257,12 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
version == null ? null : version.Id,
version == null ? null : version.VersionNo,
version == null ? null : version.Content,
version == null ? default : version.Options,
version == null ? emptyOptions : version.Options,
version == null ? null : version.CorrectOptionIndex,
version == null ? default : version.CorrectOptionIndices,
version == null ? emptyCorrectOptionIndices : version.CorrectOptionIndices,
version == null ? null : version.AnswerText,
version == null ? null : version.Explanation,
version == null ? default : version.SubQuestions,
version == null ? emptySubQuestions : version.SubQuestions,
version == null ? null : version.CodeLang,
version == null ? null : version.CodeTemplate);
}

View File

@@ -1263,7 +1263,7 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
GrantedBy = actor.UserId,
GrantedAt = command.GrantedAt ?? DateTimeOffset.UtcNow
};
grant.LegacyId = Normalize(command.LegacyId) ?? grant.LegacyId ?? $"badge:{command.BadgeId}:user:{command.UserId}";
grant.LegacyId = Normalize(command.LegacyId) ?? grant.LegacyId;
grant.Note = Normalize(command.Note) ?? grant.Note;
grant.GrantedBy ??= actor.UserId;
grant.GrantedAt ??= command.GrantedAt ?? DateTimeOffset.UtcNow;
@@ -1272,19 +1272,20 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
dbContext.UserBadges.Add(grant);
}
var dedupeKey = $"badge:{grant.Id:N}";
var notification = await dbContext.UserNotifications.FirstOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == command.UserId &&
item.NotificationType == "badge_granted" &&
item.DedupeKey == grant.LegacyId,
item.DedupeKey == dedupeKey,
cancellationToken);
notification ??= new UserNotification
{
TenantId = actor.TenantId,
UserId = command.UserId,
NotificationType = "badge_granted",
DedupeKey = grant.LegacyId,
DedupeKey = dedupeKey,
CreatedBy = actor.UserId
};
notification.Severity = NotificationSeverity.Success;