docs(plan): add user and space implementation plans
ZhenYi committed
Added plan/space.md plan/space.md +1362 −0 View plan/space.md
@@ -0,0 +1,1362 @@
1# lib/space 实施计划
2
3> 参考实现:`C:\code\work\untitled\lib\space`(features/ + handler/ 结构)
4> 本项目适配基准:`lib/auth`、`lib/user` 的 services/ + handlers/ + share/ 布局
5> 硬性约束:不写非必要测试;不写非必要注释;所有 `mod.rs` 只写模块导出,不写任何代码。
6
7---
8
9## 1. 目标与范围
10
11新建 crate `lib/space`,实现 space 域的 45 个 HTTP 端点,覆盖 6 个子域:
12**spaces(空间本体,含头像与 banner/invite_banner/embed_splash 图片上传)、members(成员)、groups(角色组,参考实现叫 roles)、invites(邀请)、bans(封禁)、sidebar(资产树,基于 `lib/models/src/dir`)**。
13
14### 1.1 端点总表(45 个)
15
16| # | 方法 | 路径 | 处理函数 | 鉴权 | 权限要求 |
17|---|------|------|----------|------|----------|
18| 1 | GET | `/spaces/{space_name}` | `get_space` | 公开(可选会话) | 公开空间人人可见;私有空间须为所有者/在册成员 |
19| 2 | GET | `/spaces/{space_name}/groups/{group_id}` | `get_group` | 公开(可选会话) | 公开空间人人可见;私有空间须有 `manage_roles` |
20| 3 | POST | `/spaces` | `create_space` | 登录 | — |
21| 4 | GET | `/spaces` | `list_spaces` | 登录 | —(仅列出自己拥有的) |
22| 5 | PATCH | `/spaces/{space_name}` | `update_space` | 登录 | 所有者 |
23| 6 | DELETE | `/spaces/{space_name}` | `delete_space` | 登录 | 所有者(软删 `is_active=false`) |
24| 7 | POST | `/spaces/{space_name}/members` | `add_member` | 登录 | 所有者 |
25| 8 | GET | `/spaces/{space_name}/members` | `list_members` | 登录 | `manage_members` |
26| 9 | GET | `/spaces/{space_name}/members/{uid}` | `get_member` | 登录 | 所有者 |
27| 10 | PATCH | `/spaces/{space_name}/members/{uid}` | `update_member` | 登录 | 所有者 |
28| 11 | DELETE | `/spaces/{space_name}/members/{uid}` | `remove_member` | 登录 | 所有者;不可移除自己 |
29| 12 | POST | `/spaces/{space_name}/groups` | `create_group` | 登录 | 所有者 |
30| 13 | GET | `/spaces/{space_name}/groups` | `list_groups` | 登录 | `manage_roles` |
31| 14 | PATCH | `/spaces/{space_name}/groups/{group_id}` | `update_group` | 登录 | 所有者 |
32| 15 | DELETE | `/spaces/{space_name}/groups/{group_id}` | `delete_group` | 登录 | 所有者(硬删,连带清理两张关联表) |
33| 16 | POST | `/spaces/{space_name}/members/{uid}/groups` | `assign_group` | 登录 | 所有者 |
34| 17 | DELETE | `/spaces/{space_name}/members/{uid}/groups/{group_id}` | `unassign_group` | 登录 | 所有者 |
35| 18 | POST | `/spaces/{space_name}/invites` | `create_invite` | 登录 | `manage_invites`;成功后向目标邮箱发邀请邮件(best-effort) |
36| 19 | GET | `/spaces/{space_name}/invites` | `list_invites` | 登录 | `manage_invites` |
37| 20 | DELETE | `/spaces/{space_name}/invites/{invite_id}` | `revoke_invite` | 登录 | `manage_invites` |
38| 21 | POST | `/spaces/invites/accept` | `accept_invite` | 登录 | 邀请码归属校验(user_id 或已激活邮箱) |
39| 22 | POST | `/spaces/{space_name}/bans` | `ban_user` | 登录 | 所有者 |
40| 23 | GET | `/spaces/{space_name}/bans` | `list_bans` | 登录 | 所有者 |
41| 24 | GET | `/spaces/{space_name}/bans/{uid}` | `get_ban` | 登录 | 所有者 |
42| 25 | PATCH | `/spaces/{space_name}/bans/{uid}` | `update_ban` | 登录 | 所有者 |
43| 26 | DELETE | `/spaces/{space_name}/bans/{uid}` | `unban_user` | 登录 | 所有者(硬删) |
44| 27 | PUT | `/spaces/{space_name}/avatar` | `upload_space_avatar` | 登录 | 所有者(base64 图片 → S3) |
45| 28 | GET | `/spaces/{space_name}/avatar` | `get_space_avatar` | 公开 | 返回当前头像信息(`spaces.avatar_url`) |
46| 29 | DELETE | `/spaces/{space_name}/avatar` | `delete_space_avatar` | 登录 | 所有者(清 URL + 删 S3 对象) |
47| 30 | PUT | `/spaces/{space_name}/images/{kind}` | `upload_space_image` | 登录 | 所有者(kind ∈ banner/invite-banner/embed-splash;base64 → S3,回写宽高) |
48| 31 | GET | `/spaces/{space_name}/images/{kind}` | `get_space_image` | 公开 | 返回图片信息(url/宽高/format) |
49| 32 | DELETE | `/spaces/{space_name}/images/{kind}` | `delete_space_image` | 登录 | 所有者(清三组字段 + 删 S3 对象) |
50| 33 | POST | `/spaces/{space_name}/dir` | `create_dir_node` | 登录 | 所有者(**仅创建 kind=Dir 目录节点,无 Object 入口**) |
51| 34 | GET | `/spaces/{space_name}/dir` | `get_space_dir_tree` | 登录 | 在册成员;**树按 dir 覆盖权限过滤可见性**(无覆盖=全员可见/组覆盖=组成员可见/用户覆盖=指定成员可见) |
52| 35 | PATCH | `/spaces/{space_name}/dir/{dir_id}` | `update_dir_node` | 登录 | 所有者(重命名) |
53| 36 | PATCH | `/spaces/{space_name}/dir/{dir_id}/position` | `move_dir_node` | 登录 | 所有者(位置/移动,可换父级) |
54| 37 | POST | `/spaces/{space_name}/dir/repair-parents` | `repair_dir_parents` | 登录 | 所有者(修复父级:孤儿挂根/破环/去自指) |
55| 38 | GET | `/spaces/{space_name}/dir/{dir_id}/overwrites/groups` | `list_dir_group_overwrites` | 登录 | 在册成员 |
56| 39 | PUT | `/spaces/{space_name}/dir/{dir_id}/overwrites/groups/{group_id}` | `set_dir_group_overwrite` | 登录 | `manage_overwrites` |
57| 40 | DELETE | `/spaces/{space_name}/dir/{dir_id}/overwrites/groups/{group_id}` | `remove_dir_group_overwrite` | 登录 | `manage_overwrites` |
58| 41 | GET | `/spaces/{space_name}/dir/{dir_id}/overwrites/users` | `list_dir_user_overwrites` | 登录 | 在册成员 |
59| 42 | PUT | `/spaces/{space_name}/dir/{dir_id}/overwrites/users/{user_id}` | `set_dir_user_overwrite` | 登录 | `manage_overwrites` |
60| 43 | DELETE | `/spaces/{space_name}/dir/{dir_id}/overwrites/users/{user_id}` | `remove_dir_user_overwrite` | 登录 | `manage_overwrites` |
61| 44 | POST | `/spaces/{space_name}/groups/{group_id}/join` | `join_group_self` | 登录 | 在册成员且 `config.space.allow_self_join_groups = true`(自助入组,reaction-join) |
62| 45 | DELETE | `/spaces/{space_name}/groups/{group_id}/join` | `leave_group_self` | 登录 | 在册成员(自助离组,不受 config 门控) |
63
64路径段约定:URL 一律用 `groups`;**权限字符串保持 role 命名**(`manage_roles` 等),与 `lib/user` 的 `OWNER_PERMISSIONS` 常量及可能已落库的权限数据保持一致。
65
66### 1.2 排除项及理由
67
68| 排除项 | 理由 |
69|--------|------|
70| channels 全部端点(聊天域) | 本项目 `lib/models` 无 channel 相关表(仅 `space_warn.channel_id` 裸列);sidebar/overwrites 改为基于 `lib/models/src/dir` 资产树实现(见 §16)|
71| `rename_space` 独立端点 | 参考实现中该用例**未路由**(dead code);重命名语义并入 `update_space` |
72| `space_name_history` 改名历史(永久占名) | 本项目无该表/模型;名称唯一性只对 `spaces.lower_name` 现值做冲突检查 |
73| metrics 中间件、rate_limit | 项目惯例(lib/user 审批时已确立:不引入) |
74| `space_warn` 相关端点 | 参考实现无任何 warn 端点,模型仅存在,不实现 |
75| 由服务端解析图片宽高 | 宽高由客户端在 `UploadSpaceImageCmd.width/height` 提供,服务端仅做非负校验 | 参考实现无此功能;避免引入 `image` 解码依赖 |
76| `ensure_owner_member`(list_members 时治愈所有者成员行) | `create_space` 在同一事务内写所有者成员行,无历史脏数据场景 |
77| admin 视图、公开广场列表 | 参考实现无此功能 |
78
79---
80
81## 2. 目录树
82
83```
84lib/space/
85├── Cargo.toml
86└── src/
87 ├── lib.rs
88 ├── errors.rs
89 ├── openapi.rs
90 ├── routes.rs
91 ├── share/
92 │ ├── mod.rs # pub mod authz; pub mod guard; pub mod lookup; pub mod session;
93 │ ├── authz.rs # 权限常量、Permissions、effective_permissions、require_perm
94 │ ├── guard.rs # space_guard 中间件(镜像 user_guard)
95 │ ├── lookup.rs # lookup_space_by_name(lower_name -> space_id)
96 │ └── session.rs # PWD_VERSION_KEY、session_token_key、require_uid、pwd_version_matches
97 ├── services/
98 │ ├── mod.rs # 仅模块导出
99 │ ├── service.rs # SpaceService { state: AppState }
100 │ ├── avatar.rs # 空间头像上传/删除/查询(镜像 user crate avatar.rs)
101 │ ├── images.rs # banner/invite_banner/embed_splash 上传/删除/查询
102 │ ├── dir.rs # 资产树(sidebar):树查询/重命名/位置/修复父级/覆盖权限
103 │ ├── spaces.rs # SpaceDto/UserBriefDto/PageQuery 等 + 6 方法
104 │ ├── members.rs # MemberDto + 5 方法
105 │ ├── groups.rs # GroupDto + 8 方法
106 │ ├── invites.rs # InviteDto + 4 方法
107 │ └── bans.rs # BanDto + 5 方法
108 └── handlers/ # 45 个文件,一函数一文件
109 ├── mod.rs # 仅模块导出
110 ├── get_space.rs
111 ├── get_group.rs
112 ├── get_space_avatar.rs
113 ├── get_space_dir_tree.rs
114 ├── get_space_image.rs
115 ├── upload_space_avatar.rs
116 ├── upload_space_image.rs
117 ├── delete_space_avatar.rs
118 ├── delete_space_image.rs
119 ├── create_dir_node.rs
120 ├── create_space.rs
121 ├── list_spaces.rs
122 ├── update_space.rs
123 ├── delete_space.rs
124 ├── add_member.rs
125 ├── list_members.rs
126 ├── get_member.rs
127 ├── update_member.rs
128 ├── remove_member.rs
129 ├── create_group.rs
130 ├── list_groups.rs
131 ├── update_group.rs
132 ├── delete_group.rs
133 ├── assign_group.rs
134 ├── join_group_self.rs
135 ├── leave_group_self.rs
136 ├── unassign_group.rs
137 ├── create_invite.rs
138 ├── list_invites.rs
139 ├── revoke_invite.rs
140 ├── accept_invite.rs
141 ├── ban_user.rs
142 ├── list_bans.rs
143 ├── get_ban.rs
144 ├── update_ban.rs
145 ├── list_dir_group_overwrites.rs
146 ├── list_dir_user_overwrites.rs
147 ├── move_dir_node.rs
148 ├── remove_dir_group_overwrite.rs
149 ├── remove_dir_user_overwrite.rs
150 ├── repair_dir_parents.rs
151 ├── set_dir_group_overwrite.rs
152 ├── set_dir_user_overwrite.rs
153 ├── update_dir_node.rs
154 └── unban_user.rs
155```
156
157`lib.rs`:
158```rust
159pub mod errors;
160pub mod handlers;
161pub mod openapi;
162pub mod routes;
163pub mod services;
164pub mod share;
165```
166
167---
168
169## 3. Cargo.toml 与 workspace 注册
170
171`lib/space/Cargo.toml`:
172```toml
173[package]
174name = "space"
175version.workspace = true
176edition.workspace = true
177
178[dependencies]
179config = { path = "../config" }
180export = { path = "../export" }
181models = { path = "../models" }
182session = { path = "../session" }
183storage = { path = "../storage" }
184axum = { workspace = true }
185sea-orm = { workspace = true }
186serde = { workspace = true, features = ["derive"] }
187serde_json = { workspace = true }
188utoipa = { version = "5.5", features = ["axum_extras", "time", "uuid"] }
189uuid = { version = "1", features = ["v7", "serde"] }
190time = { version = "0.3", features = ["serde"] }
191base64 = { workspace = true }
192mailbaby = { git = "https://github.com/mailbabys/mailbaby-rs", rev = "e408acf48a19e4fd554b020a865d144706f7c2de", features = ["rest", "grpc"] }
193tracing = { workspace = true }
194```
195说明:头像上传需要 `storage`(S3)与 `base64`;邀请邮件通过 `mailbaby` 构建 `Email`(与 lib/auth 同一 git 依赖、同 feature 声明)。
196
197根 `Cargo.toml`:`members` 追加 `"lib/space"`。
198
199---
200
201## 4. lib/config 新增 space.rs
202
203`lib/config/src/space.rs`(镜像 `user.rs` 风格):
204```rust
205use serde::Deserialize;
206
207#[derive(Clone, Debug, Deserialize)]
208#[serde(default)]
209pub struct SpaceConfig {
210 pub max_pagination: u64,
211 pub default_pagination: u64,
212 pub max_space_name_len: usize,
213 pub max_space_desc_len: usize,
214 pub max_group_name_len: usize,
215 pub max_alias_name_len: usize,
216 pub max_invite_email_len: usize,
217 pub allow_self_join_groups: bool, // 成员能否自助加入某 group(reaction-join 模式),默认 false
218}
219
220impl Default for SpaceConfig {
221 fn default() -> Self {
222 Self {
223 max_pagination: 100,
224 default_pagination: 20,
225 max_space_name_len: 64,
226 max_space_desc_len: 512,
227 max_group_name_len: 64,
228 max_alias_name_len: 64,
229 max_invite_email_len: 254,
230 allow_self_join_groups: false,
231 }
232 }
233}
234```
235`lib/config/src/lib.rs`:`pub mod space;` + `AppConfig` 增加 `pub space: SpaceConfig`。
236
237---
238
239## 5. lib/models 改动
240
241给 4 个枚举加 `ToSchema` derive(utoipa 依赖已在 user 阶段加入 models):
242- `lib/models/src/space/spaces.rs`:`AllowJoinLevel`
243- `lib/models/src/space/space_members.rs`:`MemberStatus`、`MessageNotifications`
244- `lib/models/src/dir/dirs.rs`:`DirKind`
245
246另在 `lib/models/src/space/spaces.rs` 的 `Model` 增加:**`pub welcome_object: Option<Uuid>`**(用户入会展示的资产对象,sec §9)。DB 列需已存在,数据迁移由 schema 侧负责,本 crate 只依赖模型(**前置依赖,见 §21 步骤 0**)。
247
248**DTO 一律直用 models 枚举,不平行定义字符串枚举**(防与库内数据不兼容):
249- `AllowJoinLevel` **5 变体**:None=0 / NeedMailActive=1 / NeedMailActiveAndLastOneMid=2 / NeedMailActiveAndLastOneHours=3 / NeedMailActiveAndPhoneActive=4
250- `MemberStatus` **4 变体**:Active=0 / Inactive=1 / Left=2 / Removed=3
251- `MessageNotifications` 3 变体:All / Mentions / Nothing;`DirKind` 2 变体:Dir / Object
252
253JSON 序列化为底层 i16 数值(与 lib/user 直用 models 枚举的约定一致);若前端需字符串形式,由 models 层统一加 serde 属性,不在 space crate 内重定义。
254
255---
256
257## 6. errors.rs
258
259与 `lib/user/src/errors.rs` **逐行同构**,仅做替换:`UserError` → `SpaceError`,tracing 消息 `"user handler failed"` → `"space handler failed"`。包含:
260
261- `SpaceError { BadRequest(String), Unauthorized, Forbidden, NotFound, Conflict(String), Internal(String) }`
262- `Display`、`std::error::Error`
263- `From<sea_orm::DbErr>`(unique/duplicate/23505 → `Conflict("resource already exists")`)
264- `From<TransactionError<DbErr>>`、`From<TransactionError<SpaceError>>`
265- `From<serde_json::Error>` → BadRequest
266- `From<export::ExportError>` → Internal("infrastructure error: {err}")(`send_email` 失败路径)
267- **`From<storage::StorageError>` → Internal("storage error: {err}")**——头像/图片上传主路径(`put_with_content_type`)返回 `Result<_, StorageError>`,必须处理;镜像 lib/user/src/errors.rs
268- `ApiError(IntoResponses)` 六态枚举 + `IntoResponse` + `From<SpaceError>` + `From<Box<ApiError>>` + `From<PathRejection>`/`From<QueryRejection>`
269
270其余以 `cargo check` 零警告为准。
271
272---
273
274## 7. share/ 共享层
275
276### 7.1 session.rs
277
278与 `lib/user/src/share/session.rs` **完全一致**(`PWD_VERSION_KEY`、`session_token_key(uid)`、`require_uid(&Session) -> Result<Uuid, Box<ApiError>>`、`pwd_version_matches`)。
279
280### 7.2 guard.rs
281
282`space_guard`:镜像 `user_guard`,仅把 `UserService` 换成 `SpaceService`、`UserError` 换成 `SpaceError`。逻辑:取 uid → 取 token_hash → `user_sessions` 按 `TokenHash` 查并 `find_also_related(user::Entity)` → 校验 `!is_revoked && expires_at > now && pwd_version_matches` → 失败 `session.logout_user(uid)` + 401。
283
284> 与 lib/user 相同:guard **直接经 `SpaceService.db()` 裸查询 `models::users::user_sessions`/`models::users::user`**(sea-orm 实体查询,非 SessionService),无需额外会话服务;`session`/`models` crate 已入 Cargo.toml。若未来多 crate 复用再抽公共 guard。
285
286### 7.3 lookup.rs
287
288```rust
289pub async fn lookup_space_by_name(service: &SpaceService, lower_name: &str) -> Result<spaces::Model, SpaceError>
290```
291`spaces::Entity::find().filter(LowerName.eq(lower_name)).one(read)` → `ok_or(NotFound)`。
292与参考实现的差异:无 `space_name_history` 回退(表不存在),返回完整 Model 而非仅 id(调用方都要用到)。
293
294### 7.4 authz.rs — 权限核心
295
296```rust
297pub const VIEW_SPACE: &str = "view_space";
298pub const READ: &str = "read";
299pub const MESSAGE: &str = "message";
300pub const MANAGE_MEMBERS: &str = "manage_members";
301pub const MANAGE_ROLES: &str = "manage_roles";
302pub const MANAGE_INVITES: &str = "manage_invites";
303pub const MANAGE_BANS: &str = "manage_bans";
304pub const MANAGE_CHANNELS: &str = "manage_channels";
305pub const MANAGE_OVERWRITES: &str = "manage_overwrites";
306
307// 注:manage_channels/manage_overwrites 为系统级权限位——本计划无频道端点,manage_channels 属保留位
308// (与 lib/user OWNER_PERMISSIONS 及既有数据一致,不删除);manage_overwrites 已被 dir 覆盖写实际使用。
309pub const ALL_PERMISSIONS: [&str; 9] = [ ... 上述九项 ... ];
310
311#[derive(Debug, Clone, Default)]
312pub struct Permissions { granted: HashSet<String> }
313impl Permissions {
314 pub fn owner() -> Self { ALL_PERMISSIONS 全集 }
315 pub fn has(&self, action: &str) -> bool
316 pub fn into_vec(self) -> Vec<String> // 排序去重后返回
317}
318
319pub async fn effective_permissions(
320 db: &impl ConnectionTrait,
321 space: &spaces::Model,
322 user_id: Uuid,
323) -> Result<Permissions, SpaceError>
324```
325`effective_permissions` 逻辑(与 lib/user `my_spaces` 的权限链一致,方向相反:由调用方传入已查好的 space):
3261. `space.owner_id == user_id` → `Permissions::owner()`
3272. 查 `space_bans` 主键 `(space_id, user_id)`:存在且(`expires_at` 为 None 或 > now)→ `Forbidden`
3283. 查 `space_members` 主键:不存在或 `status != Active` → `Forbidden`
3294. 查 `space_member_groups`(space_id+user_id)得 group_ids;为空 → `Permissions::default()`
3305. 查 `space_group_permissions`(group_id in)→ 汇总为 `Permissions`
331
332```rust
333pub async fn require_perm(
334 service: &SpaceService,
335 space: &spaces::Model,
336 caller: Uuid,
337 action: &str,
338) -> Result<(), SpaceError>
339```
340`effective_permissions(read 连接, ...)` 后 `perms.has(action)` 不通过 → `Forbidden`。
341
342---
343
344## 8. services/service.rs
345
346```rust
347use std::sync::Arc;
348use config::AppConfig;
349use export::{AppState, AppDatabase};
350
351#[derive(Clone)]
352pub struct SpaceService { pub state: AppState }
353
354impl SpaceService {
355 pub fn new(state: AppState) -> Self
356 pub fn config(&self) -> &AppConfig
357 pub fn db(&self) -> &Arc<AppDatabase>
358 pub fn storage(&self) -> &Arc<AppStorage> // 仅头像/图片上传删除使用
359 pub fn email(&self) -> &AppEmail // 邀请邮件发送
360}
361```
362
363`pub(crate) async fn send_email(&self, to: &str, subject: &str, text: &str, html: &str) -> Result<(), SpaceError>`:镜像 auth 的 `AuthService::send_email`(services/mod.rs)——`Email::builder(subject).to([to])` + 非空 html 时 `.html_body(html)`,`.build()` 后用 `self.state.email.send(&email)`,`From<ExportError>` 转换。import:`use export::{AppDatabase, AppEmail, AppState, AppStorage}; use mailbaby::Email;`。
364
365事务风格:统一 `TransactionTrait::transaction(self.db().primary(), |txn| Box::pin(async move { ... }))`(与 lib/user `emails.rs::set_primary` 一致)。单条写直接 `self.db().write()`。
366
367---
368
369## 9. services/spaces.rs(含全局共用 DTO)
370
371### 9.1 DTO
372
373```rust
374#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
375pub struct UserBriefDto {
376 pub uid: Uuid,
377 pub username: String,
378 pub display_name: String,
379 pub avatar_url: String,
380}
381impl From<user::Model> for UserBriefDto // 四字段直取
382
383#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
384pub struct SpaceDto {
385 pub id: Uuid,
386 pub lower_name: String,
387 pub name: String,
388 pub description: Option<String>,
389 pub owner: Option<UserBriefDto>,
390 pub avatar_url: Option<String>,
391 pub is_private: bool,
392 pub is_active: bool,
393 pub allow_join: bool,
394 pub allow_invite: bool,
395 pub nums_members: i64,
396 pub welcome_message: Option<String>,
397 pub allow_join_level: AllowJoinLevel, // models 枚举直用
398 pub member_must_enable_2fa: bool,
399 pub welcome_object: Option<Uuid>, // 入会展示的资产对象
400 pub created_at: OffsetDateTime,
401 pub updated_at: OffsetDateTime,
402}
403
404#[derive(Debug, Clone, Deserialize, ToSchema)]
405pub struct CreateSpaceCmd {
406 pub name: String,
407 #[serde(default)] pub description: Option<String>,
408 #[serde(default)] pub avatar_url: Option<String>,
409 #[serde(default)] pub is_private: bool,
410 #[serde(default = "default_true")] pub allow_join: bool,
411 #[serde(default = "default_true")] pub allow_invite: bool,
412 #[serde(default)] pub welcome_message: Option<String>,
413 #[serde(default)] pub allow_join_level: Option<AllowJoinLevel>,
414 #[serde(default)] pub member_must_enable_2fa: bool,
415 #[serde(default)] pub welcome_object: Option<Uuid>,
416}
417
418#[derive(Debug, Clone, Deserialize, ToSchema)]
419pub struct UpdateSpaceCmd {
420 #[serde(default)] pub name: Option<String>,
421 #[serde(default)] pub description: Option<Option<String>>,
422 #[serde(default)] pub avatar_url: Option<Option<String>>,
423 #[serde(default)] pub is_private: Option<bool>,
424 #[serde(default)] pub allow_join: Option<bool>,
425 #[serde(default)] pub allow_invite: Option<bool>,
426 #[serde(default)] pub welcome_message: Option<Option<String>>,
427 #[serde(default)] pub allow_join_level: Option<AllowJoinLevel>,
428 #[serde(default)] pub member_must_enable_2fa: Option<bool>,
429 #[serde(default)] pub welcome_object: Option<Option<Uuid>>,
430}
431
432#[derive(Debug, Clone, Serialize, ToSchema)]
433pub struct SpacePage { pub items: Vec<SpaceDto>, pub total: i64 }
434
435#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
436pub struct PageQuery {
437 pub limit: Option<u64>,
438 pub offset: Option<u64>,
439}
440
441#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
442pub struct SpaceNamePath { pub space_name: String }
443```
444时间字段一律 `OffsetDateTime`(serde time),**不**做 RFC3339 字符串转换。`fn default_true() -> bool { true }` 为文件内私有辅助函数(非注释、非 mod.rs 代码,允许)。
445
446### 9.2 方法(6 个)
447
448**`create_space(&self, caller: Uuid, cmd) -> SpaceDto`**
4491. `name = cmd.name.trim()`;空或 `> max_space_name_len` → BadRequest;`description` 超长 → BadRequest
4502. `lower = name.to_lowercase()`,校验 `[a-z0-9-]+`(参考实现的 `is_valid_lower_name`,提为本文件私有函数,create 也启用)
4513. `spaces::Entity::find().filter(LowerName.eq(&lower)).one(read)` 存在 → `Conflict("space name {lower} is already taken")`
4524. `cmd.member_must_enable_2fa` 为真时:查 `users` caller,`totp_enabled == false` → Forbidden
4535. 事务(primary):插 `spaces` 行(id=Uuid::now_v7(), **nums_members=1**——所有者成员行在同一事务内写入,计数从创建即含 owner,否则"0 行但实际 1 成员"不一致, is_active=true, banner/embed 系列列全部 None, invite_sort_url_hash=None, `allow_join_level = cmd.allow_join_level.unwrap_or(AllowJoinLevel::None)`(**模型列为非 Option,缺省必须显式给枚举 None 档**), welcome_object=cmd.welcome_object)+ 插 `space_members` 所有者行(space_id, caller, status=Active, joined_at/created_at=now, updated_at=Some(now))
4546. 组装 `SpaceDto`,owner 由 caller 查 `users` 填充
455
456**`get_space(&self, caller: Option<Uuid>, space_name: &str) -> SpaceDto`**
4571. `lookup_space_by_name`;`!is_active` → NotFound
4582. `!is_private` → 直接返回(owner 填 UserBriefDto)
4593. `is_private`:`caller` 为 None → NotFound(掩盖存在性);否则 `effective_permissions`,`Ok` 返回视图,`Err(Forbidden)` → NotFound,其余错误透传
460
461**`update_space(&self, caller, space_name, cmd) -> SpaceDto`**
4621. lookup;`owner_id != caller` → Forbidden("only the owner can update the space")
4632. name 变更时:trim 校验 + `[a-z0-9-]+` + 冲突检查(`LowerName.eq(new) AND Id.ne(id)` 命中 → Conflict)→ 同时 Set name 与 lower_name
4643. description/welcome_message 变更时校验 `max_space_desc_len`
4654. 其余字段 `if let Some(v) = ... { Set(v) }`;`welcome_object` 为 `Some(v)` 时 `Set(v)`(`v: Option<Uuid>`),**不做存在性校验**(资产对象可由 dir 之外的系统创建/删除);`updated_at = Set(now_utc)`
4665. 事务更新;返回视图(owner 填充)
467
468**`delete_space(&self, caller, space_name) -> ()`**:lookup;owner 校验;事务内 `is_active=Set(false)`、`updated_at=Set(now)`。返回 `AppResponseData::empty()`。
469
470> 软删不级联清理(成员/组/邀请/ban/dir 均保留),但依赖方须校验空间活性:本期在 `accept_invite` 补 `is_active` 校验(见 §12.2 步骤 6),其他读写路径的活性校验按业务需要后续加。
471
472**`list_spaces(&self, caller, q: PageQuery) -> SpacePage`**
473- `limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination)`,`offset = q.offset.unwrap_or(0)`
474- `OwnerId.eq(caller)` 分页 + count(futures::join! 并行可选;顺序两次查询亦可)
475- owner 只有 caller 本人,批量填充即 `load_user_brief(caller)` 一次
476
477> 语义:只列 caller **拥有**的空间;成员视角(我加入的空间)由 `lib/user` 的 `GET /me/spaces`(`my_spaces`)覆盖,二者互补,此处不重复实现。
478
479**`load_user_briefs(&self, uids: &[Uuid]) -> HashMap<Uuid, UserBriefDto>`**(文件内或 service 私有辅助):
480`user::Entity::find().filter(Column::Uid.is_in(uids 去重)).all(read)` → map 构建。供 members/invites/bans 复用。
481
482---
483
484## 10. services/members.rs
485
486### 10.1 DTO
487
488```rust
489#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
490pub struct MemberDto {
491 pub space_id: Uuid,
492 pub user: Option<UserBriefDto>,
493 pub alias_name: Option<String>,
494 pub alias_avatar_url: Option<String>,
495 pub status: MemberStatus, // models 枚举
496 pub last_active_at: Option<OffsetDateTime>,
497 pub left_at: Option<OffsetDateTime>,
498 pub invited_at: Option<OffsetDateTime>,
499 pub invited_by: Option<UserBriefDto>,
500 pub joined_at: OffsetDateTime,
501 pub created_at: OffsetDateTime,
502 pub updated_at: Option<OffsetDateTime>,
503 pub message_notifications: MessageNotifications,
504 pub muted: bool,
505 pub mute_until: Option<OffsetDateTime>,
506 pub mobile_push: bool,
507 pub suppress_everyone: bool,
508 pub suppress_roles: bool,
509 pub hide_muted_channels: bool,
510}
511
512#[derive(Debug, Clone, Deserialize, ToSchema)]
513pub struct AddMemberCmd {
514 pub user_id: Uuid,
515 #[serde(default)] pub alias_name: Option<String>,
516 #[serde(default)] pub alias_avatar_url: Option<String>,
517 #[serde(default)] pub message_notifications: Option<MessageNotifications>,
518 #[serde(default)] pub muted: Option<bool>,
519 #[serde(default)] pub mute_until: Option<OffsetDateTime>,
520 #[serde(default)] pub mobile_push: Option<bool>,
521 #[serde(default)] pub suppress_everyone: Option<bool>,
522 #[serde(default)] pub suppress_roles: Option<bool>,
523 #[serde(default)] pub hide_muted_channels: Option<bool>,
524}
525
526#[derive(Debug, Clone, Deserialize, ToSchema)]
527pub struct UpdateMemberCmd { // 全 Option,双 Option 表"清空"
528 #[serde(default)] pub alias_name: Option<Option<String>>,
529 #[serde(default)] pub alias_avatar_url: Option<Option<String>>,
530 #[serde(default)] pub message_notifications: Option<MessageNotifications>,
531 #[serde(default)] pub muted: Option<bool>,
532 #[serde(default)] pub mute_until: Option<Option<OffsetDateTime>>,
533 #[serde(default)] pub mobile_push: Option<bool>,
534 #[serde(default)] pub suppress_everyone: Option<bool>,
535 #[serde(default)] pub suppress_roles: Option<bool>,
536 #[serde(default)] pub hide_muted_channels: Option<bool>,
537}
538
539#[derive(Debug, Clone, Serialize, ToSchema)]
540pub struct MemberPage { pub items: Vec<MemberDto>, pub total: i64 }
541```
542注意:`mute_until` 直接用 `OffsetDateTime`(time serde),**删除**参考实现的 RFC3339 字符串解析函数。
543
544### 10.2 方法(5 个)
545
546所有者校验统一走文件内私有 `async fn owned_space(&self, space: &spaces::Model, caller: Uuid) -> Result<(), SpaceError>`(非 owner → Forbidden "only the owner can manage members");space 由 handler 传入?不——**service 方法签名统一先接 `space_name: &str` 并在内部 `lookup_space_by_name`**,与 §9.2 一致。
547
548**`add_member(&self, caller, space_name, cmd) -> MemberDto`**
5491. lookup + owned_space
5502. `space_members` 主键 `(space_id, cmd.user_id)` 已存在 → Conflict
5513. `space.member_must_enable_2fa`:查目标 `users.totp_enabled`,false → Forbidden
5524. `alias_name` 超过 `max_alias_name_len` → BadRequest
5535. 插入成员行(status=Active, joined_at=created_at=now, invited_at/invited_by=None, 各开关取 cmd 默认 false / message_notifications 默认 All)
5546. `nums_members + 1`:`spaces::Entity::update_many().filter(Id.eq(space_id)).col_expr(NumsMembers, Expr::col(NumsMembers).add(1)).exec(write)`
5557. 填 user + invited_by(None)返回
556
557**`get_member(&self, caller, space_name, uid) -> MemberDto`**:lookup + owned_space;主键查询 NotFound 兜底;填 user/invited_by。
558
559**`update_member(&self, caller, space_name, uid, cmd) -> MemberDto`**:lookup + owned_space;事务内取行→按 cmd 逐字段 Set(alias_name 校验 max_alias_name_len)→ `updated_at=Set(Some(now))` → update;返回填充视图。
560
561**`remove_member(&self, caller, space_name, uid) -> ()`**
5621. lookup + owned_space;`caller == uid` → BadRequest("owner cannot remove themselves")
5632. 事务:① **`space_member_groups::Entity::delete_many()`(`SpaceId + UserId`)清理该用户组关联**(否则重加成员时旧组权限意外复活)② 成员行 `status=Set(Removed)`、`left_at=Set(Some(now))`、`updated_at=Set(Some(now))` ③ `nums_members - 1`(同上 col_expr,`.sub(1)`)
564
565**`list_members(&self, caller, space_name, q: PageQuery) -> MemberPage`**
5661. lookup;`require_perm(space, caller, MANAGE_MEMBERS)`
5672. 分页 `SpaceId.eq` 按 `JoinedAt asc` + count
5683. `load_user_briefs(user_ids + invited_by_ids)` 一次填充
569
570---
571
572## 11. services/groups.rs(参考实现的 roles)
573
574### 11.1 DTO
575
576```rust
577#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
578pub struct GroupDto {
579 pub id: Uuid,
580 pub space_id: Uuid,
581 pub name: String,
582 pub position: i64,
583 pub color: String,
584 pub unicode_emoji: Option<String>,
585 pub icon_hash: Option<String>,
586 pub version: i32,
587 pub created_at: OffsetDateTime,
588 pub updated_at: Option<OffsetDateTime>,
589 pub created_by: Option<UserBriefDto>,
590 pub permissions: Vec<String>,
591}
592
593#[derive(Debug, Clone, Deserialize, ToSchema)]
594pub struct CreateGroupCmd {
595 pub name: String,
596 #[serde(default)] pub color: Option<String>,
597 #[serde(default)] pub unicode_emoji: Option<String>,
598 #[serde(default)] pub icon_hash: Option<String>,
599 #[serde(default)] pub position: Option<i64>,
600 #[serde(default)] pub permissions: Vec<String>,
601}
602
603#[derive(Debug, Clone, Deserialize, ToSchema)]
604pub struct UpdateGroupCmd {
605 #[serde(default)] pub name: Option<String>,
606 #[serde(default)] pub color: Option<String>,
607 #[serde(default)] pub unicode_emoji: Option<Option<String>>,
608 #[serde(default)] pub icon_hash: Option<Option<String>>,
609 #[serde(default)] pub position: Option<i64>,
610 #[serde(default)] pub permissions: Option<Vec<String>>,
611}
612
613#[derive(Debug, Clone, Deserialize, ToSchema)]
614pub struct AssignGroupCmd { pub group_id: Uuid }
615
616#[derive(Debug, Clone, Serialize, ToSchema)]
617pub struct GroupPage { pub items: Vec<GroupDto>, pub total: i64 }
618```
619
620> `space_groups.created_by` 模型列为 **`Uuid`(非 Option)**;DTO 的 `created_by: Option<UserBriefDto>` 仅用于"用户可能已删除"的填充场景(`load_user_briefs` 缺失即 None),插入/更新组时值取 `Set(caller)` 等真实 Uuid。
621
622### 11.2 方法(8 个)
623
624**`create_group(&self, caller, space_name, cmd) -> GroupDto`**
6251. lookup + owned_space("only the owner can manage groups")
6262. name trim 非空且 `<= max_group_name_len`;同 space 同名 → Conflict(预检,唯一索引兜底)
6273. 事务:插 `space_groups`(id=now_v7, position=cmd.position.unwrap_or(0), color=cmd.color.unwrap_or_default(), version=1, created_by=caller);`replace_permissions(txn, group_id, &cmd.permissions)` = 先 delete_many 再逐条 insert `space_group_permissions`
6284. 返回 GroupDto(permissions=cmd.permissions 原样)
629
630**`get_group(&self, caller: Option<Uuid>, space_name, group_id) -> GroupDto`**(公开路由)
6311. lookup(group.space_id 不符 → NotFound;`!space.is_active` → NotFound)
6322. `space.is_private` 时:caller None → NotFound;`require_perm(space, caller, MANAGE_ROLES)` 的 Forbidden → NotFound
6333. 读 permissions + created_by 填充返回
634
635**`update_group(&self, caller, space_name, group_id, cmd) -> GroupDto`**
6361. lookup + owned_space;事务内取 group(space_id 不符 → NotFound)
6372. name 变更:校验 + 同 space 重名预检(排除自身)→ Conflict
6383. 逐字段 Set;`version = Set(version + 1)`;`updated_at = Set(Some(now))`
6394. `cmd.permissions` 为 Some 时事务内 replace_permissions
6405. 提交后重读 permissions、created_by 填充返回
641
642**`delete_group(&self, caller, space_name, group_id) -> ()`**:lookup + owned_space;事务:删 `space_member_groups`(GroupId eq)→ 删 `space_group_permissions`(GroupId eq)→ 删 `space_groups` 行。
643
644**`list_groups(&self, caller, space_name, q: PageQuery) -> GroupPage`**:lookup;`require_perm(MANAGE_ROLES)`;`SpaceId.eq` 按 `Position asc` 分页 + count;逐 group 读 permissions + `load_user_briefs(created_by 集合)` 填充。
645
646**`assign_group(&self, caller, space_name, uid, cmd) -> ()`**:lookup + owned_space;校验 group 属于该 space(NotFound);插 `space_member_groups`(space_id, uid, group_id, created_at=now;唯一冲突由 DbErr 映射为 409)。
647
648**`unassign_group(&self, caller, space_name, uid, group_id) -> ()`**:lookup + owned_space;`delete_many().filter(UserId.eq(uid)).filter(GroupId.eq(group_id))`(参考实现未过滤 space_id;我们**补上** `SpaceId.eq` 使语义严谨)。
649
650**`join_group_self(&self, caller, space_name, group_id) -> ()`**(自助入组,reaction-join 模式)
6511. lookup + 成员校验(`effective_permissions` ok;非成员/封禁未过期 → Forbidden)
6522. 配置门控:`self.config().space.allow_self_join_groups` 为 false → Forbidden("self-join is disabled")
6533. group 归属校验(`space_groups`:`Id.eq(group_id) AND SpaceId.eq(space_id)`)→ NotFound
6544. 已存在自身 `space_member_groups` 主键 `(space_id, caller, group_id)` → Conflict
6555. 插 `space_member_groups`(created_at=now);**加入即生效该组全部权限,这就是"自分配权限"的落地方式**(不直接给用户挂权限,避免新表)
656
657**`leave_group_self(&self, caller, space_name, group_id) -> ()`**(自助离组)
6581. lookup + 成员校验
6592. `delete_many`(`SpaceId + UserId + GroupId`),rows==0 → NotFound
6603. 不受 `allow_self_join_groups` 门控(退组不会提权,始终允许)
661
662---
663
664## 12. services/invites.rs
665
666### 12.1 DTO
667
668```rust
669#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
670pub struct InviteDto {
671 pub id: Uuid,
672 pub space_id: Uuid,
673 pub user: Option<UserBriefDto>,
674 pub email: String,
675 pub groups: Vec<Uuid>,
676 pub created_at: OffsetDateTime,
677 pub expires_at: Option<OffsetDateTime>,
678 pub accepted_at: Option<OffsetDateTime>,
679 pub revoked_at: Option<OffsetDateTime>,
680}
681
682#[derive(Debug, Clone, Deserialize, ToSchema)]
683pub struct CreateInviteCmd {
684 #[serde(default)] pub user_id: Option<Uuid>,
685 #[serde(default)] pub email: Option<String>,
686 #[serde(default)] pub group_ids: Vec<Uuid>,
687 #[serde(default)] pub expires_at: Option<OffsetDateTime>,
688}
689
690#[derive(Debug, Clone, Deserialize, ToSchema)]
691pub struct AcceptInviteCmd { pub accept_code: String }
692
693#[derive(Debug, Clone, Serialize, ToSchema)]
694pub struct InvitePage { pub items: Vec<InviteDto>, pub total: i64 }
695```
696
697### 12.2 方法(4 个)
698
699**`create_invite(&self, caller, space_name, cmd) -> InviteDto`**
7001. lookup;`require_perm(MANAGE_INVITES)`
7012. email 规整(trim + to_lowercase;空→None;超 `max_invite_email_len`、含空白、无 `@`、`@` 开头/结尾 → BadRequest)
7023. `user_id` 与 email 均 None → BadRequest("either user_id or email must be provided")
7034. `group_ids` sort+dedup 后为空 → BadRequest("at least one group is required")
7045. 指定 user_id:用户须存在(NotFound);若同时给 email:须匹配该用户已激活未删邮箱(`user_emails`:`UserId.eq + LowerEmail.eq + IsActivated=true + DeletedAt is null`),否则 BadRequest
7056. 仅 email:反查 `user_emails` 得 user_id(可 None)
7067. group 归属校验:`space_groups` 中 `SpaceId.eq + Id.in(group_ids)` 的 count == group_ids.len(),否则 BadRequest
7078. 活跃邀请冲突:同 space + user_id(AcceptedAt null + RevokedAt null)存在 → Conflict;同 space + email 存在 → Conflict
7089. 写库(事务):插 `space_invite`(id=now_v7, email=email.unwrap_or_default(), `accept_code = format!("{}-{}", Uuid::now_v7().simple(), Uuid::now_v7().simple())`, expires_at=cmd 或 None);逐条插 `space_invite_groups`
70910. 确定收件邮箱:`email` 已解析直接用;若为 None 且 `user_id` 为 Some,查 `user_emails`(`UserId.eq + IsPrimary.eq(true) + IsActivated.eq(true) + DeletedAt is null`)取用户主邮箱;仍取不到则跳过邮件并 `tracing::warn`
71011. 尽力发送邀请邮件(**best-effort,失败仅告警,不影响建邀结果**;调用 `self.send_email`):
711 - subject:`You're invited to join {space.name}`
712 - 正文:空间名、被邀者邮箱、有效期(expires_at 存在时),接受链接 = `{config.web.web_base_url}/spaces/invites/accept?code={accept_code}`
713 - HTML:按钮式链接 + 纯文本链接兜底;无需引入 maud 模板,邀请邮件就地用字符串拼接(全程无 Web 页面,不复制 auth 的模板壳)
71412. 填 user 返回
715
716**`revoke_invite(&self, caller, space_name, invite_id) -> InviteDto`**:lookup;`require_perm(MANAGE_INVITES)`;查 invite(space_id 不符 → NotFound);`revoked_at = Set(Some(now))` 更新;填 user 返回。
717
718**`accept_invite(&self, caller, cmd) -> ()`**(无 space_name,全事务)
7191. 按 `AcceptCode` 查 invite → NotFound
7202. 归属:`invite.user_id == Some(caller)`,或(user_id 为 None 且 email 非空且 caller 的已激活未删邮箱中含该 email)→ 否则 Forbidden
7213. `revoked_at` 非 None → Conflict("invite has been revoked");`accepted_at` 非 None → Conflict("invite already accepted");`expires_at` 已过 → Conflict("invite has expired")
7224. 读 `space_invite_groups` 得 group_ids;空 → Conflict("invite has no valid groups");`space_groups` 归属 count 校验失败 → Conflict
7235. `space_members` 主键已存在 → Conflict("user is already a member of this space")
7246. 查 space(NotFound);**`space.is_active == false` → NotFound**(与 get_space 同为 NotFound 掩盖,阻止对已删空间的邀请入会);`member_must_enable_2fa` 且 caller `totp_enabled==false` → Forbidden
7257. 插成员行(status=Active, invited_at=Some(invite.created_at), invited_by=None, message_notifications=All, 其余 false)
7268. 逐 group 插 `space_member_groups`
7279. `nums_members + 1`
72810. `accepted_at = Set(Some(now))` 更新 invite;提交
729
730> 邀请者信息取舍:`space_invite` 无 `created_by` 列(与参考实现一致),accept 时成员行 `invited_by` 留空;邀请发起者仍可从 invite 列表/审计回溯。如需在成员上保留邀请者,需新增列,本期不做。
731
732**`list_invites(&self, caller, space_name, q: PageQuery) -> InvitePage`**:lookup;`require_perm(MANAGE_INVITES)`;`SpaceId.eq` 按 `CreatedAt desc` 分页 + count;`load_user_briefs` 填 user;groups 由 `space_invite_groups`(`InviteId in`)一次性查出后按 invite 分组。
733
734---
735
736## 13. services/bans.rs
737
738### 13.1 DTO
739
740```rust
741#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
742pub struct BanDto {
743 pub space_id: Uuid,
744 pub user: Option<UserBriefDto>,
745 pub reason: Option<String>,
746 pub banned_by: Option<UserBriefDto>,
747 pub created_at: OffsetDateTime,
748 pub updated_at: OffsetDateTime,
749 pub expires_at: Option<OffsetDateTime>,
750 pub allow_agent_join: bool,
751 pub allow_speak: bool,
752 pub allow_push_repo: bool,
753 pub allow_comment_any: bool,
754}
755
756#[derive(Debug, Clone, Deserialize, ToSchema)]
757pub struct BanUserCmd {
758 pub user_id: Uuid,
759 #[serde(default)] pub reason: Option<String>,
760 #[serde(default)] pub expires_at: Option<OffsetDateTime>,
761 #[serde(default)] pub allow_agent_join: Option<bool>,
762 #[serde(default)] pub allow_speak: Option<bool>,
763 #[serde(default)] pub allow_push_repo: Option<bool>,
764 #[serde(default)] pub allow_comment_any: Option<bool>,
765}
766
767#[derive(Debug, Clone, Deserialize, ToSchema)]
768pub struct UpdateBanCmd {
769 #[serde(default)] pub reason: Option<Option<String>>,
770 #[serde(default)] pub expires_at: Option<Option<OffsetDateTime>>,
771 #[serde(default)] pub allow_agent_join: Option<bool>,
772 #[serde(default)] pub allow_speak: Option<bool>,
773 #[serde(default)] pub allow_push_repo: Option<bool>,
774 #[serde(default)] pub allow_comment_any: Option<bool>,
775}
776
777#[derive(Debug, Clone, Serialize, ToSchema)]
778pub struct BanPage { pub items: Vec<BanDto>, pub total: i64 }
779```
780
781### 13.2 方法(5 个,全部 owner-only:"only the owner can manage bans")
782
783- **`ban_user`**:lookup + owner;主键 `(space_id, user_id)` 已存在 → Conflict;插入(banned_by=caller, 各 allow_* 取 cmd unwrap_or(false));填 user/banned_by 返回。
784- **`get_ban`**:lookup + owner;主键查询;填充返回。
785- **`update_ban`**:lookup + owner;事务取行→逐字段 Set→`updated_at=Set(now)`→update;填充返回。
786- **`unban_user`**:lookup + owner;事务 `delete_by_id((space_id, user_id))`,`rows_affected == 0` → NotFound。
787- **`list_bans`**:lookup + owner;`SpaceId.eq` 按 `CreatedAt desc` 分页 + count;`load_user_briefs(user_ids + banned_by 集合)` 填充。
788
789> 封禁语义:仅阻断访问(`effective_permissions` 拦截未过期封禁),**不改成员行 status、不减 nums_members**——封禁≠成员移除,计数反映成员资格;如需"封禁即移出"由业务另行实现。`space_bans.banned_by` 模型列为 `Uuid`(非 Option),BanDto 的 `Option<UserBriefDto>` 仅因用户可能已删除。
790
791---
792
793## 14. services/avatar.rs(空间头像,新增功能)
794
795参考实现无 space 头像端点;本功能镜像 `lib/user/src/services/avatar.rs`(同款 base64 上传 + S3 + storage:// URL 约定)。
796
797```rust
798pub const MAX_SPACE_AVATAR_BYTES: usize = 5 * 1024 * 1024;
799pub const SPACE_AVATAR_PREFIX: &str = "space-avatars";
800
801#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
802#[serde(rename_all = "lowercase")]
803pub enum AvatarFormat { Jpg, Png, Webp, Gif }
804// extension/content_type/from_content_type/from_extension 四个方法与 user crate 完全一致
805
806#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
807pub struct SpaceAvatarDto {
808 pub space_id: Uuid,
809 pub url: String,
810 pub format: AvatarFormat,
811 pub bytes: u64,
812 pub updated_at: OffsetDateTime,
813}
814
815#[derive(Debug, Clone, Deserialize, ToSchema)]
816pub struct UploadSpaceAvatarCmd {
817 pub content_type: String,
818 pub data_base64: String,
819}
820```
821
822**`upload_space_avatar(&self, caller: Uuid, space_name: &str, cmd) -> SpaceAvatarDto`**
8231. `lookup_space_by_name`;`owner_id != caller` → Forbidden("only the owner can update the space avatar")
8242. content_type 校验(仅 image/jpeg|png|webp|gif)→ BadRequest;base64 解码失败/空/超 5MB → BadRequest
8253. `self.storage().client().put_with_content_type("space-avatars/{space_id}.{ext}", &bytes, content_type)`
8264. 更新 `spaces.avatar_url = "storage://space-avatars/{space_id}.{ext}"`、`updated_at = now`(write 连接)
8275. 尽力写 `space-avatars/{space_id}.meta.json`(space_id/format/bytes/uploaded_at),失败忽略
8286. 返回 SpaceAvatarDto
829
830**`delete_space_avatar(&self, caller, space_name) -> ()`**
8311. lookup + owner 校验
8322. `avatar_url = Set(None)`(模型为 `Option<String>`,清空语义必须用 None;若 `Set(Some(""))` 会与 get 的空串判断不一致)、`updated_at = Set(now)`
8333. 旧 URL 以 `storage://space-avatars/` 开头时,尽力删除对象与 `.meta.json`(失败忽略)
834
835**`get_space_avatar(&self, space_name: &str) -> Option<SpaceAvatarDto>`**(公开路由,无 caller)
8361. lookup;`avatar_url` 为 `None` → None(清空路径已统一写 None,不存在空串歧义)
8372. 扩展名推断 format(默认 Jpg),bytes=0,updated_at=space.updated_at
838
839> `AvatarFormat` 在本 crate 内重新定义(约 50 行;space 不依赖 user crate,避免 crate 间耦合)。
840
841---
842
843## 15. services/images.rs(banner / invite_banner / embed_splash 图片上传,新增功能)
844
845`spaces` 表为三类横幅图片各存 `url + width + height`(均 `Option`)。本服务按 kind 上传到 S3 并回写对应三组字段。
846
847```rust
848pub const MAX_SPACE_IMAGE_BYTES: usize = 5 * 1024 * 1024;
849pub const SPACE_IMAGE_PREFIX: &str = "space-images";
850
851#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
852#[serde(rename_all = "kebab-case")]
853pub enum SpaceImageKind { Banner, InviteBanner, EmbedSplash }
854// serde 字面量:kebab-case 下 Banner→banner、InviteBanner→invite-banner、EmbedSplash→embed-splash,
855// 直接作为路径段 /images/{kind} 的值
856
857#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
858pub struct SpaceImageDto {
859 pub space_id: Uuid,
860 pub kind: SpaceImageKind,
861 pub url: String,
862 pub format: AvatarFormat, // 复用 services::avatar::AvatarFormat
863 pub width: Option<i32>,
864 pub height: Option<i32>,
865 pub bytes: u64,
866 pub updated_at: OffsetDateTime,
867}
868
869#[derive(Debug, Clone, Deserialize, ToSchema)]
870pub struct UploadSpaceImageCmd {
871 pub content_type: String,
872 pub data_base64: String,
873 #[serde(default)] pub width: Option<i32>,
874 #[serde(default)] pub height: Option<i32>,
875}
876
877#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
878pub struct SpaceImageKindPath {
879 pub space_name: String,
880 pub kind: SpaceImageKind,
881}
882```
883
884三组字段映射(私有辅助 `fn image_fields(kind) -> (url_col, width_col, height_col)` 或直接在方法内 match):
885`Banner → (banner_url, banner_width, banner_height)`、`InviteBanner → (invite_banner_url, invite_banner_width, invite_banner_height)`、`EmbedSplash → (embed_splash_url, embed_splash_width, embed_splash_height)`。
886
887**`upload_space_image(&self, caller, space_name, kind, cmd) -> SpaceImageDto`**
8881. lookup + owner 校验("only the owner can update the space image")
8892. content_type 白名单 / base64 解码 / 空或超 5MB 校验(与 §14 头像一致)
8903. `width`/`height` 提供时非负校验,负数 → BadRequest
8914. S3 `put_with_content_type("space-images/{space_id}/{kind}.{ext}", bytes, content_type)`
8925. 按 kind 回写该组 url/width/height(`Set(Some(url))`/`Set(width)`/`Set(height)`)`+ updated_at = Set(now)`
8936. 尽力写 `space-images/{space_id}/{kind}.meta.json`(失败忽略)
8947. 返回 SpaceImageDto
895
896**`delete_space_image(&self, caller, space_name, kind) -> ()`**
8971. lookup + owner;读对应 url 字段;为空则仅清三字段(幂等)
8982. 三字段清 `None` + `updated_at = Set(now)`
8993. 旧 URL 以 `storage://space-images/` 开头时尽力删对象与 `.meta.json`
900
901**`get_space_image(&self, space_name, kind) -> Option<SpaceImageDto>`**(公开路由)
9021. lookup;对应 url 字段为 None → None
9032. 扩展名推断 format(默认 Jpg);width/height 直取库存字段(可能为 None);bytes=0;updated_at=space.updated_at
904
905---
906
907## 16. services/dir.rs(资产树 sidebar,基于 `lib/models/src/dir`,新增功能)
908
909参考实现 sidebar 为频道树、overwrites 为频道覆盖;本项目无 channel 模型,改为基于 `lib/models/src/dir` 的**空间资产树**。模型三张表:
910
911- `dir`:节点(id, space_id: Option<Uuid>, name, kind: DirKind(Dir|Object), parent_dir_id 自引用, color, position, icon, topic, purpose, is_archived/archived_at/archived_by, created_at, updated_at, created_by_uid)
912- `dir_overwrite_group`:主键 `(dir_id, group_id)` + `allow_mask/deny_mask: i64`
913- `dir_overwrite_user`:主键 `(dir_id, user_id)` + `allow_mask/deny_mask: i64`
914
915> 模型模块名:`models::dir::{dirs, dir_overwrite_group, dir_overwrite_users}`,其中文件 `dir_overwrite_users.rs`(复数)的表名为 `dir_overwrite_user`(单数)、`space_invite.rs`(单数)表名为 `space_invite`——引用时一律以 `models::dir::*` 模块与 `sea_orm(table_name)` 为准。
916
917需求收窄:**只提供 资产树获取 / 创建目录(dir) / 重命名 / 位置修改 / 修复父级 / 权限覆盖**;**可以创建 kind=Dir 的目录节点,不允许创建 kind=Object 的对象节点**(`create_dir_node` 无 kind 入参,恒为 Dir);不做单节点查询、删除、归档。
918
919```rust
920pub const MAX_DIR_NODE_NAME: usize = 255;
921
922// 覆盖掩码位定义:第 0 位=节点在 sidebar 中可见;其余位暂未定义,SetOverwriteCmd 原样存储
923pub const PERM_DIR_VISIBLE: i64 = 1 << 0;
924
925#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
926pub struct DirNodeDto {
927 pub id: Uuid,
928 pub name: String,
929 pub kind: DirKind,
930 pub parent_dir_id: Option<Uuid>,
931 pub color: Option<String>,
932 pub position: i64,
933 pub icon: Option<String>,
934 pub topic: Option<String>,
935 pub purpose: Option<String>,
936 pub is_archived: bool,
937 pub archived_at: Option<OffsetDateTime>,
938 pub created_at: OffsetDateTime,
939 pub updated_at: OffsetDateTime,
940 pub created_by_uid: Uuid,
941 pub children: Vec<DirNodeDto>,
942}
943
944#[derive(Debug, Clone, Deserialize, ToSchema)]
945pub struct CreateDirCmd {
946 pub name: String,
947 #[serde(default)] pub parent_dir_id: Option<Uuid>,
948 #[serde(default)] pub position: Option<i64>,
949}
950
951#[derive(Debug, Clone, Deserialize, ToSchema)]
952pub struct UpdateDirCmd { pub name: String }
953
954#[derive(Debug, Clone, Deserialize, ToSchema)]
955pub struct MoveDirCmd {
956 #[serde(default)] pub parent_dir_id: Option<Option<Uuid>>, // None=不变;Some(None)=移到根;Some(Some(p))=移到 p 下
957 #[serde(default)] pub position: Option<i64>,
958}
959
960#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
961pub struct RepairDirResp { pub scanned: i64, pub fixed: i64 }
962
963#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
964pub struct GroupOverwriteDto {
965 pub dir_id: Uuid, pub group_id: Uuid, pub allow_mask: i64, pub deny_mask: i64,
966}
967
968#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
969pub struct UserOverwriteDto {
970 pub dir_id: Uuid, pub user_id: Uuid, pub user: Option<UserBriefDto>, pub allow_mask: i64, pub deny_mask: i64,
971}
972
973#[derive(Debug, Clone, Deserialize, ToSchema)]
974pub struct SetOverwriteCmd { pub allow_mask: i64, pub deny_mask: i64 }
975
976#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
977pub struct DirPath { pub space_name: String, pub dir_id: Uuid }
978```
979
980权限要求:树 GET 与覆盖列表 = 在册成员(`effective_permissions` ok 即成员,复用 §7.4 语义;owner 自动为成员);树变更(建目录/重命名/位置/修复)= 所有者;覆盖写 = `manage_overwrites`。**树的可见性由覆盖权限驱动(见 `get_space_dir_tree`)**。
981
982私有辅助:所有者校验(同 §10 的 `owned_space` 文案 "only the owner can manage the space sidebar");`async fn dir_in_space(&self, space_id, dir_id) -> dirs::Model`(`find_by_id` + `space_id == Some(space_id)`,否则 NotFound)。
983
984**`create_dir_node(&self, caller, space_name, cmd) -> DirNodeDto`**(仅创建 Dir)
9851. lookup + 所有者
9862. `name = cmd.name.trim()`;空或 `> MAX_DIR_NODE_NAME` → BadRequest
9873. `parent_dir_id = Some(p)`:目标须存在且 `space_id == Some(space_id)`、`kind == Dir`,否则 NotFound/BadRequest;`None` → 根
9884. position:`Some(v)` 须 `v >= 0`;`None` → 新父级下 `max(position)+1`
9895. 插入 `dir` 行:`kind = Dir`(**无 kind 入参,Object 不可经此创建**),color/icon/topic/purpose=None,is_archived=false,created_by_uid=caller,created_at/updated_at=now
9906. 返回节点(children 空)
991
992**`get_space_dir_tree(&self, caller, space_name) -> Vec<DirNodeDto>`**(**sidebar 按权限复写过滤可见性**)
9931. lookup + 成员校验
9942. `dirs::Entity::find().filter(SpaceId.eq(space_id)).order_by_asc(Position)` 全量读出
9953. 批量读 overwrites:`dir_overwrite_group`(`DirId.is_in(node_ids)`)与 `dir_overwrite_user`(`DirId.is_in(node_ids)`),按 dir_id 建索引;查 caller 组成员(`space_member_groups`,`UserId.eq(caller)` 取 group_ids)
9964. **可见性判定**(掩码位 `PERM_DIR_VISIBLE`):
997 - 空间 owner → 恒可见
998 - 节点**无任何 overwrite 行** → **对所有成员可见**(默认模式)
999 - 节点**有 overwrite 行** → 仅当 caller 的 user_overwrite 或(其成员组的)group_overwrite 同时满足 `allow_mask & PERM_DIR_VISIBLE != 0` 且 `deny_mask & PERM_DIR_VISIBLE == 0` 时可见;即"对某 group 成员可见"(组覆盖)、"对指定成员可见"(用户覆盖);否则**隐藏**
1000 - **父节点隐藏 ⇒ 子树全部隐藏**(不可穿越)
10015. 按可见性过滤后建 children 索引;根 = `parent_dir_id` 为 None 或 指向集合外的节点;DFS 组装 `children`(排序 `Position asc`,同级同名按 created_at)
10026. 返回森林 `Vec<DirNodeDto>`
1003
1004> 已知限制:全量加载节点+overwrites+组成员后内存过滤建树,无分页/上限,适用节点数千级规模;若大空间出现性能问题,再引入分页或惰性加载,本期不做。
1005
1006**`update_dir_node(&self, caller, space_name, dir_id, cmd) -> DirNodeDto`**(重命名)
10071. lookup + 所有者;`dir_in_space`
10082. `name = cmd.name.trim()`;空或 `> MAX_DIR_NODE_NAME` → BadRequest
10093. Set name/updated_at;返回节点(children 空)
1010
1011**`move_dir_node(&self, caller, space_name, dir_id, cmd) -> DirNodeDto`**(位置/移动)
10121. lookup + 所有者;`dir_in_space`
10132. 新父级解析:
1014 - `parent_dir_id = None` → 保持现父级
1015 - `Some(None)` → 根
1016 - `Some(Some(p))` → 目标须存在且 `space_id == Some(space_id)`、`kind == Dir`、`p != dir_id`,且 p 不得是 dir_id 的后代(从 p 沿 parent 链上溯,不可达 dir_id);违反 → BadRequest/NotFound
10173. position 解析:`Some(v)` 须 `v >= 0`;`None` 且父级有变 → 取新兄弟 `max(position)+1`;`None` 且父级不变 → 保持
10184. Set parent_dir_id/position/updated_at;返回节点
1019
1020**`repair_dir_parents(&self, caller, space_name) -> RepairDirResp`**(修复父级,维护操作)
10211. lookup + 所有者
10222. 全量读该 space 节点:id→node map
10233. 修复规则(全部改为 parent 指向根,即 `parent_dir_id = None`,仅在实际变更时计数):
1024 a. 自指:`parent_dir_id == id`
1025 b. 孤儿:parent 不在集合内(含跨 space 指向)
1026 c. 破环:三色 DFS(0 未访/1 栈中/2 完成);当节点 N 的 parent P 处于"栈中"即形成闭环,将 **N** 的 parent 置 None(指向祖先的边是环的闭合边,断此边即破环)
10274. 变更的行在单事务内批量 update;返回 `{scanned, fixed}`
1028
1029> 并发说明:读(全量)→内存计算→写之间存在竞态窗口,并发变更可能使单次执行遗漏新孤儿/新环;操作幂等,重复执行收敛,此为已接受限制。
1030
1031**覆盖权限(6 个,镜像参考 overwrites 语义,实体换 dir)**
1032- **`list_dir_group_overwrites`**:lookup + 成员;`dir_in_space`;查 `dir_overwrite_group`(`DirId.eq`)→ Vec<GroupOverwriteDto>
1033- **`set_dir_group_overwrite(&self, caller, space_name, dir_id, group_id, cmd)`**:lookup + `require_perm(MANAGE_OVERWRITES)`;`dir_in_space`;group 归属校验(`space_groups`:`Id.eq(group_id) AND SpaceId.eq(space_id)`,否则 NotFound);mask 非负;主键 `(dir_id, group_id)` upsert(存在→update,否则 insert)
1034- **`remove_dir_group_overwrite`**:lookup + `require_perm(MANAGE_OVERWRITES)`;`delete_many`,rows==0 → NotFound
1035- **`list_dir_user_overwrites`**:lookup + 成员;查 `dir_overwrite_user`(`DirId.eq`);`load_user_briefs` 填 user → Vec<UserOverwriteDto>
1036- **`set_dir_user_overwrite(&self, caller, space_name, dir_id, user_id, cmd)`**:lookup + `require_perm(MANAGE_OVERWRITES)`;`dir_in_space`;mask 非负;`(dir_id, user_id)` upsert
1037- **`remove_dir_user_overwrite`**:lookup + `require_perm(MANAGE_OVERWRITES)`;`delete_by_id((dir_id, user_id))`,rows==0 → NotFound
1038
1039---
1040
1041## 17. handlers/(45 个文件)
1042
1043统一模板(受保护路由,以 `add_member` 为例):
1044```rust
1045use axum::extract::{Json, Path, State};
1046use export::rest::AppResponseData;
1047use session::Session;
1048use uuid::Uuid;
1049
1050use crate::errors::ApiError;
1051use crate::services::members::{AddMemberCmd, MemberDto};
1052use crate::services::service::SpaceService;
1053use crate::services::spaces::SpaceNamePath;
1054use crate::share::session::require_uid;
1055
1056#[utoipa::path(
1057 post,
1058 path = "/spaces/{space_name}/members",
1059 tag = "space",
1060 params(SpaceNamePath),
1061 request_body = AddMemberCmd,
1062 responses(
1063 (status = 200, description = "member added", body = AppResponseData<MemberDto>),
1064 (status = 409, description = "already a member", body = AppResponseData<NoData>)
1065 )
1066)]
1067pub async fn add_member(
1068 State(service): State<SpaceService>,
1069 session: Session,
1070 Path(path): Path<SpaceNamePath>,
1071 Json(cmd): Json<AddMemberCmd>,
1072) -> Result<AppResponseData<MemberDto>, ApiError> {
1073 let uid = require_uid(&session)?;
1074 let member = service.add_member(uid, &path.space_name, cmd).await?;
1075 Ok(AppResponseData::ok(member))
1076}
1077```
1078
1079公开路由(4 个,`get_space`/`get_group`/`get_space_avatar`/`get_space_image`)差异:**不调 `require_uid`**;前两者改 `let caller = session.uid();`(Option)传给 service,`get_space_avatar`/`get_space_image` 无需 caller。`get_space_image`/`upload_space_image`/`delete_space_image` 路径参数用 §15 的 `SpaceImageKindPath`。
1080
1081带 uid 路径参数的路由(members/{uid}、bans/{uid})用:
1082```rust
1083#[derive(Deserialize, IntoParams, ToSchema)]
1084pub struct MemberPath { pub space_name: String, pub uid: Uuid }
1085```
1086(groups/{group_id}、invites/{invite_id}、members/{uid}/groups/{group_id} 同理定义对应 Path 结构,均放各自 handler 文件内。)
1087
1088删除类返回 `AppResponseData::empty()`;分页列表返回 `AppResponseData<XxxPage>`。
1089
1090---
1091
1092## 18. routes.rs(完整)
1093
1094```rust
1095use axum::routing::{delete, get, patch, post, put};
1096use axum::Router;
1097
1098use crate::handlers::accept_invite::accept_invite;
1099use crate::handlers::add_member::add_member;
1100use crate::handlers::assign_group::assign_group;
1101use crate::handlers::ban_user::ban_user;
1102use crate::handlers::create_dir_node::create_dir_node;
1103use crate::handlers::create_group::create_group;
1104use crate::handlers::create_invite::create_invite;
1105use crate::handlers::create_space::create_space;
1106use crate::handlers::delete_group::delete_group;
1107use crate::handlers::delete_space::delete_space;
1108use crate::handlers::delete_space_avatar::delete_space_avatar;
1109use crate::handlers::delete_space_image::delete_space_image;
1110use crate::handlers::get_ban::get_ban;
1111use crate::handlers::get_group::get_group;
1112use crate::handlers::get_member::get_member;
1113use crate::handlers::get_space::get_space;
1114use crate::handlers::get_space_avatar::get_space_avatar;
1115use crate::handlers::get_space_dir_tree::get_space_dir_tree;
1116use crate::handlers::get_space_image::get_space_image;
1117use crate::handlers::join_group_self::join_group_self;
1118use crate::handlers::leave_group_self::leave_group_self;
1119use crate::handlers::list_bans::list_bans;
1120use crate::handlers::list_dir_group_overwrites::list_dir_group_overwrites;
1121use crate::handlers::list_dir_user_overwrites::list_dir_user_overwrites;
1122use crate::handlers::list_groups::list_groups;
1123use crate::handlers::list_invites::list_invites;
1124use crate::handlers::list_members::list_members;
1125use crate::handlers::list_spaces::list_spaces;
1126use crate::handlers::move_dir_node::move_dir_node;
1127use crate::handlers::remove_dir_group_overwrite::remove_dir_group_overwrite;
1128use crate::handlers::remove_dir_user_overwrite::remove_dir_user_overwrite;
1129use crate::handlers::remove_member::remove_member;
1130use crate::handlers::repair_dir_parents::repair_dir_parents;
1131use crate::handlers::revoke_invite::revoke_invite;
1132use crate::handlers::set_dir_group_overwrite::set_dir_group_overwrite;
1133use crate::handlers::set_dir_user_overwrite::set_dir_user_overwrite;
1134use crate::handlers::unassign_group::unassign_group;
1135use crate::handlers::unban_user::unban_user;
1136use crate::handlers::update_ban::update_ban;
1137use crate::handlers::update_dir_node::update_dir_node;
1138use crate::handlers::update_group::update_group;
1139use crate::handlers::update_member::update_member;
1140use crate::handlers::update_space::update_space;
1141use crate::handlers::upload_space_avatar::upload_space_avatar;
1142use crate::handlers::upload_space_image::upload_space_image;
1143use crate::services::service::SpaceService;
1144use crate::share::guard::space_guard;
1145
1146pub fn router(service: SpaceService) -> Router<SpaceService> {
1147 let public: Router<SpaceService> = Router::new()
1148 .route("/spaces/{space_name}", get(get_space))
1149 .route(
1150 "/spaces/{space_name}/groups/{group_id}",
1151 get(get_group),
1152 )
1153 .route(
1154 "/spaces/{space_name}/avatar",
1155 get(get_space_avatar),
1156 )
1157 .route(
1158 "/spaces/{space_name}/images/{kind}",
1159 get(get_space_image),
1160 );
1161
1162 let protected = Router::new()
1163 .route("/spaces", post(create_space).get(list_spaces))
1164 .route(
1165 "/spaces/invites/accept",
1166 post(accept_invite),
1167 )
1168 .route(
1169 "/spaces/{space_name}",
1170 patch(update_space).delete(delete_space),
1171 )
1172 .route(
1173 "/spaces/{space_name}/avatar",
1174 put(upload_space_avatar).delete(delete_space_avatar),
1175 )
1176 .route(
1177 "/spaces/{space_name}/images/{kind}",
1178 put(upload_space_image).delete(delete_space_image),
1179 )
1180 .route(
1181 "/spaces/{space_name}/dir",
1182 post(create_dir_node).get(get_space_dir_tree),
1183 )
1184 .route(
1185 "/spaces/{space_name}/dir/repair-parents",
1186 post(repair_dir_parents),
1187 )
1188 .route(
1189 "/spaces/{space_name}/dir/{dir_id}",
1190 patch(update_dir_node),
1191 )
1192 .route(
1193 "/spaces/{space_name}/dir/{dir_id}/position",
1194 patch(move_dir_node),
1195 )
1196 .route(
1197 "/spaces/{space_name}/dir/{dir_id}/overwrites/groups",
1198 get(list_dir_group_overwrites),
1199 )
1200 .route(
1201 "/spaces/{space_name}/dir/{dir_id}/overwrites/groups/{group_id}",
1202 put(set_dir_group_overwrite).delete(remove_dir_group_overwrite),
1203 )
1204 .route(
1205 "/spaces/{space_name}/dir/{dir_id}/overwrites/users",
1206 get(list_dir_user_overwrites),
1207 )
1208 .route(
1209 "/spaces/{space_name}/dir/{dir_id}/overwrites/users/{user_id}",
1210 put(set_dir_user_overwrite).delete(remove_dir_user_overwrite),
1211 )
1212 .route(
1213 "/spaces/{space_name}/members",
1214 post(add_member).get(list_members),
1215 )
1216 .route(
1217 "/spaces/{space_name}/members/{uid}",
1218 get(get_member)
1219 .patch(update_member)
1220 .delete(remove_member),
1221 )
1222 .route(
1223 "/spaces/{space_name}/groups",
1224 post(create_group).get(list_groups),
1225 )
1226 .route(
1227 "/spaces/{space_name}/groups/{group_id}",
1228 patch(update_group).delete(delete_group),
1229 )
1230 .route(
1231 "/spaces/{space_name}/members/{uid}/groups",
1232 post(assign_group),
1233 )
1234 .route(
1235 "/spaces/{space_name}/groups/{group_id}/join",
1236 post(join_group_self).delete(leave_group_self),
1237 )
1238 .route(
1239 "/spaces/{space_name}/members/{uid}/groups/{group_id}",
1240 delete(unassign_group),
1241 )
1242 .route(
1243 "/spaces/{space_name}/invites",
1244 post(create_invite).get(list_invites),
1245 )
1246 .route(
1247 "/spaces/{space_name}/invites/{invite_id}",
1248 delete(revoke_invite),
1249 )
1250 .route(
1251 "/spaces/{space_name}/bans",
1252 post(ban_user).get(list_bans),
1253 )
1254 .route(
1255 "/spaces/{space_name}/bans/{uid}",
1256 get(get_ban).patch(update_ban).delete(unban_user),
1257 )
1258 .route_layer(axum::middleware::from_fn_with_state(
1259 service.clone(),
1260 space_guard,
1261 ));
1262
1263 public.merge(protected)
1264}
1265```
1266注意 axum 0.8 路径参数用 `{param}`;`/spaces/invites/accept` 必须先于 `/spaces/{space_name}` 类路由注册(`Router` 精确匹配优先,但显式放在前面更清晰,避免与 `{space_name}` 冲突语义)。
1267
1268**返回类型说明**(对齐 lib/user/routes.rs):函数返回 `Router<SpaceService>`,**不调用 `.with_state()`**——`Router::with_state(s)` 会把类型降为 `Router<()>`,与返回类型不符;state 由上层应用装配时注入。public 子路由用 `let public: Router<SpaceService> = Router::new()...` 显式标注状态类型,protected 子路由由 `route_layer(from_fn_with_state(service.clone(), space_guard))` 自动确定为 `Router<SpaceService>`,二者可 merge。
1269
1270---
1271
1272## 19. openapi.rs
1273
1274`SpaceApiDoc`(镜像 user crate):
1275```rust
1276#[derive(OpenApi)]
1277#[openapi(
1278 info(title = "gitdata space api", version = "1.0"),
1279 paths( /* 45 个 handlers::* 全部 */ ),
1280 components(schemas(
1281 NoData,
1282 /* models 枚举 */ AllowJoinLevel, MemberStatus, MessageNotifications, DirKind,
1283 /* avatar */ AvatarFormat, SpaceAvatarDto, UploadSpaceAvatarCmd,
1284 /* images */ SpaceImageKind, SpaceImageDto, UploadSpaceImageCmd,
1285 /* dir */ CreateDirCmd, DirNodeDto, UpdateDirCmd, MoveDirCmd, RepairDirResp, GroupOverwriteDto, UserOverwriteDto, SetOverwriteCmd,
1286 /* spaces */ UserBriefDto, SpaceDto, CreateSpaceCmd, UpdateSpaceCmd, SpacePage, PageQuery,
1287 /* members */ MemberDto, AddMemberCmd, UpdateMemberCmd, MemberPage,
1288 /* groups */ GroupDto, CreateGroupCmd, UpdateGroupCmd, AssignGroupCmd, GroupPage,
1289 /* invites */ InviteDto, CreateInviteCmd, AcceptInviteCmd, InvitePage,
1290 /* bans */ BanDto, BanUserCmd, UpdateBanCmd, BanPage,
1291 ))
1292)]
1293pub struct SpaceApiDoc;
1294```
1295
1296---
1297
1298## 20. 适配对照表(参考实现 → 本项目)
1299
1300| 参考实现 | 本项目 | 说明 |
1301|----------|--------|------|
1302| `features/` + `handler/` 分层 | `services/` + `handlers/` + `share/` | 对齐 lib/auth、lib/user |
1303| `SpaceService { db, kv, config, jwt }` | `SpaceService { state: AppState }` + `db()/config()/storage()` | 对齐 UserService;storage 仅头像/图片使用 |
1304| jwt `inject_jwt_ctx` / `HasJwtState` | `session::Session` + `session.uid()`(公开路由)/ `require_uid`(受保护)+ `space_guard` | cookie 会话制 |
1305| `ratelimit` / `metrics` 中间件 | 删除 | 项目惯例 |
1306| `space_roles` / `space_role_permissions` / `space_member_roles` | `space_groups` / `space_group_permissions` / `space_member_groups` | 表名不同,结构同形;URL/DTO 用 group 命名,权限字符串保持 role 命名 |
1307| `space_invite.role_ids`(CSV 列) | `space_invite_groups` 关联表 | create 写关联行,accept/view 读关联表 |
1308| `spaces.public` | `spaces.is_private`(取反) | DTO 暴露 `is_private` |
1309| `spaces.member_count` | `spaces.nums_members` | 并补齐计数维护:+1(add/accept)、-1(remove) |
1310| `rename_space` + `space_name_history` 原生 SQL | 并入 `update_space`,仅查 `spaces.lower_name` 冲突 | 无历史表;语义降级为"现名唯一" |
1311| DTO 枚举平行定义 + `From` 互转 | models 枚举直接 `ToSchema` | user crate 惯例;需给 3 个枚举加 derive |
1312| RFC3339 字符串时间(`fmt_ts`) | `OffsetDateTime` | utoipa/serde time feature |
1313| `mute_until`/`expires_at` 字符串解析 | `Option<OffsetDateTime>` 直收 | 删除 parse_ts/parse_mute_until |
1314| `export::user::basic::UserBasicInfo` + `users_loader` | 本 crate `UserBriefDto` + `load_user_briefs` 辅助查询 | export 无该类型 |
1315| `ActiveModel::builder().set_x()` | 结构体字面量 + `Set()` | sea-orm 2.0 项目惯例 |
1316| `txn = writer.begin(); ... txn.commit()` | `TransactionTrait::transaction(db.primary(), closure)` | lib/user 惯例 |
1317| lookup 返回 space_id | 返回 `spaces::Model` | 调用方都需要完整行 |
1318| `ensure_owner_member` 治愈逻辑 | 删除 | create_space 事务内保证 |
1319| unassign 不过滤 space_id | 补 `SpaceId.eq` 过滤 | 语义严谨化 |
1320| update 改名无冲突预检 | 补 `LowerName` 冲突预检 → 409 | 防裸 500 |
1321| 无空间头像端点 | 新增 PUT/GET/DELETE `/spaces/{space_name}/avatar`,镜像 user crate avatar.rs;S3 前缀 `space-avatars/`,`AvatarFormat` 本 crate 内重定义 | 参考实现缺失,model 有 `avatar_url` 列 |
1322| 无 banner 上传端点 | 新增 PUT/GET/DELETE `/spaces/{space_name}/images/{kind}`(kind ∈ banner/invite-banner/embed-splash);S3 前缀 `space-images/`;宽高由客户端提供,回写 `spaces.banner_*`/`invite_banner_*`/`embed_splash_*` 三组列 | 参考实现缺失;model 三组 url/width/height 列齐备 |
1323| 邀请后不发邮件 | `create_invite` 建邀成功后向目标邮箱(显式 email 或用户主邮箱)best-effort 发送含 accept_code 链接的邮件 | 新需求:需触达被邀用户;发信组件复用 `AppEmail` + `mailbaby::Email` |
1324| sidebar(频道树) + overwrites(频道) | dir 资产树 5 类操作(树查询/重命名/位置/修复父级)+ `dir_overwrite_group`/`dir_overwrite_user` 覆盖(allow/deny 掩码) | 以 `lib/models/src/dir` 为树模型;需求收窄见 §16 |
1325| 无自助入组 | POST/DELETE `/spaces/{space_name}/groups/{group_id}/join`(`join_group_self`/`leave_group_self`),由 `SpaceConfig.allow_self_join_groups` 门控;加入即获得该组权限(reaction-join 模式) | 新需求:"用户自加入 group / 自分配权限"落地为组级自助入组 |
1326| spaces 无 welcome_object 列 | `spaces` 表新增 `welcome_object: Option<Uuid>`;create/update/DTO 暴露;入会展示的资产对象,不做存在性校验 | 新需求;对象由 dir 之外系统创建/删除,本模型仅存引用 |
1327| sidebar 无可见性过滤 | `get_space_dir_tree` 按覆盖权限过滤:无 overwrite=全员可见;group overwrite=组成员可见;user overwrite=指定成员可见;owner 全见;父隐藏则子树隐藏 | 新需求:对齐"覆盖权限驱动 sidebar 结果" |
1328| `lib.rs` 顶层导出 | 同左 | 纯模块导出 |
1329
1330---
1331
1332## 21. 实施步骤
1333
13340. **(前置依赖)** 确认 `spaces` 表已存在 `welcome_object` 列(DB migration 由 schema 侧先执行);`lib/models` 同步新增字段并给 4 个枚举加 `ToSchema`(§5)。
13351. 根 `Cargo.toml` members 加 `"lib/space"`;建 `lib/space/Cargo.toml`(§3)。
13362. `lib/config/src/space.rs` + `lib.rs` 接线(§4)。
13373. `lib/models` 四个枚举加 `ToSchema`(§5)。
13384. 建 crate 骨架:`lib.rs`、`errors.rs`、`share/`(session → guard → lookup → authz)、`services/service.rs`;此时 `cargo check -p space` 应通过。
13395. 按依赖序实现 services:spaces → members → groups → invites → bans → avatar → images → dir。
13406. 45 个 handler 文件 + `handlers/mod.rs`。
13417. `routes.rs` + `openapi.rs`。
13428. `cargo check --workspace` 与 `cargo clippy --workspace` 零错误零警告;确认所有 `mod.rs` 仅含 `pub mod` 导出、无测试、无多余注释。
1343
1344## 22. 验收清单
1345
1346- [ ] 45 端点与 §1.1 表逐一对应;公开 4 个(get_space/get_group/get_space_avatar/get_space_image)不要求登录,其余 41 个全部经 `space_guard`
1347- [ ] 受保护 handler 首行均为 `require_uid(&session)?`
1348- [ ] 权限链:owner→全量;member→groups→permissions;封禁未过期→403;非 Active 成员→403
1349- [ ] `nums_members` 在 create 初始为 1、add/accept +1、remove -1
1350- [ ] 私有空间 get_space/get_group 对无权者返回 404(不泄露存在性)
1351- [ ] invite 全生命周期校验:归属/撤销/已接受/过期/重复成员/2FA;**accept 校验 `space.is_active`(已删空间 → NotFound)**
1352- [ ] 建邀成功后向目标邮箱(显式 email 或用户主邮箱)发送含 accept_code 链接的邮件;失败仅告警,不影响建邀结果
1353- [ ] 头像上传:content_type 白名单、base64 校验、5MB 上限、owner 权限、`storage://space-avatars/` URL 约定;**storage 错误经 `From<StorageError>` → Internal 处理**;删除时尽力清理对象与 meta
1354- [ ] 图片上传:banner/invite_banner/embed_splash 三 kind 对应回写三组 url/width/height 字段;宽高非负校验;`storage://space-images/` URL 约定;GET 公开返回库存宽高
1355- [ ] 资产树:GET 成员可见;建目录/重命名/位置/修复父级 owner 专属;覆盖读写权限与 §1.1 表一致(member 读 / `manage_overwrites` 写)
1356- [ ] `create_dir_node` 恒为 kind=Dir,无 Object 创建入口;父级须为 Dir 节点
1357- [ ] move 禁止成为自己的后代(含跨父上溯检测);repair-parents 处理自指/孤儿/环,幂等可重复执行
1358- [ ] 自助入组:`allow_self_join_groups=false` 时 join 返回 Forbidden;重复入组 409;leave_group_self 幂等(0 行 → 404)
1359- [ ] `welcome_object` 随 create/update 写入并出现在 SpaceDto;update 支持置空(传 null)
1360- [ ] sidebar 可见性:`PERM_DIR_VISIBLE` 位驱动——无覆盖=全员可见;组覆盖=组成员可见;用户覆盖=指定成员可见;owner 全见;父隐藏⇒子树隐藏
1361- [ ] 所有 mod.rs 仅模块导出;无测试;无非必要注释
1362- [ ] `cargo check` / `cargo clippy` 通过