ec0bdb8
/**
* 空间「对象清理」:仅空间所有者可见的危险区。
*
* 空间对象都挂在 `dirs` 树上(目录 / 仓库 / 问题集 / 频道),频道另有
* `rooms` 行承载频道能力参数。这里把它们拍成一张扁平清单(频道仍按 room id
* 管理,其 dir 镜像跳过),并提供「删除 / 归档 / 可见性 / 清除可见性覆盖」
* 操作。频道的可见性以 dir 覆盖项为准,`rooms.is_private` 只是镜像。
*/
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 database: std::sync::Arc<export::AppDatabase>,
pub space: SpaceService,
pub chat: chat::services::service::ChatService,
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<bool>,
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(
database: std::sync::Arc<export::AppDatabase>,
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}/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 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 dir_internal(error: dir::Error) -> ApiError {
match error {
dir::Error::InvalidInput(message) => ApiError::bad_request(message),
dir::Error::NotFound => ApiError::not_found("directory not found"),
dir::Error::Database(error) => db_internal(error),
}
}
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<models::space::spaces::Model, ApiError> {
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)
}
/// 目录路径:沿 `parent_dir_id` 收集名字再反转为 `a / b / c`。
fn dir_path(node: &dirs::Model, index: &HashMap<Uuid, (String, Option<Uuid>)>) -> 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<Uuid, (String, Option<Uuid>)>) -> 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: &impl sea_orm::ConnectionTrait,
dir_id: Uuid,
) -> Result<repos::Model, ApiError> {
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<Vec<CleanupObjectDto>>)
)
)]
pub async fn list_cleanup_objects(
State(state): State<CleanupState>,
session: Session,
Path(space_name): Path<String>,
) -> Result<AppResponseData<Vec<CleanupObjectDto>>, ApiError> {
let uid = session::require_uid(&session)?;
let space = require_owner(&state, &space_name, uid).await?;
let db = &state.database;
let nodes = dirs::Entity::find()
.filter(dirs::Column::SpaceId.eq(space.id))
.all(db.read())
.await
.map_err(db_internal)?;
let node_ids: Vec<Uuid> = 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<Uuid, &repos::Model> =
repo_rows.iter().map(|row| (row.dir_id, row)).collect();
let board_by_dir: HashMap<Uuid, &boards::Model> =
board_rows.iter().map(|row| (row.dir_id, row)).collect();
let mut overwrite_counts: HashMap<Uuid, i64> = 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<Uuid, i64> = 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<Uuid, i64> = 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<Uuid, (String, Option<Uuid>)> = nodes
.iter()
.map(|node| (node.id, (node.name.clone(), node.parent_dir_id)))
.collect();
let room_index: HashMap<Uuid, (String, Option<Uuid>)> = 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 {
// Space channels own a dir row for the sidebar tree, but cleanup keeps
// addressing them by room id below; skip the dir mirror to avoid listing
// the same channel twice.
if matches!(
node.kind,
dirs::DirKind::ChatChannel | dirs::DirKind::VoiceChannel
) {
continue;
}
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.is_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(match room.dir_id {
Some(dir_id) => overwrite_counts.get(&dir_id).copied().unwrap_or(0) > 0,
None => room.is_private,
}),
is_archived: false,
child_count: room_child_counts.get(&room.id).copied().unwrap_or(0),
overwrite_count: room
.dir_id
.and_then(|dir_id| overwrite_counts.get(&dir_id).copied())
.unwrap_or(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<NoData>))
)]
pub async fn set_cleanup_visibility(
State(state): State<CleanupState>,
session: Session,
Path((space_name, kind, object_id)): Path<(String, String, Uuid)>,
Json(cmd): Json<SetCleanupVisibilityCmd>,
) -> Result<AppResponseData<NoData>, ApiError> {
let uid = session::require_uid(&session)?;
let space = require_owner(&state, &space_name, uid).await?;
let db = &state.database;
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 dir_id = room.dir_id;
let owner_id = room.owner_id;
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)?;
if let Some(dir_id) = dir_id {
if cmd.is_private {
dir::set_user_overwrite(
db.write(),
dir_id,
owner_id,
dir::PERM_VISIBLE,
0,
None,
)
.await
.map_err(dir_internal)?;
} else {
dir::clear_user_overwrites(db.write(), dir_id)
.await
.map_err(dir_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<NoData>))
)]
pub async fn set_cleanup_archive(
State(state): State<CleanupState>,
session: Session,
Path((space_name, kind, object_id)): Path<(String, String, Uuid)>,
Json(cmd): Json<SetRepoArchivedCmd>,
) -> Result<AppResponseData<NoData>, 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.database.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<NoData>))
)]
pub async fn delete_cleanup_object(
State(state): State<CleanupState>,
session: Session,
Path((space_name, kind, object_id)): Path<(String, String, Uuid)>,
) -> Result<AppResponseData<NoData>, ApiError> {
let uid = session::require_uid(&session)?;
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()
.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
.chat
.delete_room(uid, room.id)
.await
.map_err(map_chat)?;
}
"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"))?;
if matches!(
node.kind,
dirs::DirKind::ChatChannel | dirs::DirKind::VoiceChannel
) {
return Err(ApiError::bad_request(
"channels are deleted from their room row instead",
));
}
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<NoData>))
)]
pub async fn clear_cleanup_overwrites(
State(state): State<CleanupState>,
session: Session,
Path((space_name, kind, object_id)): Path<(String, String, Uuid)>,
) -> Result<AppResponseData<NoData>, 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.database;
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<Vec<crate::cleanup::CleanupObjectDto>>,
AppResponseData<NoData>,
))
)]
pub struct CleanupApiDoc;