This commit is contained in:
2026-01-05 19:11:16 +08:00
73 changed files with 400 additions and 323 deletions

View File

@@ -26,25 +26,25 @@ public sealed class TenantBillingStatementConfiguration : IEntityTypeConfigurati
builder.Property(x => x.Currency).HasMaxLength(8).HasDefaultValue("CNY");
builder.Property(x => x.Status).HasConversion<int>();
// 2. (空行后) JSON 字段(当前以 text 存储 JSON 字符串,便于兼容历史迁移)
// 2. JSON 字段(当前以 text 存储 JSON 字符串,便于兼容历史迁移)
builder.Property(x => x.LineItemsJson).HasColumnType("text");
// 3. (空行后) 备注字段
// 3. 备注字段
builder.Property(x => x.Notes).HasMaxLength(512);
// 4. (空行后) 唯一约束与索引
// 4. 唯一约束与索引
builder.HasIndex(x => new { x.TenantId, x.StatementNo }).IsUnique();
// 5. (空行后) 性能索引(高频查询:租户+状态+到期日)
// 5. 性能索引(高频查询:租户+状态+到期日)
builder.HasIndex(x => new { x.TenantId, x.Status, x.DueDate })
.HasDatabaseName("idx_billing_tenant_status_duedate");
// 6. (空行后) 逾期扫描索引(仅索引 Pending/Overdue
// 6. 逾期扫描索引(仅索引 Pending/Overdue
builder.HasIndex(x => new { x.Status, x.DueDate })
.HasDatabaseName("idx_billing_status_duedate")
.HasFilter($"\"Status\" IN ({(int)TenantBillingStatus.Pending}, {(int)TenantBillingStatus.Overdue})");
// 7. (空行后) 创建时间索引(支持列表倒序)
// 7. 创建时间索引(支持列表倒序)
builder.HasIndex(x => x.CreatedAt)
.HasDatabaseName("idx_billing_created_at");
}

View File

@@ -25,14 +25,14 @@ public sealed class TenantPaymentConfiguration : IEntityTypeConfiguration<Tenant
builder.Property(x => x.RefundReason).HasMaxLength(512);
builder.Property(x => x.Notes).HasMaxLength(512);
// 2. (空行后) 复合索引:租户+账单
// 2. 复合索引:租户+账单
builder.HasIndex(x => new { x.TenantId, x.BillingStatementId });
// 3. (空行后) 支付记录时间排序索引
// 3. 支付记录时间排序索引
builder.HasIndex(x => new { x.BillingStatementId, x.PaidAt })
.HasDatabaseName("idx_payment_billing_paidat");
// 4. (空行后) 交易号索引(部分索引:仅非空)
// 4. 交易号索引(部分索引:仅非空)
builder.HasIndex(x => x.TransactionNo)
.HasDatabaseName("idx_payment_transaction_no")
.HasFilter("\"TransactionNo\" IS NOT NULL");

View File

