feat(auth): extract Tenant to Platform domain

- Add Fengling.Platform domain and infrastructure projects
- Move Tenant aggregate from AuthService/Console to Platform.Domain
- Add TenantRepository and SeedData to Platform
- Remove duplicate Tenant/TenantInfo models from AuthService and Console
- Update controllers and services to use Platform.Domain.Tenant
- Add new migrations for PlatformDbContext

BREAKING CHANGE: Tenant entity now uses strongly-typed ID (TenantId)
This commit is contained in:
movingsam 2026-02-18 23:00:09 +08:00
parent 9516e1cd93
commit 74122b2c8c
9 changed files with 95 additions and 144 deletions

View File

@ -1,5 +1,7 @@
namespace Fengling.Console.Controllers;
using Fengling.Console.Services;
/// <summary>
/// 租户管理控制器
/// 提供租户的增删改查以及租户用户、角色、配置管理功能
@ -10,11 +12,13 @@ namespace Fengling.Console.Controllers;
public class TenantsController : ControllerBase
{
private readonly ITenantService _tenantService;
private readonly IH5LinkService _h5LinkService;
private readonly ILogger<TenantsController> _logger;
public TenantsController(ITenantService tenantService, ILogger<TenantsController> logger)
public TenantsController(ITenantService tenantService, IH5LinkService h5LinkService, ILogger<TenantsController> logger)
{
_tenantService = tenantService;
_h5LinkService = h5LinkService;
_logger = logger;
}
@ -298,4 +302,36 @@ public class TenantsController : ControllerBase
return StatusCode(500, new { message = ex.Message });
}
}
/// <summary>
/// 生成H5访问链接和二维码
/// </summary>
/// <param name="id">租户ID</param>
/// <returns>H5链接和二维码Base64</returns>
/// <response code="200">成功返回链接和二维码</response>
/// <response code="404">租户不存在</response>
/// <response code="500">服务器内部错误</response>
[HttpGet("{id}/h5-link")]
[Produces("application/json")]
[ProducesResponseType(typeof(H5LinkResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<H5LinkResult>> GetH5Link(long id)
{
try
{
var result = await _h5LinkService.GenerateH5LinkAsync(id);
return Ok(result);
}
catch (KeyNotFoundException ex)
{
_logger.LogWarning(ex, "Tenant not found: {TenantId}", id);
return NotFound(new { message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating H5 link for tenant {TenantId}", id);
return StatusCode(500, new { message = "Failed to generate H5 link" });
}
}
}

View File

@ -7,7 +7,6 @@ namespace Fengling.Console.Datas;
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: IdentityDbContext<ApplicationUser, ApplicationRole, long>(options)
{
public DbSet<Tenant> Tenants { get; set; }
public DbSet<AccessLog> AccessLogs { get; set; }
public DbSet<AuditLog> AuditLogs { get; set; }
@ -23,27 +22,16 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
entity.OwnsOne(e => e.TenantInfo, navigationBuilder =>
{
navigationBuilder.Property(e => e.Id).HasColumnName("TenantId");
navigationBuilder.Property(e => e.TenantId).HasColumnName("TenantCode");
navigationBuilder.Property(e => e.Name).HasColumnName("TenantName");
navigationBuilder.Property(e => e.TenantCode).HasColumnName("TenantCode");
navigationBuilder.Property(e => e.TenantId).HasColumnName("TenantId");
navigationBuilder.Property(e => e.TenantName).HasColumnName("TenantName");
navigationBuilder.WithOwner();
});
});
builder.Entity<ApplicationRole>(entity => { entity.Property(e => e.Description).HasMaxLength(200); });
builder.Entity<Tenant>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.TenantId).IsUnique();
entity.Property(e => e.TenantId).HasMaxLength(50);
entity.Property(e => e.Name).HasMaxLength(100);
entity.Property(e => e.ContactName).HasMaxLength(50);
entity.Property(e => e.ContactEmail).HasMaxLength(100);
entity.Property(e => e.ContactPhone).HasMaxLength(20);
entity.Property(e => e.Status).HasMaxLength(20);
entity.Property(e => e.Description).HasMaxLength(500);
});
builder.Entity<AccessLog>(entity =>
{

View File

@ -25,11 +25,14 @@
<PackageReference Include="OpenIddict.Server" />
<PackageReference Include="OpenIddict.Server.AspNetCore" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="SkiaSharp" />
<PackageReference Include="QRCoder" />
</ItemGroup>
<ItemGroup>
<!-- <ProjectReference Include="..\Fengling.AuthService\Fengling.AuthService.csproj" />-->
<ProjectReference Include="..\YarpGateway\YarpGateway.csproj" />
<ProjectReference Include="..\Fengling.Platform\Fengling.Platform.Infrastructure\Fengling.Platform.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@ -1,63 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Fengling.Console.Models.Entities;
public class Tenant
{
private long _id;
private string _tenantId;
private string _name;
[Key]
public long Id
{
get => _id;
set => _id = value;
}
[MaxLength(50)]
[Required]
public string TenantId
{
get => _tenantId;
set => _tenantId = value;
}
[MaxLength(100)]
[Required]
public string Name
{
get => _name;
set => _name = value;
}
[MaxLength(50)]
[Required]
public string ContactName { get; set; } = string.Empty;
[MaxLength(100)]
[Required]
[EmailAddress]
public string ContactEmail { get; set; } = string.Empty;
[MaxLength(20)]
public string? ContactPhone { get; set; }
public int? MaxUsers { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[MaxLength(500)]
public string? Description { get; set; }
[MaxLength(20)]
public string Status { get; set; } = "active";
public DateTime? ExpiresAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public bool IsDeleted { get; set; }
public TenantInfo Info => new(Id, TenantId, Name);
}

View File

@ -1,3 +0,0 @@
namespace Fengling.Console.Models.Entities;
public record TenantInfo(long Id, string TenantId, string Name);

View File

@ -1,16 +1,17 @@
using Fengling.Console.Models.Entities;
using Fengling.Platform.Domain.AggregatesModel.TenantAggregate;
namespace Fengling.Console.Repositories;
public interface ITenantRepository
{
Task<Tenant?> GetByIdAsync(long id);
Task<Tenant?> GetByTenantIdAsync(string tenantId);
Task<Tenant?> GetByTenantCodeAsync(string tenantCode);
Task<IEnumerable<Tenant>> GetAllAsync();
Task<IEnumerable<Tenant>> GetPagedAsync(int page, int pageSize, string? name = null, string? tenantId = null, string? status = null);
Task<int> CountAsync(string? name = null, string? tenantId = null, string? status = null);
Task<IEnumerable<Tenant>> GetPagedAsync(int page, int pageSize, string? name = null, string? tenantCode = null, TenantStatus? status = null);
Task<int> CountAsync(string? name = null, string? tenantCode = null, TenantStatus? status = null);
Task AddAsync(Tenant tenant);
Task UpdateAsync(Tenant tenant);
Task DeleteAsync(Tenant tenant);
Task<int> GetUserCountAsync(long tenantId);
Task<int> GetUserCountAsync(TenantId tenantId);
}

View File

@ -1,19 +1,21 @@
using Fengling.Console.Datas;
using Fengling.Console.Models.Entities;
using Fengling.Platform.Domain.AggregatesModel.TenantAggregate;
using Fengling.Platform.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace Fengling.Console.Repositories;
public class TenantRepository(ApplicationDbContext context) : ITenantRepository
public class TenantRepository(PlatformDbContext context,ApplicationDbContext identityDbContext) : ITenantRepository
{
public async Task<Tenant?> GetByIdAsync(long id)
{
return await context.Tenants.FindAsync(id);
}
public async Task<Tenant?> GetByTenantIdAsync(string tenantId)
public async Task<Platform.Domain.AggregatesModel.TenantAggregate.Tenant?> GetByTenantCodeAsync(string tenantCode)
{
return await context.Tenants.FirstOrDefaultAsync(t => t.TenantId == tenantId);
return await context.Tenants.FirstOrDefaultAsync(t => t.TenantCode == tenantCode);
}
public async Task<IEnumerable<Tenant>> GetAllAsync()
@ -22,7 +24,7 @@ public class TenantRepository(ApplicationDbContext context) : ITenantRepository
}
public async Task<IEnumerable<Tenant>> GetPagedAsync(int page, int pageSize, string? name = null,
string? tenantId = null, string? status = null)
string? tenantCode = null, TenantStatus? status = null)
{
var query = context.Tenants.AsQueryable();
@ -31,12 +33,12 @@ public class TenantRepository(ApplicationDbContext context) : ITenantRepository
query = query.Where(t => t.Name.Contains(name));
}
if (!string.IsNullOrEmpty(tenantId))
if (!string.IsNullOrEmpty(tenantCode))
{
query = query.Where(t => t.TenantId.Contains(tenantId));
query = query.Where(t => t.TenantCode.Contains(tenantCode));
}
if (!string.IsNullOrEmpty(status))
if (status.HasValue)
{
query = query.Where(t => t.Status == status);
}
@ -48,7 +50,7 @@ public class TenantRepository(ApplicationDbContext context) : ITenantRepository
.ToListAsync();
}
public async Task<int> CountAsync(string? name = null, string? tenantId = null, string? status = null)
public async Task<int> CountAsync(string? name = null, string? tenantCode = null, TenantStatus? status = null)
{
var query = context.Tenants.AsQueryable();
@ -57,12 +59,12 @@ public class TenantRepository(ApplicationDbContext context) : ITenantRepository
query = query.Where(t => t.Name.Contains(name));
}
if (!string.IsNullOrEmpty(tenantId))
if (!string.IsNullOrEmpty(tenantCode))
{
query = query.Where(t => t.TenantId.Contains(tenantId));
query = query.Where(t => t.TenantCode.Contains(tenantCode));
}
if (!string.IsNullOrEmpty(status))
if (status.HasValue)
{
query = query.Where(t => t.Status == status);
}
@ -88,8 +90,8 @@ public class TenantRepository(ApplicationDbContext context) : ITenantRepository
await context.SaveChangesAsync();
}
public async Task<int> GetUserCountAsync(long tenantId)
public async Task<int> GetUserCountAsync(TenantId tenantId)
{
return await context.Users.CountAsync(u => u.TenantInfo.Id == tenantId && !u.IsDeleted);
return await identityDbContext.Users.CountAsync(u => u.TenantInfo.TenantId == tenantId && !u.IsDeleted);
}
}
}

