Skip to content

Mud.Feishu.Redis ​

飞书事件订阅组件 Redis 分布式去重扩展。

功能特性 ​

  • 事件去重: 基于 EventId 的分布式去重,使用 Redis Hash + Lua 脚本实现状态机模式(Processing → Completed),支持超时恢复和异常回滚
  • Nonce 去重: 防止重放攻击,使用 Redis SET NX EX 确保请求唯一性
  • SeqID 去重: WebSocket 二进制消息序列号去重,使用 Redis Sorted Set 支持范围查询,含 TTL 与写入时裁剪(有界增长)
  • 多应用隔离: 所有去重器支持 appKey 参数,避免跨应用事件冲突
  • SeqID 实例隔离: scopeKey 构造参数,多实例共享 Redis 时不互相判重
  • 统一键构造: RedisKeyBuilder 统一四类键构造,含分段转义、长度上限、空前缀护栏
  • 可分类异常: FeishuRedisException + FeishuRedisFailureKind,消费侧可区分连接故障与服务端错误
  • 健康检查: 内置 Redis 健康检查,自动注册到 ASP.NET Core 健康检查系统
  • 配置验证: 启动时自动验证配置有效性(ValidateOnStart),敏感信息掩码输出
  • 原子性操作: 使用 Lua 脚本确保去重操作的原子性,服务端时钟消除时钟漂移
  • 自动过期: Redis 自动清理过期数据,无需手动维护
  • 分布式支持: 适用于多实例部署场景,Cluster 下多节点聚合扫描
  • AOT 兼容: net8.0+ 原生 AOT 支持,源生成 JSON 序列化,无反射依赖

⚠️ v3 变更:本组件不提供内置的 Redis 故障降级到内存去重能力(原 RedisFeishuEventDistributedDeduplicatorWithFallback 已移除)。 如需降级,由上层(Webhook NonceValidationFailureMode / WebSocket 异常处理)按 FeishuRedisFailureKind 决定策略。

安装 ​

bash
dotnet add package Mud.Feishu.Redis

快速开始 ​

配置 Redis 连接 ​

在 appsettings.json 中添加配置:

json
{
  "FeishuRedis": {
    "ServerAddress": "localhost:6379",
    "Password": "",
    "EventCacheExpiration": "48:00:00",
    "NonceTtl": "00:05:00",
    "SeqIdCacheExpiration": "48:00:00",
    "EventKeyPrefix": "feishu:event:",
    "NonceKeyPrefix": "feishu:nonce:",
    "SeqIdKeyPrefix": "feishu:seqid:",
    "SeqIdScopeKey": "",  // 可选,为空时自动合成 {AppKey}|{MachineName}
    "AppKey": "default",  // 用于 SeqID scopeKey 合成
    "ConnectTimeout": 5000,
    "SyncTimeout": 5000,
    "Ssl": false,
    "AllowAdmin": false,
    "AbortOnConnectFail": true,
    "ConnectRetry": 3,
    "DefaultDatabase": 0
  }
}

💡 TimeSpan 配置说明:三段格式 "hh:mm:ss" 首段是小时("48:00:00" 表示 48 小时);需要按天配置时使用四段格式 "d.hh:mm:ss"("2.00:00:00" 表示 48 小时)。

注册服务 ​

方式一:从配置文件注册所有去重服务 ​

csharp
using Mud.Feishu.Redis.Extensions;

// 自动从配置文件读取 Redis 连接信息并注册所有去重服务
builder.Services.AddFeishuRedisDeduplicators(builder.Configuration);

方式二:代码配置注册所有去重服务 ​

csharp
// 通过代码配置 Redis 连接信息
builder.Services.AddFeishuRedisDeduplicators(options =>
{
    options.ServerAddress = "localhost:6379";
    options.Password = "your_password";
    options.EventCacheExpiration = TimeSpan.FromHours(48);
    options.NonceTtl = TimeSpan.FromMinutes(5);
    options.ConnectRetry = 3;
});

方式三:自定义配置节名称 ​

csharp
// 使用自定义配置节名称
builder.Services.AddFeishuRedisDeduplicators(
    builder.Configuration,
    sectionName: "MyRedisConfig");

完整示例 ​

csharp
var builder = WebApplication.CreateBuilder(args);

