forked from gongxuegit/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;
|
namespace Tiku.Api.Options;
|
||||||
|
|
||||||
public static class OptionsValidation
|
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)
|
public static bool BeValidCorsOptions(CorsOptions options)
|
||||||
{
|
{
|
||||||
if (options.AllowCredentials && options.AllowedOrigins.Length == 0)
|
if (options.AllowCredentials && options.AllowedOrigins.Length == 0)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ using Tiku.Api.Security;
|
|||||||
using Tiku.Application;
|
using Tiku.Application;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Infrastructure;
|
using Tiku.Infrastructure;
|
||||||
|
using Tiku.Infrastructure.Commerce;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
using Tiku.Infrastructure.Storage;
|
using Tiku.Infrastructure.Storage;
|
||||||
|
|
||||||
@@ -133,10 +134,9 @@ try
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var connectionString =
|
var connectionString = OptionsValidation.ResolveDatabaseConnectionString(
|
||||||
builder.Configuration.GetConnectionString("Database") ??
|
builder.Configuration,
|
||||||
builder.Configuration["DATABASE_URL"] ??
|
builder.Environment.IsDevelopment());
|
||||||
"Host=localhost;Database=tiku;Username=postgres";
|
|
||||||
|
|
||||||
builder.Services.AddInfrastructure(connectionString);
|
builder.Services.AddInfrastructure(connectionString);
|
||||||
builder.Services.Configure<ObjectStorageOptions>(
|
builder.Services.Configure<ObjectStorageOptions>(
|
||||||
@@ -172,10 +172,28 @@ try
|
|||||||
? useInternalEndpoint
|
? useInternalEndpoint
|
||||||
: options.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>()
|
builder.Services.AddOptions<JwtOptions>()
|
||||||
.Bind(builder.Configuration.GetSection("Security:Jwt"))
|
.Bind(builder.Configuration.GetSection("Security:Jwt"))
|
||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
|
.Validate(
|
||||||
|
options => OptionsValidation.BeValidJwtOptions(options, builder.Environment.IsProduction()),
|
||||||
|
"Production JWT signing key must be explicitly configured and cannot use the development key.")
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
var jwtOptions = builder.Configuration
|
var jwtOptions = builder.Configuration
|
||||||
.GetSection("Security:Jwt")
|
.GetSection("Security:Jwt")
|
||||||
|
|||||||
@@ -24,6 +24,15 @@
|
|||||||
"WindowSeconds": 60,
|
"WindowSeconds": 60,
|
||||||
"QueueLimit": 0
|
"QueueLimit": 0
|
||||||
},
|
},
|
||||||
|
"Security": {
|
||||||
|
"Jwt": {
|
||||||
|
"SigningKey": "development-only-tiku-signing-key-change-before-production"
|
||||||
|
},
|
||||||
|
"TenantSecrets": {
|
||||||
|
"KeyId": "development-v1",
|
||||||
|
"MasterKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||||
|
}
|
||||||
|
},
|
||||||
"Storage": {
|
"Storage": {
|
||||||
"AliyunOss": {
|
"AliyunOss": {
|
||||||
"Region": "cn-hangzhou",
|
"Region": "cn-hangzhou",
|
||||||
|
|||||||
@@ -84,10 +84,14 @@
|
|||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Issuer": "tiku-backend",
|
"Issuer": "tiku-backend",
|
||||||
"Audience": "tiku-api",
|
"Audience": "tiku-api",
|
||||||
"SigningKey": "development-only-tiku-signing-key-change-before-production",
|
"SigningKey": "",
|
||||||
"AccessTokenMinutes": 30,
|
"AccessTokenMinutes": 30,
|
||||||
"RefreshTokenDays": 30,
|
"RefreshTokenDays": 30,
|
||||||
"ValidateSessions": true
|
"ValidateSessions": true
|
||||||
|
},
|
||||||
|
"TenantSecrets": {
|
||||||
|
"KeyId": "",
|
||||||
|
"MasterKey": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public sealed class JwtOptions
|
|||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Required]
|
[System.ComponentModel.DataAnnotations.Required]
|
||||||
[System.ComponentModel.DataAnnotations.MinLength(32)]
|
[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)]
|
[System.ComponentModel.DataAnnotations.Range(1, 1440)]
|
||||||
public int AccessTokenMinutes { get; set; } = 30;
|
public int AccessTokenMinutes { get; set; } = 30;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public sealed class DesignTimeTikuDbContextFactory : IDesignTimeDbContextFactory
|
|||||||
{
|
{
|
||||||
var connectionString =
|
var connectionString =
|
||||||
Environment.GetEnvironmentVariable("DATABASE_URL") ??
|
Environment.GetEnvironmentVariable("DATABASE_URL") ??
|
||||||
"Host=localhost;Database=tiku;Username=postgres";
|
$"Host=localhost;Database=tiku;Username={Environment.UserName}";
|
||||||
|
|
||||||
var options = new DbContextOptionsBuilder<TikuDbContext>()
|
var options = new DbContextOptionsBuilder<TikuDbContext>()
|
||||||
.UseNpgsql(connectionString, npgsql =>
|
.UseNpgsql(connectionString, npgsql =>
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ public sealed class TenantSecret : AuditableTenantEntity
|
|||||||
public string SecretKey { get; set; } = string.Empty;
|
public string SecretKey { get; set; } = string.Empty;
|
||||||
public string SecretRef { get; set; } = string.Empty;
|
public string SecretRef { get; set; } = string.Empty;
|
||||||
public TenantSecretStatus Status { get; set; } = TenantSecretStatus.Active;
|
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? RotatedAt { get; set; }
|
||||||
public DateTimeOffset? ExpiresAt { get; set; }
|
public DateTimeOffset? ExpiresAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ using Tiku.Infrastructure.Persistence;
|
|||||||
|
|
||||||
namespace Tiku.Infrastructure.Commerce;
|
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(
|
public async Task<IReadOnlyCollection<TenantPaymentAccountItem>> GetPaymentAccountsAsync(
|
||||||
CommerceAdminActor actor,
|
CommerceAdminActor actor,
|
||||||
@@ -88,11 +90,19 @@ public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdm
|
|||||||
secret.RotatedAt = DateTimeOffset.UtcNow;
|
secret.RotatedAt = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var protectedPayload = tenantSecretProtector.Protect(
|
||||||
|
actor.TenantId,
|
||||||
|
secretRef,
|
||||||
|
JsonObjectOrDefault(command.SecretPayload));
|
||||||
|
|
||||||
secret.Purpose = command.Purpose.Trim();
|
secret.Purpose = command.Purpose.Trim();
|
||||||
secret.Provider = provider;
|
secret.Provider = provider;
|
||||||
secret.SecretKey = command.SecretKey.Trim();
|
secret.SecretKey = command.SecretKey.Trim();
|
||||||
secret.Status = command.Status;
|
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;
|
secret.ExpiresAt = command.ExpiresAt;
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
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;
|
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(
|
public async Task<JsonElement> GetActiveSecretPayloadAsync(
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
@@ -28,16 +30,28 @@ internal sealed class TenantSecretService(TikuDbContext dbContext) : ITenantSecr
|
|||||||
item.SecretRef == secretRef &&
|
item.SecretRef == secretRef &&
|
||||||
item.Status == TenantSecretStatus.Active &&
|
item.Status == TenantSecretStatus.Active &&
|
||||||
(item.ExpiresAt == null || item.ExpiresAt > now))
|
(item.ExpiresAt == null || item.ExpiresAt > now))
|
||||||
.Select(item => item.SecretPayload)
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (secret.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
if (secret is null)
|
||||||
{
|
{
|
||||||
throw new PaymentProviderException(
|
throw new PaymentProviderException(
|
||||||
"Payment provider secret is not configured.",
|
"Payment provider secret is not configured.",
|
||||||
"payment_secret_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 Microsoft.EntityFrameworkCore;
|
||||||
using Tiku.Application.Catalog;
|
using Tiku.Application.Catalog;
|
||||||
using Tiku.Application.Content;
|
using Tiku.Application.Content;
|
||||||
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Content;
|
using Tiku.Domain.Content;
|
||||||
using Tiku.Domain.QuestionBanks;
|
using Tiku.Domain.QuestionBanks;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
@@ -279,6 +280,9 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
|||||||
throw new ContentNavigationNotFoundException("Question collection was not found.");
|
throw new ContentNavigationNotFoundException("Question collection was not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var emptyOptions = JsonDefaults.Array();
|
||||||
|
var emptyCorrectOptionIndices = JsonDefaults.Array();
|
||||||
|
var emptySubQuestions = JsonDefaults.Array();
|
||||||
var query =
|
var query =
|
||||||
from item in dbContext.QuestionCollectionItems.AsNoTracking()
|
from item in dbContext.QuestionCollectionItems.AsNoTracking()
|
||||||
join question in dbContext.Questions.AsNoTracking()
|
join question in dbContext.Questions.AsNoTracking()
|
||||||
@@ -313,12 +317,12 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
|||||||
item.SortOrder,
|
item.SortOrder,
|
||||||
version == null ? null : version.Id,
|
version == null ? null : version.Id,
|
||||||
version == null ? null : version.Content,
|
version == null ? null : version.Content,
|
||||||
version == null ? default : version.Options,
|
version == null ? emptyOptions : version.Options,
|
||||||
version == null ? null : version.CorrectOptionIndex,
|
version == null ? null : version.CorrectOptionIndex,
|
||||||
version == null ? default : version.CorrectOptionIndices,
|
version == null ? emptyCorrectOptionIndices : version.CorrectOptionIndices,
|
||||||
version == null ? null : version.AnswerText,
|
version == null ? null : version.AnswerText,
|
||||||
version == null ? null : version.Explanation,
|
version == null ? null : version.Explanation,
|
||||||
version == null ? default : version.SubQuestions,
|
version == null ? emptySubQuestions : version.SubQuestions,
|
||||||
version == null ? null : version.CodeLang,
|
version == null ? null : version.CodeLang,
|
||||||
version == null ? null : version.CodeTemplate);
|
version == null ? null : version.CodeTemplate);
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken);
|
await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken);
|
||||||
|
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||||
|
|
||||||
var question = new Question
|
var question = new Question
|
||||||
{
|
{
|
||||||
@@ -42,6 +43,7 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
|||||||
};
|
};
|
||||||
ApplyQuestion(question, command);
|
ApplyQuestion(question, command);
|
||||||
dbContext.Questions.Add(question);
|
dbContext.Questions.Add(question);
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
var version = BuildQuestionVersion(actor, question.Id, 1, command);
|
var version = BuildQuestionVersion(actor, question.Id, 1, command);
|
||||||
dbContext.QuestionVersions.Add(version);
|
dbContext.QuestionVersions.Add(version);
|
||||||
@@ -49,6 +51,7 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
|||||||
await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken);
|
await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken);
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
return new ContentManagementResult<QuestionManagementItem>(ToQuestionItem(question, version));
|
return new ContentManagementResult<QuestionManagementItem>(ToQuestionItem(question, version));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IReferralService, ReferralService>();
|
services.AddScoped<IReferralService, ReferralService>();
|
||||||
services.AddScoped<ICrmService, CrmService>();
|
services.AddScoped<ICrmService, CrmService>();
|
||||||
services.AddScoped<ICommissionService, CommissionService>();
|
services.AddScoped<ICommissionService, CommissionService>();
|
||||||
|
services.AddSingleton<ITenantSecretProtector, TenantSecretProtector>();
|
||||||
services.AddScoped<ITenantSecretService, TenantSecretService>();
|
services.AddScoped<ITenantSecretService, TenantSecretService>();
|
||||||
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();
|
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();
|
||||||
services.AddScoped<IPaymentProviderGateway, PaymentProviderGateway>();
|
services.AddScoped<IPaymentProviderGateway, PaymentProviderGateway>();
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ using Tiku.Application.Growth;
|
|||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Growth;
|
using Tiku.Domain.Growth;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Commerce;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure.Growth;
|
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)
|
private static readonly HashSet<string> SensitiveKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
@@ -254,8 +257,15 @@ public sealed class CrmService(TikuDbContext dbContext) : ICrmService
|
|||||||
item.RotatedAt = DateTimeOffset.UtcNow;
|
item.RotatedAt = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var protectedPayload = tenantSecretProtector.Protect(
|
||||||
|
tenantId,
|
||||||
|
secretRef,
|
||||||
|
JsonSerializer.SerializeToElement(new { webhookSecret = secret }));
|
||||||
item.Status = TenantSecretStatus.Active;
|
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)
|
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 eventType = NormalizeChoice(command.EventType, AllowedEventTypes, "enter", "invalid_referral_event_type");
|
||||||
var source = NormalizeChoice(command.Source, AllowedSources, "unknown", "invalid_referral_source");
|
var source = NormalizeChoice(command.Source, AllowedSources, "unknown", "invalid_referral_source");
|
||||||
var resolution = await ResolveCodeCoreAsync(actor.TenantId, code, cancellationToken);
|
var resolution = await ResolveCodeCoreAsync(actor.TenantId, code, cancellationToken);
|
||||||
|
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||||
var track = new ReferralTrack
|
var track = new ReferralTrack
|
||||||
{
|
{
|
||||||
TenantId = actor.TenantId,
|
TenantId = actor.TenantId,
|
||||||
@@ -148,7 +149,6 @@ public sealed class ReferralService(
|
|||||||
UserAgent = Truncate(NormalizeOptional(userAgent), 1024),
|
UserAgent = Truncate(NormalizeOptional(userAgent), 1024),
|
||||||
Metadata = command.Metadata ?? JsonDefaults.Object()
|
Metadata = command.Metadata ?? JsonDefaults.Object()
|
||||||
};
|
};
|
||||||
dbContext.ReferralTracks.Add(track);
|
|
||||||
|
|
||||||
ReferralLead? lead = null;
|
ReferralLead? lead = null;
|
||||||
CrmWebhookQueueItem? crmQueue = null;
|
CrmWebhookQueueItem? crmQueue = null;
|
||||||
@@ -164,16 +164,29 @@ public sealed class ReferralService(
|
|||||||
false,
|
false,
|
||||||
command.Metadata,
|
command.Metadata,
|
||||||
cancellationToken);
|
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;
|
lead.FirstTrackId = track.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
track.LeadId = lead.Id;
|
|
||||||
crmQueue = await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.track_event", cancellationToken);
|
crmQueue = await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.track_event", cancellationToken);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dbContext.ReferralTracks.Add(track);
|
||||||
|
}
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
return new ReferralTrackResult(ToTrackItem(track), lead is null ? null : ToLeadItem(lead, true), ToQueuePreview(crmQueue));
|
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.SecretKey).HasMaxLength(120);
|
||||||
builder.Property(entity => entity.SecretRef).HasMaxLength(300);
|
builder.Property(entity => entity.SecretRef).HasMaxLength(300);
|
||||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
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.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.SecretKey }).IsUnique();
|
||||||
builder.HasIndex(entity => new { entity.TenantId, entity.Purpose, entity.Provider, entity.Status });
|
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")
|
.HasColumnName("created_at")
|
||||||
.HasDefaultValueSql("now()");
|
.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")
|
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("expires_at");
|
.HasColumnName("expires_at");
|
||||||
@@ -12984,12 +13005,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("character varying(120)")
|
.HasColumnType("character varying(120)")
|
||||||
.HasColumnName("secret_key");
|
.HasColumnName("secret_key");
|
||||||
|
|
||||||
b.Property<JsonElement>("SecretPayload")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("jsonb")
|
|
||||||
.HasColumnName("secret_payload")
|
|
||||||
.HasDefaultValueSql("'{}'::jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("SecretRef")
|
b.Property<string>("SecretRef")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(300)
|
.HasMaxLength(300)
|
||||||
@@ -13029,7 +13044,10 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.HasIndex("TenantId", "Purpose", "Provider", "Status")
|
b.HasIndex("TenantId", "Purpose", "Provider", "Status")
|
||||||
.HasDatabaseName("ix_tenant_secrets_tenant_id_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 =>
|
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSettings", b =>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Tiku.Application.Catalog;
|
using Tiku.Application.Catalog;
|
||||||
using Tiku.Application.QuestionBanks;
|
using Tiku.Application.QuestionBanks;
|
||||||
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Content;
|
using Tiku.Domain.Content;
|
||||||
using Tiku.Domain.QuestionBanks;
|
using Tiku.Domain.QuestionBanks;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
@@ -222,6 +223,9 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
|
|||||||
|
|
||||||
private IQueryable<QuestionCatalogItem> ProjectQuestions(IQueryable<Question> questions)
|
private IQueryable<QuestionCatalogItem> ProjectQuestions(IQueryable<Question> questions)
|
||||||
{
|
{
|
||||||
|
var emptyOptions = JsonDefaults.Array();
|
||||||
|
var emptyCorrectOptionIndices = JsonDefaults.Array();
|
||||||
|
var emptySubQuestions = JsonDefaults.Array();
|
||||||
return
|
return
|
||||||
from question in questions
|
from question in questions
|
||||||
join version in dbContext.QuestionVersions.AsNoTracking()
|
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.Id,
|
||||||
version == null ? null : version.VersionNo,
|
version == null ? null : version.VersionNo,
|
||||||
version == null ? null : version.Content,
|
version == null ? null : version.Content,
|
||||||
version == null ? default : version.Options,
|
version == null ? emptyOptions : version.Options,
|
||||||
version == null ? null : version.CorrectOptionIndex,
|
version == null ? null : version.CorrectOptionIndex,
|
||||||
version == null ? default : version.CorrectOptionIndices,
|
version == null ? emptyCorrectOptionIndices : version.CorrectOptionIndices,
|
||||||
version == null ? null : version.AnswerText,
|
version == null ? null : version.AnswerText,
|
||||||
version == null ? null : version.Explanation,
|
version == null ? null : version.Explanation,
|
||||||
version == null ? default : version.SubQuestions,
|
version == null ? emptySubQuestions : version.SubQuestions,
|
||||||
version == null ? null : version.CodeLang,
|
version == null ? null : version.CodeLang,
|
||||||
version == null ? null : version.CodeTemplate);
|
version == null ? null : version.CodeTemplate);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1263,7 +1263,7 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
|
|||||||
GrantedBy = actor.UserId,
|
GrantedBy = actor.UserId,
|
||||||
GrantedAt = command.GrantedAt ?? DateTimeOffset.UtcNow
|
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.Note = Normalize(command.Note) ?? grant.Note;
|
||||||
grant.GrantedBy ??= actor.UserId;
|
grant.GrantedBy ??= actor.UserId;
|
||||||
grant.GrantedAt ??= command.GrantedAt ?? DateTimeOffset.UtcNow;
|
grant.GrantedAt ??= command.GrantedAt ?? DateTimeOffset.UtcNow;
|
||||||
@@ -1272,19 +1272,20 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
|
|||||||
dbContext.UserBadges.Add(grant);
|
dbContext.UserBadges.Add(grant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var dedupeKey = $"badge:{grant.Id:N}";
|
||||||
var notification = await dbContext.UserNotifications.FirstOrDefaultAsync(
|
var notification = await dbContext.UserNotifications.FirstOrDefaultAsync(
|
||||||
item =>
|
item =>
|
||||||
item.TenantId == actor.TenantId &&
|
item.TenantId == actor.TenantId &&
|
||||||
item.UserId == command.UserId &&
|
item.UserId == command.UserId &&
|
||||||
item.NotificationType == "badge_granted" &&
|
item.NotificationType == "badge_granted" &&
|
||||||
item.DedupeKey == grant.LegacyId,
|
item.DedupeKey == dedupeKey,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
notification ??= new UserNotification
|
notification ??= new UserNotification
|
||||||
{
|
{
|
||||||
TenantId = actor.TenantId,
|
TenantId = actor.TenantId,
|
||||||
UserId = command.UserId,
|
UserId = command.UserId,
|
||||||
NotificationType = "badge_granted",
|
NotificationType = "badge_granted",
|
||||||
DedupeKey = grant.LegacyId,
|
DedupeKey = dedupeKey,
|
||||||
CreatedBy = actor.UserId
|
CreatedBy = actor.UserId
|
||||||
};
|
};
|
||||||
notification.Severity = NotificationSeverity.Success;
|
notification.Severity = NotificationSeverity.Success;
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ using Tiku.Application.Auth;
|
|||||||
using Tiku.Application.Growth;
|
using Tiku.Application.Growth;
|
||||||
using Tiku.Application.Storage;
|
using Tiku.Application.Storage;
|
||||||
using Tiku.Domain.Identity;
|
using Tiku.Domain.Identity;
|
||||||
|
using Tiku.Domain.QuestionBanks;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.IntegrationTests.Infrastructure;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace Tiku.IntegrationTests.Api;
|
namespace Tiku.IntegrationTests.Api;
|
||||||
@@ -18,7 +20,7 @@ public sealed class ApiTestFactory(
|
|||||||
IReferralQrcodeGenerator? referralQrcodeGenerator = null,
|
IReferralQrcodeGenerator? referralQrcodeGenerator = null,
|
||||||
IPaymentProviderGateway? paymentProviderGateway = null) : WebApplicationFactory<Program>
|
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)
|
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
@@ -34,8 +36,13 @@ public sealed class ApiTestFactory(
|
|||||||
services.Remove(descriptor);
|
services.Remove(descriptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
services.AddDbContext<TikuDbContext>(options =>
|
services.AddSingleton(_ => NpgsqlDataSource.Create(database.ConnectionString));
|
||||||
options.UseInMemoryDatabase(databaseName));
|
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)
|
if (wechatOAuthClient is not null)
|
||||||
{
|
{
|
||||||
@@ -67,6 +74,20 @@ public sealed class ApiTestFactory(
|
|||||||
await dbContext.SaveChangesAsync();
|
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(
|
public async Task<Guid> SeedActiveSessionAsync(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
Guid? tenantId = null,
|
Guid? tenantId = null,
|
||||||
@@ -102,4 +123,13 @@ public sealed class ApiTestFactory(
|
|||||||
.Select(session => session.Id)
|
.Select(session => session.Id)
|
||||||
.SingleAsync();
|
.SingleAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
base.Dispose(disposing);
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
database.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
|
using Tiku.Domain.Catalog;
|
||||||
using Tiku.Domain.Content;
|
using Tiku.Domain.Content;
|
||||||
|
using Tiku.Domain.QuestionBanks;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
|
|
||||||
namespace Tiku.IntegrationTests.Api;
|
namespace Tiku.IntegrationTests.Api;
|
||||||
@@ -16,6 +18,7 @@ public sealed class AssetEndpointTests
|
|||||||
await using var factory = new ApiTestFactory();
|
await using var factory = new ApiTestFactory();
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
Tenant(tenantId, "master"),
|
Tenant(tenantId, "master"),
|
||||||
|
new Region { Id = regionId, TenantId = tenantId, Name = "测试地区" },
|
||||||
new ContentAsset
|
new ContentAsset
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -162,6 +165,13 @@ public sealed class AssetEndpointTests
|
|||||||
await using var factory = new ApiTestFactory();
|
await using var factory = new ApiTestFactory();
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
Tenant(tenantId, "master"),
|
Tenant(tenantId, "master"),
|
||||||
|
new Question
|
||||||
|
{
|
||||||
|
Id = questionId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Type = "choice",
|
||||||
|
Status = QuestionStatus.Published
|
||||||
|
},
|
||||||
new VideoExplanation
|
new VideoExplanation
|
||||||
{
|
{
|
||||||
Id = videoId,
|
Id = videoId,
|
||||||
|
|||||||
@@ -299,12 +299,16 @@ public sealed class CatalogEndpointTests
|
|||||||
var tenantId = Guid.NewGuid();
|
var tenantId = Guid.NewGuid();
|
||||||
var regionId = Guid.NewGuid();
|
var regionId = Guid.NewGuid();
|
||||||
var schoolId = 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);
|
var futureExamAt = new DateTimeOffset(DateTimeOffset.UtcNow.Date.AddDays(10), TimeSpan.Zero);
|
||||||
await using var factory = new ApiTestFactory();
|
await using var factory = new ApiTestFactory();
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
Tenant(tenantId, "master"),
|
Tenant(tenantId, "master"),
|
||||||
new Region { Id = regionId, TenantId = tenantId, Name = "浙江" },
|
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 = schoolId, TenantId = tenantId, RegionId = regionId, Name = "测试院校" },
|
||||||
|
new School { Id = otherSchoolId, TenantId = tenantId, RegionId = otherRegionId, Name = "其他院校" },
|
||||||
new ExamDate
|
new ExamDate
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -327,8 +331,8 @@ public sealed class CatalogEndpointTests
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TenantId = tenantId,
|
TenantId = tenantId,
|
||||||
RegionId = Guid.NewGuid(),
|
RegionId = otherRegionId,
|
||||||
SchoolId = Guid.NewGuid(),
|
SchoolId = otherSchoolId,
|
||||||
ExamName = "其他地区学校考试",
|
ExamName = "其他地区学校考试",
|
||||||
ExamAt = futureExamAt.AddDays(2),
|
ExamAt = futureExamAt.AddDays(2),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
@@ -420,10 +424,12 @@ public sealed class CatalogEndpointTests
|
|||||||
{
|
{
|
||||||
var tenantId = Guid.NewGuid();
|
var tenantId = Guid.NewGuid();
|
||||||
var regionId = Guid.NewGuid();
|
var regionId = Guid.NewGuid();
|
||||||
|
var otherRegionId = Guid.NewGuid();
|
||||||
await using var factory = new ApiTestFactory();
|
await using var factory = new ApiTestFactory();
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
Tenant(tenantId, "master"),
|
Tenant(tenantId, "master"),
|
||||||
new Region { Id = regionId, TenantId = tenantId, Name = "浙江" },
|
new Region { Id = regionId, TenantId = tenantId, Name = "浙江" },
|
||||||
|
new Region { Id = otherRegionId, TenantId = tenantId, Name = "其他地区" },
|
||||||
new SvipPlan
|
new SvipPlan
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -449,7 +455,7 @@ public sealed class CatalogEndpointTests
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TenantId = tenantId,
|
TenantId = tenantId,
|
||||||
RegionId = Guid.NewGuid(),
|
RegionId = otherRegionId,
|
||||||
Name = "其他地区卡",
|
Name = "其他地区卡",
|
||||||
PriceCents = 1000,
|
PriceCents = 1000,
|
||||||
Days = 7,
|
Days = 7,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
|
using Tiku.Domain.Catalog;
|
||||||
using Tiku.Domain.Content;
|
using Tiku.Domain.Content;
|
||||||
using Tiku.Domain.QuestionBanks;
|
using Tiku.Domain.QuestionBanks;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
@@ -17,6 +18,7 @@ public sealed class ContentNavigationEndpointTests
|
|||||||
await using var factory = new ApiTestFactory();
|
await using var factory = new ApiTestFactory();
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
Tenant(tenantId, "master"),
|
Tenant(tenantId, "master"),
|
||||||
|
new Region { Id = regionId, TenantId = tenantId, Name = "测试地区" },
|
||||||
new ContentEntry
|
new ContentEntry
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -193,23 +195,6 @@ public sealed class ContentNavigationEndpointTests
|
|||||||
Status = ContentStatus.Active
|
Status = ContentStatus.Active
|
||||||
},
|
},
|
||||||
new Question
|
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,
|
Id = archivedQuestionId,
|
||||||
TenantId = tenantId,
|
TenantId = tenantId,
|
||||||
@@ -218,15 +203,6 @@ public sealed class ContentNavigationEndpointTests
|
|||||||
Status = QuestionStatus.Archived
|
Status = QuestionStatus.Archived
|
||||||
},
|
},
|
||||||
new QuestionCollectionItem
|
new QuestionCollectionItem
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
TenantId = tenantId,
|
|
||||||
CollectionId = collectionId,
|
|
||||||
QuestionId = publishedQuestionId,
|
|
||||||
SortOrder = 1,
|
|
||||||
Score = 2
|
|
||||||
},
|
|
||||||
new QuestionCollectionItem
|
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TenantId = tenantId,
|
TenantId = tenantId,
|
||||||
@@ -234,6 +210,32 @@ public sealed class ContentNavigationEndpointTests
|
|||||||
QuestionId = archivedQuestionId,
|
QuestionId = archivedQuestionId,
|
||||||
SortOrder = 2
|
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 client = factory.CreateClient();
|
||||||
|
|
||||||
using var response = await client.GetAsync($"/api/catalog/question-collections/questions?tenantCode=master&collectionId={collectionId}");
|
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 seed = await SeedStudentAsync(factory);
|
||||||
var regionId = Guid.NewGuid();
|
var regionId = Guid.NewGuid();
|
||||||
var schoolId = Guid.NewGuid();
|
var schoolId = Guid.NewGuid();
|
||||||
|
var otherRegionId = Guid.NewGuid();
|
||||||
await factory.SeedAsync(
|
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
|
new StudentProfile
|
||||||
{
|
{
|
||||||
TenantId = seed.TenantId,
|
TenantId = seed.TenantId,
|
||||||
@@ -81,7 +85,7 @@ public sealed class ProfileEndpointTests
|
|||||||
new ExamDate
|
new ExamDate
|
||||||
{
|
{
|
||||||
TenantId = seed.TenantId,
|
TenantId = seed.TenantId,
|
||||||
RegionId = Guid.NewGuid(),
|
RegionId = otherRegionId,
|
||||||
ExamName = "其他地区考试",
|
ExamName = "其他地区考试",
|
||||||
ExamAt = DateTimeOffset.UtcNow.AddDays(1)
|
ExamAt = DateTimeOffset.UtcNow.AddDays(1)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
|
using Tiku.Domain.Catalog;
|
||||||
using Tiku.Domain.Content;
|
using Tiku.Domain.Content;
|
||||||
using Tiku.Domain.QuestionBanks;
|
using Tiku.Domain.QuestionBanks;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
@@ -62,8 +63,20 @@ public sealed class QuestionBankEndpointTests
|
|||||||
await using var factory = new ApiTestFactory();
|
await using var factory = new ApiTestFactory();
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
Tenant(tenantId, "master"),
|
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 QuestionBank { Id = bankId, TenantId = tenantId, Name = "题库" },
|
||||||
new QuestionCollection { Id = collectionId, TenantId = tenantId, Name = "题集", Status = ContentStatus.Active },
|
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
|
new Question
|
||||||
{
|
{
|
||||||
Id = includedQuestionId,
|
Id = includedQuestionId,
|
||||||
@@ -74,8 +87,7 @@ public sealed class QuestionBankEndpointTests
|
|||||||
Type = "choice",
|
Type = "choice",
|
||||||
TypeLabel = "单选题",
|
TypeLabel = "单选题",
|
||||||
Difficulty = 2,
|
Difficulty = 2,
|
||||||
Status = QuestionStatus.Published,
|
Status = QuestionStatus.Published
|
||||||
CurrentVersionId = versionId
|
|
||||||
},
|
},
|
||||||
new QuestionVersion
|
new QuestionVersion
|
||||||
{
|
{
|
||||||
@@ -84,23 +96,14 @@ public sealed class QuestionBankEndpointTests
|
|||||||
QuestionId = includedQuestionId,
|
QuestionId = includedQuestionId,
|
||||||
VersionNo = 2,
|
VersionNo = 2,
|
||||||
Content = "题干关键词"
|
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 client = factory.CreateClient();
|
||||||
|
|
||||||
using var response = await client.GetAsync(
|
using var response = await client.GetAsync(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
|
using Tiku.Domain.Catalog;
|
||||||
using Tiku.Domain.Content;
|
using Tiku.Domain.Content;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ public sealed class StudyContentEndpointTests
|
|||||||
await using var factory = new ApiTestFactory();
|
await using var factory = new ApiTestFactory();
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
Tenant(tenantId, "master"),
|
Tenant(tenantId, "master"),
|
||||||
|
new Region { Id = regionId, TenantId = tenantId, Name = "测试地区" },
|
||||||
new VocabularyUnit
|
new VocabularyUnit
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Tiku.Api.Contracts;
|
using Tiku.Api.Contracts;
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
@@ -93,6 +94,21 @@ public sealed class TenantCommerceEndpointTests
|
|||||||
Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.OK, accountsResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, accountsResponse.StatusCode);
|
||||||
Assert.Contains("wechat_pay", await accountsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
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]
|
[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]
|
[Theory]
|
||||||
[InlineData(typeof(TenantAuthProvider), nameof(TenantAuthProvider.ConfigPublic), "'{}'::jsonb")]
|
[InlineData(typeof(TenantAuthProvider), nameof(TenantAuthProvider.ConfigPublic), "'{}'::jsonb")]
|
||||||
[InlineData(typeof(TenantSecret), nameof(TenantSecret.SecretPayload), "'{}'::jsonb")]
|
|
||||||
[InlineData(typeof(SmsVerificationCode), nameof(SmsVerificationCode.Metadata), "'{}'::jsonb")]
|
[InlineData(typeof(SmsVerificationCode), nameof(SmsVerificationCode.Metadata), "'{}'::jsonb")]
|
||||||
[InlineData(typeof(AuthLoginEvent), nameof(AuthLoginEvent.Metadata), "'{}'::jsonb")]
|
[InlineData(typeof(AuthLoginEvent), nameof(AuthLoginEvent.Metadata), "'{}'::jsonb")]
|
||||||
[InlineData(typeof(AuthSession), nameof(AuthSession.Metadata), "'{}'::jsonb")]
|
[InlineData(typeof(AuthSession), nameof(AuthSession.Metadata), "'{}'::jsonb")]
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio">
|
<PackageReference Include="xunit.runner.visualstudio">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
- [`docs/adr/0001-authoritative-dotnet-backend.md`](adr/0001-authoritative-dotnet-backend.md)
|
- [`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-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