View File

@ -28,7 +28,8 @@ public class UserRepository : IUserRepository
return await _context.Users.ToListAsync();
}
public async Task<IEnumerable<ApplicationUser>> GetPagedAsync(int page, int pageSize, string? userName = null, string? email = null, string? tenantId = null)
public async Task<IEnumerable<ApplicationUser>> GetPagedAsync(int page, int pageSize, string? userName = null,
string? email = null, string? tenantCode = null)
{
var query = _context.Users.AsQueryable();
@ -42,9 +43,9 @@ public class UserRepository : IUserRepository
query = query.Where(u => u.Email != null && u.Email.Contains(email));
}
if (!string.IsNullOrEmpty(tenantId))
if (!string.IsNullOrEmpty(tenantCode))
{
query = query.Where(u => u.TenantInfo.Id.ToString() == tenantId);
query = query.Where(u => u.TenantInfo.TenantCode.Contains(tenantCode));
}
return await query
@ -54,7 +55,7 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<int> CountAsync(string? userName = null, string? email = null, string? tenantId = null)
public async Task<int> CountAsync(string? userName = null, string? email = null, string? tenantCode = null)
{
var query = _context.Users.AsQueryable();
@ -68,9 +69,9 @@ public class UserRepository : IUserRepository
query = query.Where(u => u.Email != null && u.Email.Contains(email));
}
if (!string.IsNullOrEmpty(tenantId))
if (!string.IsNullOrEmpty(tenantCode))
{
query = query.Where(u => u.TenantInfo.Id.ToString() == tenantId);
query = query.Where(u => u.TenantInfo.TenantCode.Contains(tenantCode));
}
return await query.CountAsync();

