/** * 空间「对象清理」:仅空间所有者可见的危险区。 * * 仓库、问题看板、频道各自持有空间归属,这里把它们拍成一张扁平清单, * 并提供「删除 / 归档 / 可见性」操作。对象一律按自己的主键寻址。 */ use ::session::AuthedUser; use api::http::routing::{delete, get, post}; use api::http::{Json, Path, Router, State}; use api::rest::{AppResponseData, NoData}; 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, QueryFilter}; 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; api::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 database: std::sync::Arc, pub space: SpaceService, pub chat: chat::services::service::ChatService, pub issues: IssueService, pub git: GitService, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct CleanupObjectDto { /// `repos.id`、`issues.id`(看板)或 `rooms.id`。 pub id: Uuid, /// `repo | board | text | voice | stage | category | news | guild_room` pub kind: String, pub name: String, /// 仅仓库与频道有可见性开关。 pub is_private: Option, pub is_archived: bool, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct SetCleanupVisibilityCmd { pub is_private: bool, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct SetRepoArchivedCmd { pub archived: bool, } fn room_kind_label(kind: models::room::rooms::RoomKind) -> &'static str { use models::room::rooms::RoomKind; match kind { RoomKind::Text => "text", RoomKind::Voice => "voice", RoomKind::Stage => "stage", RoomKind::Category => "category", RoomKind::News => "news", RoomKind::GuildRoom => "guild_room", _ => "thread", } } pub fn protected( database: std::sync::Arc, space: SpaceService, chat: chat::services::service::ChatService, issues: IssueService, git: GitService, ) -> Router { let state = CleanupState { database, space, chat, 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}", 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::Unauthorized => ApiError::unauthorized(), GitError::Forbidden => ApiError::forbidden(), GitError::NotFound => ApiError::not_found("resource not found"), 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 map_chat(error: chat::errors::ChatError) -> ApiError { use chat::errors::ChatError; match error { ChatError::BadRequest(message) | ChatError::Conflict(message) => { ApiError::bad_request(message) } ChatError::Unauthorized => ApiError::unauthorized(), ChatError::Forbidden => ApiError::forbidden(), ChatError::NotFound => ApiError::not_found("resource not found"), ChatError::RateLimited(message) => ApiError::bad_request(message), 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, space_name) .await .map_err(map_space)?; if space.owner_user_id != uid { return Err(ApiError::forbidden()); } Ok(space) } async fn repo_in_space( db: &impl sea_orm::ConnectionTrait, space_id: Uuid, repo_id: Uuid, ) -> Result { repos::Entity::find_by_id(repo_id) .filter(repos::Column::SpaceId.eq(space_id)) .one(db) .await .map_err(db_internal)? .ok_or_else(|| ApiError::not_found("repository not found")) } async fn room_in_space( db: &impl sea_orm::ConnectionTrait, space_id: Uuid, room_id: Uuid, ) -> Result { rooms::Entity::find_by_id(room_id) .filter(rooms::Column::SpaceId.eq(space_id)) .one(db) .await .map_err(db_internal)? .ok_or_else(|| ApiError::not_found("room 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, AuthedUser(uid): AuthedUser, Path(space_name): Path, ) -> Result>, ApiError> { let space = require_owner(&state, &space_name, uid).await?; let db = &state.database; let repo_rows = repos::Entity::find() .filter(repos::Column::SpaceId.eq(space.id)) .all(db.read()) .await .map_err(db_internal)?; let board_rows = boards::Entity::find() .filter(boards::Column::SpaceId.eq(space.id)) .all(db.read()) .await .map_err(db_internal)?; let room_rows = rooms::Entity::find() .filter(rooms::Column::SpaceId.eq(space.id)) .all(db.read()) .await .map_err(db_internal)?; let mut items = Vec::with_capacity(repo_rows.len() + board_rows.len() + room_rows.len()); for repo in repo_rows { items.push(CleanupObjectDto { id: repo.id, kind: "repo".to_owned(), name: repo.name, is_private: Some(repo.is_private), is_archived: repo.is_archived, }); } for board in board_rows { items.push(CleanupObjectDto { id: board.id, kind: "board".to_owned(), name: board.name, is_private: None, is_archived: false, }); } for room in room_rows { items.push(CleanupObjectDto { id: room.id, kind: room_kind_label(room.kind).to_owned(), name: room.name, is_private: Some(room.is_private), is_archived: !room.is_active, }); } items.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.cmp(&b.name))); Ok(AppResponseData::ok(items)) } #[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 = "repo id or room id") ), request_body = SetCleanupVisibilityCmd, responses((status = 200, description = "updated", body = AppResponseData)) )] pub async fn set_cleanup_visibility( State(state): State, AuthedUser(uid): AuthedUser, Path((space_name, kind, object_id)): Path<(String, String, Uuid)>, Json(cmd): Json, ) -> Result, ApiError> { let space = require_owner(&state, &space_name, uid).await?; let db = &state.database; match kind.as_str() { "repo" => { let repo = repo_in_space(db.read(), space.id, 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 = room_in_space(db.read(), space.id, object_id).await?; 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 = "repo id") ), request_body = SetRepoArchivedCmd, responses((status = 200, description = "updated", body = AppResponseData)) )] pub async fn set_cleanup_archive( State(state): State, AuthedUser(uid): AuthedUser, Path((space_name, kind, object_id)): Path<(String, String, Uuid)>, Json(cmd): Json, ) -> Result, ApiError> { let space = require_owner(&state, &space_name, uid).await?; if kind != "repo" { return Err(ApiError::bad_request("only repositories support archiving")); } let repo = repo_in_space(state.database.read(), space.id, 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 = "board id or room id") ), responses((status = 200, description = "deleted", body = AppResponseData)) )] pub async fn delete_cleanup_object( State(state): State, AuthedUser(uid): AuthedUser, Path((space_name, kind, object_id)): Path<(String, String, Uuid)>, ) -> Result, ApiError> { let space = require_owner(&state, &space_name, uid).await?; let db = &state.database; match kind.as_str() { "repo" => { return Err(ApiError::bad_request( "repositories cannot be deleted here; archive them instead", )); } "board" => { let board = boards::Entity::find_by_id(object_id) .filter(boards::Column::SpaceId.eq(space.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 = room_in_space(db.read(), space.id, object_id).await?; state .chat .delete_room(uid, room.id) .await .map_err(map_chat)?; } _ => { return Err(ApiError::bad_request("unsupported object kind")); } } 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, ), components(schemas( crate::cleanup::CleanupObjectDto, crate::cleanup::SetCleanupVisibilityCmd, crate::cleanup::SetRepoArchivedCmd, AppResponseData>, AppResponseData, )) )] pub struct CleanupApiDoc;