Files
iam-service/src/services/tenant.rs
2026-01-31 11:11:55 +08:00

399 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::models::{CreateTenantRequest, Tenant, UpdateTenantRequest, UpdateTenantStatusRequest};
use common_telemetry::AppError;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::instrument;
use uuid::Uuid;
#[derive(Clone)]
struct EnabledAppsCacheEntry {
enabled_apps: Vec<String>,
version: i32,
updated_at: String,
expires_at: Instant,
}
#[derive(Clone)]
pub struct TenantService {
pool: PgPool,
enabled_apps_cache: Arc<RwLock<HashMap<Uuid, EnabledAppsCacheEntry>>>,
}
impl TenantService {
/// 创建租户服务实例。
pub fn new(pool: PgPool) -> Self {
Self {
pool,
enabled_apps_cache: Arc::new(RwLock::new(HashMap::new())),
}
}
#[instrument(skip(self, req))]
/// 创建新租户并初始化默认状态与配置。
///
/// 业务规则:
/// - 默认 `status=active`。
/// - `config` 未提供时默认 `{}`。
///
/// 输出:
/// - 返回新建租户记录(含 `id`
///
/// 异常:
/// - 数据库写入失败(如连接异常、约束失败等)
pub async fn create_tenant(&self, req: CreateTenantRequest) -> Result<Tenant, AppError> {
let mut config = req
.config
.unwrap_or_else(|| Value::Object(Default::default()));
if !config.is_object() {
config = Value::Object(Default::default());
}
if let Some(obj) = config.as_object_mut() {
obj.insert("enabled_apps".to_string(), Value::Array(vec![]));
obj.insert(
"enabled_apps_version".to_string(),
Value::Number(serde_json::Number::from(0)),
);
}
let query = r#"
INSERT INTO tenants (name, status, config)
VALUES ($1, 'active', $2)
RETURNING id, name, status, config
"#;
let mut tx = self.pool.begin().await?;
let tenant = sqlx::query_as::<_, Tenant>(query)
.bind(req.name)
.bind(config)
.fetch_one(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO tenant_entitlements (tenant_id, enabled_apps, version)
VALUES ($1, '{}'::text[], 0)
ON CONFLICT (tenant_id) DO NOTHING
"#,
)
.bind(tenant.id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(tenant)
}
#[instrument(skip(self))]
/// 根据租户 ID 查询租户信息。
///
/// 异常:
/// - 若租户不存在,返回 `NotFound("Tenant not found")`。
pub async fn get_tenant(&self, tenant_id: Uuid) -> Result<Tenant, AppError> {
let query = "SELECT id, name, status, config FROM tenants WHERE id = $1";
sqlx::query_as::<_, Tenant>(query)
.bind(tenant_id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| AppError::NotFound("Tenant not found".into()))
}
#[instrument(skip(self, req))]
/// 更新租户基础信息(名称 / 配置)。
///
/// 说明:
/// - 仅更新 `UpdateTenantRequest` 中提供的字段,未提供字段保持不变。
///
/// 异常:
/// - 若租户不存在,返回 `NotFound("Tenant not found")`。
pub async fn update_tenant(
&self,
tenant_id: Uuid,
req: UpdateTenantRequest,
) -> Result<Tenant, AppError> {
let query = r#"
UPDATE tenants
SET
name = COALESCE($1, name),
config = COALESCE($2, config),
updated_at = NOW()
WHERE id = $3
RETURNING id, name, status, config
"#;
sqlx::query_as::<_, Tenant>(query)
.bind(req.name)
.bind(req.config)
.bind(tenant_id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| AppError::NotFound("Tenant not found".into()))
}
#[instrument(skip(self, req))]
/// 更新租户状态字段(如 active / disabled
///
/// 异常:
/// - 若租户不存在,返回 `NotFound("Tenant not found")`。
pub async fn update_tenant_status(
&self,
tenant_id: Uuid,
req: UpdateTenantStatusRequest,
) -> Result<Tenant, AppError> {
let query = r#"
UPDATE tenants
SET
status = $1,
updated_at = NOW()
WHERE id = $2
RETURNING id, name, status, config
"#;
sqlx::query_as::<_, Tenant>(query)
.bind(req.status)
.bind(tenant_id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| AppError::NotFound("Tenant not found".into()))
}
#[instrument(skip(self))]
/// 删除指定租户。
///
/// 异常:
/// - 若租户不存在,返回 `NotFound("Tenant not found")`。
pub async fn delete_tenant(&self, tenant_id: Uuid) -> Result<(), AppError> {
let result = sqlx::query("DELETE FROM tenants WHERE id = $1")
.bind(tenant_id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Tenant not found".into()));
}
Ok(())
}
#[instrument(skip(self))]
pub async fn get_enabled_apps(
&self,
tenant_id: Uuid,
) -> Result<(Vec<String>, i32, String), AppError> {
let now = Instant::now();
if let Some(hit) = self
.enabled_apps_cache
.read()
.await
.get(&tenant_id)
.cloned()
{
if hit.expires_at > now {
return Ok((hit.enabled_apps, hit.version, hit.updated_at));
}
}
let row = sqlx::query_as::<_, (Vec<String>, i32, chrono::DateTime<chrono::Utc>)>(
r#"
SELECT enabled_apps, version, updated_at
FROM tenant_entitlements
WHERE tenant_id = $1
"#,
)
.bind(tenant_id)
.fetch_optional(&self.pool)
.await?;
let (enabled_apps, version, updated_at) = match row {
Some((apps, v, ts)) => (apps, v, ts.to_rfc3339()),
None => {
let exists: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM tenants WHERE id = $1")
.bind(tenant_id)
.fetch_optional(&self.pool)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("Tenant not found".into()));
}
let ts = chrono::Utc::now().to_rfc3339();
(vec![], 0, ts)
}
};
let ttl = Duration::from_secs(60);
self.enabled_apps_cache.write().await.insert(
tenant_id,
EnabledAppsCacheEntry {
enabled_apps: enabled_apps.clone(),
version,
updated_at: updated_at.clone(),
expires_at: now + ttl,
},
);
Ok((enabled_apps, version, updated_at))
}
#[instrument(skip(self, enabled_apps))]
pub async fn set_enabled_apps(
&self,
tenant_id: Uuid,
enabled_apps: Vec<String>,
expected_version: Option<i32>,
actor_user_id: Uuid,
) -> Result<(Vec<String>, i32, String), AppError> {
let normalized = normalize_apps(enabled_apps);
self.validate_apps_exist(&normalized).await?;
let mut tx = self.pool.begin().await?;
let current = sqlx::query_as::<_, (Vec<String>, i32)>(
r#"
SELECT enabled_apps, version
FROM tenant_entitlements
WHERE tenant_id = $1
FOR UPDATE
"#,
)
.bind(tenant_id)
.fetch_optional(&mut *tx)
.await?;
if current.is_none() {
let exists: Option<Uuid> = sqlx::query_scalar("SELECT id FROM tenants WHERE id = $1")
.bind(tenant_id)
.fetch_optional(&mut *tx)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("Tenant not found".into()));
}
sqlx::query(
r#"
INSERT INTO tenant_entitlements (tenant_id, enabled_apps, version)
VALUES ($1, '{}'::text[], 0)
ON CONFLICT (tenant_id) DO NOTHING
"#,
)
.bind(tenant_id)
.execute(&mut *tx)
.await?;
}
let (before_apps, before_version) = current.unwrap_or_else(|| (vec![], 0));
if let Some(ev) = expected_version {
if ev != before_version {
return Err(AppError::AlreadyExists(
"enabled_apps:version_conflict".into(),
));
}
}
let (new_version, updated_at): (i32, chrono::DateTime<chrono::Utc>) = sqlx::query_as(
r#"
UPDATE tenant_entitlements
SET enabled_apps = $1,
version = version + 1,
updated_at = NOW()
WHERE tenant_id = $2
RETURNING version, updated_at
"#,
)
.bind(&normalized)
.bind(tenant_id)
.fetch_one(&mut *tx)
.await?;
sqlx::query(
r#"
UPDATE tenants
SET config =
jsonb_set(
jsonb_set(COALESCE(config, '{}'::jsonb), '{enabled_apps}', to_jsonb($1::text[]), true),
'{enabled_apps_version}', to_jsonb($2::int), true
),
updated_at = NOW()
WHERE id = $3
"#,
)
.bind(&normalized)
.bind(new_version)
.bind(tenant_id)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO tenant_enabled_apps_history (tenant_id, version, enabled_apps, actor_user_id)
VALUES ($1, $2, $3, $4)
"#,
)
.bind(tenant_id)
.bind(new_version)
.bind(&normalized)
.bind(actor_user_id)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'tenant.enabled_apps.update', 'tenant', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({
"before": { "enabled_apps": before_apps, "version": before_version },
"after": { "enabled_apps": normalized, "version": new_version }
}))
.execute(&mut *tx)
.await?;
tx.commit().await?;
let ttl = Duration::from_secs(60);
let now = Instant::now();
let updated_at = updated_at.to_rfc3339();
self.enabled_apps_cache.write().await.insert(
tenant_id,
EnabledAppsCacheEntry {
enabled_apps: normalized.clone(),
version: new_version,
updated_at: updated_at.clone(),
expires_at: now + ttl,
},
);
Ok((normalized, new_version, updated_at))
}
async fn validate_apps_exist(&self, enabled_apps: &[String]) -> Result<(), AppError> {
if enabled_apps.is_empty() {
return Ok(());
}
let rows: Vec<String> = sqlx::query_scalar("SELECT id FROM apps WHERE id = ANY($1)")
.bind(enabled_apps)
.fetch_all(&self.pool)
.await?;
let found: HashSet<String> = rows.into_iter().collect();
for app in enabled_apps {
if !found.contains(app) {
return Err(AppError::BadRequest(format!("Unknown app: {app}")));
}
}
Ok(())
}
}
fn normalize_apps(enabled_apps: Vec<String>) -> Vec<String> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for a in enabled_apps {
let v = a.trim().to_lowercase();
if v.is_empty() {
continue;
}
if seen.insert(v.clone()) {
out.push(v);
}
}
out.sort();
out
}