View File

@ -4,12 +4,14 @@ using Microsoft.AspNetCore.Identity;
using System.Security.Claims;
using Fengling.Console.Datas;
using Fengling.Console.Models.Entities;
using Fengling.Platform.Domain.AggregatesModel.TenantAggregate;
namespace Fengling.Console.Services;
public interface ITenantService
{
Task<(IEnumerable<TenantDto> Items, int TotalCount)> GetTenantsAsync(int page, int pageSize, string? name = null, string? tenantId = null, string? status = null);
Task<(IEnumerable<TenantDto> Items, int TotalCount)> GetTenantsAsync(int page, int pageSize, string? name = null,
string? tenantCode = null, TenantStatus? status = null);
Task<TenantDto?> GetTenantAsync(long id);
Task<IEnumerable<UserDto>> GetTenantUsersAsync(long tenantId);
Task<IEnumerable<object>> GetTenantRolesAsync(long tenantId);
@ -29,10 +31,11 @@ public class TenantService(
IHttpContextAccessor httpContextAccessor)
: ITenantService
{
public async Task<(IEnumerable<TenantDto> Items, int TotalCount)> GetTenantsAsync(int page, int pageSize, string? name = null, string? tenantId = null, string? status = null)
public async Task<(IEnumerable<TenantDto> Items, int TotalCount)> GetTenantsAsync
(int page, int pageSize, string? name = null, string? tenantCode = null, TenantStatus? status = null)
{
var tenants = await repository.GetPagedAsync(page, pageSize, name, tenantId, status);
var totalCount = await repository.CountAsync(name, tenantId, status);
var tenants = await repository.GetPagedAsync(page, pageSize, name, tenantCode, status);
var totalCount = await repository.CountAsync(name, tenantCode, status);
var tenantDtos = new List<TenantDto>();
foreach (var tenant in tenants)
@ -41,7 +44,7 @@ public class TenantService(
tenantDtos.Add(new TenantDto
{
Id = tenant.Id,
TenantId = tenant.TenantId,
TenantCode = tenant.TenantCode,
Name = tenant.Name,
ContactName = tenant.ContactName,
ContactEmail = tenant.ContactEmail,
@ -66,7 +69,7 @@ public class TenantService(
return new TenantDto
{
Id = tenant.Id,
TenantId = tenant.TenantId,
TenantCode = tenant.TenantCode,
Name = tenant.Name,
ContactName = tenant.ContactName,
ContactEmail = tenant.ContactEmail,
@ -100,8 +103,8 @@ public class TenantService(
UserName = user.UserName,
Email = user.Email,
RealName = user.RealName,
TenantId = user.TenantInfo.Id,
TenantName = user.TenantInfo.Name,
TenantCode = user.TenantInfo.TenantCode,
TenantName = user.TenantInfo.TenantName,
Roles = roles.ToList(),
EmailConfirmed = user.EmailConfirmed,
IsActive = !user.LockoutEnabled || user.LockoutEnd == null || user.LockoutEnd < DateTimeOffset.UtcNow,
@ -156,33 +159,23 @@ public class TenantService(
throw new KeyNotFoundException($"Tenant with ID {id} not found");
}
await CreateAuditLog("tenant", "update", "TenantSettings", tenant.Id, tenant.TenantId, null, System.Text.Json.JsonSerializer.Serialize(settings));
await CreateAuditLog("tenant", "update", "TenantSettings", tenant.Id, tenant.Name, null, System.Text.Json.JsonSerializer.Serialize(settings));
}
public async Task<TenantDto> CreateTenantAsync(CreateTenantDto dto)
{
var tenant = new Tenant
{
TenantId = dto.TenantId,
Name = dto.Name,
ContactName = dto.ContactName,
ContactEmail = dto.ContactEmail,
ContactPhone = dto.ContactPhone,
MaxUsers = dto.MaxUsers,
Description = dto.Description,
Status = dto.Status,
ExpiresAt = dto.ExpiresAt,
CreatedAt = DateTime.UtcNow
};
var tenant = new Tenant(dto.TenantCode, dto.Name, dto.ContactName, dto.ContactEmail,
dto.ContactPhone,dto.MaxUsers,dto.Description,dto.ExpiresAt);
await repository.AddAsync(tenant);
await CreateAuditLog("tenant", "create", "Tenant", tenant.Id, tenant.TenantId, null, System.Text.Json.JsonSerializer.Serialize(dto));
await CreateAuditLog("tenant", "create", "Tenant", tenant.Id, tenant.Name, null,
System.Text.Json.JsonSerializer.Serialize(dto));
return new TenantDto
{
Id = tenant.Id,
TenantId = tenant.TenantId,
TenantCode = tenant.TenantCode,
Name = tenant.Name,
ContactName = tenant.ContactName,
ContactEmail = tenant.ContactEmail,
@ -206,24 +199,17 @@ public class TenantService(
var oldValue = System.Text.Json.JsonSerializer.Serialize(tenant);
tenant.Name = dto.Name;
tenant.ContactName = dto.ContactName;
tenant.ContactEmail = dto.ContactEmail;
tenant.ContactPhone = dto.ContactPhone;
tenant.MaxUsers = dto.MaxUsers;
tenant.Description = dto.Description;
tenant.Status = dto.Status;
tenant.ExpiresAt = dto.ExpiresAt;
tenant.UpdatedAt = DateTime.UtcNow;
tenant.UpdateInfo(dto.Name,dto.ContactName,dto.ContactEmail,dto.ContactPhone);
await repository.UpdateAsync(tenant);
await CreateAuditLog("tenant", "update", "Tenant", tenant.Id, tenant.TenantId, oldValue, System.Text.Json.JsonSerializer.Serialize(tenant));
await CreateAuditLog("tenant", "update", "Tenant", tenant.Id, tenant.Name, oldValue,
System.Text.Json.JsonSerializer.Serialize(tenant));
return new TenantDto
{
Id = tenant.Id,
TenantId = tenant.TenantId,
TenantCode = tenant.TenantCode,
Name = tenant.Name,
ContactName = tenant.ContactName,
ContactEmail = tenant.ContactEmail,
@ -255,10 +241,10 @@ public class TenantService(
await context.SaveChangesAsync();
}
tenant.IsDeleted = true;
tenant.Delete();;
await repository.UpdateAsync(tenant);
await CreateAuditLog("tenant", "delete", "Tenant", tenant.Id, tenant.TenantId, oldValue);
await CreateAuditLog("tenant", "delete", "Tenant", tenant.Id, tenant.Name, oldValue);
}
private async Task CreateAuditLog(string operation, string action, string targetType, long? targetId, string? targetName, string? oldValue = null, string? newValue = null)