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

@@ -1,7 +1,42 @@
using Microsoft.Extensions.Configuration;
using Tiku.Application.Security;
namespace Tiku.Api.Options;
public static class OptionsValidation
{
public const string DevelopmentSigningKey = "development-only-tiku-signing-key-change-before-production";
public static string ResolveDatabaseConnectionString(
IConfiguration configuration,
bool isDevelopment)
{
var connectionString =
configuration.GetConnectionString("Database") ??
configuration["DATABASE_URL"];
if (!string.IsNullOrWhiteSpace(connectionString))
{
return connectionString;
}
if (isDevelopment)
{
return $"Host=localhost;Database=tiku;Username={Environment.UserName}";
}
throw new InvalidOperationException(
"Database connection is required outside Development. Configure ConnectionStrings:Database or DATABASE_URL.");
}
public static bool BeValidJwtOptions(JwtOptions options, bool isProduction)
{
return !isProduction ||
!string.Equals(
options.SigningKey,
DevelopmentSigningKey,
StringComparison.Ordinal);
}
public static bool BeValidCorsOptions(CorsOptions options)
{
if (options.AllowCredentials && options.AllowedOrigins.Length == 0)

View File

@@ -17,6 +17,7 @@ using Tiku.Api.Security;
using Tiku.Application;
using Tiku.Application.Security;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Commerce;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Storage;
@@ -133,10 +134,9 @@ try
});
}
var connectionString =
builder.Configuration.GetConnectionString("Database") ??
builder.Configuration["DATABASE_URL"] ??
"Host=localhost;Database=tiku;Username=postgres";
var connectionString = OptionsValidation.ResolveDatabaseConnectionString(
builder.Configuration,
builder.Environment.IsDevelopment());
builder.Services.AddInfrastructure(connectionString);
builder.Services.Configure<ObjectStorageOptions>(
@@ -172,10 +172,28 @@ try
? useInternalEndpoint
: options.UseInternalEndpoint;
});
builder.Services.AddOptions<TenantSecretEncryptionOptions>()
.Bind(builder.Configuration.GetSection(TenantSecretEncryptionOptions.SectionName))
.PostConfigure(options =>
{
options.KeyId = builder.Configuration["TIKU_TENANT_SECRET_KEY_ID"] ?? options.KeyId;
options.MasterKey = builder.Configuration["TIKU_TENANT_SECRET_MASTER_KEY"] ?? options.MasterKey;
})
.Validate(
TenantSecretEncryptionOptions.BeValid,
"Tenant secret encryption requires a key ID and a base64-encoded 32-byte master key.")
.Validate(
options => !builder.Environment.IsProduction() ||
!TenantSecretEncryptionOptions.IsDevelopmentDefault(options),
"Production tenant secret encryption cannot use the development master key.")
.ValidateOnStart();
builder.Services.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection("Security:Jwt"))
.ValidateDataAnnotations()
.Validate(
options => OptionsValidation.BeValidJwtOptions(options, builder.Environment.IsProduction()),
"Production JWT signing key must be explicitly configured and cannot use the development key.")
.ValidateOnStart();
var jwtOptions = builder.Configuration
.GetSection("Security:Jwt")

View File