@@ -25,13 +25,13 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId);
// 2. (空行后) 按状态过滤
// 2. 按状态过滤
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
// 3. (空行后) 按日期范围过滤(账单周期)
// 3. 按日期范围过滤(账单周期)
if (from.HasValue)
{
query = query.Where(x => x.PeriodStart >= from.Value);
@@ -42,7 +42,7 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
query = query.Where(x => x.PeriodEnd <= to.Value);
}
// 4. (空行后) 排序返回
// 4. 排序返回
return await query
.OrderByDescending(x => x.PeriodEnd)
.ToListAsync(cancellationToken);
@@ -102,7 +102,7 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
// 1. 以当前 UTC 时间作为逾期判断基准
var now = DateTime.UtcNow;
// 2. (空行后) 查询逾期且仍处于待支付的账单(仅 Pending 才允许自动切换为 Overdue
// 2. 查询逾期且仍处于待支付的账单(仅 Pending 才允许自动切换为 Overdue
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
@@ -120,7 +120,7 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
var now = DateTime.UtcNow;
var dueTo = now.AddDays(daysAhead);
// 2. (空行后) 仅查询待支付账单
// 2. 仅查询待支付账单
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
@@ -198,19 +198,19 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
.AsNoTracking()
.Where(x => x.DeletedAt == null);
// 2. (空行后) 按租户过滤(可选)
// 2. 按租户过滤(可选)
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 按状态过滤(可选)
// 3. 按状态过滤(可选)
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
// 4. (空行后) 按日期范围过滤(账单周期)
// 4. 按日期范围过滤(账单周期)
if (from.HasValue)
{
query = query.Where(x => x.PeriodStart >= from.Value);
@@ -221,7 +221,7 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
query = query.Where(x => x.PeriodEnd <= to.Value);
}
// 5. (空行后) 按金额范围过滤(应付金额,包含边界)
// 5. 按金额范围过滤(应付金额,包含边界)
if (minAmount.HasValue)
{
query = query.Where(x => x.AmountDue >= minAmount.Value);
@@ -232,7 +232,7 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
query = query.Where(x => x.AmountDue <= maxAmount.Value);
}
// 6. (空行后) 关键字过滤(账单号或租户名)
// 6. 关键字过滤(账单号或租户名)
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
@@ -249,10 +249,10 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
select b;
}
// 7. (空行后) 统计总数
// 7. 统计总数
var total = await query.CountAsync(cancellationToken);
// 8. (空行后) 分页查询
// 8. 分页查询
var items = await query
.OrderByDescending(x => x.PeriodEnd)
.Skip((pageNumber - 1) * pageSize)
@@ -279,7 +279,7 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
&& x.PeriodStart >= startDate
&& x.PeriodEnd <= endDate);
// 2. (空行后) 聚合统计(金额统一使用:应付 - 折扣 + 税费)
// 2. 聚合统计(金额统一使用:应付 - 折扣 + 税费)
var now = DateTime.UtcNow;
var totalAmount = await query.SumAsync(x => x.AmountDue - x.DiscountAmount + x.TaxAmount, cancellationToken);
var paidAmount = await query.Where(x => x.Status == TenantBillingStatus.Paid).SumAsync(x => x.AmountPaid, cancellationToken);
@@ -288,13 +288,13 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
.Where(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now)
.SumAsync(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid, cancellationToken);
// 3. (空行后) 数量统计
// 3. 数量统计
var totalCount = await query.CountAsync(cancellationToken);
var paidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Paid, cancellationToken);
var unpaidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue, cancellationToken);
var overdueCount = await query.CountAsync(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now, cancellationToken);
// 4. (空行后) 趋势统计
// 4. 趋势统计
var normalizedGroupBy = NormalizeGroupBy(groupBy);
var trendRaw = await query
.Select(x => new
@@ -307,7 +307,7 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
})
.ToListAsync(cancellationToken);
// 4.1 (空行后) 在内存中按 Day/Week/Month 聚合(避免依赖特定数据库函数扩展)
// 4.1 在内存中按 Day/Week/Month 聚合(避免依赖特定数据库函数扩展)
var trend = trendRaw
.GroupBy(x => GetTrendBucket(x.PeriodStart, normalizedGroupBy))
.Select(g => new TenantBillingTrendDataPoint
@@ -371,7 +371,7 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
var dayOfWeek = (int)date.DayOfWeek; // Sunday=0, Monday=1, ...
var daysSinceMonday = (dayOfWeek + 6) % 7;
// 2. (空行后) 回退到周一 00:00:00UTC
// 2. 回退到周一 00:00:00UTC
var monday = date.AddDays(-daysSinceMonday);
return new DateTime(monday.Year, monday.Month, monday.Day, 0, 0, 0, DateTimeKind.Utc);
}

View File

