Skip to content
Clone
7f0e1a0
1580 lines 52.3 KB Rust
use crate::errors::ApiError;
use crate::services::dto::SpacePath;
use crate::services::service::SpaceService;
use crate::services::types::{
AddMemberBody, BanMemberBody, CheckPermissionBody, CreateOrganizationBody, CreateRoleBody,
InviteSummary, MemberSummary, OrganizationSummary, PermissionCheck, PermissionGrant,
RoleSummary, SetMemberRoleBody, SetRolePermissionsBody, UpdateBanBody, UpdateMemberBody,
UpdateOrganizationBody,
};
use api::http::{Json, Path, Query, State};
use api::middleware::RequestId;
use api::rest::{AppResponseData, NoData};
use session::Session;
use uuid::Uuid;
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct SpaceMemberPath {
pub space_name: String,
pub member_id: Uuid,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct SpaceRolePath {
pub space_name: String,
pub role_id: Uuid,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct SpaceBanPath {
pub space_name: String,
pub user_id: i64,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct SpaceInvitePath {
pub space_name: String,
pub invite_id: Uuid,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(deny_unknown_fields)]
pub struct OrgListQuery {
pub limit: Option<u64>,
pub offset: Option<u64>,
pub include_archived: Option<bool>,
}
impl OrgListQuery {
fn bounds(&self) -> Result<(u64, u64), api::pagination::PageError> {
api::pagination::page_bounds(self.limit, self.offset, 20, 100)
}
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct MemberListQuery {
pub limit: Option<u64>,
pub offset: Option<u64>,
pub include_inactive: Option<bool>,
pub include_banned: Option<bool>,
}
impl MemberListQuery {
fn bounds(&self) -> Result<(u64, u64), api::pagination::PageError> {
api::pagination::page_bounds(self.limit, self.offset, 20, 100)
}
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct InviteListQuery {
pub limit: Option<u64>,
pub offset: Option<u64>,
pub include_revoked: Option<bool>,
}
impl InviteListQuery {
fn bounds(&self) -> Result<(u64, u64), api::pagination::PageError> {
api::pagination::page_bounds(self.limit, self.offset, 20, 100)
}
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateRoleBody {
pub name: Option<String>,
pub description: Option<String>,
pub rank: Option<i32>,
pub default_role: Option<bool>,
pub self_assignable: Option<bool>,
#[serde(default)]
pub expected_version: i32,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct TransferBody {
pub target_user_id: i64,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct ReasonBody {
pub reason: Option<String>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct DeleteOrgQuery {
#[serde(default)]
pub force: bool,
pub reason: Option<String>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct CreateInviteBody {
pub role_id: Option<Uuid>,
pub target_user_id: Option<i64>,
/// 定向邀请的收件邮箱;与 `target_user_id` 二选一,两者都空则是公开令牌。
pub email: Option<String>,
/// 兑换后一并授予的额外角色。
#[serde(default)]
pub extra_role_ids: Vec<Uuid>,
#[serde(default)]
pub max_uses: i32,
pub ttl_secs: Option<u64>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct RedeemInviteBody {
pub token: String,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct CreateInviteResponse {
pub invite: InviteSummary,
pub token: String,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct RedeemInviteResponse {
pub organization_id: Uuid,
pub member_id: Uuid,
pub joined: bool,
}
#[utoipa::path(
post,
path = "/spaces",
tag = "space",
operation_id = "space_create",
summary = "新建组织",
description = "创建者自动成为 OWNER 并写入 owner/admin/member 三个内置角色。slug 全局唯一且只允许小写字母、数字与连字符。",
request_body = CreateOrganizationBody,
responses(
(status = 200, description = "组织已创建", body = AppResponseData<OrganizationSummary>),
(status = 401, description = "会话未绑定账号", body = AppResponseData<NoData>),
(status = 409, description = "slug 已被占用", body = AppResponseData<NoData>)
)
)]
pub async fn create_space(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
body: Json<CreateOrganizationBody>,
) -> Result<AppResponseData<OrganizationSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let summary = svc
.create_space(user_id, body.into_inner(), Some(request_id.0.as_str()))
.await?;
Ok(AppResponseData::ok(summary))
}
#[utoipa::path(
get,
path = "/spaces",
tag = "space",
operation_id = "space_list",
summary = "列示我加入的组织",
description = "默认只返回 ACTIVE 组织;`include_archived=true` 时一并返回已归档组织。",
params(OrgListQuery),
responses(
(status = 200, description = "组织列表", body = AppResponseData<api::pagination::Page<OrganizationSummary>>),
(status = 401, description = "会话未绑定账号", body = AppResponseData<NoData>)
)
)]
pub async fn list_spaces(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Query(query): Query<OrgListQuery>,
) -> Result<AppResponseData<api::pagination::Page<OrganizationSummary>>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let (limit, offset) = query.bounds()?;
let rows = svc
.list_spaces(
user_id,
0,
query.include_archived.unwrap_or(false),
limit,
offset,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(rows))
}
#[utoipa::path(
get,
path = "/spaces/{space_name}",
tag = "space",
operation_id = "space_get",
summary = "组织详情",
params(("space_name" = String, Path, description = "空间 slug")),
responses(
(status = 200, description = "组织详情", body = AppResponseData<OrganizationSummary>),
(status = 404, description = "组织不存在", body = AppResponseData<NoData>)
)
)]
pub async fn get_space(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
) -> Result<AppResponseData<OrganizationSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let (summary, _) = svc
.get_space(user_id, Some(space_id), None, Some(request_id.0.as_str()))
.await?;
Ok(AppResponseData::ok(summary))
}
#[utoipa::path(
patch,
path = "/spaces/{space_name}",
tag = "space",
operation_id = "space_update",
summary = "修改组织",
description = "需要 `org.update`。`expected_version` 与当前 version 不一致时返回 409。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = UpdateOrganizationBody,
responses(
(status = 200, description = "已更新", body = AppResponseData<OrganizationSummary>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>),
(status = 409, description = "版本冲突", body = AppResponseData<NoData>)
)
)]
pub async fn update_space(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
body: Json<UpdateOrganizationBody>,
) -> Result<AppResponseData<OrganizationSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let summary = svc
.update_space(
user_id,
space_id,
body.into_inner(),
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(summary))
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/archive",
tag = "space",
operation_id = "space_archive",
summary = "归档组织",
description = "需要 `org.archive`。归档后全部权限判定返回 false,可恢复。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = ReasonBody,
responses(
(status = 200, description = "已归档", body = AppResponseData<OrganizationSummary>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>)
)
)]
pub async fn archive_space(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
body: Json<ReasonBody>,
) -> Result<AppResponseData<OrganizationSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let summary = svc
.archive_space(
user_id,
space_id,
body.reason.as_deref(),
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(summary))
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/restore",
tag = "space",
operation_id = "space_restore",
summary = "恢复归档组织",
description = "仅 OWNER 或平台管理员可执行。",
params(("space_name" = String, Path, description = "空间 slug")),
responses(
(status = 200, description = "已恢复", body = AppResponseData<OrganizationSummary>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>)
)
)]
pub async fn restore_space(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
) -> Result<AppResponseData<OrganizationSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let summary = svc
.restore_space(user_id, space_id, Some(request_id.0.as_str()))
.await?;
Ok(AppResponseData::ok(summary))
}
#[utoipa::path(
delete,
path = "/spaces/{space_name}",
tag = "space",
operation_id = "space_delete",
summary = "删除组织",
description = "仍存在活跃成员且未指定 `force=true` 时返回 409。删除会清理成员、角色与邀请。",
params(
("space_name" = String, Path, description = "空间 slug"),
("force" = Option<bool>, Query, description = "是否连成员一并清理"),
("reason" = Option<String>, Query, description = "删除原因,最长 512 字符")
),
responses(
(status = 200, description = "已删除", body = AppResponseData<bool>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>),
(status = 409, description = "仍有活跃成员", body = AppResponseData<NoData>)
)
)]
pub async fn delete_space(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
Query(query): Query<DeleteOrgQuery>,
) -> Result<AppResponseData<bool>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let deleted = svc
.delete_space(
user_id,
space_id,
query.force,
query.reason.as_deref(),
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(deleted))
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/transfer",
tag = "space",
operation_id = "space_transfer",
summary = "转移 OWNER",
description = "目标必须是活跃成员。原 Owner 降为 ADMIN。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = TransferBody,
responses(
(status = 200, description = "已转移", body = AppResponseData<OrganizationSummary>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>),
(status = 404, description = "目标不是活跃成员", body = AppResponseData<NoData>)
)
)]
pub async fn transfer_ownership(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
body: Json<TransferBody>,
) -> Result<AppResponseData<OrganizationSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let summary = svc
.transfer_ownership(
user_id,
space_id,
body.target_user_id,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(summary))
}
#[utoipa::path(
get,
path = "/spaces/{space_name}/members",
tag = "space",
operation_id = "space_member_list",
summary = "列示组织成员",
description = "需要 `member.view`。默认只返回活跃成员。",
params(
("space_name" = String, Path, description = "空间 slug"),
("limit" = Option<u64>, Query, description = "每页条数,默认 20,最多 100"),
("offset" = Option<u64>, Query, description = "跳过条数,最多 5000"),
("include_inactive" = Option<bool>, Query, description = "包含已退出成员"),
("include_banned" = Option<bool>, Query, description = "包含已封禁成员")
),
responses(
(status = 200, description = "成员列表", body = AppResponseData<api::pagination::Page<MemberSummary>>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>)
)
)]
pub async fn list_members(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
Query(query): Query<MemberListQuery>,
) -> Result<AppResponseData<api::pagination::Page<MemberSummary>>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let (limit, offset) = query.bounds()?;
let rows = svc
.admin_list_members(
user_id,
space_id,
query.include_inactive.unwrap_or(false),
query.include_banned.unwrap_or(false),
limit,
offset,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(rows))
}
#[utoipa::path(
get,
path = "/spaces/{space_name}/members/{member_id}",
tag = "space",
operation_id = "space_member_get",
summary = "成员详情",
description = "需要 `member.view`。包含别名、通知偏好与封禁附加信息。",
params(
("space_name" = String, Path, description = "空间 slug"),
("member_id" = Uuid, Path, description = "会籍 id")
),
responses(
(status = 200, description = "成员详情", body = AppResponseData<MemberSummary>),
(status = 404, description = "成员不存在", body = AppResponseData<NoData>)
)
)]
pub async fn get_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceMemberPath>,
) -> Result<AppResponseData<MemberSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let member = svc
.admin_get_member(
user_id,
space_id,
Some(path.member_id),
0,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(member))
}
#[utoipa::path(
patch,
path = "/spaces/{space_name}/members/@me",
tag = "space",
operation_id = "space_member_update_self",
summary = "更新自己的会籍资料",
description = "只允许修改自己的别名、头像与通知偏好;无需 `member.role.update`。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = UpdateMemberBody,
responses(
(status = 200, description = "已更新", body = AppResponseData<MemberSummary>),
(status = 403, description = "不是活跃成员", body = AppResponseData<NoData>)
)
)]
pub async fn update_self_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
body: Json<UpdateMemberBody>,
) -> Result<AppResponseData<MemberSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let member = svc
.update_self_member(
user_id,
space_id,
body.into_inner(),
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(member))
}
#[utoipa::path(
patch,
path = "/spaces/{space_name}/members/{member_id}",
tag = "space",
operation_id = "space_member_update",
summary = "更新成员资料",
description = "需要 `member.role.update`。修改别名、头像、通知偏好与免打扰;封禁中的会籍走封禁接口。",
params(
("space_name" = String, Path, description = "空间 slug"),
("member_id" = Uuid, Path, description = "会籍 id")
),
request_body = UpdateMemberBody,
responses(
(status = 200, description = "已更新", body = AppResponseData<MemberSummary>),
(status = 403, description = "权限不足或目标已封禁", body = AppResponseData<NoData>),
(status = 409, description = "版本冲突", body = AppResponseData<NoData>)
)
)]
pub async fn update_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceMemberPath>,
body: Json<UpdateMemberBody>,
) -> Result<AppResponseData<MemberSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let member = svc
.admin_update_member(
user_id,
space_id,
path.member_id,
body.into_inner(),
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(member))
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/members",
tag = "space",
operation_id = "space_member_add",
summary = "直接添加成员",
description = "需要 `member.add`。已退出的会籍按复活处理,已封禁的返回 403。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = AddMemberBody,
responses(
(status = 200, description = "成员已添加", body = AppResponseData<MemberSummary>),
(status = 403, description = "权限不足或成员已封禁", body = AppResponseData<NoData>),
(status = 404, description = "用户不存在", body = AppResponseData<NoData>)
)
)]
pub async fn add_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
body: Json<AddMemberBody>,
) -> Result<AppResponseData<MemberSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let (member, _) = svc
.admin_add_member(
user_id,
space_id,
body.into_inner(),
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(member))
}
#[utoipa::path(
delete,
path = "/spaces/{space_name}/members/{member_id}",
tag = "space",
operation_id = "space_member_remove",
summary = "移除成员",
description = "需要 `member.remove`。OWNER 不能移除,需先转移所有权。会籍行保留用于复活。",
params(
("space_name" = String, Path, description = "空间 slug"),
("member_id" = Uuid, Path, description = "会籍 id"),
("reason" = Option<String>, Query, description = "移除原因")
),
responses(
(status = 200, description = "已移除", body = AppResponseData<bool>),
(status = 403, description = "权限不足或目标是 OWNER", body = AppResponseData<NoData>)
)
)]
pub async fn remove_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceMemberPath>,
Query(query): Query<ReasonBody>,
) -> Result<AppResponseData<bool>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let removed = svc
.admin_remove_member(
user_id,
space_id,
Some(path.member_id),
0,
query.reason.as_deref(),
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(removed))
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/leave",
tag = "space",
operation_id = "space_leave",
summary = "退出组织",
description = "OWNER 必须先转移所有权,否则返回 409。",
params(("space_name" = String, Path, description = "空间 slug")),
responses(
(status = 200, description = "已退出", body = AppResponseData<bool>),
(status = 409, description = "OWNER 需先转移所有权", body = AppResponseData<NoData>)
)
)]
pub async fn leave_organization(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
) -> Result<AppResponseData<bool>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let left = svc
.leave_organization(user_id, space_id, Some(request_id.0.as_str()))
.await?;
Ok(AppResponseData::ok(left))
}
#[utoipa::path(
patch,
path = "/spaces/{space_name}/members/{member_id}/role",
tag = "space",
operation_id = "space_member_role",
summary = "改粗粒度角色",
description = "只接受 MEMBER 与 ADMIN。OWNER 只能通过转移所有权变更。",
params(
("space_name" = String, Path, description = "空间 slug"),
("member_id" = Uuid, Path, description = "会籍 id")
),
request_body = SetMemberRoleBody,
responses(
(status = 200, description = "已更新", body = AppResponseData<MemberSummary>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>),
(status = 409, description = "版本冲突", body = AppResponseData<NoData>)
)
)]
pub async fn set_member_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceMemberPath>,
body: Json<SetMemberRoleBody>,
) -> Result<AppResponseData<MemberSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let member = svc
.set_member_role(
user_id,
space_id,
path.member_id,
body.into_inner(),
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(member))
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/members/{member_id}/ban",
tag = "space",
operation_id = "space_member_ban",
summary = "封禁成员",
description = "写 banned_at 并清空角色绑定与直挂权限。封禁优先于一切授权。",
params(
("space_name" = String, Path, description = "空间 slug"),
("member_id" = Uuid, Path, description = "会籍 id")
),
request_body = BanMemberBody,
responses(
(status = 200, description = "已封禁", body = AppResponseData<MemberSummary>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>)
)
)]
pub async fn ban_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceMemberPath>,
body: Json<BanMemberBody>,
) -> Result<AppResponseData<MemberSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let member = svc
.ban_member(
user_id,
space_id,
path.member_id,
body.reason.as_deref(),
body.expires_at.flatten(),
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(member))
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/members/{member_id}/unban",
tag = "space",
operation_id = "space_member_unban",
summary = "解封成员",
description = "清空 banned_at。已清空的角色不会自动恢复。",
params(
("space_name" = String, Path, description = "空间 slug"),
("member_id" = Uuid, Path, description = "会籍 id")
),
responses(
(status = 200, description = "已解封", body = AppResponseData<MemberSummary>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>)
)
)]
pub async fn unban_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceMemberPath>,
) -> Result<AppResponseData<MemberSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let member = svc
.unban_member(
user_id,
space_id,
path.member_id,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(member))
}
#[utoipa::path(
get,
path = "/spaces/{space_name}/bans",
tag = "space",
operation_id = "space_ban_list",
summary = "封禁列表",
description = "需要 `member.ban`。按封禁时间倒序。",
params(
("space_name" = String, Path, description = "空间 slug"),
("limit" = Option<u64>, Query, description = "每页条数,默认 20,最多 100"),
("offset" = Option<u64>, Query, description = "跳过条数,最多 5000")
),
responses(
(status = 200, description = "封禁列表", body = AppResponseData<api::pagination::Page<MemberSummary>>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>)
)
)]
pub async fn list_bans(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
Query(query): Query<MemberListQuery>,
) -> Result<AppResponseData<api::pagination::Page<MemberSummary>>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let (limit, offset) = query.bounds()?;
let rows = svc
.admin_list_bans(
user_id,
space_id,
limit,
offset,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(rows))
}
#[utoipa::path(
get,
path = "/spaces/{space_name}/bans/{user_id}",
tag = "space",
operation_id = "space_ban_get",
summary = "封禁详情",
description = "需要 `member.ban`。未封禁返回 404。",
params(
("space_name" = String, Path, description = "空间 slug"),
("user_id" = i64, Path, description = "被封禁用户 id")
),
responses(
(status = 200, description = "封禁详情", body = AppResponseData<MemberSummary>),
(status = 404, description = "未封禁或成员不存在", body = AppResponseData<NoData>)
)
)]
pub async fn get_ban(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceBanPath>,
) -> Result<AppResponseData<MemberSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let member = svc
.admin_get_ban(user_id, space_id, path.user_id, Some(request_id.0.as_str()))
.await?;
Ok(AppResponseData::ok(member))
}
#[utoipa::path(
patch,
path = "/spaces/{space_name}/bans/{user_id}",
tag = "space",
operation_id = "space_ban_update",
summary = "更新封禁",
description = "需要 `member.ban`。改原因、到期时间与四个放行位;未封禁返回 404。",
params(
("space_name" = String, Path, description = "空间 slug"),
("user_id" = i64, Path, description = "被封禁用户 id")
),
request_body = UpdateBanBody,
responses(
(status = 200, description = "已更新", body = AppResponseData<MemberSummary>),
(status = 404, description = "未封禁或成员不存在", body = AppResponseData<NoData>)
)
)]
pub async fn update_ban(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceBanPath>,
body: Json<UpdateBanBody>,
) -> Result<AppResponseData<MemberSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let member = svc
.admin_update_ban(
user_id,
space_id,
path.user_id,
body.into_inner(),
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(member))
}
#[utoipa::path(
delete,
path = "/spaces/{space_name}/bans/{user_id}",
tag = "space",
operation_id = "space_ban_delete",
summary = "按用户解封",
description = "需要 `member.ban`。等价于按会籍 id 解封,便于按用户维度操作。",
params(
("space_name" = String, Path, description = "空间 slug"),
("user_id" = i64, Path, description = "被解封用户 id")
),
responses(
(status = 200, description = "已解封", body = AppResponseData<MemberSummary>),
(status = 404, description = "成员不存在", body = AppResponseData<NoData>)
)
)]
pub async fn unban_by_user(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceBanPath>,
) -> Result<AppResponseData<MemberSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let member = svc
.admin_get_ban(user_id, space_id, path.user_id, Some(request_id.0.as_str()))
.await?;
let unbaned = svc
.unban_member(user_id, space_id, member.id, Some(request_id.0.as_str()))
.await?;
Ok(AppResponseData::ok(unbaned))
}
#[utoipa::path(
put,
path = "/spaces/{space_name}/members/{member_id}/permissions",
tag = "space",
operation_id = "space_member_permissions",
summary = "设置成员直挂权限",
description = "整表替换语义。需要 `role.permission.update`,OWNER 不接受直挂覆盖。",
params(
("space_name" = String, Path, description = "空间 slug"),
("member_id" = Uuid, Path, description = "会籍 id")
),
request_body = SetRolePermissionsBody,
responses(
(status = 200, description = "已更新", body = AppResponseData<Vec<String>>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>)
)
)]
pub async fn set_member_permissions(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceMemberPath>,
body: Json<SetRolePermissionsBody>,
) -> Result<AppResponseData<Vec<String>>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let grants: Vec<PermissionGrant> = body.into_inner().permissions;
let permissions = svc
.set_member_permissions(
user_id,
space_id,
path.member_id,
grants,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(permissions))
}
#[utoipa::path(
get,
path = "/spaces/{space_name}/roles",
tag = "space",
operation_id = "space_role_list",
summary = "列示组织角色",
description = "需要 `role.view`。含内置 owner/admin/member 三个系统角色。",
params(("space_name" = String, Path, description = "空间 slug")),
responses(
(status = 200, description = "角色列表", body = AppResponseData<Vec<RoleSummary>>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>)
)
)]
pub async fn list_roles(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
) -> Result<AppResponseData<Vec<RoleSummary>>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let rows = svc
.list_roles(user_id, space_id, Some(request_id.0.as_str()))
.await?;
Ok(AppResponseData::ok(rows))
}
#[utoipa::path(
get,
path = "/spaces/{space_name}/roles/{role_id}",
tag = "space",
operation_id = "space_role_get",
summary = "角色详情",
description = "需要 `role.view`。",
params(
("space_name" = String, Path, description = "空间 slug"),
("role_id" = Uuid, Path, description = "角色 id")
),
responses(
(status = 200, description = "角色详情", body = AppResponseData<RoleSummary>),
(status = 404, description = "角色不存在", body = AppResponseData<NoData>)
)
)]
pub async fn get_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceRolePath>,
) -> Result<AppResponseData<RoleSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let role = svc
.get_role(user_id, space_id, path.role_id, Some(request_id.0.as_str()))
.await?;
Ok(AppResponseData::ok(role))
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/roles",
tag = "space",
operation_id = "space_role_create",
summary = "新建角色",
description = "需要 `role.create`。key 在组织内唯一,权限键必须在白名单内。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = CreateRoleBody,
responses(
(status = 200, description = "角色已创建", body = AppResponseData<RoleSummary>),
(status = 409, description = "key 已占用", body = AppResponseData<NoData>)
)
)]
pub async fn create_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
body: Json<CreateRoleBody>,
) -> Result<AppResponseData<RoleSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let role = svc
.create_role(
user_id,
space_id,
body.into_inner(),
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(role))
}
#[utoipa::path(
patch,
path = "/spaces/{space_name}/roles/{role_id}",
tag = "space",
operation_id = "space_role_update",
summary = "修改角色",
description = "需要 `role.update`。OWNER 系统角色不允许改 rank 与 default_role。",
params(
("space_name" = String, Path, description = "空间 slug"),
("role_id" = Uuid, Path, description = "角色 id")
),
request_body = UpdateRoleBody,
responses(
(status = 200, description = "已更新", body = AppResponseData<RoleSummary>),
(status = 409, description = "版本冲突", body = AppResponseData<NoData>)
)
)]
pub async fn update_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceRolePath>,
body: Json<UpdateRoleBody>,
) -> Result<AppResponseData<RoleSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let body = body.into_inner();
let role = svc
.update_role(
user_id,
space_id,
path.role_id,
body.name,
body.description,
body.rank,
body.default_role,
body.self_assignable,
body.expected_version,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(role))
}
#[utoipa::path(
delete,
path = "/spaces/{space_name}/roles/{role_id}",
tag = "space",
operation_id = "space_role_delete",
summary = "删除角色",
description = "需要 `role.delete`。系统角色不可删;仍有成员绑定时返回 409。",
params(
("space_name" = String, Path, description = "空间 slug"),
("role_id" = Uuid, Path, description = "角色 id")
),
responses(
(status = 200, description = "已删除", body = AppResponseData<bool>),
(status = 409, description = "系统角色或仍被绑定", body = AppResponseData<NoData>)
)
)]
pub async fn delete_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceRolePath>,
) -> Result<AppResponseData<bool>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let deleted = svc
.delete_role(user_id, space_id, path.role_id, Some(request_id.0.as_str()))
.await?;
Ok(AppResponseData::ok(deleted))
}
#[utoipa::path(
put,
path = "/spaces/{space_name}/roles/{role_id}/permissions",
tag = "space",
operation_id = "space_role_permissions",
summary = "设置角色权限",
description = "整体替换语义,是角色权限的唯一写入口。需要 `role.permission.update`。",
params(
("space_name" = String, Path, description = "空间 slug"),
("role_id" = Uuid, Path, description = "角色 id")
),
request_body = SetRolePermissionsBody,
responses(
(status = 200, description = "已更新", body = AppResponseData<RoleSummary>),
(status = 403, description = "权限不足或系统 OWNER 角色", body = AppResponseData<NoData>)
)
)]
pub async fn set_role_permissions(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceRolePath>,
body: Json<SetRolePermissionsBody>,
) -> Result<AppResponseData<RoleSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let role = svc
.set_role_permissions(
user_id,
space_id,
path.role_id,
body.into_inner(),
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(role))
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct AssignRoleBody {
pub member_id: Uuid,
pub role_key: String,
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/roles/assign",
tag = "space",
operation_id = "space_role_assign",
summary = "绑定角色到成员",
description = "需要 `role.assign`。重复绑定幂等。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = AssignRoleBody,
responses(
(status = 200, description = "已绑定", body = AppResponseData<Vec<String>>),
(status = 404, description = "成员或角色不存在", body = AppResponseData<NoData>)
)
)]
pub async fn assign_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
body: Json<AssignRoleBody>,
) -> Result<AppResponseData<Vec<String>>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let keys = svc
.assign_role(
user_id,
space_id,
body.member_id,
&body.role_key,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(keys))
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/roles/unassign",
tag = "space",
operation_id = "space_role_unassign",
summary = "解绑成员角色",
description = "需要 `role.assign`。系统角色不可解绑。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = AssignRoleBody,
responses(
(status = 200, description = "已解绑", body = AppResponseData<Vec<String>>),
(status = 403, description = "系统角色不可解绑", body = AppResponseData<NoData>)
)
)]
pub async fn unassign_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
body: Json<AssignRoleBody>,
) -> Result<AppResponseData<Vec<String>>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let keys = svc
.unassign_role(
user_id,
space_id,
body.member_id,
&body.role_key,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(keys))
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/roles/{role_id}/join",
tag = "space",
operation_id = "space_role_join",
summary = "自助加入角色",
description = "只允许 `self_assignable = true` 的非内置角色。",
params(
("space_name" = String, Path, description = "空间 slug"),
("role_id" = Uuid, Path, description = "角色 id")
),
responses(
(status = 200, description = "已加入", body = AppResponseData<MemberSummary>),
(status = 403, description = "角色不可自助加入或不是活跃成员", body = AppResponseData<NoData>)
)
)]
pub async fn join_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceRolePath>,
) -> Result<AppResponseData<MemberSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let member = svc
.join_role(user_id, space_id, path.role_id, Some(request_id.0.as_str()))
.await?;
Ok(AppResponseData::ok(member))
}
#[utoipa::path(
delete,
path = "/spaces/{space_name}/roles/{role_id}/join",
tag = "space",
operation_id = "space_role_leave",
summary = "自助退出角色",
description = "只允许 `self_assignable = true` 的非内置角色;重复退出视为成功。",
params(
("space_name" = String, Path, description = "空间 slug"),
("role_id" = Uuid, Path, description = "角色 id")
),
responses(
(status = 200, description = "已退出", body = AppResponseData<MemberSummary>),
(status = 403, description = "角色不可自助加入或不是活跃成员", body = AppResponseData<NoData>)
)
)]
pub async fn leave_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceRolePath>,
) -> Result<AppResponseData<MemberSummary>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let member = svc
.leave_role(user_id, space_id, path.role_id, Some(request_id.0.as_str()))
.await?;
Ok(AppResponseData::ok(member))
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/permissions/check",
tag = "space",
operation_id = "space_permission_check",
summary = "单权限判定",
description = "拒绝不作为错误返回:`allowed=false` 附 `reason` 与 `matched_by`,便于调用方聚合。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = CheckPermissionBody,
responses(
(status = 200, description = "判定结果", body = AppResponseData<PermissionCheck>),
(status = 403, description = "查询他人权限但不是平台管理员", body = AppResponseData<NoData>)
)
)]
pub async fn check_permission(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
body: Json<CheckPermissionBody>,
) -> Result<AppResponseData<PermissionCheck>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let body = body.into_inner();
let check = svc
.check_permission(
user_id,
space_id,
body.user_id.unwrap_or(0),
&body.permission,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(check))
}
#[utoipa::path(
post,
path = "/spaces/{space_name}/invites",
tag = "space",
operation_id = "space_invite_create",
summary = "创建邀请",
description = "需要 `invite.create`。明文 token 只在创建响应里返回一次,之后只存 sha256。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = CreateInviteBody,
responses(
(status = 200, description = "邀请已创建", body = AppResponseData<CreateInviteResponse>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>)
)
)]
pub async fn create_invite(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
body: Json<CreateInviteBody>,
) -> Result<AppResponseData<CreateInviteResponse>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let body = body.into_inner();
let (invite, token) = svc
.create_invite_with_extras(
user_id,
space_id,
body.role_id,
body.target_user_id,
body.max_uses,
body.ttl_secs,
body.email,
&body.extra_role_ids,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(CreateInviteResponse { invite, token }))
}
#[utoipa::path(
get,
path = "/spaces/{space_name}/invites",
tag = "space",
operation_id = "space_invite_list",
summary = "列示邀请",
description = "需要 `invite.view`。不含 token 明文。",
params(
("space_name" = String, Path, description = "空间 slug"),
("limit" = Option<u64>, Query, description = "每页条数,默认 20,最多 100"),
("offset" = Option<u64>, Query, description = "跳过条数,最多 5000"),
("include_revoked" = Option<bool>, Query, description = "包含已撤销邀请")
),
responses(
(status = 200, description = "邀请列表", body = AppResponseData<api::pagination::Page<InviteSummary>>),
(status = 403, description = "权限不足", body = AppResponseData<NoData>)
)
)]
pub async fn list_invites(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpacePath>,
Query(query): Query<InviteListQuery>,
) -> Result<AppResponseData<api::pagination::Page<InviteSummary>>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let (limit, offset) = query.bounds()?;
let rows = svc
.admin_list_invites(
user_id,
space_id,
query.include_revoked.unwrap_or(false),
limit,
offset,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(rows))
}
#[utoipa::path(
delete,
path = "/spaces/{space_name}/invites/{invite_id}",
tag = "space",
operation_id = "space_invite_revoke",
summary = "撤销邀请",
description = "需要 `invite.revoke`。已撤销的邀请再次撤销返回 false。",
params(
("space_name" = String, Path, description = "空间 slug"),
("invite_id" = Uuid, Path, description = "邀请 id")
),
responses(
(status = 200, description = "已撤销", body = AppResponseData<bool>),
(status = 404, description = "邀请不存在", body = AppResponseData<NoData>)
)
)]
pub async fn revoke_invite(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
Path(path): Path<SpaceInvitePath>,
) -> Result<AppResponseData<bool>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let revoked = svc
.admin_revoke_invite(
user_id,
space_id,
path.invite_id,
None,
Some(request_id.0.as_str()),
)
.await?;
Ok(AppResponseData::ok(revoked))
}
#[utoipa::path(
post,
path = "/spaces/invites/redeem",
tag = "space",
operation_id = "space_invite_redeem",
summary = "兑换邀请",
description = "uses 递增与兑换记录写入同事务。定向邀请只能由目标用户兑换。",
request_body = RedeemInviteBody,
responses(
(status = 200, description = "已加入", body = AppResponseData<RedeemInviteResponse>),
(status = 403, description = "定向邀请目标不匹配", body = AppResponseData<NoData>),
(status = 409, description = "邀请已用尽", body = AppResponseData<NoData>)
)
)]
pub async fn redeem_invite(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
body: Json<RedeemInviteBody>,
) -> Result<AppResponseData<RedeemInviteResponse>, ApiError> {
let user_id = svc
.policy
.require_session(&session, Some(request_id.0.as_str()))
.await?;
let (organization_id, member_id, joined) = svc
.redeem_invite(user_id, &body.token, Some(request_id.0.as_str()))
.await?;
Ok(AppResponseData::ok(RedeemInviteResponse {
organization_id,
member_id,
joined,
}))
}