新能力包:锁 / 开关 / 存储 / 通知 / 本地化 / 审计 / 应用层 / 运维
一轮补齐的 8 个能力包。共同原则:业务只依赖抽象,换实现不改代码;未接入的能力在运行时为 null,不报错。
Ledon.Locking —— 分布式锁
builder.Services.AddLedonLocking(); // 进程内(单实例)
builder.Services.AddLedonLocking(o => o.Provider = LedonLockProvider.Redis,
o => o.RedisConnectionString = "localhost:6379"); // 多实例
TryAcquireAsync不等待(拿不到返回null);AcquireAsync等待超时抛TimeoutException;- 持锁默认 30 秒到期自动释放(持锁进程崩溃不死锁);
- Redis 实现 SET NX PX + Lua 校验令牌释放,不会误删他人已到期的锁。
长临界区必须续期(看门狗)
锁是固定 TTL 的:临界区执行超过 TTL 时锁已过期,别的进程能拿到同一把锁,
两个进程同时认为持有(脑裂)。ILockHandle.RenewAsync + LockWatchdog 把这件事变成两行:
await using var lease = await locker.AcquireAsync("job:nightly", expiry: TimeSpan.FromSeconds(30));
await using var watchdog = lease.WithWatchdog(logger, expiry: TimeSpan.FromSeconds(30));
watchdog.LockLost += () => logger.LogWarning("锁丢了,必须停止工作");
// 长任务用 watchdog.Lifetime:续期失败/锁丢失时它会被取消
await NightlyJobAsync(watchdog.Lifetime);
- 看门狗按
expiry / 3续期(下限 1 秒),续期是 Lua 原子操作——不会把别人的锁续命; RenewAsync返回false/ 续期调用本身失败,都按"锁已丢"处理并取消Lifetime;- 进程内实现没有"过期"概念,
RenewAsync恒成功(语义一致,业务不必区分部署形态)。
Ledon.FeatureManagement —— 功能开关与灰度
builder.Services.AddLedonFeatureManagement();
// 配置(Nacos/Apollo 远程配置同样生效)
// "Features": { "new-checkout": true, "beta-report": { "Percentage": 20 }, "vip-only": { "Tenants": [ "t-001" ] } }
- 未登记的开关默认关(fail-closed);
- 端点级开关:
.AddEndpointFilter(new FeatureGateFilter("beta-report")),未启用返回 404; - 百分比分桶稳定:同一主体判定不变——用 FNV-1a 对
开关名:主体键取模, 跨进程/跨重启一致(HashCode.Combine每进程随机种子,会让同一用户在不同实例落到不同桶)。
Ledon.MultiTenancy —— 多租户
builder.Services.AddLedonMultiTenancy(o =>
{
o.RequireTenant = true; // 强租户模式:漏带 X-Tenant-Id 直接 400,不按单租户放行
o.TenantValidator = new MyTenantValidator(); // 可选:校验租户 ID 真实存在/启用
});
app.UseLedonMultiTenancy();
- 解析来源:请求头 / 查询串 / 用户声明 / Host 子域名(
TenantSource); RequireTenant:多租户 SaaS 应该打开。否则漏带头的请求会静默看到全量数据 (跨租户泄漏),而且日志里只有一行 Debug,极难发现;TenantValidator:不信任请求里的租户 ID,否则客户端可以枚举;- 环境租户随请求管线流动(
AsyncLocal),后台任务也拿得到; - 缓存键自动按租户加前缀(配了缓存包时),不配则租户间互串。
Ledon.SignalR —— 实时推送
builder.Services.AddLedonSignalR(o => o.RedisConnectionString = "localhost:6379");
app.MapLedonSignalRHubs(); // ChatHub → /hubs/chat
// 在线状态自动维护:Hub 继承 OnlineAwareHub 即可,不用手写 OnConnectedAsync
public class ChatHub : OnlineAwareHub
{
public Task Send(string text) => Clients.All.SendAsync("recv", text);
}
- Hub 自动发现 + 约定路径映射;多实例部署挂 Redis backplane(跨实例广播);
- 在线注册表:配了 Redis 就用 Redis 实现(全局在线视图 + 跨实例踢下线); 未配则进程内(只覆盖本实例连接);
- 连接登记带 TTL 兜底:进程被杀时
OnDisconnectedAsync不会执行,靠过期自动清理。
Ledon.BlobStorage —— 文件/对象存储
// 本地磁盘
builder.Services.AddLedonBlobStorage(o =>
{
o.Provider = LedonBlobProvider.Local;
o.RootPath = "App_Data/blobs";
o.AllowedExtensions.Add(".png");
});
// S3 / MinIO(路径风格,自动建桶;AWS 官方 S3 可省略 ServiceUrl)
builder.Services.AddLedonBlobStorage(o =>
{
o.Provider = LedonBlobProvider.S3;
o.ServiceUrl = "http://localhost:9000";
o.BucketName = "uploads";
o.AccessKey = "<从密钥管理注入>";
o.SecretKey = "<从密钥管理注入>";
o.Region = "us-east-1";
});
public class AvatarService(IFileStorage storage)
{
public async Task<string> UploadAsync(Stream content, CancellationToken ct)
{
var path = $"avatars/{Guid.NewGuid():N}.png";
await storage.SaveAsync(path, content, ct: ct);
return path;
}
}
- 路径即主键;防目录穿越;默认拒绝覆盖(S3 用
If-None-Match: *);大小上限默认 100MB; - 本地写入先落临时文件再原子替换:超限/失败不会留下半截文件;
- 多租户默认按
tenants/{tenantId}/加前缀,租户间文件互不可见;ListAsync同样限定在当前租户; - S3 兼容 MinIO(
ForcePathStyle = true);未配置访问密钥时走 AWS 默认凭证链。
Ledon.Notifications —— 通知发送
builder.Services.AddLedonNotifications(o =>
{
o.SmtpHost = "smtp.example.com";
o.FromAddress = "noreply@example.com";
});
await sender.SendAsync(new Notification("email", "a@b.c", "欢迎注册", "感谢注册"), ct);
- 渠道可替换(实现
INotificationSender即接入短信/推送); - 失败自动重试(
MaxRetries默认 3 次,指数退避:RetryBaseDelay × 2^(n-1), 封顶RetryMaxDelay,可选 ±25% 抖动——批量同时失败时不挤在同一时刻重试); - 批量发送有界并发(
BatchConcurrency默认 8;串行发送 100 条要约 100 倍延迟); - 消息去重(
DeduplicationKey):渠道实际送达但返回失败时不会让用户收到重复通知; INotificationLog可查发送记录与成功率(Attempts记真实尝试次数)。
Ledon.Localization —— 多语言
builder.Services.AddLedonLocalization(o => { o.DefaultCulture = "zh-CN"; o.SupportedCultures.Add("en-US"); });
app.UseLedonRequestLocalization(); // 放在 UseRouting 之前
- 文化解析优先级:查询串 → Cookie →
Accept-Language(按 q 值)→ 默认; - 语言相同地区不同时回退(
zh-TW→zh-CN);名单外文化被忽略; - 文案来自
Resources/{culture}.json,改文件即时生效。
Ledon.AuditLog —— 操作审计
builder.Services.AddLedonAudit<AppDbContext>();
app.UseLedonAuditContext();
- 零改动:EF SaveChanges 拦截器自动记录新增/修改/删除,修改只记真实变化的属性;
- 审计写在业务事务提交之后:业务回滚不留假记录;审计自身失败只记日志;
- 操作人来自登录声明;后台任务用
AuditContext.BeginScope(userId, name)声明; - 查询
IAuditStore.QueryAsync(entityType, entityId);保留策略PurgeOlderThanAsync也由后台自动执行:RetentionDays(默认 90,0 = 关闭)按RetentionSweepInterval(默认 1 小时)清理过期流水。
Ledon.Application —— 应用层管线(CQRS)
builder.Services.AddLedonApplication(o =>
{
o.UseValidation(); // IValidatable 请求先校验
o.UseLogging(); // 请求类型 + 耗时 + span
o.UseTransactions(); // 包进 IUnitOfWork(未注册则直通)
o.UseCaching(); // ICacheableQuery 自动走缓存
});
public sealed record CreateOrder(string Sku, int Qty) : ICommand<Guid>, IValidatable
{
public IReadOnlyCollection<string> Validate() => Qty <= 0 ? ["数量必须大于 0"] : [];
}
public class CreateOrderHandler(IUnitOfWork uow) : ICommandHandler<CreateOrder, Guid> { /* 只管业务 */ }
var id = await sender.SendAsync(new CreateOrder("SKU-1", 2), ct);
处理器经约定扫描自动注册;行为按登记顺序织入(外层先执行);自定义行为实现 IPipelineBehavior<TRequest>。
Ledon.Admin —— 统一运维端点
builder.Services.AddLedonAdmin(o =>
{
o.RoutePrefix = "/ops"; // 默认 /admin/ops(首尾斜杠自动规范化)
o.RequireAuthorization = true; // 生产建议开启;需同时注册认证方案,否则启动失败
});
端点({前缀} = RoutePrefix) |
内容 |
|---|---|
GET {前缀}/summary |
待投递/死信、队列累计指标、运行中工作流、定时任务与近期失败 |
GET {前缀}/outbox/dead-letters |
搁置消息清单 |
POST {前缀}/outbox/dead-letters/{id}/replay |
重放单条死信 |
GET {前缀}/scheduler |
任务清单 + 最近执行历史 |
只有死信重放一个写操作;未接入的能力返回空数据而不是报错。运维端点会暴露内部运行信息:
生产环境请开启 RequireAuthorization 或在外层网关做访问控制;只声明授权而未注册认证方案时,
框架会在启动阶段直接报错,避免静默裸奔。