@@ -773,8 +773,8 @@ public sealed class TakeoutAppDbContext(
.HasComment("发布状态0=草稿1=已发布。");
// 1. 解决 EF Core 默认值哨兵问题:当我们希望插入 false/0 时,若数据库配置了 default 且 EF 认为该值是“未设置”,会导致 insert 省略列,最终落库为默认值。
// 2. (空行后) 发布状态使用 -1 作为哨兵,避免 Draft=0 被误判为“未设置”而触发数据库默认值(发布/草稿切换必须可控)。
// 3. (空行后) 将布尔开关哨兵值设置为数据库默认值true 作为哨兵false 才会被显式写入,从而保证“可见性/可售开关”在新增时可正确落库。
// 2. 发布状态使用 -1 作为哨兵,避免 Draft=0 被误判为“未设置”而触发数据库默认值(发布/草稿切换必须可控)。
// 3. 将布尔开关哨兵值设置为数据库默认值true 作为哨兵false 才会被显式写入,从而保证“可见性/可售开关”在新增时可正确落库。
builder.Property(x => x.IsPublicVisible)
.HasDefaultValue(true)
.HasSentinel(true)
@@ -784,7 +784,7 @@ public sealed class TakeoutAppDbContext(
.HasSentinel(true)
.HasComment("是否允许新租户购买/选择(仅影响新购)。");
// 4. (空行后) 展示配置:推荐标识与标签(用于套餐展示页/对比页)
// 4. 展示配置:推荐标识与标签(用于套餐展示页/对比页)
builder.Property(x => x.IsRecommended)
.HasDefaultValue(false)
.HasComment("是否推荐展示(运营推荐标识)。");

View File

@@ -178,7 +178,7 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLog
// 1. 保存业务库变更
await context.SaveChangesAsync(cancellationToken);
// 2. (空行后) 保存日志库变更
// 2. 保存日志库变更
await logsContext.SaveChangesAsync(cancellationToken);
}

View File

@@ -406,7 +406,7 @@ public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext, Take
// 1. 保存业务库变更
await dbContext.SaveChangesAsync(cancellationToken);
// 2. (空行后) 保存日志库变更
// 2. 保存日志库变更
await logsContext.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -105,21 +105,21 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalizedName}%"));
}
// 4. (空行后) 按联系人过滤(模糊匹配)
// 4. 按联系人过滤(模糊匹配)
if (!string.IsNullOrWhiteSpace(contactName))
{
var normalizedContactName = contactName.Trim();
query = query.Where(x => EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{normalizedContactName}%"));
}
// 5. (空行后) 按联系电话过滤(模糊匹配)
// 5. 按联系电话过滤(模糊匹配)
if (!string.IsNullOrWhiteSpace(contactPhone))
{
var normalizedContactPhone = contactPhone.Trim();
query = query.Where(x => EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{normalizedContactPhone}%"));
}
// 6. (空行后) 兼容关键字查询:名称/编码/联系人/电话
// 6. 兼容关键字查询:名称/编码/联系人/电话
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalizedKeyword = keyword.Trim();
@@ -130,10 +130,10 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{normalizedKeyword}%"));
}
// 7. (空行后) 先统计总数,再按创建时间倒序分页
// 7. 先统计总数,再按创建时间倒序分页
var total = await query.CountAsync(cancellationToken);
// 8. (空行后) 查询当前页数据
// 8. 查询当前页数据
var items = await query
.OrderByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize)
@@ -169,18 +169,18 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
// 1. 标准化名称
var normalized = name.Trim();
// 2. (空行后) 构建查询(名称使用 ILike 做不区分大小写的等值匹配)
// 2. 构建查询(名称使用 ILike 做不区分大小写的等值匹配)
var query = context.Tenants
.AsNoTracking()
.Where(x => EF.Functions.ILike(x.Name, normalized));
// 3. (空行后) 更新场景排除自身
// 3. 更新场景排除自身
if (excludeTenantId.HasValue)
{
query = query.Where(x => x.Id != excludeTenantId.Value);
}
// 4. (空行后) 判断是否存在
// 4. 判断是否存在
return query.AnyAsync(cancellationToken);
}
@@ -282,7 +282,7 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
await context.TenantReviewClaims.AddAsync(claim, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// 2. (空行后) 写入审计日志
// 2. 写入审计日志
await logsContext.TenantAuditLogs.AddAsync(auditLog, cancellationToken);
await logsContext.SaveChangesAsync(cancellationToken);
return true;
@@ -292,7 +292,7 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
// 1. 释放实体跟踪避免重复写入
context.Entry(claim).State = EntityState.Detached;
// 2. (空行后) 返回抢占失败
// 2. 返回抢占失败
return false;
}
}
@@ -398,7 +398,7 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
// 1. 保存业务库变更
await context.SaveChangesAsync(cancellationToken);
// 2. (空行后) 保存日志库变更
// 2. 保存日志库变更
await logsContext.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -33,14 +33,14 @@ public sealed class BillingDomainService(
throw new InvalidOperationException("该订阅周期的账单已存在。");
}
// 2. (空行后) 查询套餐价格信息
// 2. 查询套餐价格信息
var package = await tenantPackageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken);
if (package is null)
{
throw new InvalidOperationException("订阅未关联有效套餐,无法生成账单。");
}
// 3. (空行后) 选择价格(简化规则:优先按年/按月)
// 3. 选择价格(简化规则:优先按年/按月)
var days = (subscription.EffectiveTo - subscription.EffectiveFrom).TotalDays;
var amountDue = days >= 300 ? package.YearlyPrice : package.MonthlyPrice;
if (!amountDue.HasValue)
@@ -48,7 +48,7 @@ public sealed class BillingDomainService(
throw new InvalidOperationException("套餐价格未配置,无法生成账单。");
}
// 4. (空行后) 生成账单明细
// 4. 生成账单明细
var lineItems = new List<BillingLineItem>
{
BillingLineItem.Create(
@@ -58,7 +58,7 @@ public sealed class BillingDomainService(
unitPrice: amountDue.Value)
};
// 5. (空行后) 构建账单实体
// 5. 构建账单实体
var now = DateTime.UtcNow;
return new TenantBillingStatement
{
@@ -98,7 +98,7 @@ public sealed class BillingDomainService(
// 1. 计算金额
var amountDue = quotaPackage.Price * quantity;
// 2. (空行后) 生成账单明细
// 2. 生成账单明细
var lineItems = new List<BillingLineItem>
{
BillingLineItem.Create(
@@ -108,7 +108,7 @@ public sealed class BillingDomainService(
unitPrice: quotaPackage.Price)
};
// 3. (空行后) 构建账单实体
// 3. 构建账单实体
var now = DateTime.UtcNow;
var billing = new TenantBillingStatement
{
@@ -139,7 +139,7 @@ public sealed class BillingDomainService(
// 1. 账单号格式BILL-{yyyyMMdd}-{序号}
var date = DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
// 2. (空行后) 使用雪花 ID 作为全局递增序号,确保分布式唯一
// 2. 使用雪花 ID 作为全局递增序号,确保分布式唯一
var sequence = idGenerator.NextId();
return $"BILL-{date}-{sequence}";
}
@@ -154,7 +154,7 @@ public sealed class BillingDomainService(
return 0;
}
// 2. (空行后) 批量标记逾期(防御性:再次判断 Pending
// 2. 批量标记逾期(防御性:再次判断 Pending
var processedAt = DateTime.UtcNow;
var updated = 0;
foreach (var billing in overdueBillings)
@@ -172,7 +172,7 @@ public sealed class BillingDomainService(
updated++;
}
// 3. (空行后) 持久化
// 3. 持久化
if (updated > 0)
{
await billingRepository.SaveChangesAsync(cancellationToken);

View File

@@ -33,7 +33,7 @@ public sealed class BillingExportService : IBillingExportService
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Billings");
// 2. (空行后) 写入表头
// 2. 写入表头
var headers = new[]
{
"Id", "TenantId", "StatementNo", "BillingType", "Status",
@@ -46,7 +46,7 @@ public sealed class BillingExportService : IBillingExportService
worksheet.Cell(1, i + 1).Value = headers[i];
}
// 3. (空行后) 写入数据行
// 3. 写入数据行
for (var rowIndex = 0; rowIndex < billings.Count; rowIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -73,7 +73,7 @@ public sealed class BillingExportService : IBillingExportService
worksheet.Cell(r, 16).Value = billing.LineItemsJson ?? string.Empty;
}
// 4. (空行后) 自动调整列宽并输出
// 4. 自动调整列宽并输出
worksheet.Columns().AdjustToContents();
using var stream = new MemoryStream();
@@ -102,7 +102,7 @@ public sealed class BillingExportService : IBillingExportService
// 2. 标题
column.Item().Text("Billings Export").FontSize(16).SemiBold();
// 3. (空行后) 逐条输出
// 3. 逐条输出
for (var i = 0; i < billings.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -129,7 +129,7 @@ public sealed class BillingExportService : IBillingExportService
});
});
// 4. (空行后) 输出字节
// 4. 输出字节
var bytes = document.GeneratePdf();
return Task.FromResult(bytes);
}
@@ -169,7 +169,7 @@ public sealed class BillingExportService : IBillingExportService
csv.WriteField("LineItemsJson");
await csv.NextRecordAsync();
// 3. (空行后) 写入数据行
// 3. 写入数据行
foreach (var b in billings)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -196,7 +196,7 @@ public sealed class BillingExportService : IBillingExportService
await csv.NextRecordAsync();
}
// 4. (空行后) Flush 并返回字节
// 4. Flush 并返回字节
await writer.FlushAsync(cancellationToken);
return stream.ToArray();
}

