多租户部署实战
本教程演示如何在单一 EnerOS 集群中承载多个电力运营商租户,实现拓扑、数据与配额的物理隔离。
多租户架构概览
EnerOS 的多租户体系由 eneros-tenant crate 提供,核心抽象为「租户(Tenant)— 成员(Member)— 配额(Quota)— 用量(Usage)— API Key 绑定」五元组。所有租户元数据持久化于 SQLite(SqliteTenantStorage),上层服务通过 TenantManager 与 TenantUsageTracker 两个对象完成业务操作。
| 组件 | 类型 | 职责 |
|---|---|---|
Tenant | 实体 | 租户基本信息(id / name / status / quotas / 时间戳) |
TenantMember | 实体 | 用户与租户的归属关系及角色 |
TenantQuota | 值对象 | 资源上限(设备数 / Agent 数 / API 调用 / 存储) |
TenantUsage | 值对象 | 实时用量快照(current_devices / current_agents / api_calls_today / storage_used_mb) |
TenantStatus | 枚举 | Active / Suspended / Deleted 三态 |
ApiKeyBinding | 实体 | API Key → (username, role, tenant_id) 映射 |
TenantStorage | trait | 持久化抽象(SQLite 为默认实现) |
TenantManager | 服务 | 租户/成员/配额/API Key 高层 API |
TenantUsageTracker | 服务 | 原子计数器与配额校验 |
QuotaResource | 枚举 | Devices / Agents / ApiCalls / Storage |
租户状态机:
| 状态 | 含义 | 可见性 | 可写 |
|---|---|---|---|
Active | 正常服务 | list_tenants(false) 可见 | 是 |
Suspended | 停用但保留数据 | list_tenants(true) 可见 | 否(validate_tenant_access 拒绝) |
Deleted | 软删除标记 | 始终隐藏 | 否(视为 NotFound) |
准备工作
- 已部署 EnerOS 集群(3 节点起,参考 高可用集群部署)
- 已完成内部 CA 与 mTLS 证书签发流程
- SQLite 3.34+(集群使用共享块存储挂载
/var/lib/eneros/tenant.db,或 Raft 复制引擎代理) - 已安装
eneros-cli,且具备tenant:admin角色
步骤 1:规划租户
为每个租户分配唯一 tenant_id、独立网络命名空间与证书 SAN。建议采用「业务层级 + 短代码」命名,避免与 UUID v4 主键冲突。
| 租户 | tenant_id | 网络命名空间 | 数据库 | 默认角色 | 配额等级 |
|---|---|---|---|---|---|
| 国调中心 | gs | gs-net | eneros_gs | liaison | L3(大) |
| 省调中心 | ps | ps-net | eneros_ps | operator | L2(中) |
| 地调中心 | ds | ds-net | eneros_ds | viewer | L1(小) |
| 默认租户 | default | default-net | eneros_default | viewer | L0(沙箱) |
配额等级参考表:
| 等级 | max_devices | max_agents | max_api_calls_per_day | max_storage_mb |
|---|---|---|---|---|
| L0 沙箱 | 50 | 5 | 5_000 | 512 |
| L1 小型 | 500 | 20 | 50_000 | 5_120 |
| L2 中型 | 2_000 | 50 | 200_000 | 20_480 |
| L3 大型 | 10_000 | 200 | 1_000_000 | 102_400 |
步骤 2:配置租户隔离
在 eneros.toml 中声明租户:
# /etc/eneros/eneros.toml
[tenant]
db_path = "/var/lib/eneros/tenant.db"
default_quota = { max_devices = 1000, max_agents = 50, max_api_calls_per_day = 100000, max_storage_mb = 10240 }
[[tenants]]
id = "gs"
display_name = "国调中心"
network = "gs-net"
database = "eneros_gs"
cert_san = ["*.gs.eneros.internal"]
quota = { max_devices = 10000, max_agents = 200, max_api_calls_per_day = 1000000, max_storage_mb = 102400 }
[[tenants]]
id = "ps"
display_name = "省调中心"
network = "ps-net"
database = "eneros_ps"
cert_san = ["*.ps.eneros.internal"]
quota = { max_devices = 2000, max_agents = 50, max_api_calls_per_day = 200000, max_storage_mb = 20480 }
[[tenants]]
id = "ds"
display_name = "地调中心"
network = "ds-net"
database = "eneros_ds"
cert_san = ["*.ds.eneros.internal"]
quota = { max_devices = 500, max_agents = 20, max_api_calls_per_day = 50000, max_storage_mb = 5120 }
字段说明:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | 是 | 租户唯一标识,与证书 SAN 中的 tenant 字段一致 |
display_name | string | 是 | 中文显示名,用于审计日志与 UI |
network | string | 是 | Linux network namespace 名称,需先于 eneros-os 启动前创建 |
database | string | 是 | 租户专用 SQLite/PostgreSQL 数据库实例名 |
cert_san | string[] | 是 | 证书 Subject Alternative Name 通配符,mTLS 握手强制校验 |
quota.max_devices | u32 | 否 | 最大设备数,默认 1000 |
quota.max_agents | u32 | 否 | 最大 Agent 数,默认 50 |
quota.max_api_calls_per_day | u64 | 否 | 每日 API 调用上限,默认 100_000 |
quota.max_storage_mb | u64 | 否 | 最大存储(MB),默认 10_240 |
步骤 3:使用 TenantManager 创建租户
通过 TenantManager 编程化创建租户。create_tenant 会自动生成 UUID v4 作为主键,并以 Active 状态初始化全 0 用量行。
use std::sync::Arc;
use eneros_tenant::{
SqliteTenantStorage, TenantManager, TenantQuota, TenantStatus,
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. 打开 SQLite 存储(自动建表 + WAL 模式 + foreign_keys = ON)
let storage = Arc::new(SqliteTenantStorage::new("/var/lib/eneros/tenant.db")?);
// 2. 构造 Manager
let manager = TenantManager::new(storage.clone());
// 3. 幂等确保 default 租户存在(首次部署调用)
let default = manager.ensure_default_tenant()?;
println!("default tenant: id={}, status={:?}", default.id, default.status);
// 4. 创建国调租户(L3 配额)
let gs_quota = TenantQuota {
max_devices: 10_000,
max_agents: 200,
max_api_calls_per_day: 1_000_000,
max_storage_mb: 102_400,
};
let gs = manager.create_tenant("国调中心", gs_quota)?;
println!("created tenant: id={}, name={}", gs.id, gs.name);
// 5. 创建省调租户(L2 配额)
let ps_quota = TenantQuota {
max_devices: 2_000,
max_agents: 50,
max_api_calls_per_day: 200_000,
max_storage_mb: 20_480,
};
let ps = manager.create_tenant("省调中心", ps_quota)?;
println!("created tenant: id={}, name={}", ps.id, ps.name);
// 6. 列出所有活跃租户
let active = manager.list_tenants(false)?;
println!("active tenants: {}", active.len());
for t in &active {
println!(" - {} ({}) quotas={:?}", t.name, t.id, t.quotas);
}
Ok(())
}
输出示例:
default tenant: id=default, status=Active
created tenant: id=8a7b3c2d-1e2f-4a3b-9c8d-7e6f5a4b3c2d, name=国调中心
created tenant: id=2d3e4f5a-6b7c-8d9e-0f1a-2b3c4d5e6f7a, name=省调中心
active tenants: 3
- Default Tenant (default) quotas=TenantQuota { max_devices: 1000, max_agents: 50, max_api_calls_per_day: 100000, max_storage_mb: 10240 }
- 国调中心 (8a7b3c2d-1e2f-4a3b-9c8d-7e6f5a4b3c2d) quotas=TenantQuota { max_devices: 10000, max_agents: 200, max_api_calls_per_day: 1000000, max_storage_mb: 102400 }
- 省调中心 (2d3e4f5a-6b7c-8d9e-0f1a-2b3c4d5e6f7a) quotas=TenantQuota { max_devices: 2000, max_agents: 50, max_api_calls_per_day: 200000, max_storage_mb: 20480 }
注意:
create_tenant自动生成的 UUID 与eneros.toml中的短代码(如gs)是两套标识。生产环境建议在创建后通过update_tenant回写name字段附加短代码前缀,或在default租户的固定 ID 之外显式调用存储层create_tenant写入自定义 ID。
步骤 4:签发租户证书
每张证书在 SAN 中绑定 tenant_id,由 mTLS 握手时强制校验。证书 CN 采用 <service>.<tenant>.eneros.internal 三段式命名:
use eneros_security::{CertificateAuthority, CertificateSpec};
async fn issue_tenant_certs(ca: &CertificateAuthority) -> anyhow::Result<()> {
// 国调 Agent Runtime 证书
let gs_cert = ca.issue(
CertificateSpec::new("agent-runtime.gs.eneros.internal")
.tenant("gs")
.sans(vec![
"agent-runtime.gs.eneros.internal",
"10.0.0.11", // IP SAN
])
.role("agent-runtime")
).await?;
println!("issued gs cert: serial={}", gs_cert.serial);
// 省调 Agent Runtime 证书
let ps_cert = ca.issue(
CertificateSpec::new("agent-runtime.ps.eneros.internal")
.tenant("ps")
.sans(vec!["agent-runtime.ps.eneros.internal"])
.role("agent-runtime")
).await?;
println!("issued ps cert: serial={}", ps_cert.serial);
// 省调 SCADA Gateway 证书
let gw_cert = ca.issue(
CertificateSpec::new("scada-gw.ps.eneros.internal")
.tenant("ps")
.sans(vec!["scada-gw.ps.eneros.internal"])
.role("scada-gateway")
).await?;
println!("issued ps-gw cert: serial={}", gw_cert.serial);
Ok(())
}
证书字段约定:
| 字段 | 取值规则 | 示例 |
|---|---|---|
| CN | <service>.<tenant_id>.eneros.internal | agent-runtime.gs.eneros.internal |
| SAN (DNS) | 与 CN 一致 + 通配符 | *.gs.eneros.internal |
| SAN (IP) | 节点物理 IP | 10.0.0.11 |
自定义 OID 1.3.6.1.4.1.99999.1 | tenant_id | gs |
自定义 OID 1.3.6.1.4.1.99999.2 | role | agent-runtime |
mTLS 握手时 Gateway 会提取证书中的 tenant_id OID 并与请求路径中的租户前缀比对,不一致直接拒绝。
步骤 5:成员管理与角色绑定
每个租户独立维护成员列表,成员角色决定其在租户内的权限。
use eneros_tenant::TenantManager;
fn setup_members(manager: &TenantManager, gs_id: &str, ps_id: &str) -> anyhow::Result<()> {
// 国调成员
manager.add_member(gs_id, "alice@gs.com", "admin")?;
manager.add_member(gs_id, "bob@gs.com", "operator")?;
manager.add_member(gs_id, "carol@grid-vendor.com", "liaison")?; // 厂家联络人
// 省调成员
manager.add_member(ps_id, "dave@ps.com", "admin")?;
manager.add_member(ps_id, "eve@ps.com", "operator")?;
manager.add_member(ps_id, "frank@ps.com", "viewer")?;
// 校验成员存在性
assert!(manager.is_member(gs_id, "alice@gs.com")?);
assert!(!manager.is_member(ps_id, "alice@gs.com")?); // 跨租户不可见
// 列出成员
let gs_members = manager.list_members(gs_id)?;
println!("gs members ({}):", gs_members.len());
for m in &gs_members {
println!(" - {} (role={}, added={})",
m.user_id, m.role, m.added_at);
}
Ok(())
}
角色权限矩阵:
| 角色 | 设备读 | 设备写 | Agent 部署 | 命令下发 | 配额调整 | 成员管理 |
|---|---|---|---|---|---|---|
admin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
operator | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
viewer | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
liaison | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
步骤 6:配额管理与用量跟踪
TenantUsageTracker 提供原子计数器,所有 read-modify-write 操作在 RwLock 写锁保护下完成,保证并发安全。
use std::sync::Arc;
use eneros_tenant::{
QuotaResource, SqliteTenantStorage, TenantManager,
TenantQuota, TenantUsageTracker,
};
use eneros_tenant::usage_tracker::spawn_daily_reset_task;
fn setup_quota_and_tracker(
manager: &TenantManager,
storage: Arc<SqliteTenantStorage>,
gs_id: &str,
) -> anyhow::Result<Arc<TenantUsageTracker>> {
// 1. 调整配额(如需扩容)
let new_quota = TenantQuota {
max_devices: 15_000, // 从 10000 扩到 15000
max_agents: 300,
max_api_calls_per_day: 2_000_000,
max_storage_mb: 204_800,
};
manager.update_quota(gs_id, &new_quota)?;
println!("updated gs quota: {:?}", manager.get_quota(gs_id)?);
// 2. 创建用量跟踪器
let tracker = Arc::new(TenantUsageTracker::new(storage));
// 3. 启动每日 0 点 UTC 重置任务
spawn_daily_reset_task(tracker.clone());
Ok(tracker)
}
fn register_device(
tracker: &TenantUsageTracker,
manager: &TenantManager,
gs_id: &str,
) -> anyhow::Result<()> {
// 1. 先校验配额(避免超限后回滚)
let quota = manager.get_quota(gs_id)?;
tracker.check_quota(gs_id, QuotaResource::Devices, "a)?;
// 2. 实际注册设备(业务逻辑省略)
// ...device_registry.add(...)
// 3. 递增计数器
let new_count = tracker.increment_device_count(gs_id)?;
println!("gs current_devices = {}", new_count);
Ok(())
}
fn handle_api_request(
tracker: &TenantUsageTracker,
manager: &TenantManager,
gs_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// 每次请求扣减 API 调用配额
let quota = manager.get_quota(gs_id)?;
tracker.check_quota(gs_id, QuotaResource::ApiCalls, "a)?;
let calls = tracker.increment_api_calls(gs_id)?;
println!("gs api_calls_today = {}/{}", calls, quota.max_api_calls_per_day);
Ok(())
}
TenantUsageTracker 主要方法:
| 方法 | 返回值 | 说明 |
|---|---|---|
increment_api_calls(tenant_id) | u64 | 原子递增今日 API 计数 |
increment_device_count(tenant_id) | u32 | 原子递增设备数 |
decrement_device_count(tenant_id) | u32 | 原子递减(不低于 0) |
increment_agent_count(tenant_id) | u32 | 原子递增 Agent 数 |
decrement_agent_count(tenant_id) | u32 | 原子递减(不低于 0) |
update_storage_usage(tenant_id, mb) | () | 覆盖式更新存储用量 |
get_usage(tenant_id) | TenantUsage | 读取快照(优先读缓存) |
check_quota(tenant_id, resource, quota) | () | 校验配额,超限返回 QuotaExceeded |
reset_daily_counters() | () | 重置所有租户的 api_calls_today |
maybe_reset_daily() | () | 跨天时自动触发重置 |
QuotaExceeded 错误携带 resource / limit / current 三个字段,Gateway 据此返回 HTTP 429:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 3600
{
"error": "quota_exceeded",
"resource": "api_calls",
"limit": 1000000,
"current": 1000000,
"reset_at": "2026-07-07T00:00:00Z"
}
步骤 7:API Key 绑定与请求路由
对于不支持 mTLS 的轻量客户端(如移动 App、Web 前端),使用 API Key 完成租户路由。每个 API Key 绑定到唯一的 (tenant_id, username, role) 三元组。
use eneros_tenant::TenantManager;
fn bind_api_keys(manager: &TenantManager, gs_id: &str, ps_id: &str) -> anyhow::Result<()> {
// 国调 admin 用户的 API Key(生产环境应通过 KMS 生成)
manager.bind_api_key(
"ek_live_gs_alice_5f8a7b2c9d1e3f4a",
"alice@gs.com",
"admin",
gs_id,
)?;
// 省调 operator 用户的 API Key
manager.bind_api_key(
"ek_live_ps_dave_2a3b4c5d6e7f8a9b",
"dave@ps.com",
"operator",
ps_id,
)?;
// 验证绑定
let binding = manager.get_api_key_tenant("ek_live_gs_alice_5f8a7b2c9d1e3f4a")?
.expect("API key should be bound");
assert_eq!(binding.tenant_id, gs_id);
assert_eq!(binding.username, "alice@gs.com");
assert_eq!(binding.role, "admin");
// 未知 key 返回 None
assert!(manager.get_api_key_tenant("ek_live_unknown_xxx")?.is_none());
Ok(())
}
Gateway 中间件示例:
use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
use eneros_tenant::TenantManager;
pub async fn tenant_routing_middleware(
manager: TenantManager,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
// 1. 优先从 mTLS 证书中提取 tenant_id
let tenant_id = if let Some(cert) = req.extensions().get::<ClientCert>() {
cert.tenant_id.clone()
} else {
// 2. 回退到 API Key
let api_key = req
.headers()
.get("X-API-Key")
.and_then(|h| h.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
let binding = manager
.get_api_key_tenant(api_key)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
binding.tenant_id
};
// 3. 校验租户状态(Active 才放行)
manager.validate_tenant_access(&tenant_id)
.map_err(|e| match e {
eneros_tenant::TenantError::NotFound(_) => StatusCode::NOT_FOUND,
eneros_tenant::TenantError::InvalidStatus(_) => StatusCode::FORBIDDEN,
_ => StatusCode::INTERNAL_SERVER_ERROR,
})?;
// 4. 注入租户上下文
req.extensions_mut().insert(TenantContext {
tenant_id: tenant_id.clone(),
user_id: binding.username.clone(),
role: binding.role.clone(),
});
Ok(next.run(req).await)
}
重要:
get_api_key_tenant仅返回status='active'的租户绑定。Suspended / Deleted 租户的 API Key 自动失效,无需手动清理。
步骤 8:跨租户访问控制
默认拒绝跨租户访问,显式授权才放行。liaison 角色用于厂家联络人跨租户只读访问。
use eneros_auth::{Policy, Subject, Action, Resource, AuthContext};
async fn setup_cross_tenant_policy(policy: &Policy) -> anyhow::Result<()> {
// 1. 默认拒绝所有跨租户访问
policy.deny(
Subject::any_tenant(),
Action::any(),
Resource::tenant_pattern("*"),
).await?;
// 2. 允许同租户内访问
policy.allow(
Subject::tenant("gs"),
Action::any(),
Resource::tenant("gs"),
).await?;
// 3. 厂家联络人只读跨租户访问(带条件)
policy.allow(
Subject::tenant("ps"),
Action::Read,
Resource::tenant("gs").topic("/topics/state_estimate"),
)
.when(|ctx: &AuthContext| ctx.caller.has_role("liaison"))
.await?;
// 4. 审计场景:合规系统跨租户只读
policy.allow(
Subject::service("audit-collector"),
Action::Read,
Resource::tenant_pattern("*").topic("/topics/audit_log"),
)
.with_expiry(chrono::Duration::days(90))
.await?;
Ok(())
}
访问决策表:
| 调用方租户 | 调用方角色 | 目标租户 | 资源 | 决策 |
|---|---|---|---|---|
gs | admin | gs | 任意 | Allow |
gs | viewer | gs | 命令历史 | Allow |
ps | admin | gs | 任意 | Deny(默认) |
ps | liaison | gs | /topics/state_estimate | Allow |
ps | liaison | gs | /topics/command_history | Deny(不在授权范围) |
audit-collector | service | * | /topics/audit_log | Allow(90 天有效) |
步骤 9:每日 API 计数器重置
api_calls_today 计数器每天 UTC 0 点自动归零,last_reset_date 刷新为当天。spawn_daily_reset_task 在后台 tokio 任务中循环 sleep 到下一个 UTC 午夜。
use std::sync::Arc;
use eneros_tenant::{SqliteTenantStorage, TenantUsageTracker};
use eneros_tenant::usage_tracker::spawn_daily_reset_task;
fn start_daily_reset(storage: Arc<SqliteTenantStorage>) -> Arc<TenantUsageTracker> {
let tracker = Arc::new(TenantUsageTracker::new(storage));
// 后台任务:每天 UTC 0 点重置
spawn_daily_reset_task(tracker.clone());
// 启动时立即检查一次(防止服务重启后跨天未重置)
if let Err(e) = tracker.maybe_reset_daily() {
tracing::error!("initial daily reset check failed: {}", e);
}
tracker
}
maybe_reset_daily 与 reset_daily_counters 的差异:
| 方法 | 触发条件 | 影响范围 | 副作用 |
|---|---|---|---|
reset_daily_counters | 显式调用 | 所有 active 租户 | api_calls_today=0,last_reset_date=today |
maybe_reset_daily | 任意租户 last_reset_date < today | 所有 active 租户 | 同上,但同日不重置 |
设备数 / Agent 数 / 存储用量不受每日重置影响,仅 api_calls_today 归零。
步骤 10:完整部署流程
以下为一个完整的多租户部署示例,涵盖从存储初始化到中间件挂载的全过程:
use std::sync::Arc;
use axum::{routing::get, Router};
use eneros_tenant::{
QuotaResource, SqliteTenantStorage, TenantManager, TenantQuota,
TenantUsageTracker,
};
use eneros_tenant::usage_tracker::spawn_daily_reset_task;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. 初始化存储
let storage = Arc::new(SqliteTenantStorage::new("/var/lib/eneros/tenant.db")?);
let manager = TenantManager::new(storage.clone());
let tracker = Arc::new(TenantUsageTracker::new(storage.clone()));
// 2. 启动每日重置任务
tracker.maybe_reset_daily()?;
spawn_daily_reset_task(tracker.clone());
// 3. 幂等创建租户
let gs_quota = TenantQuota {
max_devices: 10_000,
max_agents: 200,
max_api_calls_per_day: 1_000_000,
max_storage_mb: 102_400,
};
let gs = manager.ensure_tenant_by_name("国调中心", gs_quota.clone())?;
let ps = manager.ensure_tenant_by_name("省调中心", TenantQuota {
max_devices: 2_000,
max_agents: 50,
max_api_calls_per_day: 200_000,
max_storage_mb: 20_480,
})?;
// 4. 注册成员
manager.add_member(&gs.id, "alice@gs.com", "admin")?;
manager.add_member(&ps.id, "dave@ps.com", "operator")?;
// 5. 绑定 API Key
manager.bind_api_key(
"ek_live_gs_alice_xxx",
"alice@gs.com",
"admin",
&gs.id,
)?;
// 6. 挂载 Axum 中间件
let app = Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/api/v1/devices", get(list_devices))
.layer(axum::middleware::from_fn_with_state(
manager.clone(),
tenant_routing_middleware,
))
.with_state(manager);
// 7. 启动 HTTP 服务
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
tracing::info!("gateway listening on :8080");
axum::serve(listener, app).await?;
Ok(())
}
// 业务 handler 通过 TenantContext 读取当前租户
async fn list_devices(
ctx: axum::extract::Extension<TenantContext>,
) -> Result<String, StatusCode> {
let tenant_id = &ctx.0.tenant_id;
Ok(format!("listing devices for tenant {}", tenant_id))
}
上例中的
ensure_tenant_by_name是项目封装的便捷方法,内部按name查找,未命中则调用create_tenant。原版TenantManager仅提供ensure_default_tenant。
验证
按以下清单逐项验证多租户隔离是否生效:
| 验证项 | 命令 / 操作 | 预期结果 |
|---|---|---|
| 租户创建 | eneros-cli tenant list | 列出 3 个 active 租户 |
| 默认租户幂等 | 重复执行启动脚本 | default 租户仅 1 条 |
| 成员隔离 | alice@gs.com 查询 ps 设备 | 403 Forbidden |
| 配额校验 | 在 gs 下注册第 10001 台设备 | 429 + QuotaExceeded 错误 |
| API Key 失效 | 暂停 ps 租户后用其 key 请求 | 403(get_api_key_tenant 返回 None) |
| 跨天重置 | 手动修改 last_reset_date 为昨天后启动 | api_calls_today 自动归零 |
| 软删除恢复 | soft_delete_tenant("ds") 后再 get_tenant("ds") | 返回 NotFound |
| 证书 SAN 校验 | 用 ps 证书访问 gs.eneros.internal | mTLS 握手失败 |
| 并发安全 | 100 并发调用 increment_api_calls | 计数器精确为 100 |
| 持久化 | 重启 eneros-os 进程 | 租户/成员/配额/用量全部保留 |
并发安全验证脚本:
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn test_concurrent_api_calls_are_atomic() {
let storage = Arc::new(SqliteTenantStorage::new_in_memory().unwrap());
let tracker = Arc::new(TenantUsageTracker::new(storage.clone()));
let tenant = setup_test_tenant(&storage);
let mut handles = vec![];
for _ in 0..100 {
let t = tracker.clone();
let id = tenant.id.clone();
handles.push(thread::spawn(move || {
t.increment_api_calls(&id).unwrap();
}));
}
for h in handles {
h.join().unwrap();
}
let usage = tracker.get_usage(&tenant.id).unwrap();
assert_eq!(usage.api_calls_today, 100, "100 并发应精确写入 100");
}
}
调试与排错
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
tenant not found: gs | 短代码与 UUID 主键混淆 | 用 list_tenants 查看 id 字段实际值 |
QuotaExceeded 频繁触发 | maybe_reset_daily 未运行 | 检查 last_reset_date 是否早于今天 |
| API Key 403 但租户 Active | bind_api_key 调用早于 create_tenant | 查 tenant_api_keys 表的外键约束 |
| 成员重复添加报错 | add_member 不允许覆盖角色 | 先 remove_member 再重新 add_member |
| 跨租户读取时序数据 | 未挂载 tenant_routing_middleware | 检查 Gateway 路由顺序,中间件须在业务 handler 之前 |
SQLite database is locked | 写入并发过高 | 启用 WAL(PRAGMA journal_mode=WAL),或迁移到 PostgreSQL |
| 软删除后磁盘空间未释放 | soft_delete_tenant 仅置 status='deleted' | 需 VACUUM 或定期清理脚本 |
查看租户存储内部状态:
# 查看所有租户
sqlite3 /var/lib/eneros/tenant.db \
"SELECT id, name, status, max_devices, max_agents FROM tenants;"
# 查看某租户用量
sqlite3 /var/lib/eneros/tenant.db \
"SELECT * FROM tenant_usage WHERE tenant_id='8a7b3c2d-1e2f-4a3b-9c8d-7e6f5a4b3c2d';"
# 查看所有 API Key 绑定
sqlite3 /var/lib/eneros/tenant.db \
"SELECT api_key, username, role, tenant_id FROM tenant_api_keys;"
# 手动触发每日重置(应急用)
sqlite3 /var/lib/eneros/tenant.db \
"UPDATE tenant_usage SET api_calls_today=0, last_reset_date=date('now');"
数据表结构参考
eneros-tenant 使用四张表,所有时间戳以 RFC 3339 字符串存储,日期以 YYYY-MM-DD 存储:
-- 租户主表
CREATE TABLE tenants (
id TEXT PRIMARY KEY, -- UUID v4 或 "default"
name TEXT NOT NULL,
status TEXT NOT NULL, -- 'active' / 'suspended' / 'deleted'
created_at TEXT NOT NULL, -- RFC 3339
updated_at TEXT NOT NULL, -- RFC 3339
max_devices INTEGER NOT NULL,
max_agents INTEGER NOT NULL,
max_api_calls_per_day INTEGER NOT NULL,
max_storage_mb INTEGER NOT NULL
);
-- 成员表(复合主键)
CREATE TABLE tenant_members (
tenant_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL,
added_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, user_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
-- 用量表(每租户一行)
CREATE TABLE tenant_usage (
tenant_id TEXT PRIMARY KEY,
current_devices INTEGER NOT NULL,
current_agents INTEGER NOT NULL,
api_calls_today INTEGER NOT NULL,
storage_used_mb INTEGER NOT NULL,
last_reset_date TEXT NOT NULL, -- 'YYYY-MM-DD'
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
-- API Key 绑定表
CREATE TABLE tenant_api_keys (
api_key TEXT PRIMARY KEY,
username TEXT NOT NULL,
role TEXT NOT NULL,
tenant_id TEXT NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
SQLite 初始化参数(SqliteTenantStorage::new 自动执行):
PRAGMA journal_mode = WAL; -- 写前日志,提升并发读
PRAGMA synchronous = NORMAL; -- 平衡性能与持久化
PRAGMA foreign_keys = ON; -- 强制外键约束