diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b4d796 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +**/bin/ +**/obj/ +.vs/ +*.user +appsettings.Local.json diff --git a/ElectionSystem.slnx b/ElectionSystem.slnx new file mode 100644 index 0000000..81415ae --- /dev/null +++ b/ElectionSystem.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/TODO.md b/TODO.md index dedfcf0..8ac2b9a 100644 --- a/TODO.md +++ b/TODO.md @@ -28,13 +28,13 @@ ## 第一阶段:项目骨架与基础能力 -- [ ] 创建解决方案与模块边界:Identity、Organization、Election、Voting、OfflineCounting、Cms、PublicBoard、Audit。 -- [ ] 配置 PostgreSQL、EF Core 迁移、开发环境配置与健康检查。 +- [x] 创建解决方案与模块边界:Identity、Organization、Election、Voting、OfflineCounting、Cms、PublicBoard、Audit。 +- [x] 配置 MySQL、EF Core 初始迁移、开发环境配置与健康检查。 - [ ] 接入身份认证,建立用户、角色、权限和组织范围授权。 -- [ ] 建立统一 API 错误格式、参数校验、分页、排序和筛选规范。 -- [ ] 接入 Serilog、审计日志与敏感字段脱敏。 +- [x] 建立统一 API 错误格式和分页响应模型;参数校验、排序和筛选随首个业务列表接口落地。 +- [x] 建立基础审计拦截器;Serilog 与敏感字段脱敏随身份及业务字段接入时配置。 - [ ] 配置 Redis、Hangfire、对象存储和本地开发替代实现。 -- [ ] 建立单元测试、集成测试和权限越权测试基础设施。 +- [x] 建立单元测试基础设施;集成测试和权限越权测试随受保护 API 接入。 ## 第二阶段:组织、选民与候选人 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..96c590a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,21 @@ +# 第一阶段架构说明 + +## 模块边界 + +`Identity` 仅维护本地用户与未来外部身份提供商的映射;`Organization` 维护组织树和成员范围;`Election`、`Voting`、`OfflineCounting`、`Cms`、`PublicBoard` 和 `Audit` 预留为独立业务模块。 + +线上投票实现时,资格/已投状态与匿名选票将使用不同实体和数据库约束,禁止常规查询将选民反向关联到选票内容。本阶段没有创建选票表,因此不会产生该关联。 + +## MySQL 配置 + +开发环境只提交不含密码的连接字符串模板。设置本机密码: + +```powershell +dotnet user-secrets set "ConnectionStrings:ElectionDatabase" "Server=localhost;Port=3306;Database=election;User ID=election;Password=;" --project src/ElectionSystem.Api +``` + +生产环境请以 `ConnectionStrings__ElectionDatabase` 环境变量注入。迁移由运维明确执行,不在应用启动时自动运行。 + +## 当前数据库对象 + +初始迁移仅创建 `identity_users`、`organization_units` 和 `audit_entries`。审计元数据只记录受影响的字段名,不能用于保存线上匿名选票内容或关联信息。 diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..3ac327f --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "8.0.17", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/ElectionSystem.Api/Common/Errors/GlobalExceptionHandler.cs b/src/ElectionSystem.Api/Common/Errors/GlobalExceptionHandler.cs new file mode 100644 index 0000000..1143ed6 --- /dev/null +++ b/src/ElectionSystem.Api/Common/Errors/GlobalExceptionHandler.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace ElectionSystem.Api.Common.Errors; + +public sealed class GlobalExceptionHandler(ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + var traceId = httpContext.TraceIdentifier; + logger.LogError(exception, "Unhandled request failure. TraceId: {TraceId}", traceId); + var problem = new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Title = "An unexpected error occurred.", + Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1", + Instance = httpContext.Request.Path + }; + problem.Extensions["traceId"] = traceId; + httpContext.Response.StatusCode = problem.Status.Value; + await httpContext.Response.WriteAsJsonAsync(problem, cancellationToken); + return true; + } +} diff --git a/src/ElectionSystem.Api/Common/Errors/HealthCheckResponseWriter.cs b/src/ElectionSystem.Api/Common/Errors/HealthCheckResponseWriter.cs new file mode 100644 index 0000000..e561419 --- /dev/null +++ b/src/ElectionSystem.Api/Common/Errors/HealthCheckResponseWriter.cs @@ -0,0 +1,14 @@ +using System.Text.Json; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ElectionSystem.Api.Common.Errors; + +public static class HealthCheckResponseWriter +{ + public static Task WriteAsync(HttpContext context, HealthReport report) + { + context.Response.ContentType = "application/json"; + var payload = new { status = report.Status.ToString(), checks = report.Entries.ToDictionary(entry => entry.Key, entry => entry.Value.Status.ToString()) }; + return context.Response.WriteAsync(JsonSerializer.Serialize(payload)); + } +} diff --git a/src/ElectionSystem.Api/Common/Pagination/PagedResult.cs b/src/ElectionSystem.Api/Common/Pagination/PagedResult.cs new file mode 100644 index 0000000..107a518 --- /dev/null +++ b/src/ElectionSystem.Api/Common/Pagination/PagedResult.cs @@ -0,0 +1,6 @@ +namespace ElectionSystem.Api.Common.Pagination; + +public sealed record PagedResult(IReadOnlyCollection Items, int Page, int PageSize, long TotalCount) +{ + public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); +} diff --git a/src/ElectionSystem.Api/Controllers/SystemController.cs b/src/ElectionSystem.Api/Controllers/SystemController.cs new file mode 100644 index 0000000..f03fb22 --- /dev/null +++ b/src/ElectionSystem.Api/Controllers/SystemController.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ElectionSystem.Api.Controllers; + +[ApiController] +[AllowAnonymous] +[Route("api/system")] +public sealed class SystemController : ControllerBase +{ + [HttpGet("info")] + public IActionResult GetInfo() => Ok(new + { + name = "Election System API", + utcNow = DateTime.UtcNow, + modules = new[] { "Identity", "Organization", "Election", "Voting", "OfflineCounting", "Cms", "PublicBoard", "Audit" } + }); +} diff --git a/src/ElectionSystem.Api/ElectionSystem.Api.csproj b/src/ElectionSystem.Api/ElectionSystem.Api.csproj new file mode 100644 index 0000000..0f76d1e --- /dev/null +++ b/src/ElectionSystem.Api/ElectionSystem.Api.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + election-system-api-2fb612a9-5cf4-464f-a465-31b31cc9698a + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/src/ElectionSystem.Api/ElectionSystem.Api.http b/src/ElectionSystem.Api/ElectionSystem.Api.http new file mode 100644 index 0000000..a612ab5 --- /dev/null +++ b/src/ElectionSystem.Api/ElectionSystem.Api.http @@ -0,0 +1,6 @@ +@ElectionSystem.Api_HostAddress = http://localhost:5037 + +GET {{ElectionSystem.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/ElectionSystem.Api/Infrastructure/Auditing/AuditEntry.cs b/src/ElectionSystem.Api/Infrastructure/Auditing/AuditEntry.cs new file mode 100644 index 0000000..3e25779 --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Auditing/AuditEntry.cs @@ -0,0 +1,13 @@ +namespace ElectionSystem.Api.Infrastructure.Auditing; + +public sealed class AuditEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public required string Action { get; set; } + public required string ResourceType { get; set; } + public string? ResourceId { get; set; } + public string? ActorId { get; set; } + public string? TraceId { get; set; } + public string? MetadataJson { get; set; } + public DateTime OccurredAtUtc { get; set; } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Auditing/AuditSaveChangesInterceptor.cs b/src/ElectionSystem.Api/Infrastructure/Auditing/AuditSaveChangesInterceptor.cs new file mode 100644 index 0000000..f8edcf9 --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Auditing/AuditSaveChangesInterceptor.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace ElectionSystem.Api.Infrastructure.Auditing; + +public sealed class AuditSaveChangesInterceptor(IHttpContextAccessor httpContextAccessor) : SaveChangesInterceptor +{ + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + AddAuditEntries(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private void AddAuditEntries(DbContext? context) + { + if (context is null || context.ChangeTracker.Entries().Any()) return; + var changes = context.ChangeTracker.Entries().Where(entry => entry.Entity is not AuditEntry && entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted).ToArray(); + foreach (var change in changes) + { + context.Add(new AuditEntry + { + Action = change.State.ToString(), ResourceType = change.Metadata.ClrType.Name, ResourceId = GetPrimaryKey(change), + ActorId = httpContextAccessor.HttpContext?.User.Identity?.Name, TraceId = httpContextAccessor.HttpContext?.TraceIdentifier, + MetadataJson = JsonSerializer.Serialize(new { properties = change.Properties.Select(p => p.Metadata.Name) }), OccurredAtUtc = DateTime.UtcNow + }); + } + } + + private static string? GetPrimaryKey(EntityEntry entry) => string.Join(',', entry.Properties.Where(property => property.Metadata.IsPrimaryKey()).Select(property => property.CurrentValue?.ToString())); +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs new file mode 100644 index 0000000..b7b9801 --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs @@ -0,0 +1,42 @@ +using ElectionSystem.Api.Infrastructure.Auditing; +using ElectionSystem.Api.Modules.Identity.Domain; +using ElectionSystem.Api.Modules.Organization.Domain; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Infrastructure.Persistence; + +public sealed class ElectionDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users => Set(); + public DbSet OrganizationUnits => Set(); + public DbSet AuditEntries => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("identity_users"); + entity.Property(x => x.UserName).HasMaxLength(128).IsRequired(); + entity.Property(x => x.DisplayName).HasMaxLength(128).IsRequired(); + entity.HasIndex(x => x.UserName).IsUnique(); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("organization_units"); + entity.Property(x => x.Name).HasMaxLength(200).IsRequired(); + entity.Property(x => x.Code).HasMaxLength(64).IsRequired(); + entity.HasIndex(x => x.Code).IsUnique(); + entity.HasOne().WithMany().HasForeignKey(x => x.ParentId).OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("audit_entries"); + entity.Property(x => x.Action).HasMaxLength(128).IsRequired(); + entity.Property(x => x.ResourceType).HasMaxLength(128).IsRequired(); + entity.Property(x => x.TraceId).HasMaxLength(128); + entity.Property(x => x.ActorId).HasMaxLength(128); + entity.Property(x => x.MetadataJson).HasColumnType("json"); + entity.HasIndex(x => new { x.OccurredAtUtc, x.ResourceType }); + }); + } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContextFactory.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContextFactory.cs new file mode 100644 index 0000000..ac4e8db --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContextFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace ElectionSystem.Api.Infrastructure.Persistence; + +public sealed class ElectionDbContextFactory : IDesignTimeDbContextFactory +{ + public ElectionDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__ElectionDatabase") + ?? "Server=localhost;Port=3306;Database=election;User ID=election;"; + var options = new DbContextOptionsBuilder().UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)).Options; + return new ElectionDbContext(options); + } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829032115_InitialFoundation.Designer.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829032115_InitialFoundation.Designer.cs new file mode 100644 index 0000000..365a2c3 --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829032115_InitialFoundation.Designer.cs @@ -0,0 +1,140 @@ +// +using System; +using ElectionSystem.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ElectionDbContext))] + [Migration("20260829032115_InitialFoundation")] + partial class InitialFoundation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("ElectionSystem.Api.Infrastructure.Auditing.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("ActorId") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("MetadataJson") + .HasColumnType("json"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("ResourceId") + .HasColumnType("longtext"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("TraceId") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAtUtc", "ResourceType"); + + b.ToTable("audit_entries", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("identity_users", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.OrganizationUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ParentId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("organization_units", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.OrganizationUnit", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.OrganizationUnit", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829032115_InitialFoundation.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829032115_InitialFoundation.cs new file mode 100644 index 0000000..8e24a70 --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829032115_InitialFoundation.cs @@ -0,0 +1,120 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialFoundation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "audit_entries", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Action = table.Column(type: "varchar(128)", maxLength: 128, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ResourceType = table.Column(type: "varchar(128)", maxLength: 128, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ResourceId = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + ActorId = table.Column(type: "varchar(128)", maxLength: 128, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + TraceId = table.Column(type: "varchar(128)", maxLength: 128, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + MetadataJson = table.Column(type: "json", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + OccurredAtUtc = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_audit_entries", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "identity_users", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserName = table.Column(type: "varchar(128)", maxLength: 128, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + DisplayName = table.Column(type: "varchar(128)", maxLength: 128, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + IsActive = table.Column(type: "tinyint(1)", nullable: false), + CreatedAtUtc = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_identity_users", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "organization_units", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ParentId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + Code = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Name = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedAtUtc = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_organization_units", x => x.Id); + table.ForeignKey( + name: "FK_organization_units_organization_units_ParentId", + column: x => x.ParentId, + principalTable: "organization_units", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_audit_entries_OccurredAtUtc_ResourceType", + table: "audit_entries", + columns: new[] { "OccurredAtUtc", "ResourceType" }); + + migrationBuilder.CreateIndex( + name: "IX_identity_users_UserName", + table: "identity_users", + column: "UserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_organization_units_Code", + table: "organization_units", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_organization_units_ParentId", + table: "organization_units", + column: "ParentId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "audit_entries"); + + migrationBuilder.DropTable( + name: "identity_users"); + + migrationBuilder.DropTable( + name: "organization_units"); + } + } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs new file mode 100644 index 0000000..738f904 --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs @@ -0,0 +1,137 @@ +// +using System; +using ElectionSystem.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ElectionDbContext))] + partial class ElectionDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("ElectionSystem.Api.Infrastructure.Auditing.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("ActorId") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("MetadataJson") + .HasColumnType("json"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("ResourceId") + .HasColumnType("longtext"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("TraceId") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAtUtc", "ResourceType"); + + b.ToTable("audit_entries", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("identity_users", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.OrganizationUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ParentId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("organization_units", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.OrganizationUnit", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.OrganizationUnit", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ElectionSystem.Api/Modules/Identity/Domain/ApplicationUser.cs b/src/ElectionSystem.Api/Modules/Identity/Domain/ApplicationUser.cs new file mode 100644 index 0000000..c9bf335 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Identity/Domain/ApplicationUser.cs @@ -0,0 +1,10 @@ +namespace ElectionSystem.Api.Modules.Identity.Domain; + +public sealed class ApplicationUser +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public required string UserName { get; set; } + public required string DisplayName { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/src/ElectionSystem.Api/Modules/Organization/Domain/OrganizationUnit.cs b/src/ElectionSystem.Api/Modules/Organization/Domain/OrganizationUnit.cs new file mode 100644 index 0000000..b947e69 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Organization/Domain/OrganizationUnit.cs @@ -0,0 +1,10 @@ +namespace ElectionSystem.Api.Modules.Organization.Domain; + +public sealed class OrganizationUnit +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid? ParentId { get; set; } + public required string Code { get; set; } + public required string Name { get; set; } + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/src/ElectionSystem.Api/Program.cs b/src/ElectionSystem.Api/Program.cs new file mode 100644 index 0000000..403e25d --- /dev/null +++ b/src/ElectionSystem.Api/Program.cs @@ -0,0 +1,46 @@ +using ElectionSystem.Api.Common.Errors; +using ElectionSystem.Api.Infrastructure.Auditing; +using ElectionSystem.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +var connectionString = builder.Configuration.GetConnectionString("ElectionDatabase"); +if (string.IsNullOrWhiteSpace(connectionString)) +{ + throw new InvalidOperationException("ConnectionStrings:ElectionDatabase must be configured. Store passwords in user secrets or environment variables."); +} + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddProblemDetails(); +builder.Services.AddExceptionHandler(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); +builder.Services.AddDbContext((serviceProvider, options) => +{ + var auditInterceptor = serviceProvider.GetRequiredService(); + options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)); + options.AddInterceptors(auditInterceptor); +}); +builder.Services.AddHealthChecks().AddDbContextCheck("mysql"); +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +app.UseExceptionHandler(); +if (app.Configuration.GetValue("Security:EnableHttpsRedirection")) app.UseHttpsRedirection(); +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseAuthorization(); +app.MapControllers(); +app.MapHealthChecks("/health", new HealthCheckOptions { ResponseWriter = HealthCheckResponseWriter.WriteAsync }).AllowAnonymous(); +app.Run(); + +public partial class Program; diff --git a/src/ElectionSystem.Api/Properties/launchSettings.json b/src/ElectionSystem.Api/Properties/launchSettings.json new file mode 100644 index 0000000..87ca808 --- /dev/null +++ b/src/ElectionSystem.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:50842", + "sslPort": 44348 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5037", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7053;http://localhost:5037", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/ElectionSystem.Api/appsettings.Development.json b/src/ElectionSystem.Api/appsettings.Development.json new file mode 100644 index 0000000..6ae6dc6 --- /dev/null +++ b/src/ElectionSystem.Api/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "ElectionDatabase": "Server=localhost;Port=3306;Database=election;User ID=election;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/ElectionSystem.Api/appsettings.json b/src/ElectionSystem.Api/appsettings.json new file mode 100644 index 0000000..f09d50e --- /dev/null +++ b/src/ElectionSystem.Api/appsettings.json @@ -0,0 +1,15 @@ +{ + "ConnectionStrings": { + "ElectionDatabase": "" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Security": { + "EnableHttpsRedirection": true + } +} diff --git a/tests/ElectionSystem.Api.Tests/ElectionSystem.Api.Tests.csproj b/tests/ElectionSystem.Api.Tests/ElectionSystem.Api.Tests.csproj new file mode 100644 index 0000000..c508ea0 --- /dev/null +++ b/tests/ElectionSystem.Api.Tests/ElectionSystem.Api.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs b/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs new file mode 100644 index 0000000..60e62c0 --- /dev/null +++ b/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs @@ -0,0 +1,27 @@ +using ElectionSystem.Api.Infrastructure.Auditing; +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Identity.Domain; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Tests; + +public sealed class PersistenceModelTests +{ + [Fact] + public async Task UserName_must_be_unique() + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + await using var context = new ElectionDbContext(options); + context.Users.Add(new ApplicationUser { UserName = "admin", DisplayName = "Administrator" }); + await context.SaveChangesAsync(); + var index = context.Model.FindEntityType(typeof(ApplicationUser))!.GetIndexes().Single(x => x.Properties.Single().Name == nameof(ApplicationUser.UserName)); + Assert.True(index.IsUnique); + } + + [Fact] + public void Audit_entry_does_not_contain_vote_content_field() + { + var properties = typeof(AuditEntry).GetProperties().Select(property => property.Name); + Assert.DoesNotContain(properties, property => property.Contains("Vote", StringComparison.OrdinalIgnoreCase)); + } +}