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, pub offset: Option, pub include_archived: Option, } 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, pub offset: Option, pub include_inactive: Option, pub include_banned: Option, } 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, pub offset: Option, pub include_revoked: Option, } 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, pub description: Option, pub rank: Option, pub default_role: Option, pub self_assignable: Option, #[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, } #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] pub struct DeleteOrgQuery { #[serde(default)] pub force: bool, pub reason: Option, } #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] pub struct CreateInviteBody { pub role_id: Option, pub target_user_id: Option, /// 定向邀请的收件邮箱;与 `target_user_id` 二选一,两者都空则是公开令牌。 pub email: Option, /// 兑换后一并授予的额外角色。 #[serde(default)] pub extra_role_ids: Vec, #[serde(default)] pub max_uses: i32, pub ttl_secs: Option, } #[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), (status = 401, description = "会话未绑定账号", body = AppResponseData), (status = 409, description = "slug 已被占用", body = AppResponseData) ) )] pub async fn create_space( State(svc): State, session: Session, request_id: RequestId, body: Json, ) -> Result, 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>), (status = 401, description = "会话未绑定账号", body = AppResponseData) ) )] pub async fn list_spaces( State(svc): State, session: Session, request_id: RequestId, Query(query): Query, ) -> Result>, 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), (status = 404, description = "组织不存在", body = AppResponseData) ) )] pub async fn get_space( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, ) -> Result, 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), (status = 403, description = "权限不足", body = AppResponseData), (status = 409, description = "版本冲突", body = AppResponseData) ) )] pub async fn update_space( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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), (status = 403, description = "权限不足", body = AppResponseData) ) )] pub async fn archive_space( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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), (status = 403, description = "权限不足", body = AppResponseData) ) )] pub async fn restore_space( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, ) -> Result, 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, Query, description = "是否连成员一并清理"), ("reason" = Option, Query, description = "删除原因,最长 512 字符") ), responses( (status = 200, description = "已删除", body = AppResponseData), (status = 403, description = "权限不足", body = AppResponseData), (status = 409, description = "仍有活跃成员", body = AppResponseData) ) )] pub async fn delete_space( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, Query(query): Query, ) -> Result, 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), (status = 403, description = "权限不足", body = AppResponseData), (status = 404, description = "目标不是活跃成员", body = AppResponseData) ) )] pub async fn transfer_ownership( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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, Query, description = "每页条数,默认 20,最多 100"), ("offset" = Option, Query, description = "跳过条数,最多 5000"), ("include_inactive" = Option, Query, description = "包含已退出成员"), ("include_banned" = Option, Query, description = "包含已封禁成员") ), responses( (status = 200, description = "成员列表", body = AppResponseData>), (status = 403, description = "权限不足", body = AppResponseData) ) )] pub async fn list_members( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, Query(query): Query, ) -> Result>, 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), (status = 404, description = "成员不存在", body = AppResponseData) ) )] pub async fn get_member( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, ) -> Result, 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), (status = 403, description = "不是活跃成员", body = AppResponseData) ) )] pub async fn update_self_member( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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), (status = 403, description = "权限不足或目标已封禁", body = AppResponseData), (status = 409, description = "版本冲突", body = AppResponseData) ) )] pub async fn update_member( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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), (status = 403, description = "权限不足或成员已封禁", body = AppResponseData), (status = 404, description = "用户不存在", body = AppResponseData) ) )] pub async fn add_member( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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, Query, description = "移除原因") ), responses( (status = 200, description = "已移除", body = AppResponseData), (status = 403, description = "权限不足或目标是 OWNER", body = AppResponseData) ) )] pub async fn remove_member( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, Query(query): Query, ) -> Result, 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), (status = 409, description = "OWNER 需先转移所有权", body = AppResponseData) ) )] pub async fn leave_organization( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, ) -> Result, 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), (status = 403, description = "权限不足", body = AppResponseData), (status = 409, description = "版本冲突", body = AppResponseData) ) )] pub async fn set_member_role( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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), (status = 403, description = "权限不足", body = AppResponseData) ) )] pub async fn ban_member( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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), (status = 403, description = "权限不足", body = AppResponseData) ) )] pub async fn unban_member( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, ) -> Result, 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, Query, description = "每页条数,默认 20,最多 100"), ("offset" = Option, Query, description = "跳过条数,最多 5000") ), responses( (status = 200, description = "封禁列表", body = AppResponseData>), (status = 403, description = "权限不足", body = AppResponseData) ) )] pub async fn list_bans( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, Query(query): Query, ) -> Result>, 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), (status = 404, description = "未封禁或成员不存在", body = AppResponseData) ) )] pub async fn get_ban( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, ) -> Result, 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), (status = 404, description = "未封禁或成员不存在", body = AppResponseData) ) )] pub async fn update_ban( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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), (status = 404, description = "成员不存在", body = AppResponseData) ) )] pub async fn unban_by_user( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, ) -> Result, 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>), (status = 403, description = "权限不足", body = AppResponseData) ) )] pub async fn set_member_permissions( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result>, 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 = 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>), (status = 403, description = "权限不足", body = AppResponseData) ) )] pub async fn list_roles( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, ) -> Result>, 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), (status = 404, description = "角色不存在", body = AppResponseData) ) )] pub async fn get_role( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, ) -> Result, 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), (status = 409, description = "key 已占用", body = AppResponseData) ) )] pub async fn create_role( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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), (status = 409, description = "版本冲突", body = AppResponseData) ) )] pub async fn update_role( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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), (status = 409, description = "系统角色或仍被绑定", body = AppResponseData) ) )] pub async fn delete_role( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, ) -> Result, 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), (status = 403, description = "权限不足或系统 OWNER 角色", body = AppResponseData) ) )] pub async fn set_role_permissions( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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>), (status = 404, description = "成员或角色不存在", body = AppResponseData) ) )] pub async fn assign_role( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result>, 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>), (status = 403, description = "系统角色不可解绑", body = AppResponseData) ) )] pub async fn unassign_role( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result>, 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), (status = 403, description = "角色不可自助加入或不是活跃成员", body = AppResponseData) ) )] pub async fn join_role( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, ) -> Result, 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), (status = 403, description = "角色不可自助加入或不是活跃成员", body = AppResponseData) ) )] pub async fn leave_role( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, ) -> Result, 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), (status = 403, description = "查询他人权限但不是平台管理员", body = AppResponseData) ) )] pub async fn check_permission( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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), (status = 403, description = "权限不足", body = AppResponseData) ) )] pub async fn create_invite( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, body: Json, ) -> Result, 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, Query, description = "每页条数,默认 20,最多 100"), ("offset" = Option, Query, description = "跳过条数,最多 5000"), ("include_revoked" = Option, Query, description = "包含已撤销邀请") ), responses( (status = 200, description = "邀请列表", body = AppResponseData>), (status = 403, description = "权限不足", body = AppResponseData) ) )] pub async fn list_invites( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, Query(query): Query, ) -> Result>, 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), (status = 404, description = "邀请不存在", body = AppResponseData) ) )] pub async fn revoke_invite( State(svc): State, session: Session, request_id: RequestId, Path(path): Path, ) -> Result, 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), (status = 403, description = "定向邀请目标不匹配", body = AppResponseData), (status = 409, description = "邀请已用尽", body = AppResponseData) ) )] pub async fn redeem_invite( State(svc): State, session: Session, request_id: RequestId, body: Json, ) -> Result, 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, })) }