forked from xiongyuxing/tiku-backend.net
feat: establish dotnet engineering foundation
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "*"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
116
Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs
Normal file
116
Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs
Normal 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}");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <> ''"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16434
Tiku.Infrastructure/Persistence/Migrations/20260727062859_EncryptTenantSecretPayloads.Designer.cs
generated
Normal file
16434
Tiku.Infrastructure/Persistence/Migrations/20260727062859_EncryptTenantSecretPayloads.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
|
||||
61
Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs
Normal file
61
Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs
Normal 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])
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
182
Tiku.IntegrationTests/Infrastructure/PostgresTestDatabase.cs
Normal file
182
Tiku.IntegrationTests/Infrastructure/PostgresTestDatabase.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
25
Tiku.IntegrationTests/MigrationExecutionTests.cs
Normal file
25
Tiku.IntegrationTests/MigrationExecutionTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
迁移原则:
|
||||
|
||||
|
||||
87
docs/migration/phase-2-engineering-foundation.md
Normal file
87
docs/migration/phase-2-engineering-foundation.md
Normal 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 key;Production 拒绝该 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
|
||||
UnitTests:16/16
|
||||
IntegrationTests(真实 PostgreSQL):249/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 验证通过。
|
||||
|
||||
Reference in New Issue
Block a user