View File

@@ -22,13 +22,13 @@ public sealed class RedisAdminPasswordResetTokenStore(
// 1. 生成 URL 安全的随机令牌
var token = GenerateUrlSafeToken(48);
// 2. (空行后) 写入缓存ValueuserId
// 2. 写入缓存ValueuserId
await cache.SetStringAsync(BuildKey(token), userId.ToString(), new DistributedCacheEntryOptions
{
AbsoluteExpiration = expiresAt
}, cancellationToken);
// 3. (空行后) 返回令牌
// 3. 返回令牌
return token;
}
@@ -43,10 +43,10 @@ public sealed class RedisAdminPasswordResetTokenStore(
return null;
}
// 2. (空行后) 删除缓存(一次性令牌)
// 2. 删除缓存(一次性令牌)
await cache.RemoveAsync(key, cancellationToken);
// 3. (空行后) 解析用户 ID
// 3. 解析用户 ID
return long.TryParse(value, out var userId) ? userId : null;
}

View File

@@ -30,7 +30,7 @@ public sealed class IdentityUserOperationLogConsumer(TakeoutLogsDbContext logsCo
return;
}
// 2. (空行后) 构建日志实体与去重记录
// 2. 构建日志实体与去重记录
var message = context.Message;
var log = new OperationLog
{
@@ -50,7 +50,7 @@ public sealed class IdentityUserOperationLogConsumer(TakeoutLogsDbContext logsCo
});
logsContext.OperationLogs.Add(log);
// 3. (空行后) 保存并处理并发去重冲突
// 3. 保存并处理并发去重冲突
try
{
await logsContext.SaveChangesAsync(context.CancellationToken);

View File

@@ -27,7 +27,7 @@ public static class OperationLogOutboxServiceCollectionExtensions
throw new InvalidOperationException("缺少 RabbitMQ 配置。");
}
// 2. (空行后) 注册 MassTransit 与 Outbox
// 2. 注册 MassTransit 与 Outbox
services.AddMassTransit(configurator =>
{
configurator.AddConsumer<IdentityUserOperationLogConsumer>();
@@ -50,7 +50,7 @@ public static class OperationLogOutboxServiceCollectionExtensions
cfg.ConfigureEndpoints(context);
});
});
// 3. (空行后) 返回服务集合
// 3. 返回服务集合
return services;
}
}