use chrono::Utc; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, }; use uuid::Uuid; use crate::errors::{SpaceError, SpaceResult}; use crate::services::service::SpaceService; use crate::services::types::{ AddMemberBody, JoinRequestSummary, JoinRuleSummary, SYSTEM_ROLE_MEMBER, }; use crate::services::{member, service}; use models::space::{ SpaceJoinRequestState, SpaceMemberRole, space_join_requests, space_join_rules, space_roles, }; use models::users::users; fn rule_summary(row: &space_join_rules::Model) -> JoinRuleSummary { JoinRuleSummary { id: row.id, organization_id: row.organization_id, role_id: row.role_id, trigger: row.trigger.clone(), matcher: row.matcher.clone(), enabled: row.enabled, created_at: row.created_at, updated_at: row.updated_at, } } impl SpaceService { async fn join_request_summary( &self, row: &space_join_requests::Model, ) -> SpaceResult { let user = users::Entity::find_by_id(row.user_id) .one(self.access.database.read()) .await?; Ok(JoinRequestSummary { id: row.id, organization_id: row.organization_id, user_id: row.user_id, username: user .as_ref() .map(|value| value.username.clone()) .unwrap_or_default(), displayname: user.and_then(|value| value.displayname), status: row.status, note: row.note.clone(), decided_by: row.decided_by, decided_at: row.decided_at, created_at: row.created_at, updated_at: row.updated_at, }) } // 加入规则的可选字段本身就多,参数表与 HTTP 请求体一一对应,拆结构体只会增加噪音。 #[allow(clippy::too_many_arguments)] #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %envelope::context::resolve_request_id(request_id)))] pub async fn admin_create_join_rule( &self, actor_user_id: i64, organization_id: Uuid, role_id: Uuid, trigger: String, matcher: String, enabled: bool, request_id: Option<&str>, ) -> SpaceResult { self.require_permission(actor_user_id, organization_id, "role.update", request_id) .await?; let trigger = trigger.trim().to_string(); let matcher = matcher.trim().to_string(); if trigger.is_empty() || matcher.is_empty() { return Err(SpaceError::MalformedRequest); } let role = space_roles::Entity::find_by_id(role_id) .filter(space_roles::Column::OrganizationId.eq(organization_id)) .one(self.access.database.read()) .await? .ok_or(SpaceError::SpaceRoleNotFound)?; let row = space_join_rules::ActiveModel { organization_id: Set(organization_id), role_id: Set(role.id), trigger: Set(trigger), matcher: Set(matcher), enabled: Set(enabled), ..Default::default() } .insert(self.access.database.write()) .await?; service::bump_version(self.access.database.write(), organization_id).await?; Ok(rule_summary(&row)) } // 同上:可选字段多,保持与请求体一致的参数顺序。 #[allow(clippy::too_many_arguments)] #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, rule_id = %rule_id, request_id = %envelope::context::resolve_request_id(request_id)))] pub async fn admin_update_join_rule( &self, actor_user_id: i64, organization_id: Uuid, rule_id: Uuid, role_id: Option, trigger: Option, matcher: Option, enabled: Option, request_id: Option<&str>, ) -> SpaceResult { self.require_permission(actor_user_id, organization_id, "role.update", request_id) .await?; let row = space_join_rules::Entity::find_by_id(rule_id) .filter(space_join_rules::Column::OrganizationId.eq(organization_id)) .one(self.access.database.read()) .await? .ok_or(SpaceError::SpaceNotFound)?; let mut active: space_join_rules::ActiveModel = row.into(); if let Some(role_id) = role_id { let role = space_roles::Entity::find_by_id(role_id) .filter(space_roles::Column::OrganizationId.eq(organization_id)) .one(self.access.database.read()) .await? .ok_or(SpaceError::SpaceRoleNotFound)?; active.role_id = Set(role.id); } if let Some(trigger) = trigger { let trigger = trigger.trim().to_string(); if trigger.is_empty() { return Err(SpaceError::MalformedRequest); } active.trigger = Set(trigger); } if let Some(matcher) = matcher { let matcher = matcher.trim().to_string(); if matcher.is_empty() { return Err(SpaceError::MalformedRequest); } active.matcher = Set(matcher); } if let Some(enabled) = enabled { active.enabled = Set(enabled); } let updated = active.update(self.access.database.write()).await?; service::bump_version(self.access.database.write(), organization_id).await?; Ok(rule_summary(&updated)) } #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, rule_id = %rule_id, request_id = %envelope::context::resolve_request_id(request_id)))] pub async fn admin_delete_join_rule( &self, actor_user_id: i64, organization_id: Uuid, rule_id: Uuid, request_id: Option<&str>, ) -> SpaceResult { self.require_permission(actor_user_id, organization_id, "role.update", request_id) .await?; let deleted = space_join_rules::Entity::delete_many() .filter(space_join_rules::Column::Id.eq(rule_id)) .filter(space_join_rules::Column::OrganizationId.eq(organization_id)) .exec(self.access.database.write()) .await?; if deleted.rows_affected > 0 { service::bump_version(self.access.database.write(), organization_id).await?; } Ok(deleted.rows_affected > 0) } #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %envelope::context::resolve_request_id(request_id)))] pub async fn admin_list_join_rules( &self, actor_user_id: i64, organization_id: Uuid, request_id: Option<&str>, ) -> SpaceResult> { self.require_permission(actor_user_id, organization_id, "role.view", request_id) .await?; let rows = space_join_rules::Entity::find() .filter(space_join_rules::Column::OrganizationId.eq(organization_id)) .order_by_asc(space_join_rules::Column::CreatedAt) .all(self.access.database.read()) .await?; Ok(rows.iter().map(rule_summary).collect()) } #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %envelope::context::resolve_request_id(request_id)))] pub async fn admin_create_join_request( &self, actor_user_id: i64, organization_id: Uuid, note: Option, request_id: Option<&str>, ) -> SpaceResult { let organization = self.load_organization(organization_id).await?; if organization.status != models::space::SpaceStatus::Active { return Err(SpaceError::SpaceInactive); } if let Some(existing) = member::find_any(self.access.database.read(), organization_id, actor_user_id).await? { if existing.banned_at.is_some() { return Err(SpaceError::SpaceMemberBanned); } if existing.left_at.is_none() { return Err(SpaceError::SpaceMemberExists); } } if let Some(pending) = space_join_requests::Entity::find() .filter(space_join_requests::Column::OrganizationId.eq(organization_id)) .filter(space_join_requests::Column::UserId.eq(actor_user_id)) .filter(space_join_requests::Column::Status.eq(SpaceJoinRequestState::Pending)) .one(self.access.database.write()) .await? { let mut active: space_join_requests::ActiveModel = pending.into(); active.note = Set(note); let updated = active.update(self.access.database.write()).await?; return self.join_request_summary(&updated).await; } let row = space_join_requests::ActiveModel { organization_id: Set(organization_id), user_id: Set(actor_user_id), status: Set(SpaceJoinRequestState::Pending), note: Set(note), ..Default::default() } .insert(self.access.database.write()) .await?; self.join_request_summary(&row).await } #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, request_id = %envelope::context::resolve_request_id(request_id)))] pub async fn admin_list_join_requests( &self, actor_user_id: i64, organization_id: Uuid, status: Option, limit: u64, offset: u64, request_id: Option<&str>, ) -> SpaceResult<(Vec, u64)> { self.require_permission(actor_user_id, organization_id, "member.view", request_id) .await?; let mut select = space_join_requests::Entity::find() .filter(space_join_requests::Column::OrganizationId.eq(organization_id)); if let Some(status) = status { select = select.filter(space_join_requests::Column::Status.eq(status)); } let total = select.clone().count(self.access.database.read()).await?; let rows = select .order_by_desc(space_join_requests::Column::CreatedAt) .limit(limit) .offset(offset) .all(self.access.database.read()) .await?; let mut out = Vec::with_capacity(rows.len()); for row in rows { out.push(self.join_request_summary(&row).await?); } Ok((out, total)) } #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, join_request_id = %join_request_id, request_id = %envelope::context::resolve_request_id(request_id)))] pub async fn admin_withdraw_join_request( &self, actor_user_id: i64, organization_id: Uuid, join_request_id: Uuid, request_id: Option<&str>, ) -> SpaceResult { let row = space_join_requests::Entity::find_by_id(join_request_id) .filter(space_join_requests::Column::OrganizationId.eq(organization_id)) .one(self.access.database.read()) .await? .ok_or(SpaceError::SpaceMemberNotFound)?; if row.user_id != actor_user_id { return Err(SpaceError::Forbidden); } if row.status != SpaceJoinRequestState::Pending { return Err(SpaceError::Forbidden); } let mut active: space_join_requests::ActiveModel = row.into(); active.status = Set(SpaceJoinRequestState::Withdrawn); let updated = active.update(self.access.database.write()).await?; self.join_request_summary(&updated).await } #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, join_request_id = %join_request_id, request_id = %envelope::context::resolve_request_id(request_id)))] async fn finish_join_request( &self, actor_user_id: i64, organization_id: Uuid, join_request_id: Uuid, status: SpaceJoinRequestState, note: Option, request_id: Option<&str>, ) -> SpaceResult { self.require_permission(actor_user_id, organization_id, "member.add", request_id) .await?; let row = space_join_requests::Entity::find_by_id(join_request_id) .filter(space_join_requests::Column::OrganizationId.eq(organization_id)) .one(self.access.database.read()) .await? .ok_or(SpaceError::SpaceMemberNotFound)?; if row.status != SpaceJoinRequestState::Pending { return Err(SpaceError::Forbidden); } if status == SpaceJoinRequestState::Approved { let default_role = space_roles::Entity::find() .filter(space_roles::Column::OrganizationId.eq(organization_id)) .filter(space_roles::Column::Key.eq(SYSTEM_ROLE_MEMBER)) .one(self.access.database.read()) .await?; self.admin_add_member( actor_user_id, organization_id, AddMemberBody { user_id: row.user_id, role: Some(SpaceMemberRole::Member), role_keys: default_role .filter(|role| role.default_role) .map(|role| vec![role.key]) .unwrap_or_default(), }, request_id, ) .await?; } let mut active: space_join_requests::ActiveModel = row.into(); active.status = Set(status); active.decided_by = Set(Some(actor_user_id)); active.decided_at = Set(Some(Utc::now())); if let Some(note) = note { active.note = Set(Some(note)); } let updated = active.update(self.access.database.write()).await?; self.join_request_summary(&updated).await } #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, join_request_id = %join_request_id, request_id = %envelope::context::resolve_request_id(request_id)))] pub async fn admin_approve_join_request( &self, actor_user_id: i64, organization_id: Uuid, join_request_id: Uuid, request_id: Option<&str>, ) -> SpaceResult { self.finish_join_request( actor_user_id, organization_id, join_request_id, SpaceJoinRequestState::Approved, None, request_id, ) .await } #[tracing::instrument(skip_all, fields(actor_user_id = actor_user_id, organization_id = %organization_id, join_request_id = %join_request_id, request_id = %envelope::context::resolve_request_id(request_id)))] pub async fn admin_reject_join_request( &self, actor_user_id: i64, organization_id: Uuid, join_request_id: Uuid, note: Option, request_id: Option<&str>, ) -> SpaceResult { self.finish_join_request( actor_user_id, organization_id, join_request_id, SpaceJoinRequestState::Rejected, note, request_id, ) .await } }