/** * 空间「对象清理」:仅空间所有者可见的危险区。 * * 空间对象分布在两棵树里 —— `dirs`(目录 / 仓库 / 问题集)与 * `rooms`(文字、语音、舞台等频道)。这里把它们拍成一张扁平清单,并提供 * 「删除 / 归档 / 可见性 / 清除可见性覆盖」操作。删除复用各模块自己的清理 * 逻辑(问题集连同 issue 与目录节点一起删、频道软删), * 纯目录节点只有为空时才允许删除,避免留下孤儿对象。 */ use std::collections::HashMap; use ::session::Session; use ::session::share::session; use export::http::routing::{delete, get, post}; use export::http::{Json, Path, Router, State}; use export::rest::{AppResponseData, NoData}; use models::dir::{dir_overwrite_group, dir_overwrite_users, dirs}; use models::issue::issues as boards; use models::repo::repos; use models::room::rooms; use sea_orm::ActiveValue::Set; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, TransactionTrait, }; use serde::{Deserialize, Serialize}; use utoipa::{OpenApi, ToSchema}; use uuid::Uuid; use git::service::GitService; use issues::services::service::IssueService; use space::errors::SpaceError; use space::services::service::SpaceService; use space::share::lookup; export::api_error! { log: "cleanup", pub enum ApiError { BadRequest(400, "request failed validation"), Unauthorized(401, "authentication required"), Forbidden(403, "operation not allowed"), NotFound(404, "resource not found"), Internal(500, "internal server error"), } } #[derive(Clone)] pub struct CleanupState { pub space: SpaceService, pub issues: IssueService, pub git: GitService, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct CleanupObjectDto { /// `dirs.id`(目录/仓库/问题集/智能体集)或 `rooms.id`(频道)。 pub id: Uuid, /// `dir | object | repo | board | text | voice | stage | category | news | guild_room` pub kind: String, pub name: String, /// 父链拼出的可读路径,例如 `bots / 研究研究`。 pub path: String, /// 仅仓库与频道有可见性开关。 pub is_private: Option, pub is_archived: bool, /// 目录的子节点数 / 频道的子频道数。 pub child_count: i64, /// 目录上的权限覆盖条数(group + user)。 pub overwrite_count: i64, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct SetCleanupVisibilityCmd { pub is_private: bool, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct SetRepoArchivedCmd { pub archived: bool, } pub fn router(space: SpaceService, issues: IssueService, git: GitService) -> Router { let state = CleanupState { space, issues, git }; Router::<()>::new() .route("/spaces/{space_name}/cleanup", get(list_cleanup_objects)) .route( "/spaces/{space_name}/cleanup/{kind}/{object_id}/visibility", post(set_cleanup_visibility), ) .route( "/spaces/{space_name}/cleanup/{kind}/{object_id}/archive", post(set_cleanup_archive), ) .route( "/spaces/{space_name}/cleanup/{kind}/{object_id}/overwrites", delete(clear_cleanup_overwrites), ) .route( "/spaces/{space_name}/cleanup/{kind}/{object_id}", delete(delete_cleanup_object), ) .with_state(state) } fn map_space(error: SpaceError) -> ApiError { match error { SpaceError::BadRequest(message) => ApiError::bad_request(message), SpaceError::Unauthorized => ApiError::unauthorized(), SpaceError::Forbidden => ApiError::forbidden(), SpaceError::NotFound => ApiError::not_found("resource not found"), other => ApiError::internal(other.to_string()), } } fn map_git(error: git::share::errors::GitError) -> ApiError { use git::share::errors::GitError; match error { GitError::InvalidArgument(message) | GitError::Conflict(message) => { ApiError::bad_request(message) } GitError::NotFound => ApiError::not_found("resource not found"), GitError::Unauthorized => ApiError::unauthorized(), GitError::Forbidden => ApiError::forbidden(), other => ApiError::internal(other.to_string()), } } fn map_issue(error: issues::errors::IssueError) -> ApiError { use issues::errors::IssueError; match error { IssueError::BadRequest(message) | IssueError::Conflict(message) => { ApiError::bad_request(message) } IssueError::Unauthorized => ApiError::unauthorized(), IssueError::Forbidden => ApiError::forbidden(), IssueError::NotFound => ApiError::not_found("resource not found"), other => ApiError::internal(other.to_string()), } } fn db_internal(error: sea_orm::DbErr) -> ApiError { ApiError::internal(error.to_string()) } async fn require_owner( state: &CleanupState, space_name: &str, uid: i64, ) -> Result { let space = lookup::lookup_space_by_name(&state.space, uid, space_name) .await .map_err(map_space)?; if space.owner_user_id != uid { return Err(ApiError::forbidden()); } Ok(space) } /// 目录路径:沿 `parent_dir_id` 收集名字再反转为 `a / b / c`。 fn dir_path(node: &dirs::Model, index: &HashMap)>) -> String { let mut names = vec![node.name.clone()]; let mut cursor = node.parent_dir_id; let mut guard = 0; while let Some(id) = cursor { let Some((name, parent)) = index.get(&id) else { break; }; names.push(name.clone()); cursor = *parent; guard += 1; if guard > 64 { break; } } names.reverse(); names.join(" / ") } fn room_path(room: &rooms::Model, index: &HashMap)>) -> String { let mut names = vec![room.name.clone()]; let mut cursor = room.parent_id; let mut guard = 0; while let Some(id) = cursor { let Some((name, parent)) = index.get(&id) else { break; }; names.push(name.clone()); cursor = *parent; guard += 1; if guard > 64 { break; } } names.reverse(); names.join(" / ") } async fn repo_row_by_dir( db: &sea_orm::DatabaseConnection, dir_id: Uuid, ) -> Result { repos::Entity::find() .filter(repos::Column::DirId.eq(dir_id)) .one(db) .await .map_err(db_internal)? .ok_or_else(|| ApiError::not_found("repository not found")) } #[utoipa::path( get, path = "/spaces/{space_name}/cleanup", tag = "cleanup", description = "列出空间内全部目录与对象(目录/仓库/问题集/频道),仅空间所有者可访问。", params(("space_name" = String, Path, description = "space name")), responses( (status = 200, description = "cleanup objects", body = AppResponseData>) ) )] pub async fn list_cleanup_objects( State(state): State, session: Session, Path(space_name): Path, ) -> Result>, ApiError> { let uid = session::require_uid(&session)?; let space = require_owner(&state, &space_name, uid).await?; let db = state.space.db(); let nodes = dirs::Entity::find() .filter(dirs::Column::SpaceId.eq(space.id)) .all(db.read()) .await .map_err(db_internal)?; let node_ids: Vec = nodes.iter().map(|node| node.id).collect(); let (repo_rows, board_rows) = if node_ids.is_empty() { (Vec::new(), Vec::new()) } else { ( repos::Entity::find() .filter(repos::Column::DirId.is_in(node_ids.clone())) .all(db.read()) .await .map_err(db_internal)?, boards::Entity::find() .filter(boards::Column::DirId.is_in(node_ids.clone())) .all(db.read()) .await .map_err(db_internal)?, ) }; let (group_ows, user_ows) = if node_ids.is_empty() { (Vec::new(), Vec::new()) } else { ( dir_overwrite_group::Entity::find() .filter(dir_overwrite_group::Column::DirId.is_in(node_ids.clone())) .all(db.read()) .await .map_err(db_internal)?, dir_overwrite_users::Entity::find() .filter(dir_overwrite_users::Column::DirId.is_in(node_ids.clone())) .all(db.read()) .await .map_err(db_internal)?, ) }; let room_rows = rooms::Entity::find() .filter(rooms::Column::SpaceId.eq(space.id)) .filter(rooms::Column::IsActive.eq(true)) .all(db.read()) .await .map_err(db_internal)?; let repo_by_dir: HashMap = repo_rows.iter().map(|row| (row.dir_id, row)).collect(); let board_by_dir: HashMap = board_rows.iter().map(|row| (row.dir_id, row)).collect(); let mut overwrite_counts: HashMap = HashMap::new(); for row in group_ows.iter() { *overwrite_counts.entry(row.dir_id).or_default() += 1; } for row in user_ows.iter() { *overwrite_counts.entry(row.dir_id).or_default() += 1; } let mut dir_child_counts: HashMap = HashMap::new(); for node in &nodes { if let Some(parent) = node.parent_dir_id { *dir_child_counts.entry(parent).or_default() += 1; } } let mut room_child_counts: HashMap = HashMap::new(); for room in &room_rows { if let Some(parent) = room.parent_id { *room_child_counts.entry(parent).or_default() += 1; } } let dir_index: HashMap)> = nodes .iter() .map(|node| (node.id, (node.name.clone(), node.parent_dir_id))) .collect(); let room_index: HashMap)> = room_rows .iter() .map(|room| (room.id, (room.name.clone(), room.parent_id))) .collect(); let mut objects = Vec::with_capacity(nodes.len() + room_rows.len()); for node in &nodes { let (kind, name, is_private, is_archived) = if let Some(repo) = repo_by_dir.get(&node.id) { ( "repo", repo.name.clone(), Some(repo.is_private), repo.is_archived, ) } else if let Some(board) = board_by_dir.get(&node.id) { ("board", board.name.clone(), None, false) } else if node.kind == dirs::DirKind::Object { ("object", node.name.clone(), None, node.is_archived) } else { ("dir", node.name.clone(), None, node.is_archived) }; objects.push(CleanupObjectDto { id: node.id, kind: kind.to_string(), name, path: dir_path(node, &dir_index), is_private, is_archived, child_count: dir_child_counts.get(&node.id).copied().unwrap_or(0), overwrite_count: overwrite_counts.get(&node.id).copied().unwrap_or(0), }); } for room in &room_rows { objects.push(CleanupObjectDto { id: room.id, kind: { let kind = room.kind; use rooms::RoomKind; match kind { RoomKind::GuildRoom => "guild_room", RoomKind::Text => "text", RoomKind::Voice => "voice", RoomKind::Stage => "stage", RoomKind::Category => "category", RoomKind::News => "news", RoomKind::Thread => "thread", RoomKind::GroupDm => "group_dm", RoomKind::Dm => "dm", RoomKind::DirectMessage => "direct_message", } } .to_string(), name: room.name.clone(), path: room_path(room, &room_index), is_private: Some(room.is_private), is_archived: false, child_count: room_child_counts.get(&room.id).copied().unwrap_or(0), overwrite_count: 0, }); } objects.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.kind.cmp(&b.kind))); Ok(AppResponseData::ok(objects)) } #[utoipa::path( post, path = "/spaces/{space_name}/cleanup/{kind}/{object_id}/visibility", tag = "cleanup", description = "调整对象可见性:仓库与频道支持私有/公开,其余类型不支持。", params( ("space_name" = String, Path, description = "space name"), ("kind" = String, Path, description = "object kind"), ("object_id" = Uuid, Path, description = "dir id or room id") ), request_body = SetCleanupVisibilityCmd, responses((status = 200, description = "updated", body = AppResponseData)) )] pub async fn set_cleanup_visibility( State(state): State, session: Session, Path((space_name, kind, object_id)): Path<(String, String, Uuid)>, Json(cmd): Json, ) -> Result, ApiError> { let uid = session::require_uid(&session)?; let space = require_owner(&state, &space_name, uid).await?; let db = state.space.db(); match kind.as_str() { "repo" => { let repo = repo_row_by_dir(db.read(), object_id).await?; let update = git::service::repos::UpdateRepoCmd { is_private: Some(cmd.is_private), ..Default::default() }; let ctx = state .git .context(space_name.clone(), repo.name.clone()) .await .map_err(map_git)?; state .git .update_repo(&ctx, uid, update) .await .map_err(map_git)?; } "text" | "voice" | "stage" | "category" | "news" | "guild_room" | "thread" => { let room = rooms::Entity::find_by_id(object_id) .filter(rooms::Column::SpaceId.eq(space.id)) .one(db.read()) .await .map_err(db_internal)? .ok_or_else(|| ApiError::not_found("room not found"))?; let mut am: rooms::ActiveModel = room.into(); am.is_private = Set(cmd.is_private); am.updated_at = Set(time::OffsetDateTime::now_utc()); am.update(db.write()).await.map_err(db_internal)?; } _ => { return Err(ApiError::bad_request( "visibility is not adjustable for this object type", )); } } Ok(AppResponseData::empty()) } #[utoipa::path( post, path = "/spaces/{space_name}/cleanup/{kind}/{object_id}/archive", tag = "cleanup", description = "归档 / 取消归档仓库;仓库没有硬删除,危险区用归档表达下架。", params( ("space_name" = String, Path, description = "space name"), ("kind" = String, Path, description = "object kind"), ("object_id" = Uuid, Path, description = "dir id") ), request_body = SetRepoArchivedCmd, responses((status = 200, description = "updated", body = AppResponseData)) )] pub async fn set_cleanup_archive( State(state): State, session: Session, Path((space_name, kind, object_id)): Path<(String, String, Uuid)>, Json(cmd): Json, ) -> Result, ApiError> { let uid = session::require_uid(&session)?; require_owner(&state, &space_name, uid).await?; if kind != "repo" { return Err(ApiError::bad_request("only repositories support archiving")); } let repo = repo_row_by_dir(state.space.db().read(), object_id).await?; let ctx = state .git .context(space_name.clone(), repo.name.clone()) .await .map_err(map_git)?; state .git .set_repo_archived(&ctx, uid, cmd.archived) .await .map_err(map_git)?; Ok(AppResponseData::empty()) } #[utoipa::path( delete, path = "/spaces/{space_name}/cleanup/{kind}/{object_id}", tag = "cleanup", description = "删除对象:问题集/频道调用各自模块的清理逻辑,纯目录节点需为空;仓库只能用归档。", params( ("space_name" = String, Path, description = "space name"), ("kind" = String, Path, description = "object kind"), ("object_id" = Uuid, Path, description = "dir id or room id") ), responses((status = 200, description = "deleted", body = AppResponseData)) )] pub async fn delete_cleanup_object( State(state): State, session: Session, Path((space_name, kind, object_id)): Path<(String, String, Uuid)>, ) -> Result, ApiError> { let uid = session::require_uid(&session)?; let space = require_owner(&state, &space_name, uid).await?; let db = state.space.db(); match kind.as_str() { "repo" => { return Err(ApiError::bad_request( "repositories cannot be deleted here; archive them instead", )); } "board" => { let board = boards::Entity::find() .filter(boards::Column::DirId.eq(object_id)) .one(db.read()) .await .map_err(db_internal)? .ok_or_else(|| ApiError::not_found("board not found"))?; state .issues .delete_board(uid, &space_name, board.id) .await .map_err(map_issue)?; } "text" | "voice" | "stage" | "category" | "news" | "guild_room" | "thread" => { let room = rooms::Entity::find_by_id(object_id) .filter(rooms::Column::SpaceId.eq(space.id)) .one(db.read()) .await .map_err(db_internal)? .ok_or_else(|| ApiError::not_found("room not found"))?; let children = rooms::Entity::find() .filter(rooms::Column::ParentId.eq(Some(room.id))) .filter(rooms::Column::IsActive.eq(true)) .count(db.read()) .await .map_err(db_internal)?; if children > 0 { return Err(ApiError::bad_request(format!( "channel has {children} sub-channel(s); delete or move them first" ))); } state .space .delete_room(uid, room.id) .await .map_err(map_space)?; } "dir" | "object" => { let node = dirs::Entity::find_by_id(object_id) .filter(dirs::Column::SpaceId.eq(space.id)) .one(db.read()) .await .map_err(db_internal)? .ok_or_else(|| ApiError::not_found("directory not found"))?; let has_companion = repos::Entity::find() .filter(repos::Column::DirId.eq(node.id)) .count(db.read()) .await .map_err(db_internal)? > 0 || boards::Entity::find() .filter(boards::Column::DirId.eq(node.id)) .count(db.read()) .await .map_err(db_internal)? > 0; if has_companion { return Err(ApiError::bad_request( "this node holds an object; delete it from its own row instead", )); } let children = dirs::Entity::find() .filter(dirs::Column::ParentDirId.eq(Some(node.id))) .count(db.read()) .await .map_err(db_internal)?; if children > 0 { return Err(ApiError::bad_request(format!( "directory has {children} child node(s); empty it first" ))); } let txn = db.write().begin().await.map_err(db_internal)?; dir_overwrite_group::Entity::delete_many() .filter(dir_overwrite_group::Column::DirId.eq(node.id)) .exec(&txn) .await .map_err(db_internal)?; dir_overwrite_users::Entity::delete_many() .filter(dir_overwrite_users::Column::DirId.eq(node.id)) .exec(&txn) .await .map_err(db_internal)?; dirs::Entity::delete_by_id(node.id) .exec(&txn) .await .map_err(db_internal)?; txn.commit().await.map_err(db_internal)?; } _ => { return Err(ApiError::bad_request("unsupported object kind")); } } Ok(AppResponseData::empty()) } #[utoipa::path( delete, path = "/spaces/{space_name}/cleanup/{kind}/{object_id}/overwrites", tag = "cleanup", description = "清除目录节点上的全部可见性覆盖(group + user),恢复继承默认可见性。", params( ("space_name" = String, Path, description = "space name"), ("kind" = String, Path, description = "object kind"), ("object_id" = Uuid, Path, description = "dir id") ), responses((status = 200, description = "cleared", body = AppResponseData)) )] pub async fn clear_cleanup_overwrites( State(state): State, session: Session, Path((space_name, kind, object_id)): Path<(String, String, Uuid)>, ) -> Result, ApiError> { let uid = session::require_uid(&session)?; let space = require_owner(&state, &space_name, uid).await?; if !matches!(kind.as_str(), "dir" | "object" | "repo" | "board") { return Err(ApiError::bad_request( "visibility overwrites are stored on directory nodes only", )); } let db = state.space.db(); dirs::Entity::find_by_id(object_id) .filter(dirs::Column::SpaceId.eq(space.id)) .one(db.read()) .await .map_err(db_internal)? .ok_or_else(|| ApiError::not_found("directory not found"))?; let txn = db.write().begin().await.map_err(db_internal)?; dir_overwrite_group::Entity::delete_many() .filter(dir_overwrite_group::Column::DirId.eq(object_id)) .exec(&txn) .await .map_err(db_internal)?; dir_overwrite_users::Entity::delete_many() .filter(dir_overwrite_users::Column::DirId.eq(object_id)) .exec(&txn) .await .map_err(db_internal)?; txn.commit().await.map_err(db_internal)?; Ok(AppResponseData::empty()) } #[derive(OpenApi)] #[openapi( paths( crate::cleanup::list_cleanup_objects, crate::cleanup::set_cleanup_visibility, crate::cleanup::set_cleanup_archive, crate::cleanup::delete_cleanup_object, crate::cleanup::clear_cleanup_overwrites, ), components(schemas( crate::cleanup::CleanupObjectDto, crate::cleanup::SetCleanupVisibilityCmd, crate::cleanup::SetRepoArchivedCmd, AppResponseData>, AppResponseData, )) )] pub struct CleanupApiDoc;