Skip to content
Clone
ef0b83a
1516 lines 49.5 KB Rust
use crate::errors::SpaceError;
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 actix_web::web;
use envelope::{Envelope, NoData};
use export::http::State;
use session::{RequestId, 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)]
pub struct OrgListQuery {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub include_archived: Option<bool>,
}
impl OrgListQuery {
fn limit(&self) -> u64 {
self.page_size.unwrap_or(20).clamp(1, 100)
}
fn offset(&self) -> u64 {
self.page.unwrap_or(1).saturating_sub(1) * self.limit()
}
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct MemberListQuery {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub include_inactive: Option<bool>,
pub include_banned: Option<bool>,
}
impl MemberListQuery {
fn limit(&self) -> u64 {
self.page_size.unwrap_or(20).clamp(1, 100)
}
fn offset(&self) -> u64 {
self.page.unwrap_or(1).saturating_sub(1) * self.limit()
}
}
#[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>,
#[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 = "/api/v1/spaces",
tag = "auth",
operation_id = "space_create",
summary = "新建组织",
description = "创建者自动成为 OWNER 并写入 owner/admin/member 三个内置角色。slug 全局唯一且只允许小写字母、数字与连字符。",
request_body = CreateOrganizationBody,
responses(
(status = 200, description = "组织已创建", body = Envelope<OrganizationSummary>),
(status = 401, description = "会话未绑定账号", body = Envelope<NoData>),
(status = 409, description = "slug 已被占用", body = Envelope<NoData>)
)
)]
pub async fn create_space(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
body: web::Json<CreateOrganizationBody>,
) -> Result<Envelope<OrganizationSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.as_str()))
.await?;
let summary = svc
.create_space(user_id, body.into_inner(), Some(request_id.as_str()))
.await?;
Ok(Envelope::ok(summary))
}
#[utoipa::path(
get,
path = "/api/v1/spaces",
tag = "auth",
operation_id = "space_list",
summary = "列示我加入的组织",
description = "默认只返回 ACTIVE 组织;`include_archived=true` 时一并返回已归档组织。",
responses(
(status = 200, description = "组织列表", body = Envelope<Vec<OrganizationSummary>>),
(status = 401, description = "会话未绑定账号", body = Envelope<NoData>)
)
)]
pub async fn list_spaces(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
query: web::Query<OrgListQuery>,
) -> Result<Envelope<Vec<OrganizationSummary>>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.as_str()))
.await?;
let query = query.into_inner();
let rows = svc
.list_spaces(
user_id,
0,
query.include_archived.unwrap_or(false),
query.limit(),
query.offset(),
Some(request_id.as_str()),
)
.await?;
Ok(Envelope::ok(rows))
}
#[utoipa::path(
get,
path = "/api/v1/spaces/{space_name}",
tag = "auth",
operation_id = "space_get",
summary = "组织详情",
params(("space_name" = String, Path, description = "空间 slug")),
responses(
(status = 200, description = "组织详情", body = Envelope<OrganizationSummary>),
(status = 404, description = "组织不存在", body = Envelope<NoData>)
)
)]
pub async fn get_space(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
) -> Result<Envelope<OrganizationSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()))
.await?;
Ok(Envelope::ok(summary))
}
#[utoipa::path(
patch,
path = "/api/v1/spaces/{space_name}",
tag = "auth",
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 = Envelope<OrganizationSummary>),
(status = 403, description = "权限不足", body = Envelope<NoData>),
(status = 409, description = "版本冲突", body = Envelope<NoData>)
)
)]
pub async fn update_space(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
body: web::Json<UpdateOrganizationBody>,
) -> Result<Envelope<OrganizationSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(summary))
}
#[utoipa::path(
post,
path = "/api/v1/spaces/{space_name}/archive",
tag = "auth",
operation_id = "space_archive",
summary = "归档组织",
description = "需要 `org.archive`。归档后全部权限判定返回 false,可恢复。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = ReasonBody,
responses(
(status = 200, description = "已归档", body = Envelope<OrganizationSummary>),
(status = 403, description = "权限不足", body = Envelope<NoData>)
)
)]
pub async fn archive_space(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
body: web::Json<ReasonBody>,
) -> Result<Envelope<OrganizationSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(summary))
}
#[utoipa::path(
post,
path = "/api/v1/spaces/{space_name}/restore",
tag = "auth",
operation_id = "space_restore",
summary = "恢复归档组织",
description = "仅 OWNER 或平台管理员可执行。",
params(("space_name" = String, Path, description = "空间 slug")),
responses(
(status = 200, description = "已恢复", body = Envelope<OrganizationSummary>),
(status = 403, description = "权限不足", body = Envelope<NoData>)
)
)]
pub async fn restore_space(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
) -> Result<Envelope<OrganizationSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()))
.await?;
Ok(Envelope::ok(summary))
}
#[utoipa::path(
delete,
path = "/api/v1/spaces/{space_name}",
tag = "auth",
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 = Envelope<bool>),
(status = 403, description = "权限不足", body = Envelope<NoData>),
(status = 409, description = "仍有活跃成员", body = Envelope<NoData>)
)
)]
pub async fn delete_space(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
query: web::Query<DeleteOrgQuery>,
) -> Result<Envelope<bool>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let query = query.into_inner();
let deleted = svc
.delete_space(
user_id,
space_id,
query.force,
query.reason.as_deref(),
Some(request_id.as_str()),
)
.await?;
Ok(Envelope::ok(deleted))
}
#[utoipa::path(
post,
path = "/api/v1/spaces/{space_name}/transfer",
tag = "auth",
operation_id = "space_transfer",
summary = "转移 OWNER",
description = "目标必须是活跃成员。原 Owner 降为 ADMIN。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = TransferBody,
responses(
(status = 200, description = "已转移", body = Envelope<OrganizationSummary>),
(status = 403, description = "权限不足", body = Envelope<NoData>),
(status = 404, description = "目标不是活跃成员", body = Envelope<NoData>)
)
)]
pub async fn transfer_ownership(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
body: web::Json<TransferBody>,
) -> Result<Envelope<OrganizationSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(summary))
}
#[utoipa::path(
get,
path = "/api/v1/spaces/{space_name}/members",
tag = "auth",
operation_id = "space_member_list",
summary = "列示组织成员",
description = "需要 `member.view`。默认只返回活跃成员。",
params(
("space_name" = String, Path, description = "空间 slug"),
("include_inactive" = Option<bool>, Query, description = "包含已退出成员"),
("include_banned" = Option<bool>, Query, description = "包含已封禁成员")
),
responses(
(status = 200, description = "成员列表", body = Envelope<Vec<MemberSummary>>),
(status = 403, description = "权限不足", body = Envelope<NoData>)
)
)]
pub async fn list_members(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
query: web::Query<MemberListQuery>,
) -> Result<Envelope<Vec<MemberSummary>>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let query = query.into_inner();
let rows = svc
.admin_list_members(
user_id,
space_id,
query.include_inactive.unwrap_or(false),
query.include_banned.unwrap_or(false),
query.limit(),
query.offset(),
Some(request_id.as_str()),
)
.await?;
Ok(Envelope::ok(rows))
}
#[utoipa::path(
get,
path = "/api/v1/spaces/{space_name}/members/{member_id}",
tag = "auth",
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 = Envelope<MemberSummary>),
(status = 404, description = "成员不存在", body = Envelope<NoData>)
)
)]
pub async fn get_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceMemberPath>,
) -> Result<Envelope<MemberSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(member))
}
#[utoipa::path(
patch,
path = "/api/v1/spaces/{space_name}/members/@me",
tag = "auth",
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 = Envelope<MemberSummary>),
(status = 403, description = "不是活跃成员", body = Envelope<NoData>)
)
)]
pub async fn update_self_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
body: web::Json<UpdateMemberBody>,
) -> Result<Envelope<MemberSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(member))
}
#[utoipa::path(
patch,
path = "/api/v1/spaces/{space_name}/members/{member_id}",
tag = "auth",
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 = Envelope<MemberSummary>),
(status = 403, description = "权限不足或目标已封禁", body = Envelope<NoData>),
(status = 409, description = "版本冲突", body = Envelope<NoData>)
)
)]
pub async fn update_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceMemberPath>,
body: web::Json<UpdateMemberBody>,
) -> Result<Envelope<MemberSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(member))
}
#[utoipa::path(
post,
path = "/api/v1/spaces/{space_name}/members",
tag = "auth",
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 = Envelope<MemberSummary>),
(status = 403, description = "权限不足或成员已封禁", body = Envelope<NoData>),
(status = 404, description = "用户不存在", body = Envelope<NoData>)
)
)]
pub async fn add_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
body: web::Json<AddMemberBody>,
) -> Result<Envelope<MemberSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(member))
}
#[utoipa::path(
delete,
path = "/api/v1/spaces/{space_name}/members/{member_id}",
tag = "auth",
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 = Envelope<bool>),
(status = 403, description = "权限不足或目标是 OWNER", body = Envelope<NoData>)
)
)]
pub async fn remove_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceMemberPath>,
query: web::Query<ReasonBody>,
) -> Result<Envelope<bool>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(removed))
}
#[utoipa::path(
post,
path = "/api/v1/spaces/{space_name}/leave",
tag = "auth",
operation_id = "space_leave",
summary = "退出组织",
description = "OWNER 必须先转移所有权,否则返回 409。",
params(("space_name" = String, Path, description = "空间 slug")),
responses(
(status = 200, description = "已退出", body = Envelope<bool>),
(status = 409, description = "OWNER 需先转移所有权", body = Envelope<NoData>)
)
)]
pub async fn leave_organization(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
) -> Result<Envelope<bool>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()))
.await?;
Ok(Envelope::ok(left))
}
#[utoipa::path(
patch,
path = "/api/v1/spaces/{space_name}/members/{member_id}/role",
tag = "auth",
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 = Envelope<MemberSummary>),
(status = 403, description = "权限不足", body = Envelope<NoData>),
(status = 409, description = "版本冲突", body = Envelope<NoData>)
)
)]
pub async fn set_member_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceMemberPath>,
body: web::Json<SetMemberRoleBody>,
) -> Result<Envelope<MemberSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(member))
}
#[utoipa::path(
post,
path = "/api/v1/spaces/{space_name}/members/{member_id}/ban",
tag = "auth",
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 = Envelope<MemberSummary>),
(status = 403, description = "权限不足", body = Envelope<NoData>)
)
)]
pub async fn ban_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceMemberPath>,
body: web::Json<BanMemberBody>,
) -> Result<Envelope<MemberSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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(),
Some(request_id.as_str()),
)
.await?;
Ok(Envelope::ok(member))
}
#[utoipa::path(
post,
path = "/api/v1/spaces/{space_name}/members/{member_id}/unban",
tag = "auth",
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 = Envelope<MemberSummary>),
(status = 403, description = "权限不足", body = Envelope<NoData>)
)
)]
pub async fn unban_member(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceMemberPath>,
) -> Result<Envelope<MemberSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()))
.await?;
Ok(Envelope::ok(member))
}
#[utoipa::path(
get,
path = "/api/v1/spaces/{space_name}/bans",
tag = "auth",
operation_id = "space_ban_list",
summary = "封禁列表",
description = "需要 `member.ban`。按封禁时间倒序。",
params(
("space_name" = String, Path, description = "空间 slug"),
("page" = Option<u64>, Query, description = "页码,从 1 开始"),
("page_size" = Option<u64>, Query, description = "每页条数")
),
responses(
(status = 200, description = "封禁列表", body = Envelope<Vec<MemberSummary>>),
(status = 403, description = "权限不足", body = Envelope<NoData>)
)
)]
pub async fn list_bans(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
query: web::Query<MemberListQuery>,
) -> Result<Envelope<Vec<MemberSummary>>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let rows = svc
.admin_list_bans(
user_id,
space_id,
query.limit(),
query.offset(),
Some(request_id.as_str()),
)
.await?;
Ok(Envelope::ok(rows))
}
#[utoipa::path(
get,
path = "/api/v1/spaces/{space_name}/bans/{user_id}",
tag = "auth",
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 = Envelope<MemberSummary>),
(status = 404, description = "未封禁或成员不存在", body = Envelope<NoData>)
)
)]
pub async fn get_ban(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceBanPath>,
) -> Result<Envelope<MemberSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()))
.await?;
Ok(Envelope::ok(member))
}
#[utoipa::path(
patch,
path = "/api/v1/spaces/{space_name}/bans/{user_id}",
tag = "auth",
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 = Envelope<MemberSummary>),
(status = 404, description = "未封禁或成员不存在", body = Envelope<NoData>)
)
)]
pub async fn update_ban(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceBanPath>,
body: web::Json<UpdateBanBody>,
) -> Result<Envelope<MemberSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(member))
}
#[utoipa::path(
delete,
path = "/api/v1/spaces/{space_name}/bans/{user_id}",
tag = "auth",
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 = Envelope<MemberSummary>),
(status = 404, description = "成员不存在", body = Envelope<NoData>)
)
)]
pub async fn unban_by_user(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceBanPath>,
) -> Result<Envelope<MemberSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()))
.await?;
let unbaned = svc
.unban_member(user_id, space_id, member.id, Some(request_id.as_str()))
.await?;
Ok(Envelope::ok(unbaned))
}
#[utoipa::path(
put,
path = "/api/v1/spaces/{space_name}/members/{member_id}/permissions",
tag = "auth",
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 = Envelope<Vec<String>>),
(status = 403, description = "权限不足", body = Envelope<NoData>)
)
)]
pub async fn set_member_permissions(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceMemberPath>,
body: web::Json<SetRolePermissionsBody>,
) -> Result<Envelope<Vec<String>>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(permissions))
}
#[utoipa::path(
get,
path = "/api/v1/spaces/{space_name}/roles",
tag = "auth",
operation_id = "space_role_list",
summary = "列示组织角色",
description = "需要 `role.view`。含内置 owner/admin/member 三个系统角色。",
params(("space_name" = String, Path, description = "空间 slug")),
responses(
(status = 200, description = "角色列表", body = Envelope<Vec<RoleSummary>>),
(status = 403, description = "权限不足", body = Envelope<NoData>)
)
)]
pub async fn list_roles(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
) -> Result<Envelope<Vec<RoleSummary>>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()))
.await?;
Ok(Envelope::ok(rows))
}
#[utoipa::path(
get,
path = "/api/v1/spaces/{space_name}/roles/{role_id}",
tag = "auth",
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 = Envelope<RoleSummary>),
(status = 404, description = "角色不存在", body = Envelope<NoData>)
)
)]
pub async fn get_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceRolePath>,
) -> Result<Envelope<RoleSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()))
.await?;
Ok(Envelope::ok(role))
}
#[utoipa::path(
post,
path = "/api/v1/spaces/{space_name}/roles",
tag = "auth",
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 = Envelope<RoleSummary>),
(status = 409, description = "key 已占用", body = Envelope<NoData>)
)
)]
pub async fn create_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
body: web::Json<CreateRoleBody>,
) -> Result<Envelope<RoleSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(role))
}
#[utoipa::path(
patch,
path = "/api/v1/spaces/{space_name}/roles/{role_id}",
tag = "auth",
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 = Envelope<RoleSummary>),
(status = 409, description = "版本冲突", body = Envelope<NoData>)
)
)]
pub async fn update_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceRolePath>,
body: web::Json<UpdateRoleBody>,
) -> Result<Envelope<RoleSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(role))
}
#[utoipa::path(
delete,
path = "/api/v1/spaces/{space_name}/roles/{role_id}",
tag = "auth",
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 = Envelope<bool>),
(status = 409, description = "系统角色或仍被绑定", body = Envelope<NoData>)
)
)]
pub async fn delete_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceRolePath>,
) -> Result<Envelope<bool>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()))
.await?;
Ok(Envelope::ok(deleted))
}
#[utoipa::path(
put,
path = "/api/v1/spaces/{space_name}/roles/{role_id}/permissions",
tag = "auth",
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 = Envelope<RoleSummary>),
(status = 403, description = "权限不足或系统 OWNER 角色", body = Envelope<NoData>)
)
)]
pub async fn set_role_permissions(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceRolePath>,
body: web::Json<SetRolePermissionsBody>,
) -> Result<Envelope<RoleSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(role))
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct AssignRoleBody {
pub member_id: Uuid,
pub role_key: String,
}
#[utoipa::path(
post,
path = "/api/v1/spaces/{space_name}/roles/assign",
tag = "auth",
operation_id = "space_role_assign",
summary = "绑定角色到成员",
description = "需要 `role.assign`。重复绑定幂等。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = AssignRoleBody,
responses(
(status = 200, description = "已绑定", body = Envelope<Vec<String>>),
(status = 404, description = "成员或角色不存在", body = Envelope<NoData>)
)
)]
pub async fn assign_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
body: web::Json<AssignRoleBody>,
) -> Result<Envelope<Vec<String>>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(keys))
}
#[utoipa::path(
post,
path = "/api/v1/spaces/{space_name}/roles/unassign",
tag = "auth",
operation_id = "space_role_unassign",
summary = "解绑成员角色",
description = "需要 `role.assign`。系统角色不可解绑。",
params(("space_name" = String, Path, description = "空间 slug")),
request_body = AssignRoleBody,
responses(
(status = 200, description = "已解绑", body = Envelope<Vec<String>>),
(status = 403, description = "系统角色不可解绑", body = Envelope<NoData>)
)
)]
pub async fn unassign_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
body: web::Json<AssignRoleBody>,
) -> Result<Envelope<Vec<String>>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(keys))
}
#[utoipa::path(
post,
path = "/api/v1/spaces/{space_name}/roles/{role_id}/join",
tag = "auth",
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 = Envelope<MemberSummary>),
(status = 403, description = "角色不可自助加入或不是活跃成员", body = Envelope<NoData>)
)
)]
pub async fn join_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceRolePath>,
) -> Result<Envelope<MemberSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()))
.await?;
Ok(Envelope::ok(member))
}
#[utoipa::path(
delete,
path = "/api/v1/spaces/{space_name}/roles/{role_id}/join",
tag = "auth",
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 = Envelope<MemberSummary>),
(status = 403, description = "角色不可自助加入或不是活跃成员", body = Envelope<NoData>)
)
)]
pub async fn leave_role(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceRolePath>,
) -> Result<Envelope<MemberSummary>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()))
.await?;
Ok(Envelope::ok(member))
}
#[utoipa::path(
post,
path = "/api/v1/spaces/{space_name}/permissions/check",
tag = "auth",
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 = Envelope<PermissionCheck>),
(status = 403, description = "查询他人权限但不是平台管理员", body = Envelope<NoData>)
)
)]
pub async fn check_permission(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
body: web::Json<CheckPermissionBody>,
) -> Result<Envelope<PermissionCheck>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(check))
}
#[utoipa::path(
post,
path = "/api/v1/spaces/{space_name}/invites",
tag = "auth",
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 = Envelope<CreateInviteResponse>),
(status = 403, description = "权限不足", body = Envelope<NoData>)
)
)]
pub async fn create_invite(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
body: web::Json<CreateInviteBody>,
) -> Result<Envelope<CreateInviteResponse>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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
.admin_create_invite(
user_id,
space_id,
body.role_id,
body.target_user_id,
body.max_uses,
body.ttl_secs,
Some(request_id.as_str()),
)
.await?;
Ok(Envelope::ok(CreateInviteResponse { invite, token }))
}
#[utoipa::path(
get,
path = "/api/v1/spaces/{space_name}/invites",
tag = "auth",
operation_id = "space_invite_list",
summary = "列示邀请",
description = "需要 `invite.view`。不含 token 明文。",
params(
("space_name" = String, Path, description = "空间 slug"),
("include_revoked" = Option<bool>, Query, description = "包含已撤销邀请")
),
responses(
(status = 200, description = "邀请列表", body = Envelope<Vec<InviteSummary>>),
(status = 403, description = "权限不足", body = Envelope<NoData>)
)
)]
pub async fn list_invites(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpacePath>,
query: web::Query<OrgListQuery>,
) -> Result<Envelope<Vec<InviteSummary>>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.as_str()))
.await?;
let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
let query = query.into_inner();
let rows = svc
.admin_list_invites(
user_id,
space_id,
query.include_archived.unwrap_or(false),
query.limit(),
query.offset(),
Some(request_id.as_str()),
)
.await?;
Ok(Envelope::ok(rows))
}
#[utoipa::path(
delete,
path = "/api/v1/spaces/{space_name}/invites/{invite_id}",
tag = "auth",
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 = Envelope<bool>),
(status = 404, description = "邀请不存在", body = Envelope<NoData>)
)
)]
pub async fn revoke_invite(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
path: web::Path<SpaceInvitePath>,
) -> Result<Envelope<bool>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.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.as_str()),
)
.await?;
Ok(Envelope::ok(revoked))
}
#[utoipa::path(
post,
path = "/api/v1/spaces/invites/redeem",
tag = "auth",
operation_id = "space_invite_redeem",
summary = "兑换邀请",
description = "uses 递增与兑换记录写入同事务。定向邀请只能由目标用户兑换。",
request_body = RedeemInviteBody,
responses(
(status = 200, description = "已加入", body = Envelope<RedeemInviteResponse>),
(status = 403, description = "定向邀请目标不匹配", body = Envelope<NoData>),
(status = 409, description = "邀请已用尽", body = Envelope<NoData>)
)
)]
pub async fn redeem_invite(
State(svc): State<SpaceService>,
session: Session,
request_id: RequestId,
body: web::Json<RedeemInviteBody>,
) -> Result<Envelope<RedeemInviteResponse>, SpaceError> {
let user_id = svc
.require_session(&session, Some(request_id.as_str()))
.await?;
let (organization_id, member_id, joined) = svc
.redeem_invite(user_id, &body.token, Some(request_id.as_str()))
.await?;
Ok(Envelope::ok(RedeemInviteResponse {
organization_id,
member_id,
joined,
}))
}