TD-001 - PUT /api/admin/v1/tenants/{tenantId}:
- 新增 UpdateTenantCommand + UpdateTenantCommandHandler
- Controller 新增 Update 端点(tenant:update 权限)
- 校验:租户存在、name 非空、name/contactPhone 冲突返回 409
- 仓储扩展:ITenantRepository.ExistsByNameAsync
TD-002 - GET /api/admin/v1/tenants/{tenantId}/quota-usage-history:
- 新增 CQRS Query/Handler/DTO/Validator
- 支持分页(Page>=1, PageSize 1~100)
- 支持时间范围和 QuotaType 过滤
- 新增 tenant_quota_usage_histories 表(含迁移)
- 写入点:CheckTenantQuotaCommandHandler + PurchaseQuotaPackageCommandHandler
构建验证:dotnet build 通过
数据库迁移:已应用 20251218121053_AddTenantQuotaUsageHistories
395 lines
14 KiB
C#
395 lines
14 KiB
C#
using Microsoft.EntityFrameworkCore;
|
||
using Npgsql;
|
||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||
|
||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||
|
||
/// <summary>
|
||
/// 租户聚合的 EF Core 仓储实现。
|
||
/// </summary>
|
||
public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRepository
|
||
{
|
||
/// <inheritdoc />
|
||
public Task<Tenant?> FindByIdAsync(long tenantId, CancellationToken cancellationToken = default)
|
||
{
|
||
return context.Tenants
|
||
.AsNoTracking()
|
||
.FirstOrDefaultAsync(x => x.Id == tenantId, cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<IReadOnlyList<Tenant>> FindByIdsAsync(IReadOnlyCollection<long> tenantIds, CancellationToken cancellationToken = default)
|
||
{
|
||
if (tenantIds.Count == 0)
|
||
{
|
||
return Array.Empty<Tenant>();
|
||
}
|
||
|
||
return await context.Tenants
|
||
.AsNoTracking()
|
||
.Where(x => tenantIds.Contains(x.Id))
|
||
.ToListAsync(cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<IReadOnlyList<Tenant>> SearchAsync(
|
||
TenantStatus? status,
|
||
string? keyword,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
// 1. 构建基础查询
|
||
var query = context.Tenants.AsNoTracking();
|
||
|
||
// 2. 按状态过滤
|
||
if (status.HasValue)
|
||
{
|
||
query = query.Where(x => x.Status == status.Value);
|
||
}
|
||
|
||
// 3. 按关键字过滤
|
||
if (!string.IsNullOrWhiteSpace(keyword))
|
||
{
|
||
keyword = keyword.Trim();
|
||
query = query.Where(x =>
|
||
EF.Functions.ILike(x.Name, $"%{keyword}%") ||
|
||
EF.Functions.ILike(x.Code, $"%{keyword}%") ||
|
||
EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{keyword}%") ||
|
||
EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{keyword}%"));
|
||
}
|
||
|
||
// 4. 排序返回
|
||
return await query
|
||
.OrderByDescending(x => x.CreatedAt)
|
||
.ToListAsync(cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<(IReadOnlyList<Tenant> Items, int Total)> SearchPagedAsync(
|
||
TenantStatus? status,
|
||
TenantVerificationStatus? verificationStatus,
|
||
string? name,
|
||
string? contactName,
|
||
string? contactPhone,
|
||
string? keyword,
|
||
int page,
|
||
int pageSize,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var query = context.Tenants.AsNoTracking();
|
||
|
||
// 1. 按租户状态过滤
|
||
if (status.HasValue)
|
||
{
|
||
query = query.Where(x => x.Status == status.Value);
|
||
}
|
||
|
||
// 2. 按实名认证状态过滤(未提交视为 Draft)
|
||
if (verificationStatus.HasValue)
|
||
{
|
||
query = from tenant in query
|
||
join profile in context.TenantVerificationProfiles.AsNoTracking()
|
||
on tenant.Id equals profile.TenantId into profiles
|
||
from profile in profiles.DefaultIfEmpty()
|
||
where (profile == null ? TenantVerificationStatus.Draft : profile.Status) == verificationStatus.Value
|
||
select tenant;
|
||
}
|
||
|
||
// 3. 按名称/联系人/电话过滤(模糊匹配)
|
||
if (!string.IsNullOrWhiteSpace(name))
|
||
{
|
||
var normalizedName = name.Trim();
|
||
query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalizedName}%"));
|
||
}
|
||
|
||
// 4. (空行后) 按联系人过滤(模糊匹配)
|
||
if (!string.IsNullOrWhiteSpace(contactName))
|
||
{
|
||
var normalizedContactName = contactName.Trim();
|
||
query = query.Where(x => EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{normalizedContactName}%"));
|
||
}
|
||
|
||
// 5. (空行后) 按联系电话过滤(模糊匹配)
|
||
if (!string.IsNullOrWhiteSpace(contactPhone))
|
||
{
|
||
var normalizedContactPhone = contactPhone.Trim();
|
||
query = query.Where(x => EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{normalizedContactPhone}%"));
|
||
}
|
||
|
||
// 6. (空行后) 兼容关键字查询:名称/编码/联系人/电话
|
||
if (!string.IsNullOrWhiteSpace(keyword))
|
||
{
|
||
var normalizedKeyword = keyword.Trim();
|
||
query = query.Where(x =>
|
||
EF.Functions.ILike(x.Name, $"%{normalizedKeyword}%") ||
|
||
EF.Functions.ILike(x.Code, $"%{normalizedKeyword}%") ||
|
||
EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{normalizedKeyword}%") ||
|
||
EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{normalizedKeyword}%"));
|
||
}
|
||
|
||
// 7. (空行后) 先统计总数,再按创建时间倒序分页
|
||
var total = await query.CountAsync(cancellationToken);
|
||
|
||
// 8. (空行后) 查询当前页数据
|
||
var items = await query
|
||
.OrderByDescending(x => x.CreatedAt)
|
||
.Skip((page - 1) * pageSize)
|
||
.Take(pageSize)
|
||
.ToListAsync(cancellationToken);
|
||
|
||
return (items, total);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default)
|
||
{
|
||
return context.Tenants.AddAsync(tenant, cancellationToken).AsTask();
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default)
|
||
{
|
||
context.Tenants.Update(tenant);
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<bool> ExistsByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||
{
|
||
var normalized = code.Trim();
|
||
return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<bool> ExistsByNameAsync(string name, long? excludeTenantId = null, CancellationToken cancellationToken = default)
|
||
{
|
||
// 1. 标准化名称
|
||
var normalized = name.Trim();
|
||
|
||
// 2. (空行后) 构建查询(名称使用 ILike 做不区分大小写的等值匹配)
|
||
var query = context.Tenants
|
||
.AsNoTracking()
|
||
.Where(x => EF.Functions.ILike(x.Name, normalized));
|
||
|
||
// 3. (空行后) 更新场景排除自身
|
||
if (excludeTenantId.HasValue)
|
||
{
|
||
query = query.Where(x => x.Id != excludeTenantId.Value);
|
||
}
|
||
|
||
// 4. (空行后) 判断是否存在
|
||
return query.AnyAsync(cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<bool> ExistsByContactPhoneAsync(string phone, CancellationToken cancellationToken = default)
|
||
{
|
||
var normalized = phone.Trim();
|
||
return context.Tenants.AnyAsync(x => x.ContactPhone == normalized, cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<long?> FindTenantIdByContactPhoneAsync(string phone, CancellationToken cancellationToken = default)
|
||
{
|
||
// 1. 标准化手机号
|
||
var normalized = phone.Trim();
|
||
// 2. 查询租户 ID
|
||
return context.Tenants.AsNoTracking()
|
||
.Where(x => x.ContactPhone == normalized)
|
||
.Select(x => (long?)x.Id)
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<TenantVerificationProfile?> GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default)
|
||
{
|
||
return context.TenantVerificationProfiles
|
||
.IgnoreQueryFilters()
|
||
.AsNoTracking()
|
||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId, cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<IReadOnlyList<TenantVerificationProfile>> GetVerificationProfilesAsync(
|
||
IReadOnlyCollection<long> tenantIds,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
// 1. tenantIds 为空直接返回
|
||
if (tenantIds.Count == 0)
|
||
{
|
||
return Array.Empty<TenantVerificationProfile>();
|
||
}
|
||
|
||
// 2. 批量查询实名资料
|
||
return await context.TenantVerificationProfiles
|
||
.IgnoreQueryFilters()
|
||
.AsNoTracking()
|
||
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
|
||
.ToListAsync(cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default)
|
||
{
|
||
// 1. 查询现有实名资料
|
||
var existing = await context.TenantVerificationProfiles
|
||
.IgnoreQueryFilters()
|
||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == profile.TenantId, cancellationToken);
|
||
|
||
if (existing == null)
|
||
{
|
||
// 2. 不存在则新增
|
||
await context.TenantVerificationProfiles.AddAsync(profile, cancellationToken);
|
||
return;
|
||
}
|
||
|
||
// 3. 存在则更新当前值
|
||
profile.Id = existing.Id;
|
||
context.Entry(existing).CurrentValues.SetValues(profile);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<TenantReviewClaim?> GetActiveReviewClaimAsync(long tenantId, CancellationToken cancellationToken = default)
|
||
{
|
||
return context.TenantReviewClaims
|
||
.AsNoTracking()
|
||
.Where(x => x.TenantId == tenantId && x.ReleasedAt == null)
|
||
.OrderByDescending(x => x.ClaimedAt)
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<TenantReviewClaim?> FindActiveReviewClaimAsync(long tenantId, CancellationToken cancellationToken = default)
|
||
{
|
||
return context.TenantReviewClaims
|
||
.Where(x => x.TenantId == tenantId && x.ReleasedAt == null)
|
||
.OrderByDescending(x => x.ClaimedAt)
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<bool> TryAddReviewClaimAsync(
|
||
TenantReviewClaim claim,
|
||
TenantAuditLog auditLog,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
try
|
||
{
|
||
await context.TenantReviewClaims.AddAsync(claim, cancellationToken);
|
||
await context.TenantAuditLogs.AddAsync(auditLog, cancellationToken);
|
||
|
||
await context.SaveChangesAsync(cancellationToken);
|
||
return true;
|
||
}
|
||
catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg && pg.SqlState == PostgresErrorCodes.UniqueViolation)
|
||
{
|
||
context.Entry(claim).State = EntityState.Detached;
|
||
context.Entry(auditLog).State = EntityState.Detached;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task UpdateReviewClaimAsync(TenantReviewClaim claim, CancellationToken cancellationToken = default)
|
||
{
|
||
context.TenantReviewClaims.Update(claim);
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<TenantSubscription?> GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default)
|
||
{
|
||
return context.TenantSubscriptions
|
||
.IgnoreQueryFilters()
|
||
.AsNoTracking()
|
||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||
.OrderByDescending(x => x.EffectiveTo)
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<IReadOnlyList<TenantSubscription>> GetSubscriptionsAsync(
|
||
IReadOnlyCollection<long> tenantIds,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
// 1. tenantIds 为空直接返回
|
||
if (tenantIds.Count == 0)
|
||
{
|
||
return Array.Empty<TenantSubscription>();
|
||
}
|
||
|
||
// 2. 批量查询订阅数据
|
||
return await context.TenantSubscriptions
|
||
.IgnoreQueryFilters()
|
||
.AsNoTracking()
|
||
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
|
||
.OrderByDescending(x => x.EffectiveTo)
|
||
.ToListAsync(cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<TenantSubscription?> FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default)
|
||
{
|
||
return context.TenantSubscriptions
|
||
.IgnoreQueryFilters()
|
||
.FirstOrDefaultAsync(
|
||
x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == subscriptionId,
|
||
cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
|
||
{
|
||
return context.TenantSubscriptions.AddAsync(subscription, cancellationToken).AsTask();
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task UpdateSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
|
||
{
|
||
context.TenantSubscriptions.Update(subscription);
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task AddSubscriptionHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default)
|
||
{
|
||
return context.TenantSubscriptionHistories.AddAsync(history, cancellationToken).AsTask();
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<IReadOnlyList<TenantSubscriptionHistory>> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default)
|
||
{
|
||
return await context.TenantSubscriptionHistories
|
||
.IgnoreQueryFilters()
|
||
.AsNoTracking()
|
||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||
.OrderByDescending(x => x.EffectiveFrom)
|
||
.ToListAsync(cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default)
|
||
{
|
||
return context.TenantAuditLogs.AddAsync(log, cancellationToken).AsTask();
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<IReadOnlyList<TenantAuditLog>> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default)
|
||
{
|
||
return await context.TenantAuditLogs
|
||
.IgnoreQueryFilters()
|
||
.AsNoTracking()
|
||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||
.OrderByDescending(x => x.CreatedAt)
|
||
.ToListAsync(cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
return context.SaveChangesAsync(cancellationToken);
|
||
}
|
||
}
|