feat(totp): add lib/totp crate for TOTP-based 2FA (HMAC-SHA1 + AES-GCM secret storage)
zhenyi committed
Added lib/totp/src/lib.rs lib/totp/src/lib.rs +180 −0 View lib/totp/src/lib.rs
@@ -0,0 +1,180 @@
1use aes_gcm::aead::Aead;
2use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
3use base64::Engine;
4use hmac::{Hmac, Mac};
5use rand::TryRng;
6use sha1::Sha1;
7use sha2::{Digest, Sha256};
8
9pub const TOTP_ISSUER: &str = "Untitled";
10pub const TOTP_PERIOD_SECS: i64 = 30;
11pub const TOTP_DIGITS: u32 = 6;
12pub const TOTP_SECRET_BYTES: usize = 20;
13pub const TOTP_NONCE_BYTES: usize = 12;
14
15const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
18pub enum TotpError {
19 #[error("invalid code")]
20 InvalidCode,
21 #[error("invalid or expired code")]
22 InvalidOrExpiredCode,
23 #[error("stored totp secret malformed")]
24 MalformedSecret,
25 #[error("stored totp secret invalid")]
26 InvalidSecret,
27 #[error("totp secret encryption failed")]
28 EncryptFailed,
29 #[error("totp secret decryption failed")]
30 DecryptFailed,
31}
32
33pub fn base32_encode(data: &[u8]) -> String {
34 let mut out = String::with_capacity(data.len() * 8 / 5 + 1);
35 let mut buffer: u32 = 0;
36 let mut bits: u32 = 0;
37 for &b in data {
38 buffer = (buffer << 8) | u32::from(b);
39 bits += 8;
40 while bits >= 5 {
41 bits -= 5;
42 out.push(BASE32_ALPHABET[((buffer >> bits) & 0x1f) as usize] as char);
43 }
44 }
45 if bits > 0 {
46 out.push(BASE32_ALPHABET[((buffer << (5 - bits)) & 0x1f) as usize] as char);
47 }
48 out
49}
50
51pub fn base32_decode(s: &str) -> Option<Vec<u8>> {
52 let mut out = Vec::with_capacity(s.len() * 5 / 8);
53 let mut buffer: u32 = 0;
54 let mut bits: u32 = 0;
55 for c in s.chars() {
56 let v = match c {
57 'A'..='Z' => u32::from(c) - u32::from('A'),
58 'a'..='z' => u32::from(c) - u32::from('a'),
59 '2'..='7' => u32::from(c) - u32::from('2') + 26,
60 '=' => continue,
61 _ => return None,
62 };
63 buffer = (buffer << 5) | v;
64 bits += 5;
65 if bits >= 8 {
66 bits -= 8;
67 out.push((buffer >> bits) as u8);
68 }
69 }
70 Some(out)
71}
72
73fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
74 if a.len() != b.len() {
75 return false;
76 }
77 let mut diff: u8 = 0;
78 for (x, y) in a.iter().zip(b) {
79 diff |= x ^ y;
80 }
81 diff == 0
82}
83
84pub fn totp_code(secret: &[u8], counter: u64) -> String {
85 let mut mac = <Hmac<Sha1> as Mac>::new_from_slice(secret).expect("hmac key");
86 mac.update(&counter.to_be_bytes());
87 let out = mac.finalize().into_bytes();
88 let offset = (out[out.len() - 1] & 0x0f) as usize;
89 let code = u32::from_be_bytes([
90 out[offset] & 0x7f,
91 out[offset + 1],
92 out[offset + 2],
93 out[offset + 3],
94 ]) % 1_000_000;
95 format!("{code:0width$}", width = TOTP_DIGITS as usize)
96}
97
98/// RFC 4648 base32 secret 的 TOTP 校验,允许 ±1 时间窗,并拒绝不大于 last_counter 的重放。
99/// 成功返回 (counter, counter as i64)。
100pub fn verify_totp_code(
101 secret: &str,
102 code: &str,
103 last_counter: Option<i64>,
104) -> Result<(u64, i64), TotpError> {
105 let secret_bytes = base32_decode(secret).ok_or(TotpError::MalformedSecret)?;
106 let code = code.trim();
107 if code.len() != TOTP_DIGITS as usize || !code.bytes().all(|b| b.is_ascii_digit()) {
108 return Err(TotpError::InvalidCode);
109 }
110 let now_counter = time::OffsetDateTime::now_utc().unix_timestamp() / TOTP_PERIOD_SECS;
111 for offset in -1i64..=1 {
112 let counter = now_counter + offset;
113 if let Some(last) = last_counter {
114 if counter <= last {
115 continue;
116 }
117 }
118 let expected = totp_code(&secret_bytes, counter as u64);
119 if constant_time_eq(expected.as_bytes(), code.as_bytes()) {
120 return Ok((counter as u64, counter));
121 }
122 }
123 Err(TotpError::InvalidOrExpiredCode)
124}
125
126pub fn generate_secret() -> String {
127 let mut raw = vec![0u8; TOTP_SECRET_BYTES];
128 rand::rng().try_fill_bytes(&mut raw).expect("rng");
129 base32_encode(&raw)
130}
131
132pub fn otpauth_uri(secret: &str, username: &str) -> String {
133 format!(
134 "otpauth://totp/{issuer}:{user}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits={digits}&period={period}",
135 issuer = TOTP_ISSUER,
136 user = username,
137 digits = TOTP_DIGITS,
138 period = TOTP_PERIOD_SECS,
139 )
140}
141
142fn derive_key(key_material: &str) -> [u8; 32] {
143 let mut h = Sha256::new();
144 h.update(key_material.as_bytes());
145 h.finalize().into()
146}
147
148/// 用 key_material 派生 AES-256 密钥,加密 secret,输出 base64(nonce || ct)。
149pub fn encrypt_secret(key_material: &str, secret: &str) -> Result<String, TotpError> {
150 let key_bytes = derive_key(key_material);
151 let key = Key::<Aes256Gcm>::from_slice(&key_bytes);
152 let cipher = Aes256Gcm::new(key);
153 let mut nonce = [0u8; TOTP_NONCE_BYTES];
154 rand::rng().try_fill_bytes(&mut nonce).expect("rng");
155 let ct = cipher
156 .encrypt(Nonce::from_slice(&nonce), secret.as_bytes())
157 .map_err(|_| TotpError::EncryptFailed)?;
158 let mut out = Vec::with_capacity(TOTP_NONCE_BYTES + ct.len());
159 out.extend_from_slice(&nonce);
160 out.extend_from_slice(&ct);
161 Ok(base64::engine::general_purpose::STANDARD.encode(out))
162}
163
164/// 解密 `encrypt_secret` 的输出,恢复明文 secret。
165pub fn decrypt_secret(key_material: &str, stored: &str) -> Result<String, TotpError> {
166 let key_bytes = derive_key(key_material);
167 let key = Key::<Aes256Gcm>::from_slice(&key_bytes);
168 let cipher = Aes256Gcm::new(key);
169 let raw = base64::engine::general_purpose::STANDARD
170 .decode(stored)
171 .map_err(|_| TotpError::MalformedSecret)?;
172 if raw.len() < TOTP_NONCE_BYTES + 1 {
173 return Err(TotpError::MalformedSecret);
174 }
175 let (nonce, ct) = raw.split_at(TOTP_NONCE_BYTES);
176 let pt = cipher
177 .decrypt(Nonce::from_slice(nonce), ct)
178 .map_err(|_| TotpError::DecryptFailed)?;
179 String::from_utf8(pt).map_err(|_| TotpError::InvalidSecret)
180}
No newline at end of file
Added lib/totp/Cargo.toml lib/totp/Cargo.toml +14 −0 View lib/totp/Cargo.toml
@@ -0,0 +1,14 @@
1[package]
2name = "totp"
3version = "1.94.0"
4edition = "2024"
5workspace = true
6
7[dependencies]
8base64 = { workspace = true }
9rand = { workspace = true }
10sha2 = { workspace = true }
11sha1 = { workspace = true }
12hmac = { workspace = true }
13aes-gcm = { workspace = true }
14thiserror = { workspace = true }
No newline at end of file
Deleted wiki/auth-and-jwt-architecture.md wiki/auth-and-jwt-architecture.md +0 −475
@@ -1,475 +0,0 @@
1# lib/auth 与 lib/jwt 学习笔记
2
3> 状态:基于当前 `lib/auth` 与 `lib/jwt` 源码梳理(2026-08-09)。
4> 目标:理清两个 crate 的职责、关键流程、关键数据结构与扩展点,便于后续维护与改造。
5
6---
7
8## 0. 一句话总结
9
10- `lib/jwt`:纯算法 + DB/缓存层。负责**签发 / 验证 / 轮换 / 撤销** JWT,并维护 `user_sessions` 行(family / parent / grace 状态机),对上层只暴露 `AuthUser` 提取器与 `JwtState`。
11- `lib/auth`:业务编排层。负责**PoW、注册、邮箱验证、登录、刷新、注销、密码找回/重置、OAuth、协议同意**,把用户状态机(active / pwd_version / restricted)与 jwt 能力粘合起来,对外只暴露 `/auth/*` 路由。
12
13二者通过 `AuthService` 持有的 `JwtState` + 共享 `SigningKeyCache` 解耦:`jwt` 不依赖 `auth`;`auth` 借 `HasJwtState` 注入自己到 jwt 的中间件。
14
15---
16
17## 1. 模块拓扑
18
19```
20lib/
21├── auth/
22│ ├── lib.rs # 入口:re-export error / handler::router / metrics / pow
23│ ├── error.rs # AuthError(含 JwtError、EmailError、RedisError、SqlxError)
24│ ├── metrics.rs # auth_*_total / auth_*_duration_seconds 指标家族
25│ ├── pow/ # ALTCHA 适配(issue / verify / once-语义)
26│ │ ├── mod.rs
27│ │ └── error.rs
28│ ├── features/ # 业务用例层(每个子目录 4 件套:types + usecase + impls + mod)
29│ │ ├── mod.rs # AuthService 聚合 + HasJwtState + RateLimitProvider
30│ │ ├── access_token.rs # 给外部用的 SHA256(token, salt) 工具
31│ │ ├── agreement/ # terms/privacy 协议同意
32│ │ ├── email_code/ # 通用 6 位数字邮箱验证码(signup / reset / email_change)
33│ │ ├── forgot_password/ # 申请重置 → 邮件 + temp_key cookie
34│ │ ├── reset_password/ # token + 旧密码 → 改密 + 撤销所有 session + 拉黑 access jti
35│ │ ├── login/ # PoW + 凭证 → issue_login_pair
36│ │ ├── logout/ # 撤销 refresh + 撤销 access jti
37│ │ ├── oauth/ # 三个 provider + usecase 分支
38│ │ ├── password_hash/ # argon2 + 密码策略
39│ │ ├── reclaim/ # 邮箱未激活账号的"重新发送 signup 验证码"
40│ │ ├── refresh/ # 调用 jwt::rotate_refresh
41│ │ ├── register/ # 创建用户 + 发送 signup 验证码
42│ │ ├── resend_code/ # 重新发送
43│ │ └── ...
44│ └── handler/ # axum 适配层
45│ ├── routes.rs # 路由表(嵌套 /auth/*)
46│ ├── mod.rs # 子模块出口
47│ ├── openapi.rs # utoipa OpenAPI 聚合
48│ ├── cookies.rs # temp_key ↔ Cookie 转换
49│ ├── middleware/metrics.rs # HTTP 维度指标
50│ ├── login.rs / logout.rs / refresh.rs / register.rs / me.rs / pow.rs
51│ ├── agreement/{list,accept}.rs
52│ ├── email/{verify,resend,reclaim}.rs
53│ ├── password/{forgot,reset}.rs
54│ └── oauth/{providers,authorize,callback}.rs
55│
56└── jwt/
57 ├── lib.rs # pub mod ...; pub use claims/keys/handlers/...
58 ├── error.rs # JwtError(被 auth 的 From 转换消费)
59 ├── claims.rs # AccessClaims / RefreshClaims / HasJti / HasUserExt / utils
60 ├── keys.rs # SigningKeyCache(kid → CachedKey{decoding, alg, ...})
61 ├── signer.rs # encode/decode access & refresh,extract_kid
62 ├── rotation.rs # issue_login_pair / rotate_refresh / validate_access
63 ├── session.rs # user_sessions 的 CRUD:create/find/revoke/mark_replaced/delete*
64 ├── grace.rs # key/replay 纯函数(is_key_usable_for_verify、reject_replay、grace_deadline)
65 ├── handlers.rs # JwtState + LogoutRequest + TokenPairResponse + IntoResponse
66 ├── extractor.rs # AuthUser / AuthOptional / AuthAdmin(FromRequestParts)
67 ├── middleware.rs # inject_jwt_ctx + HasJwtState
68 └── metrics.rs # jwt_*_total 指标家族
69```
70
71### 1.1 两个 crate 的依赖关系
72
73- `jwt` 不依赖 `auth`;只依赖 `axum / config / db / sea-orm / rate_limit / email` 等通用基建。
74- `auth` 依赖 `jwt`(用于签发/校验/撤销)并实现 `jwt::HasJwtState`,因此 jwt 的中间件可以无差别地给 `AuthService` 注入 `JwtState` 上下文。
75- 入口装配在 `app/webapi`(典型用法):先构造 `JwtState`,再以 `JwtState` 构造 `AuthService`,最后 `Router::new().nest("/auth", auth::router(svc))`。
76
77---
78
79## 2. lib/jwt 详细说明
80
81### 2.1 Claims(`claims.rs`)
82
83| 字段 | 类型 | 含义 |
84| --- | --- | --- |
85| `sub` | `Uuid` | 用户 id |
86| `uname` | `String` | 用户名(access 携带) |
87| `is_admin` / `is_restricted` | `bool` | 权限位(access 携带) |
88| `pwd_version` | `i64` | 密码版本号;低于当前 user 行的 `passwd_version` ⇒ `SessionRevoked` |
89| `kid` | `String` | 当前 token 的签名 key id(access / refresh 都带) |
90| `jti` | `Uuid (v7)` | 唯一 id,用于黑名单(access) |
91| `family_id` / `parent_id` | `Uuid` | refresh 链:`family_id` 标识一组"派生链";`parent_id` 指向上一代 jti |
92| `iss` / `aud` | `String` | issuer / audience,由 `JwtSettings` 提供 |
93| `iat` / `exp` | `i64` | 标准 iat/exp;可配置 `clock_skew_seconds` 容差 |
94| `scope` | `String` | access 预留,尚未启用 |
95
96`HasJti` / `HasUserExt` 是 trait,让上层只关心 `jti` 和 `user_id()`,不直接碰 `AccessClaims`/`RefreshClaims` 二选一。
97
98### 2.2 密钥缓存(`keys.rs`)
99
100`SigningKeyCache` 维护 `kid → CachedKey{DecodingKey, Algorithm, is_rotating, grace_ends_at, expires_at}` 的内存映射,`is_signing=true` 的那一把同时持有 `EncodingKey` 用于签发。
101
102`load_from_db`:
103- 只加载 `is_active=true AND revoked_at IS NULL` 的 `system_signing_keys` 行。
104- 第一个 `is_signing` 行作为 `current_kid` 与 `encoding`。
105- `is_rotating=true` 的旧 key 仍保留在 map 中,供 `grace_window` 内的旧 token 继续验证。
106
107`in_grace_window(kid)`:纯查询函数,被 `grace.rs` 复用;`extract_kid → kid ∈ keys` 决定 `RotationContext` 的可用性。
108
109### 2.3 签发与验证(`signer.rs`)
110
111- `encode_*_token`:把 `kid` 写入 JWT header。
112- `decode_*_token`:构造 `Validation`(issuer / audience / leeway / exp),`decode<Claims>`。
113- `kid_from_token` / `extract_kid`:从 `Authorization: Bearer` 头里先解析 header.kid,从而绕开"不知道用哪把 key"的鸡生蛋问题。
114
115错误映射:`From<jsonwebtoken::Error> for JwtError` 把 `ExpiredSignature / InvalidSignature / InvalidAudience / ...` 各自映射到语义化变体,IO 层只需要 `match JwtError` 就能生成对应 HTTP 状态码。
116
117### 2.4 轮换与签发核心(`rotation.rs`)
118
119`RotationContext<'a> { db, settings, cache }` 是所有操作的共享入口:
120
121- **`issue_login_pair`**(登录、OAuth 完成、找回密码后直接发 token 等):
122 1. 取 `current_kid + encoding + alg`。
123 2. 生成 `AccessClaims` + `RefreshClaims`(`family_id = Uuid::now_v7()`,`parent_id = None`)。
124 3. `session::create_session` 写一行 `user_sessions`(`family_id`,无 `replaced_by`)。
125 4. 返回 `TokenPair`。
126- **`rotate_refresh`**(`/auth/refresh`):
127 1. 从 `kid` → `DecodingKey` → `decode_refresh_token`。
128 2. `pwd_version` 与 `family` 一致性检查:
129 - 旧 token `pwd_version < user.pwd_version` ⇒ `SessionRevoked`(改密后旧 token 全废)。
130 - 旧 session `is_revoked` ⇒ `SessionRevoked`。
131 - 旧 session 已 `replaced_by` 但 `grace_expires_at` 未到 ⇒ 仍允许(并发刷新兼容)。
132 - 旧 session `replaced_by` 且超过 grace ⇒ `SessionReplaced`(典型的"refresh 重放"检测)。
133 3. 派生新一对 access / refresh:`family_id` 不变;`parent_id = old_refresh.jti`。
134 4. `create_session` 写入新行 → `mark_replaced(old_id, new_id, grace_deadline)`。
135 5. 返回新 `TokenPair`。
136- **`validate_access`**:仅做签名 + 过期 + iss/aud 校验;不读 DB(access 无状态),但调用方(`AuthUser` 提取器)会在 DB 之外再查 `AccessTokenBlacklist` Redis。
137
138`pwd_version` 字段是这个体系的关键:**改密时自增,token 自带版本号,校验时强制 `token.pwd_version >= user.passwd_version`**,避免了"改密后旧 access 仍能用"的问题;`AccessTokenBlacklist` 进一步防御"改密后旧 access 在它剩余的 TTL 里仍然能调用"的窗口。
139
140### 2.5 Session 持久化(`session.rs`)
141
142`user_sessions` 表是这套体系的"链式账本":
143
144| 字段 | 说明 |
145| --- | --- |
146| `id` | 行 id(v7) |
147| `user_id` | 归属用户 |
148| `token_hash` | refresh token 的 SHA256 哈希(不存明文) |
149| `family_id` | 整条派生链的"血统" |
150| `parent_refresh_id` | 上一代 refresh 的 jti |
151| `is_revoked` / `revoked_at` | 注销 / 改密撤销 |
152| `replaced_at` / `replaced_by` | 被新行替换 |
153| `grace_expires_at` | 旧 token 还能继续生效到何时 |
154| `expires_at` | refresh token 的自然过期时间 |
155| `signing_key_id` | 用哪把 key 签的 |
156| `app_id` | 客户端 id(多端登录标识) |
157
158- `create_session`:登录或刷新时插入新行。
159- `find_session_by_hash(refresh_token)`:校验时按 token 哈希找行。
160- `mark_replaced(old, new, grace)`:刷新成功时把老行标记为"被替换",并给一段宽限期。
161- `revoke_session`:注销 / 改密时单行撤销。
162- `delete_sessions_by_user` / `delete_sessions_by_family`:管理员级强制下线。
163
164### 2.6 grace 纯函数(`grace.rs`)
165
166把"key 是否仍可用"、"refresh 是否在宽限窗口内"这些时间判定抽成纯函数,方便单测:
167
168- `is_key_usable_for_verify(key, now)`:`!expired && (rotating ⇒ 仍在 grace)`。
169- `ensure_key_usable(key)`:上式的 `Result` 包装。
170- `reject_replay(grace_expires_at, now)`:旧 refresh 在 grace 外一律拒绝。
171- `grace_deadline(start, duration)` / `kid_matches(...)`:字面意义。
172
173### 2.7 提取器与中间件(`extractor.rs`、`middleware.rs`)
174
175- `bearer_token(parts)`:从 `Authorization: Bearer ...` 头里抠出 token。
176- `validate_ctx(parts, token)`:
177 1. 从 `extensions` 拿 `SigningKeyCache`(`Arc<RwLock<_>>`)、`DatabaseConnection`、`JwtSettings`。
178 2. `validate_access` 完成签名 / iss / aud / exp 校验。
179 3. 如果 `extensions` 里有 `Option<Arc<AccessTokenBlacklist>>` ⇒ 再查 Redis 拒绝名单;命中即 `SessionRevoked`。
180- `AuthUser` / `AuthOptional` / `AuthAdmin`:axum `FromRequestParts` 实现,分别强制 / 容忍 / 强制 admin。
181- `inject_jwt_ctx`:把 `JwtState.cache / settings / db / access_blacklist` 注入到 `request.extensions`,让下游的 `AuthUser` 提取器零参数拿到。
182
183### 2.8 handlers(`handlers.rs`)
184
185- `JwtState { db, settings, cache }`:可被 `HasJwtState` 借出。
186- `IntoResponse for JwtError`:把错误变体映射成 401/403/503 等状态码。
187- `LogoutRequest { refresh_token }` / `TokenPairResponse { access_token, refresh_token, token_type, expires_in }`:对外协议。
188- `logout_handler`:从 refresh 找 session → `revoke_session`。
189- `rotate_keys_handler`:TODO 桩。
190
191### 2.9 jwt 错误(`error.rs`)
192
193`JwtError` 是所有 jwt 层错误的归一化点;上层(`auth` / `webapi`)通过 `From<JwtError> for AuthError` 进一步映射到 HTTP 状态。
194
195---
196
197## 3. lib/auth 详细说明
198
199### 3.1 入口与聚合(`features/mod.rs`)
200
201`AuthService` 是唯一的业务服务聚合:
202
203```rust
204pub struct AuthService {
205 pub db: Db, // 读写连接(来自 db crate)
206 pub email: EmailService, // 邮件服务
207 pub kv: Arc<RedisCache>, // Redis:PoW、邮箱验证码、access 黑名单
208 pub pow: PowService, // ALTCHA
209 pub config: SharedAuthConfig, // 协议、Cookie、密码策略、OAuth
210 pub jwt: JwtState, // jwt 共享状态
211}
212```
213
214- 实现 `HasJwtState` ⇒ jwt 的 `inject_jwt_ctx` 可直接给 `AuthService` 注入扩展。
215- 实现 `RateLimitProvider` ⇒ 全局限流中间件 (`rate_limit::ratelimit`) 通过 `client_id` 维度对 `/auth/*` 限流。
216
217### 3.2 PoW(`pow/mod.rs`)
218
219基于 `altcha` crate 的 HMAC PoW:
220
221- `generate_challenge_with(data)`:用 `PowSettings.hmac_secret` 签发 ALTCHA challenge,TTL 由 `challenge_ttl` 控制。
222- `issue_challenge_bound(cid)`:把 `cid` 嵌入 `data`,写入 Redis(`auth:pow:issued:<sha256>`),TTL 后自然过期;返回 `(challenge, ttl)`。
223- `verify_solution_once(challenge, solution, expected_cid)`:
224 1. Redis `GETDEL` 一次性消费(防重放)。
225 2. 校验 `cid` 绑定(防跨客户端重用)。
226 3. 调 `altcha::verify_solution`,并把耗时计入 `auth_pow_verify_duration_seconds`。
227- 错误:`MissingSecret` / `Storage(...)` / `Expired` / `InvalidSignature` / `InvalidSolution` / `NotIssued` / `ClientMismatch`。
228
229### 3.3 邮箱验证码(`features/email_code`)
230
231通用验证码,purpose 有三种:`Signup` / `PasswordReset` / `EmailChange`。
232
233- 颁发:
234 - Redis 速率限(`auth:code_rate:<purpose>:<email>`,TTL = `email_code_rate_limit_secs`)。
235 - 同邮箱同 purpose 旧码软作废(`used_at` 标 now)。
236 - 生成 `verify_code_len` 位数字码(`generate_numeric_code`)。
237 - 写到两处:
238 - Redis `auth:code:<purpose>:<temp_id>`(短 TTL,`temp_id` 走 cookie 返回客户端)。
239 - DB `user_email_verification_codes`(`token_hash = sha256(code)`,`used_at` 字段控幂等)。
240 - 返回 `TempKey { name, value: temp_id.to_string(), ttl, ... }`。
241- 校验:
242 - Redis 计数 `auth:code_attempts:<purpose>:<temp_id>`,超过 `max_verify_attempts` 删码返 429。
243 - 恒定时比较(`constant_time_eq`)。
244 - 命中后 `SET used_at=now`;如果 `purpose=Signup`,事务里同步把 `users.is_active=true` + `user_emails.is_activated=true, activated_at=now`。
245- 删码 / 删计数器(成功/失败后清理 Redis)。
246
247### 3.4 注册(`features/register`)
248
249`RegisterCmd { username?, email, password, pow, accept_agreements[] }`:
250
2511. 校验邮箱、推导 username(缺省 `email` `@` 前缀)、校验 username、校验密码强度、PoW。
2522. 判重(`email_exists` / `username_exists`,命中返 `Conflict("registration failed")` 避免侧信道)。
2533. 事务里建 user(`is_active=false`、`passwd_version=1`、`passwd_hash=argon2(password)`)+ 主邮箱记录。
2544. `email_code::issue_code(Signup)` → 异步发邮件(失败仅 warn,账号仍 pending)。
2555. 同步记录 `accept_agreements` 内的 `terms` / `privacy` 接受记录(容错)。
2566. 返 `RegisterResp { uid, pending: true, temp_key, purpose: Signup }`。
2577. handler 把 `temp_key` 写入 Cookie,浏览器下次 `email/verify` 携带 `temp_key_value` cookie。
258
259### 3.5 登录(`features/login`)
260
261`LoginCmd { identifier, password, pow, app_id? }`:
262
2631. PoW。
2642. `find_user_by_identifier`:`@` ⇒ 走 `user_emails.lower_email`(同时拉 user),否则 `users.lower_name`。
2653. 状态闸:`prohibit_login` / `is_restricted` / `is_active` ⇒ 拒绝;`passwd_hash` 为空 ⇒ 提示走第三方登录。
2664. `argon2::verify_password`。
2675. 拿到 `RotationContext`,`issue_login_pair` 签发 token pair + 新 session 行。
2686. 更新 `users.last_login_at`。
2697. 返 `LoginResp { ..., user: UserPublic }`。
270
271### 3.6 刷新(`features/refresh`)
272
273- 解析 refresh → `decode_refresh_token`(仅校验签名/iss/aud/exp)。
274- 读 `users`,检查 `restricted / prohibit_login / is_active` 与 `passwd_version`。
275- 调 `jwt::rotate_refresh(&ctx, refresh, &UserSnapshot, app_id)`,由 jwt 层完成 family / grace 的链路校验与 session 写入。
276- 返新 token pair。
277
278### 3.7 注销(`features/logout`)
279
280- 解析 refresh → 找 session → `revoke_session`。
281- 如果请求带 access token:解码 → `AccessTokenBlacklist::revoke_jti(jti, access_ttl)`(防御 access 在剩余 TTL 内仍可用)。
282- 返 `LogoutResp { revoked }`。
283
284### 3.8 找回 / 重置密码(`forgot_password` + `reset_password`)
285
286- 忘记密码:
287 - PoW → 查 `user_emails`(无则仍返 `sent: true`,避免侧信道)→ 生成 `reset_token_bytes` 字节随机 token → 邮件发送 → Redis `auth:reset:<temp_id>` + DB `user_password_reset_tokens`(`token_hash=sha256(token)`)。
288 - 返 `ForgotPasswordResp { sent: true, temp_key }`(cookie)。
289- 重置:
290 - 强密码校验。
291 - 读 temp_id cookie → Redis 校验 token(恒定时比较)→ DB 行存在 + 未过期 + `used_at` 为空 + `email.user_id == row.user_id`。
292 - 事务里:写新密码哈希(argon2),`passwd_version += 1`,`is_active = true`(如果之前未激活),`token.used_at = now`,**`jwt::delete_sessions_by_user(user_id)`**。
293 - 提交后:`AccessTokenBlacklist::revoke_user(user_id, access_ttl)`(拉黑当前已发的所有 access jti),完成 VULN-005 修复。
294 - 返 `ResetPasswordResp { ok: true }`,handler 清空 temp_key cookie。
295
296### 3.9 协议(`features/agreement`)
297
298- `GET /auth/agreements` → 返 `terms` + `privacy` 文档(来自 `AuthConfig.agreements`)。
299- `POST /auth/agreements/accept` → 校验 key 在白名单 + version 一致 → 写 `user_agreement_acceptances`(重复接受视作幂等成功)。
300
301### 3.10 OAuth(`features/oauth`)
302
303- `GET /auth/oauth/providers`:返启用的 provider 列表。
304- `POST /auth/oauth/authorize`(PoW 必须):构造 `state`、写 Redis(`auth:oauth:state:<provider>:<state>`,TTL=10min)→ 返 `authorization_url` 与 `temp_key`(cookie)。
305- `GET /auth/oauth/callback`:从 cookie 读 `temp_key` → 校验 state → 各 provider 走对应适配器(`oauth_google` / `oauth_github` / `oauth_apple`)拿 id_token / userinfo → 解析 email → 走"已有 link / email 匹配 / 自动注册"三分支(指标 `auth_oauth_user_resolution_total{branch=...}`)→ `issue_login_pair` 签 token → 清除 state cookie。
306- `OAuthCallbackOutcome` 记录 `success / state_expired / state_mismatch / provider_error / user_gate / other`。
307
308### 3.11 access_token 工具(`features/access_token.rs`)
309
310`sha256_hex(token + salt)` 的小工具,给外部模块用,避免与 `jwt::claims::sha256_hex` 行为不一致。
311
312### 3.12 路由(`handler/routes.rs`)
313
314| 方法 | 路径 | 处理器 | 备注 |
315| --- | --- | --- | --- |
316| POST | `/auth/register` | `register::handler` | 写 temp_key cookie |
317| POST | `/auth/login` | `login::handler` | PoW |
318| POST | `/auth/logout` | `logout::handler` | 撤销 refresh + access jti |
319| POST | `/auth/refresh` | `refresh::handler` | |
320| GET | `/auth/pow/challenge` | `pow::handler` | 写 Redis issued |
321| POST | `/auth/email/verify` | `email::verify::handler` | 删 temp_key cookie |
322| POST | `/auth/email/resend` | `email::resend::handler` | 写 temp_key cookie |
323| POST | `/auth/email/reclaim` | `email::reclaim::handler` | 写 temp_key cookie |
324| POST | `/auth/password/forgot` | `password::forgot::handler` | 写 temp_key cookie |
325| POST | `/auth/password/reset` | `password::reset::handler` | 读 temp_key cookie,删 cookie |
326| GET | `/auth/agreements` | `agreement::list::handler` | |
327| POST | `/auth/agreements/accept` | `agreement::accept::handler` | 需 `AuthUser` |
328| GET | `/auth/oauth/providers` | `oauth::providers::handler` | |
329| POST | `/auth/oauth/authorize` | `oauth::authorize::handler` | 写 state cookie |
330| GET | `/auth/oauth/callback` | `oauth::callback::handler` | 读 state cookie,删 cookie |
331| GET | `/auth/me` | `me::handler` | 需 `AuthUser`,挂 `inject_jwt_ctx` 中间件 |
332
333中间件(按外→内):
334- `metrics::metrics_layer`(HTTP 维度打点:route/method/status_class)。
335- `ratelimit::<AuthService>`(限流)。
336- `/auth/me` 再额外套一层 `inject_jwt_ctx::<AuthService>`。
337
338### 3.13 错误与状态码(`error.rs`)
339
340`AuthError` 把 `JwtError / EmailError / SqlxError / RedisError / PowError` 全部 `From` 化,方便 `?` 一把梭。`IntoResponse` 实现根据 `is_user_facing` 决定"返回给客户端的 detail"还是"统一 internal error"。
341
342### 3.14 指标(`metrics.rs`)
343
344主要 counter / histogram:
345
346- `auth_usecase_invocations_total{feature, outcome}` / `auth_usecase_duration_seconds{feature}`(每个 usecase 用 `Timer` RAII 自动观测)。
347- `auth_usecase_errors_total{feature, error_kind}`。
348- `auth_http_requests_total{route, method, status_class}` / `auth_http_request_duration_seconds{...}`。
349- `auth_pow_challenges_issued_total` / `auth_pow_verifications_total{outcome}` / `auth_pow_verify_duration_seconds{outcome}`。
350- `auth_password_hash_duration_seconds{op}` / `auth_password_policy_rejections_total{rule}`。
351- `auth_email_codes_issued_total{channel}` / `auth_email_code_verifications_total{outcome}`。
352- `auth_oauth_authorize_total{provider, outcome}` / `auth_oauth_callback_total{provider, outcome}` / `auth_oauth_provider_fetch_duration_seconds{provider}` / `auth_oauth_user_resolution_total{branch}`。
353- `auth_agreement_accepts_total{agreement_id}` / `auth_agreement_records_total{agreement_id}`。
354- `auth_jwt_pair_issued_total{kind=login|refresh}` / `auth_jwt_session_revoked_total{kind=logout|rotation}` / `auth_jwt_denied_jti_total`(黑名单插入次数)。
355
356---
357
358## 4. 关键流程时序
359
360### 4.1 登录
361
362```
363client --POST /auth/pow/challenge--> server
364server --Redis SET auth:pow:issued:<sha>--> Redis
365server <-{ challenge, ttl }-- client
366
367client --POST /auth/login{id,pwd,pow}--> login::handler
368 └─ login::usecase::login
369 ├─ pow.verify_solution_once(GETDEL, altcha::verify)
370 ├─ find_user_by_identifier (email or username)
371 ├─ state gate (prohibit_login / is_restricted / is_active)
372 ├─ argon2::verify_password
373 └─ issue_login_pair
374 ├─ cache.current_encoding → (kid, EncodingKey, alg)
375 ├─ AccessClaims::new / RefreshClaims::new
376 ├─ encode_access_token / encode_refresh_token
377 ├─ session::create_session (family_id, no parent)
378 └─ return TokenPair
379server <-{ access, refresh, user: UserPublic }-- client
380```
381
382### 4.2 刷新(带并发兼容)
383
384```
385client_A --POST /auth/refresh{refresh_A}--> refresh::handler
386client_B --POST /auth/refresh{refresh_B}--> refresh::handler
387(同时发生,refresh_A 和 refresh_B 同 family,refresh_B 由 A 派生)
388
389 refresh::usecase
390 ├─ decode refresh → user
391 └─ rotate_refresh(ctx, refresh, snapshot, app_id)
392 ├─ find_session_by_hash(refresh)
393 │ └─ 若 replaced_by 已存在 + grace 未到 ⇒ 仍允许(race)
394 ├─ create new session (parent_refresh_id = old.jti, grace = now+grace_window)
395 └─ mark_replaced(old, new, grace_deadline)
396```
397
398### 4.3 重置密码(VULN-005 修复路径)
399
400```
401client --POST /auth/password/forgot--> forgot
402 └─ 生成 token → mail + Redis + DB
403client --POST /auth/password/reset{token, new_pwd, temp_key_value}--> reset
404 └─ reset::usecase
405 ├─ 密码强度
406 ├─ Redis 校验 + DB token 行存在/未过期/未使用
407 ├─ txn {
408 │ users: passwd_hash = new_hash
409 │ passwd_version = old + 1
410 │ is_active = true (可选)
411 │ tokens: used_at = now
412 │ jwt_session::delete_sessions_by_user(user_id)
413 │ }
414 └─ AccessTokenBlacklist::revoke_user(user_id, access_ttl) // 旧 access 立即失效
415```
416
417### 4.4 OAuth 回调
418
419```
420client --GET /auth/oauth/authorize{provider}--> authorize (PoW)
421 └─ 生成 state → Redis SETEX auth:oauth:state:<provider>:<state> 10min
422 └─ 写 temp_key cookie(state 本身)
423server <-{ authorization_url }--
424
425browser --GET <authorization_url>?code=...&state=...--> provider
426provider --302--> /auth/oauth/callback?provider=&code=&state=
427server --GET /auth/oauth/callback--> callback
428 └─ 读 temp_key cookie(state)
429 └─ OAuth provider: exchange code → userinfo
430 └─ branch ∈ { existing_link, email_match, auto_register }
431 └─ issue_login_pair
432 └─ 清 state cookie
433server <-{ access, refresh, user_id }--
434```
435
436---
437
438## 5. 关键设计点
439
440- **family / parent / grace 状态机**:让 refresh 重放和并发刷新都自然处理:单次刷新在 DB 留两条行(老 `replaced_by=新`、`grace_expires_at`),宽限期内旧 refresh 仍可用。
441- **`pwd_version` + 双重防御**:
442 - token 自带 `pwd_version`,改密后 `user.passwd_version += 1`,旧 refresh 在 `rotate_refresh` 直接 `SessionRevoked`。
443 - access 是无状态的,**改密**与**注销**通过 `AccessTokenBlacklist`(Redis)做 best-effort 撤销;TTL = access 剩余寿命。
444- **PoW 一次性消费**:`GETDEL` 原子扣 Redis,同一 challenge 不能重放;`cid` 字段绑客户端,避免"用 A 的 challenge 给 B 用"。
445- **速率限分层**:
446 - 路由级(`rate_limit::ratelimit`)按 `client_id` 维度(IP / cookie / token)。
447 - 验证码级(`auth:code_rate`)按 (purpose, email) 维度,避免邮件轰炸。
448 - 验证码尝试级(`auth:code_attempts`)按 (purpose, temp_id) 维度,5 次后作废。
449- **错误侧信道**:
450 - 注册冲突一律 `Conflict("registration failed")`,不区分邮箱/用户名冲突。
451 - 忘记密码:邮箱不存在也返 `sent: true` + 同样的 temp_key。
452 - `reclaim`:账号不存在时返"看起来成功"的常量响应。
453- **OpenAPI 全程在 handler 层用 `#[utoipa::path]` 标注**,错误体统一为 `ErrorBody`,便于前端/SDK 自动化生成。
454- **指标可观测性**:所有 usecase 用 `metrics::Timer` RAII 自动观测耗时;outcome/error_kind 自动打点;`describe_*` 通过 `Once` 幂等注册。
455
456---
457
458## 6. 已知 TODO / 扩展点
459
460- `lib/jwt/handlers.rs::rotate_keys_handler` 仍是桩:未实现"生成新 Ed25519 key + 老 key 标记 `is_rotating=true` 进入 grace"这一动作;当前 `SigningKeyCache::load_from_db` 只把 DB 已配置好的 key 加载进来。
461- `AccessClaims.scope` 字段保留位,尚未使用。
462- `app_id`:登录/刷新都接收,但 session 里只存字符串;没有对 `app_id` 做白名单/校验。
463- 协议同意只在 key ∈ {terms, privacy} 白名单内;新增协议需要 `AuthConfig.agreements` 加条目 + `accept_agreement_inner` 增加 match 分支。
464- OAuth 三个 provider(google / github / apple)已拆分独立子模块,缺哪一个时 `OAuthProviderInfo` 中 disabled 即可。
465
466---
467
468## 7. 阅读顺序建议(如果第一次看)
469
4701. `lib/jwt/lib.rs` → `claims.rs` → `keys.rs` → `signer.rs` → `session.rs` → `rotation.rs`。
4712. `lib/auth/lib.rs` → `features/mod.rs`(`AuthService` 是什么)→ `pow/mod.rs` → `register/usecase.rs` → `login/usecase.rs`。
4723. `refresh/usecase.rs` + `logout/usecase.rs` + `reset_password/usecase.rs`:把 pwd_version / blacklist 联动看清。
4734. `email_code/usecase.rs`:看 Redis 速率限、temp_key cookie、purpose 三态。
4745. `oauth/usecase.rs` + `handler/oauth/*.rs`:三分支用户解析。
4756. `handler/routes.rs` + `handler/middleware/metrics.rs`:把"指标维度"和"中间件顺序"对上号。
Deleted wiki/lib-post-plan.md wiki/lib-post-plan.md +0 −79
@@ -1,79 +0,0 @@
1# lib/post 实施计划
2
3> 状态:已确认,待执行(2026-08-09)
4> 依据:`wiki/refactor-channel-to-space.md` 41/83 行「post ← DB schema 已有,业务层尚未实现」、523 行「post / meet 业务层 + HTTP 层(独立排期,类比 PR)」、255 行 URL 表 `/posts/{space_name}`、265 行子资源自动正确、385 行 `channel_for_post`、537 行子资源 = post 模块聚合。
5
6## 1. 目标
7
8新建 `lib/post` crate:帖子 board(collection)+ 单篇帖子 + 子资源的业务层(features)与 HTTP 层(handler),URL 采用 `{space_name}` 形态,接入 webapi 装配,补 401 集成测试。复用已建基础设施:`SpaceDb` trait、`lookup_space_by_name`、`require_active_member`、`channel_for_space`、`From<SpaceError> for PostError` 错误转换。
9
10## 2. 文件目录树(新建,类比 lib/issues)
11
12```
13lib/post/
14├── Cargo.toml # deps 同 lib/issues;dev-deps: http + tower="0.5"
15├── lib.rs # pub mod error/features/handler/metrics/service
16│ # pub use error::{PostError, PostResult}
17│ # pub use features::PostService
18│ # pub use handler::{PostsApiDoc, router as posts_router}
19├── error.rs # PostError 九变体(Forbidden(String) derive "forbidden: {0}")+ From<SpaceError>
20├── service.rs # PostService::new(db, kv, jwt, storage) + impl space::SpaceDb
21├── features/
22│ ├── mod.rs # pub mod authz/lookup/post/posts/post_label/post_mention/
23│ │ # post_reaction/post_reply/post_watch
24│ ├── authz.rs # 复用 require_active_member + 反查
25│ ├── lookup.rs # channel_for_post(两级反查)+ channel_for_post_board
26│ ├── posts/ # board 级(collection):impls.rs + mod.rs + usecase/
27│ ├── post/ # 单篇:impls.rs + mod.rs + usecase/
28│ ├── post_label/ # board 级标签 CRUD
29│ ├── post_mention/ # 提及(Post/Reply target)
30│ ├── post_reaction/ # 表情(Post/Reply target)
31│ ├── post_reply/ # 回复(维护 reply_count/last_reply_at/last_reply_by)
32│ └── post_watch/ # 关注
33├── handler/
34│ ├── mod.rs
35│ ├── routes.rs # protected scope:/posts/{space_name}...
36│ ├── openapi.rs # paths(...) 全部 handler + components(schemas)
37│ ├── middleware/
38│ └── posts/ post/ post_label/ post_mention/ post_reaction/ post_reply/ post_watch/
39└── tests/posts/
40 ├── main.rs # try_router(redis kv + JwtState + LocalStorage + PostService::new)+ expect_unauthorized
41 └── get.rs # GET /posts/test-space/1 未认证 → 401
42lib/export/post/ # mod.rs + views.rs(PostView/PostPage/PostReplyView/... ToSchema)
43```
44
45## 3. 关键设计决策
46
47| 决策点 | 方案 | 依据 |
48| --- | --- | --- |
49| URL 形态 | `/posts/{space_name}`(board 列表/建帖)、`/posts/{space_name}/{number}`(单篇)、子资源挂单篇 | wiki 255 行 |
50| 单篇参数 | `{number}`(i64,collection_id+autoincrement_id 组合唯一键),同 PR/issues 模式 | lib/db/post/post.rs 组合唯一键 |
51| 三级反查 | board 级:lookup_space_by_name → require_active_member → channel_for_space;单篇:→ channel_for_post 两级反查(post.collection_id → posts.channel_id) | wiki 385 行 + 阶段 3 模式 |
52| 鉴权 | 全部 AuthUser(同 issues),board 公开读由业务层控制 | 类比 issues |
53| 附件 | 不做(无 attachment 表;chat AttachmentKind::Post 为语义预留,不影响) | — |
54| workspace | members 加 `"lib/post"`;workspace.dependencies 加 `post = { path = "lib/post" }` | 现状缺失 |
55
56## 4. 实施步骤
57
581. 脚手架:workspace 注册 lib/post + export/post;Cargo.toml;lib.rs;error.rs(PostError 九变体 + From<SpaceError>);service.rs(PostService::new + SpaceDb impl)。
592. features 业务层(类比 PR/issues usecase 模式,事务 + 现有错误语义):
60 - lookup.rs:`channel_for_post(svc, post_id)`(两级反查)+ `channel_for_post_board(svc, space_id)`
61 - posts(board):create/list board、post CRUD(create/update/close/reopen/archive/delete)
62 - 子资源:post_reply(create/list,维护 reply_count/last_reply_at/last_reply_by)、post_reaction(create/delete)、post_mention(list/create)、post_watch(upsert/get/delete)、post_label(board 级 CRUD + 单篇 bind/unbind)
633. handler 层:routes.rs(protected scope,inject_jwt_ctx::<PostService> + ratelimit)、每端点 handler(Path<(String, Uuid/i64)> + 三步反查 + `let _channel_id` 防 unused)、openapi.rs。
644. 装配:lib/webapi/services.rs 加 `pub posts: PostService` + `PostService::new(db.clone(), kv.clone(), jwt.clone(), storage.clone())`;router.rs `.merge(post::posts_router(services.posts))`。
655. 测试:tests/posts/ 401 集成测试 + openapi 反射测试。
666. openapi.json 重生成(`cargo build -p apihost && ./target/debug/apihost openapi -o openapi.json`)。
67
68## 5. 验收标准
69
70- `cargo build --workspace` 零警告
71- `cargo test -p post`(+ 相关包)全绿;401 集成测试真跑(redis 容器存活)
72- openapi.json:`/posts/{space_name}` 前缀路径存在;`channel_id` 计数 ≤60(新增 schema 字段按 wiki 豁免口径核对)
73- 提交至新分支 `stage-post`(基线 64fca87)
74
75## 6. 已确认决策(2026-08-09)
76
77- [x] board(collection)管理端点(重命名/改 icon/description)暂不做,schema 字段保留
78- [x] close/reopen 端点做(类比 PR);archive/lock 端点不做(可由 PATCH 覆盖)
79- [x] 整个 lib/post 一个 goal 执行(类比阶段 0 PR,规模相当)
Deleted wiki/refactor-channel-to-space.md wiki/refactor-channel-to-space.md +0 −564
@@ -1,564 +0,0 @@
1# 重构路线图:channel → space_name(最终版)
2
3> 本文档汇总所有架构决策,作为实施阶段的依据。**所有数值已与当前代码对齐核查**。
4>
5> **目标读者**:本路线图由 agent **按轮次执行**。每个阶段对应一个 agent 轮次,agent 读取对应章节作为任务清单,完成后核对"验收"清单作为完成标志。
6>
7> **轮次定义**:阶段 0-5 共 **6 轮**(推荐路径),阶段 6 是收尾轮。阶段 7-8 是可选远期轮次。
8
9---
10
11## 0. 核心原则
12
131. **channel 退化为内部位置映射层**,**不对外暴露**(URL/响应中不出现 channel_id)。
142. **channel 仍是空间内的物理位置容器**——业务资源通过 `channel_id` 挂在 channel 树的具体节点上。
153. **业务资源**(project / repo / issue / post / meet)**双归属**:`space_id`(对外)+ `channel_id`(内部位置)。
164. **URL 中使用 `space_name`**(小写,符合 `[a-z0-9-]+` 字符约束),不是 `space_id`。
175. **重命名后旧 URL 仍可用**:`space_name_history` 表做回退查询。
186. **二级子资源靠父资源聚合**,不独立挂 space。
197. **handler 入口做 active 成员校验**(细粒度权限由各模块原有 `authorize_*` 继续负责)。
208. **OpenAPI 反射与现状对齐**:补齐缺失的 project / pull_request / mcp。
219. **旧 URL 直接 404**,不保留过渡期。
2210. **不删除 `channel_id` 列**——它是资源在 space 内的物理位置,删除后资源变成"悬空"(没有归属节点)。
23
24---
25
26## 1. 业务层级模型(重构后)
27
28```text
29space (1) ── (n) channel ← space 内的层级容器(Directory/Object 树形结构)
30 │ channel_id 是资源在 space 内的**物理位置**
31 ├─ (n) project ← 双归属: space_id + channel_id
32 ├─ (n) repo ← 双归属: space_id + channel_id
33 │ ├─ pull_request / pr_comment / pr_review / pr_event_line ⚠️ 待阶段 0 补 HTTP
34 │ └─ branch / tag / commit / tree / blob / notes
35 ├─ (n) issue ← issues 模块聚合(issue 挂在 collection,collection 双归属)
36 │ ├─ issue_watch / issue_reaction / issue_label / issue_member
37 │ ├─ issue_pin / issue_attachment / issue_event
38 │ ├─ issue_dependency / issue_repo_rel / issue_stopwatch
39 │ ├─ issue_tracked_time / issue_content_history
40 │ └─ comment
41 ├─ (n) post ← DB schema 已有,业务层尚未实现
42 ├─ (n) meet ← DB schema 已有,业务层尚未实现
43 └─ (n) chat ← DB schema 已有;HTTP 层通过 chat/glayer WebSocket
44```
45
46> **channel 不是"位置映射的轻量引用",而是"空间内可命名的物理容器"**。
47> 与 Slack/Discord 类似:每个 space 有自己的 channel 树,repo / issue / meet 必须挂在某个 channel 节点上。
48>
49> **设计要点**:
50> 1. 每个一级模块(project/repo/issue/post/meet)下挂自己的子资源,子资源的归属由**父资源**决定——不需要为每个子资源独立加 `space_id` 列。
51> 2. **不删除 `channel_id` 列**——它是业务资源在 space 内的具体物理位置,没有它资源无法归类、无法在 channel 树中显示。
52> 3. URL 用 `space_name` + 业务 id;`channel_id` 仅作为内部 ID 用于业务 join 和权限 overwrites。
53
54---
55
56## 2. 数据模型改造
57
58### 2.1 当前 openapi.json 状态(实测基线)
59
60`/tmp/openapi_v2.json` 实测:**169 个 path**。
61
62| 模块 | 路由数 | 当前 path 数 | 备注 |
63|------|------|------|------|
64| auth | 16 | 16 | OK |
65| health | 2 | 2 | OK |
66| issues | 53 | 31 | 缺 22 个(`channels_id` 暴露在 path 中) |
67| repo | 71 | 48 | 缺 23 个 |
68| **spaces** | 35 | **19** | OK(grep "space" 失误误判为 0) |
69| topics | 1 | 1 | OK |
70| user | 50 | 50 | OK |
71| **project** | 30+ | **0** | `ProjectApiDoc::paths()` 空 |
72| **pull_request** | 14(业务层) | **0** | **HTTP 层完全缺失** |
73| **mcp** | (WebSocket) | **0** | 无 openapi.rs(chat 通过 WebSocket,不在 REST) |
74
75### 2.2 schema 影响表(已核查)
76
77#### 一级表(直接挂 space,改造目标)
78
79| 表 | 当前宿主 | 期望宿主 | 备注 |
80|------|------|------|------|
81| `repos` | `channel_id: Uuid` | `space_id: Uuid` | 强引用 channel |
82| `issues`(collection 表) | `channel_id: Uuid` | `space_id: Uuid` | 强引用 |
83| `posts` | `channel_id: Uuid` | `space_id: Uuid` | discussion(业务层尚未实现) |
84| `chat` | `channel_id: Uuid` | `space_id: Uuid` | 平级(业务层通过 chatlayer) |
85| `meets` | `channel_id: Uuid` + `room_name` | `space_id: Uuid` | 保留 `room_name` 作 LiveKit |
86
87#### 已合规(不动)
88
89| 表 | 当前 | 备注 |
90|------|------|------|
91| `project` | `space_id: Uuid` + `channel_id: Option<Uuid>` | **已合规** |
92| `pull_request` | `repo_id: Uuid` + `issue_id: Uuid` | 挂在 repo 下 |
93| `space_warn` | `space_id: Uuid` + `channel_id: Option<Uuid>` | **已合规(双键)** |
94
95#### 关联表(多对多连接表,`channel_id` 是冗余字段,不重构)
96
97| 表 | 备注 |
98|------|------|
99| `issue_label` | issue ↔ label 多对多 |
100| `post_labels` | post ↔ label 多对多 |
101
102#### 用户级作用域(特殊,不归 space)
103
104| 表 | 备注 |
105|------|------|
106| `user_saved_messages` | 用户收藏消息,`channel_id` 是历史遗留 |
107| `user_recent_mentions` | 用户被@提及历史 |
108| `user_scheduled_messages` | 定时消息 |
109
110> **这些用户级表的 `channel_id` 字段是"上下文引用"而非"宿主字段"**——重构时**不动**,只在跨 tenant 时可能需要重新审视(留待后续 PR)。
111
112#### 二级表(靠父资源聚合,不动)
113
114| 表 | 备注 |
115|------|------|
116| `pull_request_comment` / `pull_request_review` / `pull_request_event_line` | 父 = PR |
117| `issue_watch` / `issue_reaction` / `issue_member` / `issue_pin` / `issue_attachment` / `issue_event` / `issue_dependency` / `issue_repo_rel` / `issue_stopwatch` / `issue_tracked_time` / `issue_content_history` / `comment` | 父 = issue |
118| chat 子资源(`chat_messages` / `chat_message_edits` / `chat_pinned_messages`) | 父 = chat |
119
120#### 消息上下文(特殊,`channel_id` 是引用)
121
122| 表 | 备注 |
123|------|------|
124| `chat_message_mentions` | `@mention` 上下文,`channel_id: Option<Uuid>` 指向消息所在频道 |
125
126#### channel 内部(保留,不改)
127
128| 表 | 备注 |
129|------|------|
130| `channels` | 频道树根 |
131| `channel_overwrite_users` | channel 权限 |
132| `channel_overwrite_role` | channel 权限 |
133
134### 2.3 双归属模型(space_id + channel_id)
135
136**核心设计**:业务资源**同时**有 `space_id` 和 `channel_id`,二者**不是冗余**而是**不同维度**:
137
138| 字段 | 作用 | 对外可见 |
139|------|------|---------|
140| `space_id` | 资源属于哪个 space(顶层租户/组织) | URL 用 `space_name` |
141| `channel_id` | 资源在 space 内的具体位置(channel 树节点) | **不外露**(内部 ID) |
142
143**为什么不删除 channel_id**:
144
145> channel 是 space 内的**层级容器**(Directory/Object 树,类似 Slack/Discord 频道)。业务资源(repo / issue / post / meet)必须挂在某个具体 channel 节点上,才能:
146> - 在 channel 树中**显示**
147> - 接收 channel-level **overwrites**(权限)
148> - 拥有物理**路径**(被 group / 分类)
149>
150> **删了 channel_id = 资源变成"悬空"**:没有归属节点,无法分组、无法显示、无法应用 channel-level 权限。
151
152#### 一级表(双归属,最终目标 schema)
153
154| 表 | space_id | channel_id | 备注 |
155|------|----------|-----------|------|
156| `repos` | `Uuid`(新加) | `Uuid`(保留) | repo 在 space 内挂在某个 channel |
157| `issues`(collection 表) | `Uuid`(新加) | `Uuid`(保留) | issue collection 挂在 channel |
158| `posts` | `Uuid`(新加) | `Uuid`(保留) | discussion 挂在 channel |
159| `chat` | `Uuid`(新加) | `Uuid`(保留) | chat 会话挂在 channel |
160| `meets` | `Uuid`(新加) | `Uuid` + `room_name`(保留) | 视频会议挂在 channel |
161
162#### 已合规(双归属)
163
164| 表 | space_id | channel_id | 备注 |
165|------|----------|-----------|------|
166| `project` | `Uuid` ✅ | `Option<Uuid>` ✅ | 已合规 |
167| `space_warn` | `Uuid` ✅ | `Option<Uuid>` ✅ | 已合规 |
168
169#### 二级表(保持现状,靠父资源传递)
170
171| 表 | 宿主字段 | 备注 |
172|------|-----------|------|
173| `pull_request_*` | `pull_request_id` → `pull_requests.repo_id` | 父 = repo |
174| `issue_*`(子资源) | `issue_id` → `issues.id` | 父 = issue |
175| `post_*`(子资源) | `post_id` → `posts.id` | 父 = post |
176
177> 二级表的 channel_id 通过**父资源传递**得到:`(space_id, channel_id) = parent.(space_id, channel_id)`
178
179### 2.4 新增 `space_name_history` 表
180
181```sql
182-- lib/db/schema_name_history.sql
183CREATE TABLE IF NOT EXISTS space_name_history (
184 id UUID PRIMARY KEY,
185 space_id UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
186 lower_name TEXT NOT NULL UNIQUE,
187 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
188);
189CREATE INDEX IF NOT EXISTS idx_space_name_history_space
190 ON space_name_history (space_id);
191```
192
193**字符约束**(`spaces.lower_name`):仅允许 `[a-z0-9-]+`(创建/重命名时校验)。
194
195**唯一性策略**:`spaces.lower_name` + `space_name_history.lower_name` 双重 unique → 同一时刻一个 `lower_name` 只能指一个 space(含历史)。**Trade-off**:用过即永久占用,除非显式释放。
196
197### 2.5 重命名流程
198
199```text
200rename_space(space_id, new_name):
201 0. 校验 new_lower 匹配 [a-z0-9-]+
202 1. 在事务中:
203 INSERT space_name_history (new_lower, space_id)
204 UPDATE spaces.lower_name = new_lower_name
205 2. 旧 lower_name 保留在 history 中(已用过的不删)
206```
207
208> **事务 + 校验是必须的**:防止并发 rename 造成历史记录不连续,也防止大小写不一致。
209
210### 2.6 回退查询
211
212```rust
213// lib/space/features/lookup.rs
214pub async fn lookup_space_by_name(svc, lower_name) -> SpaceResult<Uuid> {
215 // 1. 先查 spaces(命中当前 name)
216 if let Some(space) = spaces::find_by_lower_name(lower_name).await? {
217 return Ok(space.id);
218 }
219 // 2. 回退查 space_name_history(命中旧 name,仍能解析)
220 if let Some(hist) = space_name_history::find_by_lower_name(lower_name).await? {
221 return Ok(hist.space_id);
222 }
223 Err(SpaceError::NotFound(format!("space {lower_name}")))
224}
225```
226
227### 2.7 dvcs 服务对 channel_id 的依赖
228
229dvcs(gitsmart / gitssh / gitauth / gitsync)**仍在使用 `repo.channel_id`**:
230
231```text
232dvcs/gitsmart/service.rs : resolved.repo.channel_id.to_string() (git 存储路径)
233 resolved.repo.channel_id (LFS 存储路径)
234dvcs/gitsync/* : repo.channel_id (post-receive 同步)
235dvcs/gitssh/handler/* : 通过 repo.channel_id 解析 SSH git 操作
236dvcs/gitauth/resolve.rs : 解析 repo → user permissions 时用到
237```
238
239> **影响**:本次 channel → space 重构后,dvcs 服务**仍需通过 repo.channel_id 访问**——channel_id 作为内部 ID继续存在。
240> 但 `repo.channel_id` 在阶段 6 双键 migration 之前**仍是必需字段**,阶段 6 之后才能视情况删。
241> dvcs 改动面估算:~6 文件,需要与 channel 同步迁移或并行工作。
242
243---
244
245## 3. URL 改造总表
246
247| 模块 | 旧 | 新 |
248|------|------|------|
249| repo | `/repo/{channel_id}` | `/repos/{space_name}` |
250| repo | `/repo/{channel_id}/{repo_id}` | `/repos/{space_name}/{repo_id}` |
251| issues | `/issues/{channel_id}` | `/issues/{space_name}` |
252| issues | `/issues/{channel_id}/{issue_id}` | `/issues/{space_name}/{issue_id}` |
253| project | `/spaces/{space_id}/projects/{project_id}` | **`/projects/{space_name}/{project_id}`** |
254| space 顶层 | `/spaces/{sid}` | **`/spaces/{space_name}`** |
255| post | (暂无 HTTP) | `/posts/{space_name}` |
256| meet | (暂无 HTTP) | `/meets/{space_name}` |
257| chat | (WebSocket `/api/v1/chat/ws`) | 不变(不在 REST 范畴) |
258
259> **路径扁平化**:把原本 `/spaces/{space_id}/projects/...` 改为 `/projects/{space_name}/...`,更可读。
260
261### URL 子资源自动覆盖
262
263- **repo** 改完,PR / branch / tag / commit 全部自动正确(URL 形如 `/repos/{space_name}/{repo_id}/prs/...`,父 URL 改对即可)
264- **issues** 改完,issue_watch / issue_reaction / comment 全部自动正确
265- **post** 改完,post_label / post_mention / post_reaction / post_reply / post_watch 全部自动正确
266- **chat** 子资源通过 WebSocket,与 REST URL 无关
267
268**真正的一级模块改造点**:`repo` / `issues` / `project` / `space 顶层`。
269
270---
271
272## 4. handler 内部流程(统一模板)
273
274```rust
275// 改前
276async fn handler(
277 State(svc): State<RepoService>,
278 AuthUser { claims }: AuthUser,
279 Path((channel_id, repo_id)): Path<(Uuid, Uuid)>,
280) -> RepoResult<...> {
281 svc.get_repo(claims.sub, channel_id, repo_id).await
282}
283
284// 改后
285async fn handler(
286 State(svc): State<RepoService>,
287 AuthUser { claims }: AuthUser,
288 Path((space_name, repo_id)): Path<(String, Uuid)>,
289) -> RepoResult<...> {
290 // 1. space_name → space_id(含 history 回退)
291 let space_id = space::lookup::lookup_space_by_name(svc, &space_name).await?;
292
293 // 2. 校验 caller 是该 space 的 active 成员
294 space::authz::require_active_member(svc, claims.sub, space_id).await?;
295
296 // 3. 内部反查 channel_id(用于 overwrites / 业务 join,**不外露**)
297 let channel_id = svc.lookup_repo_channel(repo_id).await?;
298
299 // 4. 业务调用(内部 usecase 签名保持不变)
300 svc.get_repo(claims.sub, channel_id, repo_id).await
301}
302```
303
304### 暴露面边界
305
306| 层 | channel_id | space_id | space_name |
307|------|----------------|----------|-----------|
308| URL path | ❌ | ❌ | ✅ |
309| OpenAPI parameters | ❌ | ❌ | ✅ |
310| handler `Path<>` | ❌ | ❌ | ✅ |
311| 内部 usecase 函数签名 | ✅ | ⚠️ | ❌ |
312| DB schema(最终目标) | 仅一级表保留(migration 加 `space_id` 列) | ✅ | 通过 `spaces.lower_name` + `space_name_history` |
313| 错误响应(客户端可见) | ❌ | ❌ | ✅("space {name} not found") |
314| 导出 view JSON | ❌ | 可选 | 可选 |
315| 内部 trace/log attribute | ✅ | ✅ | ✅ |
316| dvcs 服务(SSH/git 协议) | ✅(内部 ID) | ⚠️ | ❌ |
317
318---
319
320## 5. 阶段划分
321
322> 阶段 0-8 顺序编号。**阶段 0** 是推荐起点(前置 PR HTTP 补齐),阶段 8 是远期前端迁移。**已删除"删 channel_id 列"阶段**——channel_id 必须长期保留。
323
324### 阶段 0:(前置)补齐 PR 模块 HTTP 层(3 天)
325
326> PR 业务层完整可用但**完全没有 HTTP 端点**——是**最大缺口**。建议**先于 channel 重构**完成。
327
328**0.1 RepoService impl 层**(0.5d)
329- `lib/repo/features/pull_request/impls.rs`(新建):把 14 个 usecase 函数包装为 `RepoService` 方法
330- `lib/repo/features/pull_request/mod.rs`:添加 `pub use impls::*` 导出
331
332**0.2 HTTP handler 层**(1d)—— 参考 `lib/repo/handler/branch/`:
333
334```
335lib/repo/handler/pull_request/
336├── list.rs GET /repos/{space_name}/{repo_id}/pulls
337├── get.rs GET /repos/{space_name}/{repo_id}/pulls/{number}
338├── create.rs POST /repos/{space_name}/{repo_id}/pulls
339├── update.rs PATCH /repos/{space_name}/{repo_id}/pulls/{number}
340├── close.rs POST /repos/{space_name}/{repo_id}/pulls/{number}/close
341├── reopen.rs POST .../{number}/reopen
342├── lock.rs POST .../{number}/lock
343├── unlock.rs POST .../{number}/unlock
344├── merge.rs POST .../{number}/merge
345├── comment/{list,create}.rs
346├── review/{list,submit}.rs
347└── event.rs GET .../{number}/events
348```
349
350> **路径设计原则**:URL 用 `{space_name}`(如果先于阶段 2-5 实施,则用 `{channel_id}` 占位,后续阶段 2-5 改造时统一替换)。
351
352**0.3 路由注册**(0.5d)
353- `lib/repo/handler/routes.rs`:合并 PR 路由块到 protected
354- 复用 `inject_jwt_ctx::<RepoService>` 与 `ratelimit::<RepoService>`
355
356**0.4 OpenAPI 反射**(0.5d)
357- `lib/repo/handler/openapi.rs::ApiDoc` 的 `paths(...)` 加入 14 个 handler
358- `components(schemas(...))` 加入 PR 相关的 8 个 schema
359
360**0.5 测试**(0.5d)
361- `lib/repo/tests/pull_request/`:每个端点一个集成测试
362
363**验收**:
364- `cargo test -p repo` 全绿
365- `jq '[.paths | keys[] | select(contains("/pulls/"))] | length' openapi.json` ≥ 14
366- `curl /repos/{space_name}/{repo_id}/pulls` 返回 401/403 而非 panic
367
368### 阶段 1:基础设施 + 新表 + OpenAPI 修复(3 天)
369
370**1.1 新表与 schema**(0.5d)
371- 新增 `space_name_history` 表到 `lib/db/schema_name_history.sql`
372- 在 `lib/db/schema.rs::sync_schema` 之前执行该 SQL(参考 `schema_extras.sql` 模式)
373- 加 `[a-z0-9-]+` 字符约束校验到 `rename_space`
374
375**1.2 space 重命名逻辑**(0.5d)
376- `lib/space/features/spaces/usecase.rs::rename_space`:事务中插入 history + 更新 spaces
377- 单元测试:rename 后旧 URL 仍能解析;并发 rename 安全性
378
379**1.3 反查与权限 helper**(1d)
380- `lib/space/features/lookup.rs::lookup_space_by_name(svc, lower_name) -> SpaceResult<Uuid>`
381- `lib/space/features/authz.rs::require_active_member(svc, caller, space_id) -> SpaceResult<()>`
382- 4 个业务反查 helper:
383 - `lib/repo/features/lookup.rs::channel_for_repo(svc, repo_id)`
384 - `lib/issues/features/lookup.rs::channel_for_issue(svc, issue_id)`
385 - `lib/post/features/lookup.rs::channel_for_post(svc, post_id)`
386 - `lib/chat/features/lookup.rs::channel_for_chat(svc, chat_id)`
387
388**1.4 OpenAPI 修复**(1d)
389- **1.4.1** 补 `lib/project/handler/openapi.rs` 的 `paths()`(30 个 handler)和 `components(schemas(...))`
390- **1.4.2** 排查 `lib/space/handler/openapi.rs` 装配——实测 `SpaceApiDoc` 已正常生效(19 paths 已存在),无需改动
391- **1.4.3** 决定 `lib/mcp/` OpenAPI 策略:chat 是 WebSocket,不在 REST,**不需要 OpenAPI**
392- **1.4.4** 加 CI grep 检查:`openapi.json` 中 `channel_id` 字符串应不增长
393
394**验收**:
395- DB 有 `space_name_history` 表
396- `cargo test -p space -p repo -p issues -p project` 全绿
397- 重命名测试通过(旧 URL 仍能解析)
398- `jq '[.paths | keys[] | select(contains("projects"))] | length' openapi.json` ≥ 30
399
400### 阶段 2:repo 模块 URL 改造(3 天)
401
402- `lib/repo/handler/routes.rs`:71 URL 改造(注意:实测当前 openapi 是 48,缺 23 个未注册的 handler,可能源于 axum 0.8 wildcard 冲突导致部分删除了,需要核对)
403- 48 个 handler 文件改 `Path<(Uuid, Uuid)>` → `Path<(String, Uuid)>`
404- 加 `lookup_space_by_name` + `require_active_member` + `channel_for_repo` 三步
405- `utopa::path` 改路径(约 80 处)
406- `lib/repo/tests/` 测试更新
407- 重生成 `openapi.json` 验证 `channel_id` 减少
408
409**验收**:
410- `cargo build --workspace` 零警告
411- `cargo test -p repo` 全绿
412- `curl /repos/{space_name}/repos/{repo_id}` 返回 401/403 而非 panic
413- openapi.json 中 `channel_id` 字符串减少 ≥ 50
414
415### 阶段 3:issues 模块 URL 改造(3 天)
416
417同阶段 2 模板。实测当前 31 path,缺 22 个。
418
419### 阶段 4:project + space 顶层路径扁平化(1 天)
420
421- `lib/project/handler/routes.rs`:71 URL 改造 + 路径扁平化
422- `lib/space/handler/routes.rs`:顶层 `/spaces/{sid}` → `/spaces/{space_name}`(**注**:space 模块已经有 19 path 实测,要核对哪些用了 `{sid}`)
423
424### 阶段 5:dvcs 适配(1 天)
425
426- dvcs/gitsmart / gitssh / gitauth / gitsync 共 ~6 文件
427- 这些文件**仍依赖 `repo.channel_id`**——但通过新的反查 helper 取值即可,**不需要改 URL 形态**
428
429### 阶段 6:跨模块装配 + 收尾(1 天)
430
431- webapi / mcp / agent / livechat 顶层装配更新
432- 错误消息统一为 "forbidden: not a member of space {name}"
433- 全局 grep `openapi.json`:不出现 `channel_id`(除 schema 字段)
434- 更新 README / wiki
435
436- [x] 阶段 6 完成(2026-08-09):顶层装配无 channel_id 引用无需改动;错误消息统一为 "forbidden: not a member of space {name}"(lib/space authz+channels+overwrites、lib/repo authz、lib/issues authz 共 5 处,均保持 Forbidden 变体);openapi.json 60 处 channel_id 全部为 schema 字段(无 path 参数、无 operationId);仓库无 README,本 wiki 加完成标注
437
438### 阶段 7:(可选,远期)schema 双键 migration
439
440> 不在本路线图强制执行。**注意**:迁移目标**不是删除 channel_id**,而是**加上 space_id 形成双键**——channel_id 是资源在 space 内的物理位置,必须保留。
441
442- DB migration:5 张一级表(repos / issues / posts / chat / meets)加 `space_id: Uuid NOT NULL` 列
443- 应用层 INSERT 双写(从 `channel.space_id` 反推)
444- 读:`WHERE space_id = ? AND id = ?`,`channel_id` 保留作 channel 树定位与权限 overwrites
445- **完成后 channel 表 / channel_id 列均不再变动**
446
447### 阶段 8:(可选,远期)前端 + 第三方客户端迁移
448
449- 每个 app 单独排期(apphost / githook / gitpro / livechat / mails)
450
451---
452
453## 6. 工作量估算(已纠正)
454
455| 阶段 | 工作量 | 风险 | 依赖 |
456|------|------|------|------|
457| **0(PR HTTP 补齐,建议前置)** | **3d** | 中(新增模块) | 无 |
458| 1 (基础设施 + 新表 + OpenAPI) | 3d | 低 | 无 |
459| 2 (repo URL 改造) | 3d | 中 | 1 |
460| 3 (issues URL 改造) | 3d | 中 | 1 |
461| 4 (project + space 扁平化) | 1d | 低 | 1 |
462| 5 (dvcs 适配) | 1d | 中 | 2 |
463| 6 (跨模块装配 + 收尾) | 1d | 低 | 2,3,4,5 |
464| **合计(推荐路径)** | **15d ≈ 6 轮次(阶段 0-5 各 1 轮,阶段 6 收尾)** | | |
465| 7 (schema 加 space_id 列,**保留** channel_id) | 5d | 高 | 2,3 |
466| 8 (前端迁移) | 5d+ | 中 | 6 |
467
468> **轮次定义**:每个阶段 = 1 个 agent 轮次。阶段 0-5 共 6 轮。阶段 6 是收尾轮。
469> 阶段 7-8 是**可选远期**,按需启动新轮次。
470> **轮次依赖**:上游轮次必须完成,下游才能开始(详见 mermaid 图)。
471>
472> **执行节奏**:每轮次开始前,agent 读取"阶段 N"小节作为任务清单;完成后核对"验收"小节作为完成标志。
473
474---
475
476## 7. 风险与缓解
477
478| 风险 | 缓解 |
479|------|------|
480| 旧 URL 全部失效 | 客户端同步发版;服务返回 404 携带说明 |
481| ~300 文件改动面 | 每阶段独立 PR,每 PR < 1500 行 diff |
482| OpenAPI 拼装回归 | 阶段 1.4.4 加 CI grep 检查 |
483| 反查性能(业务 id → channel_id) | 主键查询,< 1ms |
484| **space_name 反查性能** | `spaces.lower_name` + `space_name_history.lower_name` 双重 unique index;< 1ms |
485| **重命名时唯一性冲突** | `space_name_history.lower_name` 全局 unique;rename 前预检查;事务保证 |
486| **并发 rename** | rename_space 加 advisory lock 或事务隔离级别 SERIALIZABLE |
487| **旧 URL 永久可用导致 name 永久占用** | 接受 trade-off;如需清理,加 `released_at` 字段 + 定期归档 |
488| **dvcs 依赖 channel_id** | 阶段 5 同步迁移;阶段 6/7/8 与 dvcs 团队协调 |
489| **lower_name 字符约束影响老数据** | 上线前 audit:现存 lower_name 是否都符合 `[a-z0-9-]+`;不合规的 rename 一次 |
490| **前端分享链接(重命名后旧 URL 走 history)** | 接受 trade-off;如需 301 重定向,单独排期 |
491| **权限回归** | 阶段 1 helper 单元测试 + 集成测试覆盖 happy + 跨 space 越权 |
492
493---
494
495## 8. 依赖图(mermaid)
496
497```mermaid
498flowchart LR
499 P0[阶段 0<br/>PR HTTP 补齐] --> P2[阶段 2<br/>repo URL]
500 P1[阶段 1<br/>基础设施 + OpenAPI] --> P2
501 P1 --> P3[阶段 3<br/>issues URL]
502 P1 --> P4[阶段 4<br/>project + space 扁平化]
503 P2 --> P5[阶段 5<br/>dvcs 适配]
504 P2 --> P6[阶段 6<br/>装配 + 收尾]
505 P3 --> P6
506 P4 --> P6
507 P5 --> P6
508
509 P2 --> P7[阶段 7<br/>schema 双键<br/>保留 channel_id]
510 P3 --> P7
511 P6 --> P8[阶段 8<br/>前端迁移]
512```
513
514---
515
516## 9. 不在本路线图内
517
518- ❌ **删除 `channel` 表 / `channel_id` 数据库列**——channel_id 是资源在 space 内的物理位置,必须长期保留
519- ❌ 改造 dvcs / githook / gitpro / gitsmart SSH Git 协议服务(阶段 5 适配性改动)
520- ❌ 改造前端 apps(阶段 8)
521- ❌ 性能优化(缓存、批量反查)
522- ❌ 301 重定向表(替代 history 表)
523- ❌ post / meet 业务层 + HTTP 层(独立排期,类比 PR)
524
525---
526
527## 10. 决策汇总(已确认)
528
529| 决策点 | 选择 | 原因 |
530|------|------|------|
531| channel 角色 | **空间内层级容器**(Directory/Object 双形态)+ 内部 ID | 不是简单的"位置映射"——业务资源必须在 channel 树中有具体位置 |
532| **不删除 channel_id 列** | **永久保留** | 资源需要 channel 树定位、权限 overwrites、UI 分组;删除后变"悬空" |
533| 双归属模型 | 业务资源同时有 `space_id`(对外)+ `channel_id`(内部) | 二者不是冗余,是不同维度 |
534| 业务归属层级 | space → project/repo/issue/post/meet/chat | 平级业务 |
535| PR 归属 | 挂在 repo 下 | 不直接挂 space |
536| issue 子资源 | issues 模块聚合,不独立挂 space | 父 issue 决定归属 |
537| post 子资源 | post 模块聚合,不独立挂 space | 父 post 决定归属 |
538| chat 形态 | WebSocket `/api/v1/chat/ws`,不在 REST OpenAPI | 实时通信 |
539| URL 中 space 标识 | `space_name`(小写 + `[a-z0-9-]+`) | 可读、跨租户调试友好 |
540| 路径扁平化 | `/spaces/{sid}/projects/...` → `/projects/{space_name}/...` | 更可读 |
541| 重命名兼容 | `space_name_history` 表 + 回退查询 | 旧 URL 永久可用 |
542| 重命名并发 | 事务隔离 | 防 history 不连续 |
543| 唯一性策略 | `spaces.lower_name` + `space_name_history.lower_name` 双重 unique | 防冲突 |
544| 权限校验粒度 | handler 入口只检查 active 成员资格 | 细粒度由各模块 `authorize_*` 处理 |
545| 旧 URL 处理 | 直接 404,不保留 | 客户端同步发版 |
546| OpenAPI 反射 | utoipa 反射,path 和 schemas 显式列出 | 阶段 1 补 project,阶段 0 补 PR |
547| dvcs 适配 | 阶段 5 同步;channel_id 保留为内部 ID | 渐进迁移 |
548
549---
550
551## 11. 评审清单
552
553> 实施前请确认:
554
555- [x] 业务层级模型是否符合预期
556- [x] **双归属模型(space_id + channel_id 长期保留)**是否同意
557- [x] URL 设计(`/repos/{space_name}/...` 形式 + 扁平化)是否同意
558- [x] `space_name_history` 表的设计 + 唯一性策略 + 字符约束是否接受
559- [x] 重命名永久占用 trade-off 是否接受
560- [x] 权限校验粒度(active 成员检查)是否够用
561- [x] 旧 URL 直接 404 是否同意
562- [x] 阶段编号 0-8 是否合理(PR 阶段 0 前置;已删除"删 channel_id"阶段)
563- [x] 工作量估算 15d / 6 轮次是否合理
564- [x] 文档/发布说明需要提前给客户端吗 → **不需要**(PR 描述中写明变更即可)
No newline at end of file