@@ -24,6 +24,15 @@
"WindowSeconds": 60,
"QueueLimit": 0
},
"Security": {
"Jwt": {
"SigningKey": "development-only-tiku-signing-key-change-before-production"
},
"TenantSecrets": {
"KeyId": "development-v1",
"MasterKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
}
},
"Storage": {
"AliyunOss": {
"Region": "cn-hangzhou",

View File

@@ -84,10 +84,14 @@
"Jwt": {
"Issuer": "tiku-backend",
"Audience": "tiku-api",
"SigningKey": "development-only-tiku-signing-key-change-before-production",
"SigningKey": "",
"AccessTokenMinutes": 30,
"RefreshTokenDays": 30,
"ValidateSessions": true
},
"TenantSecrets": {
"KeyId": "",
"MasterKey": ""
}
},
"AllowedHosts": "*"

View File

@@ -10,7 +10,7 @@ public sealed class JwtOptions
[System.ComponentModel.DataAnnotations.Required]
[System.ComponentModel.DataAnnotations.MinLength(32)]
public string SigningKey { get; set; } = "development-only-tiku-signing-key-change-before-production";
public string SigningKey { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Range(1, 1440)]
public int AccessTokenMinutes { get; set; } = 30;

View File

@@ -10,7 +10,7 @@ public sealed class DesignTimeTikuDbContextFactory : IDesignTimeDbContextFactory
{
var connectionString =
Environment.GetEnvironmentVariable("DATABASE_URL") ??
"Host=localhost;Database=tiku;Username=postgres";
$"Host=localhost;Database=tiku;Username={Environment.UserName}";
var options = new DbContextOptionsBuilder<TikuDbContext>()
.UseNpgsql(connectionString, npgsql =>

View File

@@ -18,7 +18,10 @@ public sealed class TenantSecret : AuditableTenantEntity
public string SecretKey { get; set; } = string.Empty;
public string SecretRef { get; set; } = string.Empty;
public TenantSecretStatus Status { get; set; } = TenantSecretStatus.Active;
public JsonElement SecretPayload { get; set; } = JsonDefaults.Object();
public string EncryptionKeyId { get; set; } = string.Empty;
public byte[] EncryptedPayload { get; set; } = [];
public byte[] EncryptionNonce { get; set; } = [];
public byte[] EncryptionTag { get; set; } = [];
public DateTimeOffset? RotatedAt { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
}

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;

View File

@@ -7,7 +7,9 @@ using Tiku.Application.Auth;
using Tiku.Application.Growth;
using Tiku.Application.Storage;
using Tiku.Domain.Identity;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.IntegrationTests.Infrastructure;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
@@ -18,7 +20,7 @@ public sealed class ApiTestFactory(
IReferralQrcodeGenerator? referralQrcodeGenerator = null,
IPaymentProviderGateway? paymentProviderGateway = null) : WebApplicationFactory<Program>
{
private readonly string databaseName = Guid.NewGuid().ToString();
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
{
@@ -34,8 +36,13 @@ public sealed class ApiTestFactory(
services.Remove(descriptor);
}
services.AddDbContext<TikuDbContext>(options =>
options.UseInMemoryDatabase(databaseName));
services.AddSingleton(_ => NpgsqlDataSource.Create(database.ConnectionString));
services.AddDbContextPool<TikuDbContext>((serviceProvider, options) =>
{
var dataSource = serviceProvider.GetRequiredService<NpgsqlDataSource>();
options.UseNpgsql(dataSource, npgsql =>
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
});
if (wechatOAuthClient is not null)
{
@@ -67,6 +74,20 @@ public sealed class ApiTestFactory(
await dbContext.SaveChangesAsync();
}
public async Task SeedQuestionWithVersionAsync(Question question, QuestionVersion version)
{
question.CurrentVersionId = null;
await SeedAsync(question);
await SeedAsync(version);
using var scope = Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var persistedQuestion = await dbContext.Questions.SingleAsync(item =>
item.TenantId == question.TenantId && item.Id == question.Id);
persistedQuestion.CurrentVersionId = version.Id;
await dbContext.SaveChangesAsync();
}
public async Task<Guid> SeedActiveSessionAsync(
Guid userId,
Guid? tenantId = null,
@@ -102,4 +123,13 @@ public sealed class ApiTestFactory(
.Select(session => session.Id)
.SingleAsync();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
database.Dispose();
}
}
}

View File

@@ -1,7 +1,9 @@
using System.Net;
using System.Text.Json;
using Tiku.Domain.Common;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
namespace Tiku.IntegrationTests.Api;
@@ -16,6 +18,7 @@ public sealed class AssetEndpointTests
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Region { Id = regionId, TenantId = tenantId, Name = "测试地区" },
new ContentAsset
{
Id = Guid.NewGuid(),
@@ -162,6 +165,13 @@ public sealed class AssetEndpointTests
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Question
{
Id = questionId,
TenantId = tenantId,
Type = "choice",
Status = QuestionStatus.Published
},
new VideoExplanation
{
Id = videoId,

View File

@@ -299,12 +299,16 @@ public sealed class CatalogEndpointTests
var tenantId = Guid.NewGuid();
var regionId = Guid.NewGuid();
var schoolId = Guid.NewGuid();
var otherRegionId = Guid.NewGuid();
var otherSchoolId = Guid.NewGuid();
var futureExamAt = new DateTimeOffset(DateTimeOffset.UtcNow.Date.AddDays(10), TimeSpan.Zero);
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Region { Id = regionId, TenantId = tenantId, Name = "浙江" },
new Region { Id = otherRegionId, TenantId = tenantId, Name = "其他地区" },
new School { Id = schoolId, TenantId = tenantId, RegionId = regionId, Name = "测试院校" },
new School { Id = otherSchoolId, TenantId = tenantId, RegionId = otherRegionId, Name = "其他院校" },
new ExamDate
{
Id = Guid.NewGuid(),
@@ -327,8 +331,8 @@ public sealed class CatalogEndpointTests
{
Id = Guid.NewGuid(),
TenantId = tenantId,
RegionId = Guid.NewGuid(),
SchoolId = Guid.NewGuid(),
RegionId = otherRegionId,
SchoolId = otherSchoolId,
ExamName = "其他地区学校考试",
ExamAt = futureExamAt.AddDays(2),
IsActive = true
@@ -420,10 +424,12 @@ public sealed class CatalogEndpointTests
{
var tenantId = Guid.NewGuid();
var regionId = Guid.NewGuid();
var otherRegionId = Guid.NewGuid();
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Region { Id = regionId, TenantId = tenantId, Name = "浙江" },
new Region { Id = otherRegionId, TenantId = tenantId, Name = "其他地区" },
new SvipPlan
{
Id = Guid.NewGuid(),
@@ -449,7 +455,7 @@ public sealed class CatalogEndpointTests
{
Id = Guid.NewGuid(),
TenantId = tenantId,
RegionId = Guid.NewGuid(),
RegionId = otherRegionId,
Name = "其他地区卡",
PriceCents = 1000,
Days = 7,

View File

@@ -1,6 +1,7 @@
using System.Net;
using System.Text.Json;
using Tiku.Domain.Common;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
@@ -17,6 +18,7 @@ public sealed class ContentNavigationEndpointTests
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Region { Id = regionId, TenantId = tenantId, Name = "测试地区" },
new ContentEntry
{
Id = Guid.NewGuid(),
@@ -193,23 +195,6 @@ public sealed class ContentNavigationEndpointTests
Status = ContentStatus.Active
},
new Question
{
Id = publishedQuestionId,
TenantId = tenantId,
PrimaryCollectionId = collectionId,
Type = "choice",
TypeLabel = "单选题",
Status = QuestionStatus.Published,
CurrentVersionId = versionId
},
new QuestionVersion
{
Id = versionId,
TenantId = tenantId,
QuestionId = publishedQuestionId,
Content = "题干"
},
new Question
{
Id = archivedQuestionId,
TenantId = tenantId,
@@ -218,15 +203,6 @@ public sealed class ContentNavigationEndpointTests
Status = QuestionStatus.Archived
},
new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
CollectionId = collectionId,
QuestionId = publishedQuestionId,
SortOrder = 1,
Score = 2
},
new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
@@ -234,6 +210,32 @@ public sealed class ContentNavigationEndpointTests
QuestionId = archivedQuestionId,
SortOrder = 2
});
await factory.SeedQuestionWithVersionAsync(
new Question
{
Id = publishedQuestionId,
TenantId = tenantId,
PrimaryCollectionId = collectionId,
Type = "choice",
TypeLabel = "单选题",
Status = QuestionStatus.Published
},
new QuestionVersion
{
Id = versionId,
TenantId = tenantId,
QuestionId = publishedQuestionId,
Content = "题干"
});
await factory.SeedAsync(new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
CollectionId = collectionId,
QuestionId = publishedQuestionId,
SortOrder = 1,
Score = 2
});
using var client = factory.CreateClient();
using var response = await client.GetAsync($"/api/catalog/question-collections/questions?tenantCode=master&collectionId={collectionId}");

View File

@@ -0,0 +1,61 @@
using Microsoft.Extensions.Configuration;
using Tiku.Api.Options;
using Tiku.Application.Security;
using Tiku.Infrastructure.Commerce;
namespace Tiku.IntegrationTests.Api;
public sealed class ProductionConfigurationTests
{
[Fact]
public void Database_connection_is_required_outside_development()
{
var configuration = new ConfigurationBuilder().Build();
var exception = Assert.Throws<InvalidOperationException>(() =>
OptionsValidation.ResolveDatabaseConnectionString(configuration, isDevelopment: false));
Assert.Contains("Database connection is required", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Development_database_default_uses_current_system_user_without_a_password()
{
var configuration = new ConfigurationBuilder().Build();
var connectionString = OptionsValidation.ResolveDatabaseConnectionString(
configuration,
isDevelopment: true);
Assert.Contains($"Username={Environment.UserName}", connectionString, StringComparison.Ordinal);
Assert.DoesNotContain("Password=", connectionString, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Production_rejects_the_committed_development_jwt_key()
{
var options = new JwtOptions
{
SigningKey = OptionsValidation.DevelopmentSigningKey
};
Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: true));
Assert.True(OptionsValidation.BeValidJwtOptions(options, isProduction: false));
}
[Fact]
public void Tenant_secret_encryption_requires_a_32_byte_base64_key()
{
Assert.False(TenantSecretEncryptionOptions.BeValid(new TenantSecretEncryptionOptions()));
Assert.False(TenantSecretEncryptionOptions.BeValid(new TenantSecretEncryptionOptions
{
KeyId = "key-v1",
MasterKey = Convert.ToBase64String(new byte[16])
}));
Assert.True(TenantSecretEncryptionOptions.BeValid(new TenantSecretEncryptionOptions
{
KeyId = "key-v1",
MasterKey = Convert.ToBase64String(new byte[32])
}));
}
}

View File

@@ -62,7 +62,11 @@ public sealed class ProfileEndpointTests
var seed = await SeedStudentAsync(factory);
var regionId = Guid.NewGuid();
var schoolId = Guid.NewGuid();
var otherRegionId = Guid.NewGuid();
await factory.SeedAsync(
new Region { Id = regionId, TenantId = seed.TenantId, Name = "四川" },
new Region { Id = otherRegionId, TenantId = seed.TenantId, Name = "其他地区" },
new School { Id = schoolId, TenantId = seed.TenantId, RegionId = regionId, Name = "美术学院" },
new StudentProfile
{
TenantId = seed.TenantId,
@@ -81,7 +85,7 @@ public sealed class ProfileEndpointTests
new ExamDate
{
TenantId = seed.TenantId,
RegionId = Guid.NewGuid(),
RegionId = otherRegionId,
ExamName = "其他地区考试",
ExamAt = DateTimeOffset.UtcNow.AddDays(1)
});

View File

@@ -1,6 +1,7 @@
using System.Net;
using System.Text.Json;
using Tiku.Domain.Common;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
@@ -62,8 +63,20 @@ public sealed class QuestionBankEndpointTests
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Subject { Id = subjectId, TenantId = tenantId, Name = "测试科目" },
new Category { Id = categoryId, TenantId = tenantId, SubjectId = subjectId, Name = "测试分类" },
new QuestionBank { Id = bankId, TenantId = tenantId, Name = "题库" },
new QuestionCollection { Id = collectionId, TenantId = tenantId, Name = "题集", Status = ContentStatus.Active },
new Question
{
Id = excludedQuestionId,
TenantId = tenantId,
QuestionBankId = bankId,
SubjectId = subjectId,
Type = "choice",
Status = QuestionStatus.Archived
});
await factory.SeedQuestionWithVersionAsync(
new Question
{
Id = includedQuestionId,
@@ -74,8 +87,7 @@ public sealed class QuestionBankEndpointTests
Type = "choice",
TypeLabel = "单选题",
Difficulty = 2,
Status = QuestionStatus.Published,
CurrentVersionId = versionId
Status = QuestionStatus.Published
},
new QuestionVersion
{
@@ -84,23 +96,14 @@ public sealed class QuestionBankEndpointTests
QuestionId = includedQuestionId,
VersionNo = 2,
Content = "题干关键词"
},
new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
CollectionId = collectionId,
QuestionId = includedQuestionId
},
new Question
{
Id = excludedQuestionId,
TenantId = tenantId,
QuestionBankId = bankId,
SubjectId = subjectId,
Type = "choice",
Status = QuestionStatus.Archived
});
await factory.SeedAsync(new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
CollectionId = collectionId,
QuestionId = includedQuestionId
});
using var client = factory.CreateClient();
using var response = await client.GetAsync(

View File

@@ -1,6 +1,7 @@
using System.Net;
using System.Text.Json;
using Tiku.Domain.Common;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.Tenancy;
@@ -16,6 +17,7 @@ public sealed class StudyContentEndpointTests
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Region { Id = regionId, TenantId = tenantId, Name = "测试地区" },
new VocabularyUnit
{
Id = Guid.NewGuid(),

View File

@@ -1,6 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.Auth;
@@ -93,6 +94,21 @@ public sealed class TenantCommerceEndpointTests
Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, accountsResponse.StatusCode);
Assert.Contains("wechat_pay", await accountsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
using var scope = factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var storedSecret = await dbContext.TenantSecrets
.AsNoTracking()
.SingleAsync(item => item.TenantId == seed.TenantId);
var configService = scope.ServiceProvider.GetRequiredService<IPaymentProviderConfigService>();
var resolvedAccount = await configService.GetActiveAccountAsync(seed.TenantId, "wechat_pay");
Assert.Equal("development-v1", storedSecret.EncryptionKeyId);
Assert.NotEmpty(storedSecret.EncryptedPayload);
Assert.Equal(12, storedSecret.EncryptionNonce.Length);
Assert.Equal(16, storedSecret.EncryptionTag.Length);
Assert.Equal("pem", resolvedAccount.SecretPayload.GetProperty("privateKey").GetString());
Assert.Equal("v3", resolvedAccount.SecretPayload.GetProperty("apiV3Key").GetString());
}
[Fact]

View File

@@ -0,0 +1,182 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Infrastructure;
internal sealed class PostgresTestDatabase : IDisposable
{
private const string DatabasePrefix = "tiku_it_";
private static readonly Lazy<PostgresTestDatabaseTemplate> Template = new(
PostgresTestDatabaseTemplate.Create,
LazyThreadSafetyMode.ExecutionAndPublication);
private readonly string adminConnectionString;
private bool disposed;
private PostgresTestDatabase(string adminConnectionString, string databaseName, string connectionString)
{
this.adminConnectionString = adminConnectionString;
DatabaseName = databaseName;
ConnectionString = connectionString;
}
public string DatabaseName { get; }
public string ConnectionString { get; }
public static PostgresTestDatabase Create()
{
var template = Template.Value;
var adminConnectionString = template.AdminConnectionString;
var databaseName = $"{DatabasePrefix}{Guid.NewGuid():N}";
var adminBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString);
using (var adminConnection = new NpgsqlConnection(adminBuilder.ConnectionString))
{
adminConnection.Open();
using var createDatabase = adminConnection.CreateCommand();
createDatabase.CommandText =
$"CREATE DATABASE {QuoteIdentifier(databaseName)} TEMPLATE {QuoteIdentifier(template.DatabaseName)}";
createDatabase.ExecuteNonQuery();
}
var databaseBuilder = new NpgsqlConnectionStringBuilder(adminBuilder.ConnectionString)
{
Database = databaseName,
Pooling = true,
MaxPoolSize = 10
};
var database = new PostgresTestDatabase(
adminBuilder.ConnectionString,
databaseName,
databaseBuilder.ConnectionString);
return database;
}
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
var builder = new NpgsqlConnectionStringBuilder(adminConnectionString);
using var adminConnection = new NpgsqlConnection(builder.ConnectionString);
adminConnection.Open();
using (var terminateConnections = adminConnection.CreateCommand())
{
terminateConnections.CommandText =
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()";
terminateConnections.Parameters.AddWithValue(DatabaseName);
terminateConnections.ExecuteNonQuery();
}
using var dropDatabase = adminConnection.CreateCommand();
dropDatabase.CommandText = $"DROP DATABASE IF EXISTS {QuoteIdentifier(DatabaseName)}";
dropDatabase.ExecuteNonQuery();
}
internal static string QuoteIdentifier(string identifier)
{
if (!identifier.StartsWith(DatabasePrefix, StringComparison.Ordinal) ||
identifier.Any(character => !char.IsAsciiLetterOrDigit(character) && character != '_'))
{
throw new InvalidOperationException("Refusing to use an unsafe integration-test database name.");
}
return $"\"{identifier}\"";
}
}
internal sealed class PostgresTestDatabaseTemplate : IDisposable
{
private const string TemplatePrefix = "tiku_it_template_";
private bool disposed;
private PostgresTestDatabaseTemplate(string adminConnectionString, string databaseName)
{
AdminConnectionString = adminConnectionString;
DatabaseName = databaseName;
}
public string AdminConnectionString { get; }
public string DatabaseName { get; }
public static PostgresTestDatabaseTemplate Create()
{
var adminConnectionString = Environment.GetEnvironmentVariable("TIKU_TEST_POSTGRES_ADMIN") ??
$"Host=localhost;Database=postgres;Username={Environment.UserName};Pooling=false;Timeout=5;Command Timeout=60";
var adminBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString);
if (!string.Equals(adminBuilder.Database, "postgres", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"TIKU_TEST_POSTGRES_ADMIN must target the postgres maintenance database.");
}
var databaseName = $"{TemplatePrefix}{Environment.ProcessId}_{Guid.NewGuid():N}";
var template = new PostgresTestDatabaseTemplate(adminBuilder.ConnectionString, databaseName);
try
{
using (var adminConnection = new NpgsqlConnection(template.AdminConnectionString))
{
adminConnection.Open();
using var createDatabase = adminConnection.CreateCommand();
createDatabase.CommandText =
$"CREATE DATABASE {PostgresTestDatabase.QuoteIdentifier(databaseName)}";
createDatabase.ExecuteNonQuery();
}
var databaseBuilder = new NpgsqlConnectionStringBuilder(template.AdminConnectionString)
{
Database = databaseName,
Pooling = false
};
var options = new DbContextOptionsBuilder<TikuDbContext>()
.UseNpgsql(databaseBuilder.ConnectionString, npgsql =>
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName))
.Options;
using (var dbContext = new TikuDbContext(options))
{
dbContext.Database.Migrate();
}
using (var adminConnection = new NpgsqlConnection(template.AdminConnectionString))
{
adminConnection.Open();
using var preventConnections = adminConnection.CreateCommand();
preventConnections.CommandText =
$"ALTER DATABASE {PostgresTestDatabase.QuoteIdentifier(databaseName)} ALLOW_CONNECTIONS false";
preventConnections.ExecuteNonQuery();
}
AppDomain.CurrentDomain.ProcessExit += (_, _) => template.Dispose();
return template;
}
catch
{
template.Dispose();
throw;
}
}
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
using var adminConnection = new NpgsqlConnection(AdminConnectionString);
adminConnection.Open();
using var dropDatabase = adminConnection.CreateCommand();
dropDatabase.CommandText =
$"DROP DATABASE IF EXISTS {PostgresTestDatabase.QuoteIdentifier(DatabaseName)}";
dropDatabase.ExecuteNonQuery();
}
}

View File

@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.IntegrationTests.Api;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests;
public sealed class MigrationExecutionTests
{
[Fact]
public async Task Api_test_database_uses_postgresql_and_applies_every_migration()
{
await using var factory = new ApiTestFactory();
using var scope = factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync();
var expectedMigrations = dbContext.Database.GetMigrations();
Assert.Equal("Npgsql.EntityFrameworkCore.PostgreSQL", dbContext.Database.ProviderName);
Assert.Equal(expectedMigrations, appliedMigrations);
Assert.NotEmpty(appliedMigrations);
Assert.True(await dbContext.Database.CanConnectAsync());
}
}

View File

@@ -513,7 +513,6 @@ public sealed class PersistenceModelTests
[Theory]
[InlineData(typeof(TenantAuthProvider), nameof(TenantAuthProvider.ConfigPublic), "'{}'::jsonb")]
[InlineData(typeof(TenantSecret), nameof(TenantSecret.SecretPayload), "'{}'::jsonb")]
[InlineData(typeof(SmsVerificationCode), nameof(SmsVerificationCode.Metadata), "'{}'::jsonb")]
[InlineData(typeof(AuthLoginEvent), nameof(AuthLoginEvent.Metadata), "'{}'::jsonb")]
[InlineData(typeof(AuthSession), nameof(AuthSession.Metadata), "'{}'::jsonb")]

View File

@@ -23,7 +23,6 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">

View File

@@ -6,6 +6,7 @@
- [`docs/adr/0001-authoritative-dotnet-backend.md`](adr/0001-authoritative-dotnet-backend.md)
- [`docs/migration/phase-1-repository-baseline.md`](migration/phase-1-repository-baseline.md)
- [`docs/migration/phase-2-engineering-foundation.md`](migration/phase-2-engineering-foundation.md)
迁移原则:

View File

@@ -0,0 +1,87 @@
# 第二阶段:.NET 工程底座
完成日期2026-07-27。
本阶段按 greenfield 项目建设,不承担旧 Supabase / NestJS 数据兼容。数据库结构以 EF Core 实体、Fluent Configuration 和 Migration 为唯一权威来源API 运行时不自动修改数据库。
## 数据库开发与发布边界
- 开发采用 code first修改实体或 Fluent Configuration 后生成 EF Core Migration。
- Migration 统一保存在 `Tiku.Infrastructure/Persistence/Migrations/`
- `Tiku.DbMigrator` 是执行 Migration 的独立入口。
- `Tiku.Api``Tiku.Worker` 不调用 `Database.Migrate()`,避免多实例启动时争抢 DDL也避免应用进程持有结构变更权限。
- 发布前生成并审查 SQL生产环境由部署流程显式执行 DbMigrator 或审核后的 SQL。
- Development 未配置连接串时默认连接本机 `tiku` 数据库,并使用当前系统用户名,不内置密码。
- 非 Development 环境必须显式配置 `ConnectionStrings:Database``DATABASE_URL`,缺失时启动失败。
常用命令:
```bash
dotnet ef migrations add <MigrationName> \
--project Tiku.Infrastructure \
--startup-project Tiku.DbMigrator
dotnet ef migrations script \
--project Tiku.Infrastructure \
--startup-project Tiku.DbMigrator
dotnet run --project Tiku.DbMigrator
```
## 真实 PostgreSQL 测试基线
`Tiku.IntegrationTests` 已移除 EF Core InMemory provider全部集成测试连接真实 PostgreSQL
- 管理连接可通过 `TIKU_TEST_POSTGRES_ADMIN` 覆盖。
- 默认管理连接为 `Host=localhost;Database=postgres;Username=<当前系统用户>`
- 测试进程创建一次已执行完整 Migration 的模板数据库。
- 每个 `ApiTestFactory` 从模板克隆独立 `tiku_it_*` 数据库,测试之间不共享业务数据。
- Factory 释放时终止连接并删除克隆库;进程退出时删除模板库。
- 测试只允许管理 `tiku_it_*` 命名空间,不访问或清理已有 `tiku` 业务数据库。
真实关系型测试暴露并修正了以下 InMemory 无法可靠验证的问题:
- 测试夹具缺失的 Region、School、Subject、Category、Question 等外键实体。
- `Question``QuestionVersion` 当前版本引用形成的插入循环,改为事务内分阶段保存。
- `ReferralLead.FirstTrackId``ReferralTrack.LeadId` 形成的插入循环,改为事务内分阶段保存。
- PostgreSQL JSON 映射不能将未定义的 `default(JsonElement)` 生成为 SQL literal缺省数组改为有效的 `[]`
- 徽章发放不再伪造超长 legacy ID通知使用新记录 ID 生成独立、稳定的去重键。
## 配置与密钥安全
- 根配置不再提交可用于生产的 JWT signing key。
- Development 只使用明确标识的开发 JWT keyProduction 拒绝该 key。
- 租户第三方凭据使用 AES-256-GCM envelope 保存。
- 每条密文使用 12-byte nonce、16-byte authentication tag。
- AAD 绑定 `tenantId``secretRef``keyId`,密文不能跨租户或跨引用替换。
- 主密钥必须是 Base64 编码的 32 字节值Production 拒绝仓库内的开发测试 key。
- 运行时配置键为 `Security:TenantSecrets:KeyId``Security:TenantSecrets:MasterKey`;环境变量可使用 `TIKU_TENANT_SECRET_KEY_ID``TIKU_TENANT_SECRET_MASTER_KEY`
- 新 schema 只保留 `encryption_key_id``encrypted_payload``encryption_nonce``encryption_tag`,不保留明文字段或明文回退读取路径。
本阶段的 `EncryptTenantSecretPayloads` Migration 会直接删除旧 `secret_payload` 字段。这是 greenfield 决策,不提供旧数据转换或兼容窗口。
## 验证结果
2026-07-27 在本机 PostgreSQL 18.4、.NET SDK 10.0.301 上完成:
```text
dotnet build通过0 warning / 0 error
dotnet test通过265/265
UnitTests16/16
IntegrationTests真实 PostgreSQL249/249
dotnet format --verify-no-changes通过
git diff --check通过
EF Core migration script通过生成 4184 行 SQL
测试数据库清理:通过,无 tiku_it_* 残留
```
## 第二阶段退出条件
- [x] EF Core Migration 成为 code-first schema 的唯一版本历史。
- [x] API 与 Worker 不在启动时自动执行 Migration。
- [x] 生产数据库、JWT 和租户密钥配置缺失时 fail fast。
- [x] 租户第三方凭据只保存 AES-GCM 密文 envelope。
- [x] 集成测试完全切换到隔离的真实 PostgreSQL 数据库。
- [x] 修复真实数据库暴露的外键、循环依赖、JSON 和字段长度问题。
- [x] 全量构建、测试、格式与 Migration SQL 验证通过。