// 注册 Redis 去重服务(从配置文件读取)
builder.Services.AddFeishuRedisDeduplicators(builder.Configuration);

// 注册 WebSocket 事件订阅
builder.Services.CreateFeishuWebSocketServiceBuilder(builder.Configuration)
    .AddHandler<MessageEventHandler>()
    .Build();

var app = builder.Build();

// 健康检查已自动注册,可通过端点查看
app.MapHealthChecks("/health");

app.Run();

配置选项 ​

RedisOptions 配置项 ​

参数类型默认值说明
ServerAddressstring"localhost:6379"Redis 服务器地址(host:port 或 redis://host:port 或 rediss://host:port)
Passwordstring""Redis 密码
EventCacheExpirationTimeSpan48 小时事件去重缓存过期时间
NonceTtlTimeSpan5 分钟Nonce 有效期
SeqIdCacheExpirationTimeSpan48 小时SeqID 去重缓存过期时间
EventKeyPrefixstring"feishu:event:"事件去重键前缀
NonceKeyPrefixstring"feishu:nonce:"Nonce 去重键前缀
SeqIdKeyPrefixstring"feishu:seqid:"SeqID 去重键前缀
SeqIdScopeKeystring""(自动合成)SeqID 实例隔离维度,为空时自动合成 {AppKey}|{MachineName}
AppKeystring"default"用于 SeqID scopeKey 合成
ConnectTimeoutint5000ms连接超时时间(最小 1000ms)
SyncTimeoutint5000ms同步超时时间(最小 1000ms)
Sslboolfalse是否启用 TLS/SSL
AllowAdminboolfalse是否允许管理员操作(仅生产环境需要时启用)
AbortOnConnectFailbooltrue是否在连接失败时中止
ConnectRetryint3连接重试次数
DefaultDatabaseint?null默认数据库索引
ClientNamestring?null客户端名称(默认自动生成)

ℹ️ 高级去重参数:在 FeishuRedis:Deduplication 子节下可配置 ProcessingTimeout(处理超时阈值,默认 10 分钟)。注意:CacheExpiration 和 KeyPrefix 由上表中的 EventCacheExpiration / EventKeyPrefix 优先覆盖。

去重服务详解 ​

事件去重(Event Deduplication) ​

事件去重采用 状态机模式,使用 Redis Hash 存储事件状态,Lua 脚本保证原子性:

Pending → Processing → Completed
              ↓            ↑
         (超时恢复)    (正常完成)
              ↓
         (异常回滚 → Pending)

状态说明:

状态说明
Pending初始状态,事件尚未开始处理
Processing事件正在处理中,其他实例将跳过
Completed事件处理完成,永久跳过
TimeoutRecoverable处理中超时,允许重新处理

Redis 数据结构:

  • Key: {keyPrefix}{appKey}:{eventId}
  • Type: Hash
  • Fields: status (processing/completed), timestamp (UTC 时间)
  • TTL: 由 EventCacheExpiration 指定

核心 API:

方法说明
TryMarkAsProcessingAsync尝试标记事件为处理中,返回去重结果
MarkAsCompletedAsync标记事件为已完成
RollbackProcessingAsync回滚处理中状态,允许重新处理
IsProcessedAsync检查事件是否已处理
GetStatusAsync获取事件当前状态
RemoveAsync手动移除去重标记
RemoveRangeAsync批量移除去重标记
GetCachedCountAsync获取缓存中的事件数量

Nonce 去重(Nonce Deduplication) ​

使用 Redis SETNX + EXPIRE 实现原子性去重,防止重放攻击:

Redis 数据结构:

  • Key: {keyPrefix}{appKey}:{nonce}
  • Type: String
  • Value: "1"
  • TTL: 由 NonceTtl 指定

核心 API:

方法说明
TryMarkAsUsedAsync尝试标记 Nonce 为已使用
IsUsedAsync检查 Nonce 是否已使用
RemoveAsync手动移除 Nonce 标记
RemoveRangeAsync批量移除 Nonce 标记
GetCachedCountAsync获取缓存中的 Nonce 数量

SeqID 去重(SeqID Deduplication) ​

使用 Redis String + Sorted Set 实现 WebSocket 消息序列号去重:

Redis 数据结构:

  • Key: {keyPrefix}{scopeKey}{seqId} (String 类型,记录已处理状态)
  • Sorted Set: {keyPrefix}{scopeKey}set (记录所有已处理的 SeqID,支持范围查询)
  • TTL: 由 SeqIdCacheExpiration 指定(写入时刷新,Sorted Set 同生命周期)

💡 scopeKey 默认为 {AppKey}|{MachineName},多实例共享 Redis 时不互相判重。可通过 RedisOptions.SeqIdScopeKey 自定义。 Sorted Set 在写入时执行 ZREMRANGEBYSCORE 裁剪过期成员,确保集合有界增长。

⚠️ GetCacheCount / GetMaxProcessedSeqId 的语义已收窄为「TTL 窗口内」。

核心 API:

方法说明
TryMarkAsProcessedAsync尝试标记 SeqID 为已处理
IsProcessedAsync检查 SeqID 是否已处理
GetMaxProcessedSeqId获取最大已处理 SeqID
GetCacheCount获取缓存数量
ClearCacheAsync清空缓存

异常处理 ​

所有 Redis 去重器在 Redis 异常时抛出 FeishuRedisException,消费侧可按 FailureKind 分类处理:

FeishuRedisFailureKind含义典型场景建议策略
Connection连接故障网络中断、Redis 宕机可降级到内存去重或拒绝请求
Timeout操作超时大 Value、网络延迟重试或降级
Server服务端/配置错误Cluster MOVED、权限拒绝不重试,告警运维
csharp
try
{
    var result = await deduplicator.TryMarkAsProcessingAsync(eventId);
}
catch (FeishuRedisException ex)
{
    if (ex.FailureKind == FeishuRedisFailureKind.Connection)
    {
        // 可降级到内存去重
        logger.LogWarning(ex, "Redis 连接故障,降级到内存去重");
    }
    else
    {
        // 服务端/配置类错误不降级
        throw;
    }
}

⚠️ 本组件不提供内置的 Redis 故障降级到内存去重的能力。如需降级,由上层(Webhook NonceValidationFailureMode / WebSocket 异常处理)决定策略。

best-effort API ​

以下 API 设计为 best-effort(失败记日志、不抛出):

  • RollbackProcessingAsync(事件回滚)
  • RemoveAsync / RemoveRangeAsync(手动移除去重标记)
  • GetCachedCountAsync(获取缓存数量)
  • ClearCacheAsync(清空 SeqID 缓存)

健康检查 ​

注册 Redis 去重服务时,健康检查会自动注册到 ASP.NET Core 健康检查系统:

csharp
// 健康检查已自动注册,标签为 "redis" 和 "feishu"
builder.Services.AddFeishuRedisDeduplicators(builder.Configuration);

// 映射健康检查端点
app.MapHealthChecks("/health");

健康检查会执行 Redis PING 命令,并返回延迟和连接端点信息。

多应用隔离 ​

所有去重器支持 appKey 参数,在多应用场景下避免事件冲突:

csharp
// 标记事件为处理中(指定应用)
var result = await deduplicator.TryMarkAsProcessingAsync(eventId, appKey: "hr-app");

// 标记事件为已完成(指定应用)
await deduplicator.MarkAsCompletedAsync(eventId, appKey: "hr-app");

// 检查事件是否已处理(指定应用)
var isProcessed = await deduplicator.IsProcessedAsync(eventId, appKey: "hr-app");

Redis 键格式:{keyPrefix}{appKey}:{eventId},不同应用的事件互不干扰。

常见问题 ​

1. 如何更改默认的缓存过期时间? ​

在配置文件中修改:

json
{
  "FeishuRedis": {
    "EventCacheExpiration": "7.00:00:00",
    "NonceTtl": "00:10:00",
    "SeqIdCacheExpiration": "7.00:00:00"
  }
}

2. 多个环境如何隔离数据? ​

通过配置文件设置不同的键前缀:

开发环境 appsettings.Development.json:

json
{
  "FeishuRedis": {
    "EventKeyPrefix": "dev:feishu:event:",
    "NonceKeyPrefix": "dev:feishu:nonce:",
    "SeqIdKeyPrefix": "dev:feishu:seqid:"
  }
}

生产环境 appsettings.Production.json:

json
{
  "FeishuRedis": {
    "EventKeyPrefix": "prod:feishu:event:",
    "NonceKeyPrefix": "prod:feishu:nonce:",
    "SeqIdKeyPrefix": "prod:feishu:seqid:"
  }
}

💡 键前缀非空且不以 * 开头,否则启动期校验失败(R-01 护栏)。

3. 如何使用 TLS/SSL 连接 Redis? ​

在配置文件中启用 SSL:

json
{
  "FeishuRedis": {
    "ServerAddress": "secure.redis.com:6380",
    "Password": "your_password",
    "Ssl": true
  }
}

或使用 rediss:// 协议:

json
{
  "FeishuRedis": {
    "ServerAddress": "rediss://secure.redis.com:6380"
  }
}

4. 如何处理事件处理超时? ​

事件去重器内置了超时恢复机制。当事件处于 Processing 状态超过 processingTimeout(默认 10 分钟)时,TryMarkAsProcessingAsync 会返回 TimeoutRecoverable 结果,允许重新处理:

csharp
var result = await deduplicator.TryMarkAsProcessingAsync(eventId);

if (result.Status == DeduplicationStatus.TimeoutRecoverable)
{
    // 事件之前处理超时,可以安全地重新处理
    _logger.LogWarning("事件 {EventId} 处理超时,重新处理", result.EventId);
}

5. 如何回滚事件处理状态? ​

当事件处理过程中发生异常时,可以回滚状态,允许后续重新处理:

csharp
try
{
    var result = await deduplicator.TryMarkAsProcessingAsync(eventId);
    if (result.IsDuplicate)
        return;

    // 处理业务逻辑...
    await ProcessBusinessLogicAsync();

    // 标记为已完成
    await deduplicator.MarkAsCompletedAsync(eventId);
}
catch (Exception ex)
{
    // 回滚处理状态,允许重新处理
    await deduplicator.RollbackProcessingAsync(eventId);
    throw;
}

⚠️ 自 3.0.0 起 MarkAsCompletedAsync 不再为不存在的键创建永久记录;事件去重的亚秒级 ttl 会直接抛异常。

6. 配置验证失败怎么办? ​

服务注册时会自动验证配置有效性,常见验证规则:

  • ServerAddress 不能为空,格式须为 host:port 或 redis://host:port 或 rediss://host:port
  • ConnectTimeout 必须至少为 1000 毫秒
  • SyncTimeout 必须至少为 1000 毫秒
  • ConnectRetry 不能为负数
  • 键前缀不能为空、不能以 * 开头;TTL 不能为 0 或负数

验证失败时应用将无法启动,并在日志中输出具体错误信息。

安全 ​

令牌明文存储 ​

Redis 令牌存储(RedisTokenStore / RedisUserTokenStore)默认以明文形式将 access token 和 refresh token 持久化到 Redis。这意味着:

  • 拥有 Redis 访问权限的人员可以直接读取令牌。
  • Redis 数据落盘(RDB/AOF)或备份文件中包含明文令牌。

如需加密存储,请启用 FeishuAppOptions.EnableTokenEncryption = true 并注册 IEncryptionProvider:

csharp
builder.Services.AddFeishuApp(options =>
{
    options.EnableTokenEncryption = true;
});
// 注册 IEncryptionProvider 实现(如 AES-GCM)
builder.Services.AddSingleton<IEncryptionProvider, AesEncryptionProvider>();

未启用加密时,启动期会产生告警日志(Warning 级别),提示令牌以明文存储。

ℹ️ 自 3.0.0 起 Redis 令牌键布局变更为 feishu:{appKey}:token*(与 Memory 对齐,旧键不再读取); 请改用 PerAppRedisTokenStoreFactory(SingletonFeishuTokenStoreFactory 已废弃)。

键前缀护栏 ​

RedisKeyBuilder 在构造键时强制:

  • 前缀非空(空前缀 + * pattern 会退化为全库 SCAN,R-01 护栏)。
  • 前缀不以 * 开头(通配符前缀导致 SCAN 匹配所有键)。
  • 分段转义:段内的 : 替换为 \:,杜绝 appKey="a:b" 与 appKey="a" + eventId="b:c" 产生相同键(R-20)。
  • 单段长度上限 256 字节,防止超长键 DoS。

许可证 ​

MIT License