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));
+ }
+}