Skip to content
Clone

chore(docs): remove obsolete architecture documents

ZhenYi committed
parent 6a5c56d commit fc966c0 Browse files

2 files changed

+0 −1019

File tree 2
Deleted docs/业务架构.md
@@ -1,403 +0,0 @@
1# GitData 业务架构
2
3> 只读分析生成,2026-09-03。依据源码(`lib/`、`app/`、`config/`、`openapi.json`)反推业务语义,关键处标注 `文件:行`。
4> 技术实现见 [技术架构.md](./技术架构.md)。
5
6---
7
8## 1. 产品定位
9
10**GitData 是一个可自托管的多租户“研发 + 协作”一体化平台**,把三类通常分开采购的产品合成一套账号体系与一套权限模型:
11
12| 能力线 | 对标产品 | 承载 crate | 完成度 |
13|---|---|---|---|
14| Git 仓库托管(HTTP/SSH/LFS/保护规则/代码浏览与分析) | Gitea / GitLab | `lib/git` | 主流程完整 |
15| 团队即时通讯(频道/线程/DM/表情/附件/在场/提醒) | Slack / Discord | `lib/chat` | 主流程完整 |
16| 项目与工单(看板/标签/里程碑/依赖/工时/事件线) | Linear / Jira | `lib/issues` | 主流程完整 |
17| 组织与租户(空间/组/成员/目录树/覆写/邀请/封禁/加入规则) | 自研模型 | `lib/space` | 完整 |
18| 身份与账号安全(口令/邮箱验证/2FA/恢复码/PAT/SSH Key/PoW/注销) | 自研模型 | `lib/auth` `lib/user` | 完整 |
19
20三条产品线的独特组合点:**空间(Space)既是聊天 workspace,又是 Git 命名空间,又是看板容器,并且共用同一棵目录树做 ACL**。这是它区别于“Gitea + Slack 拼盘”的结构性特征。
21
22```
23GitData = 空间(租户) × [ 目录树(知识库/看板/仓库的统一起居与 ACL) ]
24 [ 聊天房间 ] [ Issue 看板 ] [ Git 仓库 + LFS + 流水线 ]
25```
26
27**产品名与主体的公开线索**:Git 依赖 `gitbaby` 私有 crate 托管在 `gitea.gitdata.me`(`Cargo.toml:96`),即该实例已在自建域名上自举托管自己的源码。
28
29### 目标用户与角色
30
31| 角色 | 来源 | 关注点 |
32|---|---|---|
33| 平台运营方 | `config.apihost.admin_uids` | 内部运维接口(refs 强改、pack/maintenance、pipeline report、license 写入) |
34| 空间 Owner | `spaces.owner_id` | 成员、组、目录、频道、仓库治理 |
35| 空间成员(经组授权) | `space_member_groups` + `space_group_permissions` | 日常协作 |
36| 仓库协作者 | `repo_collaboration.mode` | 代码读写与保护规则 |
37| 外部自动化 | Access Key(PAT)、Webhook inbound token | CI/机器人投递消息、脚本推送 |
38| 匿名访客 | 公开仓库读、公共空间图片 | 开源镜像/展示 |
39
40---
41
42## 2. 业务域地图
43
44```mermaid
45graph TD
46 subgraph 身份域
47 U[用户/邮箱/资料/偏好/设备/会话]
48 SEC[口令/2FA/恢复码/PAT/SSH Key/协议/注销]
49 end
50 subgraph 租户域
51 SP[空间 Space]
52 G[组 Group + 组权限]
53 M[成员/邀请/封禁/警告/加入规则]
54 D[目录树 Dir + 覆写 Overwrite]
55 end
56 subgraph 协作域
57 R[聊天房间 Room/消息/线程/DM/表情/附件/书签/草稿/提醒/在场]
58 I[看板 Board/工单 Issue/评论/标签/里程碑/依赖/工时/事件线]
59 end
60 subgraph 代码域
61 RE[仓库 Repo/分支/标签/提交/差异/LFS/保护规则/语言分析]
62 PL[流水线 Pipeline]
63 WH[Webhook · 保留]
64 end
65 subgraph 平台域
66 SY[系统设置/角色/权限/公告/任务/签名密钥 · 保留]
67 PD[产品与应用市场 Products · 保留]
68 PR[Pull Request · 仅数据模型]
69 end
70 U --> SP --> M --> G
71 SP --> D
72 D --> RE
73 D --> I
74 D --> R
75 U --> R
76 U --> I
77 U --> RE
78 RE --> PL
79 RE -.-> WH
80 I -.-> PR
81 SEC --> U
82```
83
84**域间唯一强耦合**:`dir` 目录树。`repos.dir_id`、`issues.dir_id`(看板)、`labels.dir_id` 都挂在树上(`lib/models/src/repo/repos.rs:11`、`issue/issues.rs:11`、`issue/issue_label.rs:11`),空间成员数与房间数是反规范缓存,Issue 与聊天各自独立写 `user_notifications`。
85
86---
87
88## 3. 核心业务对象
89
90### 3.1 对象关系(关键事实)
91
92```mermaid
93erDiagram
94 USERS ||--o{ SPACE_MEMBERS : "加入"
95 USERS ||--o{ SPACES : "拥有(owner_id)"
96 SPACES ||--o{ SPACE_GROUPS : "分组"
97 SPACE_GROUPS ||--o{ SPACE_GROUP_PERMISSIONS : "授予(字符串权限)"
98 SPACE_MEMBERS }o--o{ SPACE_GROUPS : "space_member_groups"
99 SPACES ||--o{ DIR : "目录树(parent 自引用)"
100 DIR ||--o{ DIR_OVERWRITE_GROUP : "allow/deny 掩码"
101 DIR ||--o{ DIR_OVERWRITE_USER : "allow/deny 掩码"
102 DIR ||--o{ REPOS : "仓库挂载"
103 DIR ||--o{ ISSUES : "看板挂载(issues 表=看板)"
104 DIR ||--o{ ROOMS : "聊天频道(space_id+lower_name 唯一)"
105 REPOS ||--o{ GIT_COMMIT : "同步产物"
106 REPOS ||--o{ GIT_BRANCH : "同步产物"
107 REPOS ||--o{ GIT_TAG : "同步产物"
108 REPOS ||--o{ GIT_LANGUAGE : "语言分析"
109 REPOS ||--o{ GIT_LFS : "大对象"
110 REPOS ||--o{ REPO_BRANCH_PROTECTION_POLICY : "保护"
111 REPOS ||--o{ PIPELINE_RUN : "流水线"
112 ISSUES ||--o{ ISSUE : "工单(看板内 autoincrement_id)"
113 ISSUE ||--o{ COMMENT : "评论(可自引用)"
114 ISSUE ||--o{ ISSUE_EVENT_LINE : "事件线"
115 ISSUE ||--o{ TRACKED_TIME : "工时"
116 ISSUE }o--o{ ISSUE_LABEL : "issue_relation_label"
117 ISSUE }o--o{ REPOS : "issue_relation_repo"
118 ISSUE ||--o{ ISSUE_DEPENDENCY : "依赖(无环检测)"
119 ROOMS ||--o{ CHAT_MESSAGE : "seq 有序"
120 CHAT_MESSAGE ||--o{ CHAT_THREAD : "线程"
121 CHAT_MESSAGE ||--o{ CHAT_REACTION : "表情"
122 CHAT_MESSAGE ||--o{ CHAT_ATTACHMENT : "附件(S3)"
123 ROOMS ||--o{ ROOM_MEMBERS : "role: Member/Moderator/Admin/Owner"
124```
125
126### 3.2 对象语义要点
127
128| 对象 | 业务语义 | 值得注意的建模决定 |
129|---|---|---|
130| `users` | 账号主体 | `bot`/`system` 标志区分机器人账号;`visibility ∈ {public, registered_only, members_only, private}`;`audience` 控制“谁能给我发私信/提及”;`prohibit_login`/`is_restricted`/`is_suspended` 三种管控开关并存 |
131| `spaces` | 租户/团队(Slack workspace 语义) | `owner_id` 单主;三套封面图(banner/invite_banner/embed_splash)+ 欢迎语,明显为“对外邀请落地页”设计;`member_must_enable_2fa` 是**唯一真正生效的空间级安全策略**(`space/src/services/members.rs:108-110`、`invites.rs:426-434`) |
132| `space_groups` | 组 = Discord role | 携带 `position/color/unicode_emoji/icon/version`(UI 语义齐全),权限通过 `space_group_permissions` 字符串清单授予 |
133| `dir` | 统一目录树(知识库/分组/仓库与看板的容器) | `kind ∈ {Dir, Object}`;**可不属于任何空间**(`space_id` 可空);纯邻接表,遍历在 Rust 侧做;`max_dir_nodes = 5000` 超限强制分层翻页 |
134| `rooms` | 聊天房间 | 一个 `RoomKind` 15 枚举同时容纳 Slack 式(Text/Thread/Category/DM/GroupDm)与 Discord 式(Voice/Stage/News/GuildRoom),并预留 `user_limit/bitrate/rate_limit_per_user`(语音视频未实现,字段先行) |
135| `issues`(表) | **看板/集合** | 表名复数是“集合”,单数表 `issue` 才是工单;状态由 `closed_at IS NOT NULL` 派生,**没有 status 列**,即无“进行中/待验证”等中间态 |
136| `repos` | 仓库 | 合并/变体策略字段齐备(`allow_rebase/allow_squash_merge/allow_merge_commits/allow_rebase_explicit`),但 **PR 未实现,故这些字段当前无消费场景**;`storage_limit_size` 预留配额 |
137| `products` | 应用/机器人/扩展 | `ProductKind{App,Bot,Extension}` + `price/price_currency/nums_installs/is_published` + `product_versions/installs/reviews` ⇒ **明确的插件市场 + 付费分发意图**;无任何服务层 |
138| `pull_request` | 合并请求 | 4 张表 + `issue_relation_pull`;**无 service、无 handler、无路由** |
139
140---
141
142## 4. 权限与治理模型(业务视角)
143
144### 4.1 四层授权栈
145
146| 层 | 授予方式 | 业务能力 |
147|---|---|---|
148| 平台 | 配置文件 `admin_uids` 白名单 | `/api/v1/internal/*`:refs 强改、patch apply、pack 重算、maintenance、pipeline report、license 写入、LFS 清理 |
149| 空间 | owner 全权;成员基线 3 项;组附加 | `view_space` `read` `message` `manage_members` `manage_roles` `manage_invites` `manage_bans` `manage_channels` `manage_overwrites`(`lib/space/src/share/authz.rs:14-22`) |
150| 目录 | 组/用户覆写的 allow/deny 位 | 目前只有 1 位:`PERM_DIR_VISIBLE = 1<<0`,即“这条目录(及其中的仓库/看板)对谁可见”;deny 优先于 allow,用户覆写优先于组覆写 |
151| 资源 | 仓库协作模式 / 房间角色 / 看板管理权 | `repo_collaboration.mode ∈ {Read,Write,Admin}`;`RoomMemberRole ∈ {Member,Moderator,Admin,Owner}`;看板复用空间 `manage_channels` |
152
153**业务规则**(源码核对):
154
155- 组权限是**纯并集**,无继承、无 deny;唯一的 deny 能力在目录可见性位。
156- 空间 owner 判定短路,`owner_id == uid` 直接得全 9 权限(跳过封禁检查——被自己的空间“封禁”在数据上不可能成立)。
157- 封禁即全禁:任何未过期 `space_bans` 行 → `Forbidden`;表里的 `allow_agent_join/allow_speak/allow_push_repo/allow_comment_any` 四个细分豁免位**从未被读取**。
158- “看不见的即不存在”:不可见的看板/房间返回 **404 而非 403**(`issues/src/share/lookup.rs:39-58`),避免通过状态码探测资源存在性。
159- 房间内 `role` 只用于管理动作(kick/ban 需 Moderator,改他人角色需 Admin,转让 owner 需恰为房主),普通读写仍走空间权限 + 私有房间成员判定。
160- 邀请提权防护:非 owner 不能把自己不具备的组授予他人(`space/src/services/invites.rs:162-176`)。
161- 加入路径只有三条:管理员直接加人、接受邀请、`space_join_rules` 的**表情反应自动入组**(在工单/评论上打指定 emoji 即触发入组,`issues/src/services/reactions.rs:117-127`)。配置里的 `allow_join/allow_invite/allow_join_level` 三字段目前是**纯展示**,没有自助加入接口。
162- `POST /spaces/{name}/groups/{id}/join`(自助入组)受 `allow_self_join_groups = false` 默认关闭 ⇒ 默认部署下该端点恒 403。
163
164### 4.2 治理与合规能力
165
166| 能力 | 实现 |
167|---|---|
168| 最低年龄 | 注册需 `date_of_birth`,`auth.min_age = 13`(`auth/src/services/register.rs`) |
169| 协议签署 | `auth.agreements`(terms/privacy,带 `key + version`)在注册时批量记录,`PUT /me/agreements/{key}` 增量同意;版本不符即拒;协议正文由配置提供 markdown 渲染(**无 CMS**);不强制阻断其他功能 |
170| 举报 | `POST /me/reports`,`ReportStatus{open,under_review,resolved,dismissed}`,仅举报人可列 |
171| 警告 | `space_warn` 表存在,**全库零读写** |
172| 停用/封禁 | 平台侧 `user_suspensions`(仅登录时校验)+ 空间侧 `space_bans` + 房间侧 `room_bans` |
173| 注销 | 14 天冷静期(`user.deletion_grace_days`),到期执行**脱敏**:用户名改 `deleted-{uuid 前 8}`、清空口令/TOTP、邮箱重写为 `{anon}@deleted.invalid`、吊销全部会话、删除 PAT/SSH Key/恢复码 |
174| 审计留痕 | 工单事件线(22 类,实际写 14 类)、内容历史快照(`issue_content_history`)、消息编辑历史(`chat_message_edit`)、登录设备与 IP 哈希(sha256 存储,非明文)、Webhook `last_used_at` |
175| 反滥用 | PoW 注册/登录/找回(默认 difficulty 16)、登录失败 5/300s、验证码尝试 5 次即作废、验证码速率限制、每日创建配额(下表) |
176
177### 4.3 配额(`[quota]`,24h 滚动窗口,0 = 关闭)
178
179| 动作 | 上限 |
180|---|---:|
181| 创建空间 | 10/日 |
182| 创建房间 | 50/日 |
183| 创建工单 | 300/日 |
184| 创建评论 | 1000/日 |
185| 发起私信 | 100/日 |
186| 创建 Webhook | 20/日 |
187
188另有零散细流控:房间邀请接受 10/60s、Webhook 投递 30/60s、邮箱验证码发送 60s 间隔、慢发言模式 `rooms.rate_limit_per_user`(0–3600 秒,空间管理员豁免)。
189
190---
191
192## 5. 关键业务流程
193
194### 5.1 注册 → 激活 → 登录
195
196```mermaid
197sequenceDiagram
198 participant C as 客户端(wasm PoW 求解)
199 participant A as apihost
200 participant R as Redis
201 participant D as Postgres
202 participant E as mails/SMTP
203 C->>A: GET /auth/pow/challenge
204 A->>A: nonce=uuidv7, difficulty, expires, HMAC(HKDF(mac_key))
205 A-->>C: Challenge(挑战绑定当前 Cookie)
206 C->>C: solve: SHA256("nonce:answer") 前导零位 ≥ difficulty
207 C->>A: POST /auth/register {email, password, pow_answer, dob, accept_agreements}
208 A->>R: 校验 PoW(单次使用,通过即删)
209 A->>D: [事务] users(is_active=false) + user_profile + user_emails(primary, 未激活)
210 A->>E: 发送 6 位验证码(失败仅记 warn,注册仍成功)
211 C->>A: POST /auth/email/verify {temp_id, code}
212 A->>R: 取明文码比对(尝试超限即作废)
213 A->>D: is_active=true(若是 reclaim 流程额外 passwd_version+1 踢全站会话)
214 C->>A: POST /auth/login → (needs_2fa?) POST /auth/login/2fa
215 A->>D: user_session 影子行(token_hash) + user_devices upsert + last_login_at
216 A-->>C: Set-Cookie gitdata_session(会话内含 uid/资料快照/pwd_version/token_hash)
217```
218
219业务口径:注册返回 `{user_id, pending:true, purpose, temp_id, expires_in_secs}`,即**未激活账号可存在但不可用**;未验证邮箱的用户无法登录(`is_active=false`)。找回密码/重发验证码对未知邮箱返回**伪成功**(随机 `temp_id`)以避免账号枚举(`auth/src/services/email.rs:273-279`)。
220
221### 5.2 建空间 → 拉人 → 建目录 → 开频道/仓库/看板
222
223```
224POST /spaces 配额 quota:space_create;名称须为小写 ASCII+数字+'-';
225 同事务写 owner + 一条 Active space_members
226POST /spaces/{s}/groups manage_roles → 建组 + 组权限清单(字符串)
227POST /spaces/{s}/members manage_members;member_must_enable_2fa 会拦下未开 2FA 的目标
228 → 提交后尽力补入该空间所有公开房间(跨事务,失败仅 warn)
229POST /spaces/{s}/invites manage_invites;必须至少绑定一个组;邮箱必须是目标已激活邮箱;
230 accept_code = 128bit;发信链接形如 GET 但接口是 POST(不一致)
231POST /spaces/invites/accept 凭 code 入组 + 建/复活成员行 + nums_members+1
232POST /spaces/{s}/dir manage_channels → 建 Dir 节点(kind=Dir),position=兄弟最大+1
233PUT /spaces/{s}/dir/{id}/overwrites/{groups|users}/{id} manage_overwrites → allow/deny 位
234POST /chat/spaces/{s}/rooms manage_channels + 配额 room_create → 建 rooms + 树内定位
235POST /repos/{space} 仓库挂载到 dir;建 bare 目录 repo_root/{repo_uuid}
236POST /issues/{s}/boards manage_channels + 配额 → 同事务写 issues(看板) + dir(Object)
237```
238
239### 5.3 push 的业务后果(平台最核心的自动化链路)
240
241```mermaid
242sequenceDiagram
243 participant Dev as 开发者(git)
244 participant GH as githost
245 participant DB as Postgres
246 participant RD as Redis
247 participant SH as synchost
248 Dev->>GH: git push (HTTP Basic=Access Key / SSH 公钥)
249 GH->>DB: 解析 ref 更新 → 分支/标签保护策略判定(应用层 pre-receive)
250 alt 命中拒绝规则
251 GH-->>Dev: remote: rejected(整推首个拒绝即中止)
252 else 通过
253 GH->>GH: 装捕获用 post-receive 钩子 + 执行 git receive-pack
254 GH->>RD: 响应流结束后 LPUSH gitdata:tasks
255 GH-->>Dev: ok
256 end
257 SH->>RD: BRPOP
258 SH->>DB: 单事务:新提交 / 分支 / 标签 / 语言 / 统计 / License
259 SH->>SH: git gc → git fsck
260 Note over SH: 失败重投 ≤5 次 → LPUSH gitdata:dlq(当前无人消费)
261 Note over SH: 完成后应发 Webhook,当前实现是 Noop(通知丢弃)
262```
263
264业务上的一次 push 会改变:提交列表与作者归因(按 `user_emails` 把邮箱映射回平台账号)、分支列表(消失的分支软删,受保护分支豁免,默认分支按 `main→master→trunk` 优先重排)、标签、主语言与语言构成、仓库大小/提交数/最后推送时间、License 识别。
265
266### 5.4 工单生命周期
267
268```
269创建: 配额 issue_create(300/日) → [事务] 看板行锁取号 autoincrement_id → issue + 事件线 Created
270 + 提及 materialize 为 issue_member(Mentioned) + user_notifications(Mention) + 提及邮件(同步 inline)
271更新: PATCH 三态语义(缺失=不变/null=清空/值=设置);标题变更进事件线,描述/正文/优先级/截止
272 仅进 issue_content_history(**不写事件线**)
273评论: 配额 comment_create(1000/日);支持 parent_id 子树;删除评论把子评论重挂到根
274 回复 → 通知被回复者(Reply);@提及 → Mention 通知 + 邮件
275成员: assignee → 通知(Assignment);issue_user.is_read 是独立的已读回执通道
276状态: POST .../close 与 .../reopen;bulk 支持 1..=100 个引用(UUID 或 #号),
277 不可见/无权限/同态的静默跳过,只回 {updated:n}
278删除: 手工 14 步级联(置顶/反应/评论/事件/成员/标签/PR/仓库/已读/关注/依赖双向/计时/工时/历史)
279时间: stopwatch 开始/停止 → 落一条 tracked_time(seconds);里程碑计数现算
280关联: issue↔repo 关联可用;issue↔PR 关联有表无接口
281```
282
283### 5.5 聊天消息链路
284
285```
286发送: 幂等 nonce(房间+作者作用域) → 慢发言模式检查 → [事务] chat_room_seq 取号 → chat_message
287 → 事件发布到 Redis chat:room/{uuid} → 订阅该房间的 WS 连接收到 event{room_id, type:message_published}
288 → @提及 额外写 chat:user/{uid}(mention)+ user_notifications + 邮件(尊重静音/通知级别,tokio::spawn 异步)
289 → @everyone 需管理权限;异步扇出到成员(尊重 suppress_everyone/静音/通知级别),编辑消息会重解析提及
290线程: 根消息 → chat_thread(root_message_id 唯一) + chat_thread_member(notify_level, last_read_seq)
291附件: POST /chat/attachments 拿 presign PUT(≤100MiB,危险 content-type 拒绝,
292 待处理附件 ≤200/人)→ 客户端直传 S3 → POST .../finalize(服务端 head() 取权威 size/type)
293 → send_message 时校验“属于本人 + 未绑定 + 已上传”的数量吻合后绑定 message_id/room_id
294 → 转发复制附件行(共享 S3 对象,删除时校验共享);worker 每小时清理过期孤儿附件与 S3 对象
295下载: GET /chat/attachments/{id}/file → 服务端流式代理 + nosniff + inline 白名单
296已读: PUT /chat/rooms/{id}/read-state 单调推进 → 触发 read_marker 用户事件
297在场: PUT /chat/presence → Redis EX 300 写穿 + chat_presence 落库;读时 Redis 优先,回落 DB 视为 Offline
298提醒: chat_message_reminder → apihost worker 到点投递(失败回退 Pending 重试)+ WS 用户事件
299链接卡片: 发送后异步抓取 http(s) 链接(≤3 条,1MiB/10s,拒绝私网地址)→ og:/title 元数据
300 → chat_link_unfurl 缓存(7 天) → 回填消息 embeds → 广播 message_edited
301Inbound Webhook: POST /chat/hooks/{token}(SHA-256(token) 查房 + 30/60s 限流)→ 以机器人身份落消息(可指定 thread_id)
302Outbound Webhook: 房间事件(message_published/edited/deleted)→ chat_webhook_delivery 队列
303 → worker 投递(HMAC-SHA256 签名、SSRF 防护、60s×attempts 退避、5 次后 Failed)
304DM: 1:1(dm:{lo}:{hi} 幂等键)与群 DM(≤10 人,成员集 SHA-256 幂等键)
305```
306
307### 5.6 代码浏览(前端如何拿到页面)
308
309后端不产整页。前端按需拉取**组件级 HTML 片段**并 `innerHTML` 注入:
310`GET /api/v1/repos/{space}/{repo}/html/{tree|blob-header|blob-content|readme|commits|commit-diff|blame|refs-summary|breadcrumbs|clone-buttons|star-button|watch-button|badge|quickstart|...}`(共 20 个)。
311README 走 `pulldown-cmark` 渲染,语言/徽章/最近提交/克隆地址等自带 Tailwind class。数据型浏览与聚合查询走 `/api/v1/repos/*` REST 与 `/api/v1/graphql`。
312
313---
314
315## 6. 业务能力清单(按对外 API 面)
316
317407 个 operation / 281 个 path(`openapi.json`)。
318
319| 域 | 能力 |
320|---|---|
321| **认证**(`/auth`,25) | PoW 挑战、注册、登录、2FA 登录交换、登出(幂等)、邮箱验证/重发/找回、改密、忘记密码/重置、改邮箱(票据三步)、协议清单与同意、2FA 开关与恢复码、Access Key CRUD+轮换、SSH Key CRUD |
322| **自助**(`/me`,28) | 资料/用户名/头像(上传-读-删)/偏好(主题、通知位标志、DM 与提及受众)、多邮箱与设主、会话列表与吊销、设备管理、通知与未读数、推送订阅、近期提及、收藏消息、表情包(meme)、举报、注销申请/查看/撤销、停权记录、我的空间 |
323| **用户公开面**(`/users`,5) | 列表/按 ID/按用户名/头像/公开主页详情(唯一公开端点) |
324| **空间**(`/spaces`,26) | 空间 CRUD、成员增删改查与自改、组 CRUD 与加入退出、目录树读写/排序/父级修复、组与用户覆写、邀请生成/接受/撤销、封禁与解禁、加入规则、四类空间图片(头像/横幅/邀请图/启动图) |
325| **聊天**(`/chat`,55) | 房间列表/建/改/删/加入/离开/成员管理/转让、邀请、消息收发/编辑/删除/批量删除/转发/置顶/历史、线程、反应、已读、DM(含群 DM)、附件、表情、书签、草稿、在场、搜索(FTS + CJK 子串回退)、提醒、链接卡片、房间入站/出站 Webhook、WS 与 ticket |
326| **工单**(`/issues`,39) | 看板 CRUD、工单 CRUD + 批量状态 + 关/重开、评论(含树)、标签与绑定、里程碑、反应、关注、成员(负责人/提及/参与者)、置顶、依赖与反向依赖、事件线、内容历史、计时器与工时、仓库关联、已读 |
327| **Git**(`/repos` 等,67 + `/graphql`) | 仓库 CRUD/归档、分支(建改删与默认分支)、标签、提交与状态、树/对象/路径历史、差异与补丁、blame、子模块、签名校验、冲突三方检测、远程导入/拉/推、LFS 锁与清理、Hooks 脚本执行、Pipelines 触发/排程/查询/上报/取消、语言与贡献者与活跃度分析、归档与 bundle、star/watch/topic、分支与标签保护白名单 |
328| **内部**(`/api/v1/internal/*`,不计文档) | refs 强列/强改/解析、提交同步、patch apply、pack 统计/入库/repack、maintenance prune/fsck、LFS 清理、内容原语写入、license 写入、pipeline cancel/report |
329
330---
331
332## 7. 对外契约与集成形态
333
334| 客户端 | 通道 | 鉴权 |
335|---|---|---|
336| Web 前端 | REST `/api/v1/*` + WS `/api/v1/chat/ws` + HTML 片段 + GraphQL | 会话 Cookie `gitdata_session` |
337| 桌面/移动 | 同上 + `POST /me/push-subscriptions` | Cookie |
338| `git` CLI | `/{space}/{repo}.git/*`(智能协议,含 LFS)、SSH:22 | **HTTP Basic = Access Key(`{key_uuid}.{secret}`,仅作用于仓库与 GraphQL);SSH 公钥** |
339| CI / 外部 Runner | `/api/v1/internal/*`(pipeline report/cancel、refs、pack、maintenance) | 平台管理员会话(当前仅此一条路) |
340| 聊天集成 | `POST /chat/hooks/{token}`(Slack Incoming Webhook 式) | 房间 token |
341| 第三方浏览器 | 默认禁止(CORS 空白名单) | — |
342
343集成上的现实约束(业务需知):
344- **仓库出站 Webhook 尚未接通**(`WebhookTx` 只有 Noop 实现),因此“push 触发外部 CI”目前只能靠外部轮询或自建消费 `gitdata:dlq/tasks`。
345- **PAT 作用域被文档限定在仓库端点**(`lib/webapi/src/openapi.rs` 的 Authentication 段),非 git 场景没有可用的服务账号令牌。
346- Deploy key 不可用(实体未编译),仓库级机器写入只能用个人 PAT。
347- Pull Request 无 API,代码合流目前只能靠“远程推 + 人工/脚本”或外部工具。
348
349---
350
351## 8. 商业与运营要素
352
353| 维度 | 现状 |
354|---|---|
355| 资源配额 | 仓库 `storage_limit_size`、LFS 尺寸与 `git.max_blob_size`(100 MiB)、聊天附件 100 MiB、头像/空间图 5 MiB、目录节点 5000、请求体 2 MiB、GraphQL complexity 1000、WS 帧 256 KiB |
356| 反滥用 | PoW + 每日配额 + 多维限流键;**读接口无限流**(对外服务需前置网关) |
357| 通知触达 | 站内 `user_notifications`(6 类:mention/reply/follow/invite/assignment/system)+ 邮件 + WS 用户频道 + Web Push(VAPID/aes128gcm,经 Redis `push:jobs` 队列由 apihost worker 投递到系统通知中心;在线用户跳过,`push_flags` 为禁用位掩码) |
358| 国际化 | `user.locale/language`、`email.default_locale`、`timezone` 字段齐备;渲染文本目前中文注释 + 英文消息,`render_and_send` 显式丢弃 `locale` |
359| 变现预留 | `products{kind,price,price_currency,is_published,nums_installs}` + `product_versions/installs/reviews` ⇒ 应用市场与分成模型的数据面;**无支付、无计费、无订阅实体** |
360| 多租户隔离 | 逻辑隔离(同库同行级 `space_id`/`dir_id` + 应用层 ACL),**无库级/Schema 级租户隔离**;对象存储为单桶扁平前缀 |
361| 自举 | 依赖 crate 托管在自己的实例域名上(`gitea.gitdata.me/GitDataAi/GitBaby.git`) |
362
363---
364
365## 9. 已实现 / 预留 / 未接通(路线图基线)
366
367**完整主流程**:账号与凭据体系、空间与组与成员、目录树与覆写、聊天全域(含 WS/附件/线程/搜索/提醒)、工单全域、仓库托管(HTTP+SSH+LFS+保护规则+分析+归档+浏览片段)、push 后同步、GraphQL 只读查询。
368
369**数据模型已就位、业务未接通**(按投入产出比可作为下一步):
370
371| 能力 | 缺什么 |
372|---|---|
373| Pull Request | `pull_request(_comment/_review/_event_line)` + `issue_relation_pull` 表齐;无 service/handler/路由,`repos` 的合并策略与 `nums_pr` 无消费方 |
374| 出站 Webhook | `webhook/repo_webhook/webhook_delivery` 三表齐;`WebhookTx` 只有 Noop;无投递 worker、无重试与签名 |
375| 应用市场 | `products` 四表齐(含定价/安装量/评价);无任何服务层 |
376| Deploy Key | `repo_deploy_keys.rs` 存在但未在 `repo/mod.rs` 声明(不参与编译);SSH 侧无接入 |
377| 流水线执行 | API 面完整(trigger/schedule/report/cancel/list)+ `pipeline_run` 状态机;**Runner 与触发编排不在本仓** |
378| 系统管理面 | `system_settings/roles/permissions/announcements/tasks/rate_limits/signing_keys/security_keys` 7 表零读写;WebAuthn/passkey 无实现 |
379| 用户关系 | 关注/粉丝、拉黑:有表有实体,**无 service/handler/路由**(定时消息功能已整体移除) |
380| 空间策略 | `allow_join/allow_invite/allow_join_level`、`space_warn`、封禁细分豁免位:字段在、行为无 |
381
382**已实现但与预期不符的细节**(业务侧需知晓):工单描述/正文/优先级/截止日变更不进事件线(只在内容历史);置顶与仓库关联不产生事件;工单邮件不发用户偏好过滤(聊天会过滤);注销不清理空间成员/仓库/聊天/头像对象;默认安装 `email.enabled = false` 下验证邮件不发;邮件内链接指向未注册的路由。
383
384---
385
386## 10. 术语表
387
388| 术语 | 含义 |
389|---|---|
390| Space(空间) | 租户/工作区,同时是 Git 命名空间与聊天 workspace;`lower_name` 全局唯一且即 URL 段 |
391| Group(组) | 空间内的角色组(Discord role 语义),承载一组字符串权限 |
392| Dir(目录) | 全平台统一的树形容器与 ACL 载体;`Dir` 为文件夹,`Object` 为叶子(仓库/看板挂于此) |
393| Overwrite(覆写) | 目录节点上的组/用户 allow-deny 位,现仅表达“可见性” |
394| Room(房间) | 聊天单元;有空间的房间是频道,无空间的是 DM/群 DM |
395| Thread(线程) | 挂在工作消息下的子会话,独立成员与已读游标 |
396| Board(看板) | `issues` 表行,工单集合,绑定一个 `Object` 目录节点 |
397| Issue | 工单;可按 UUID 或看板内编号 `autoincrement_id` 寻址 |
398| Event Line(事件线) | 工单时间轴记录(22 类事件) |
399| Repo | 仓库;bare 目录名即仓库 UUID,对象可共享 alternates |
400| Access Key | PAT 形态的 `{key_id}.{secret}`,用于 git HTTP 与 GraphQL |
401| Pipeline Run | 流水线运行记录(`Queued/Running/CancelRequested/...`),由外部 Runner 回报 |
402| Internal API | 平台管理员专属运维面,不在对外文档中 |
403| HTML Fragment API | 后端为前端提供的组件级 HTML 片段(非整页 SSR) |
Deleted docs/技术架构.md
@@ -1,616 +0,0 @@
1# GitData 技术架构
2
3> 只读分析生成,2026-09-03。所有结论均来自仓库源码,关键处标注 `文件:行`。
4> 业务语义见 [业务架构.md](./业务架构.md)。
5
6---
7
8## 1. 总览
9
10| 维度 | 结论 |
11|---|---|
12| 形态 | 纯后端多进程服务,**仓库内无任何前端代码**(无 npm/package.json/静态资源) |
13| 语言/工具链 | Rust 2024 edition,`resolver = "3"`,实测 `rustc 1.97.1`;无 `rust-toolchain.toml` |
14| 规模 | 21 个 workspace 成员(15 lib + 6 app),`lib/` 约 59.4k 行、`app/` 约 435 行 |
15| Web 框架 | axum 0.8.9 + tower/tower-http 0.5/0.7 |
16| 异步运行时 | tokio 1.53(multi-thread),优雅退出统一用 `tokio::select!` 监听 SIGINT/SIGTERM |
17| 持久层 | sea-orm 2.0.2 + sqlx-postgres + rustls;**实体优先 schema sync**(无版本号迁移) |
18| 缓存/消息 | redis 1.6(standalone/sentinel/cluster/valkey)、oxcache 0.4.3(moka L1 + redis L2) |
19| 对象存储 | rust-s3-async 0.35(兼容 MinIO/RustFS 等自定义 endpoint) |
20| Git 引擎 | `gitbaby`(私有 crate,`gitea.gitdata.me/GitDataAi/GitBaby.git@69bd99a`,55 文件/106 处引用)+ **系统 `git` 二进制** + `gix` 0.86(辅助) |
21| API 规范 | utoipa 5.5 + `utoipa-swagger-ui`,`openapi.json`(246 path / 355 operation)为生成产物并纳入仓库 |
22| GraphQL | async-graphql 7.0,**只读**(`EmptyMutation`/`EmptySubscription`) |
23| 可观测 | tracing + EnvFilter(各进程 inline 初始化);OTLP HTTP 指标推送 30s;无独立 scrape 端点 |
24| 邮件 | lettre 0.11(tokio1-rustls) |
25| 测试/注释策略 | `AGENTS.md`:非必要不写测试、注释、文档;全仓 17 处 `#[test]`/`#[cfg(test)]`,`unsafe` 2 处,`todo!()` 0 处 |
26
27### 外部依赖清单(运行时)
28
29```
30PostgreSQL ── 唯一事实源(业务数据 + git 元数据 + session 影子表)
31Redis ── 会话存储 / 配额限流 / 同步队列 / 同步锁 / WS fan-out / 邮件队列 / PoW & 缓存
32S3 兼容存储 ── 头像、空间图片、聊天附件、表情、LFS 对象
33系统 git ── upload-pack / receive-pack / fetch / gc / fsck / index-pack / rev-list
34外部 Runner ── pipeline 的 schedule/report/cancel 端点(内部路由)由外部执行器调用
35```
36
37---
38
39## 2. 工程结构与依赖方向
40
41### 2.1 workspace 分层
42
43```
44第 0 层(基础设施,无业务) config models pow-solver
45第 1 层(能力封装) storage email cache → export
46第 2 层(会话/身份) session
47第 3 层(业务域) space → auth user chat issues git webapi(装配)
48第 4 层(进程入口) app/apihost githost synchost mails migrate openapi
49```
50
51关键依赖事实(由各 `Cargo.toml` 核对):
52
53- `export` 名为“导出”,实际是**共享 HTTP + 基础设施层**:`AppState`、`AppDatabase`、`AppRedis`、`AppStorage`、`ExportError`、响应信封、分页、trace 中间件、health。
54- 依赖倒置点:`auth`、`user` 依赖 `space`(仅用于 `resolve_user_avatar_url` 与 tenancy 读取,`auth/Cargo.toml:9`、`user/Cargo.toml:30`),`space` 反向不依赖它们。
55- `cache` 只被 `lib/git` 与 `app/synchost` 使用;identity 层完全没碰。
56- `git` 依赖 `space`(目录树可见性)、`cache`、`session`;`chat`/`issues` 不依赖 `git`,二者通过 `models` 的 `dir`/`room` 共享基础设施。
57- 域 crate 均不依赖 `webapi`;`webapi` 是唯一“知道所有域”的装配 crate(`lib/webapi/src/lib.rs:44`)。
58
59```
60config ─┬─ storage ─┬─ export ─── session ─┬─ auth ─┐
61 ├─ email ──┘ ├─ user ─┼─ space
62 ├─ cache (仅 git/synchost) ├─ chat │
63 └─ models ─────────────────────────┴─ issues┤ git ── webapi ── apihost
64 pow-solver ─────┘
65```
66
67### 2.2 各 crate 体量
68
69| crate | 行数 | 文件 | 定位 |
70|---|---:|---:|---|
71| lib/git | 25,115 | 255 | Git 托管全域(传输/服务/GraphQL/同步/LFS/HTML 片段) |
72| lib/chat | 10,212 | 51 | 即时通讯(含 WS 网关与后台 worker) |
73| lib/issues | 8,667 | 107 | 看板与工单 |
74| lib/space | 7,144 | 71 | 租户、成员、组、目录树、封禁、邀请 |
75| lib/auth | 6,859 | 88 | 注册/登录/2FA/令牌/协议/注销 |
76| lib/models | 4,472 | 130 | 114 个 SeaORM 实体 + schema SQL |
77| lib/user | 2,993 | 48 | 个人资料、邮箱、头像、会话、推送 |
78| lib/email | 1,822 | 13 | SMTP + 模板 + 队列 + worker |
79| lib/config | 1,546 | 21 | 18 个配置段 |
80| lib/export | 1,054 | 9 | 共享状态/错误/中间件/分页 |
81| lib/session | 776 | 8 | axum_session Redis 适配 |
82| lib/storage | 589 | 3 | S3 客户端封装 |
83| lib/webapi | 257 | 3 | 路由装配 + OpenAPI + metrics |
84| lib/cache | 177 | 3 | oxcache 两级链 |
85| lib/pow-solver | 129 | 3 | SHA-256 PoW(含 wasm 导出) |
86
87### 2.3 域 crate 的统一目录约定
88
89```
90lib/<domain>/src/
91├── routes.rs 唯一路由注册点(Router<XService>)
92├── openapi.rs utoipa 文档注册(手写 path/schema 清单,可能与 routes.rs 漂移)
93├── errors.rs 域错误 enum(约 7~9 变体)→ ApiError(IntoResponses) → 信封
94├── handlers/ 传输层:只做提取 + require_uid + 调一个 service 方法 + 包信封
95│ (chat/user/auth 一文件一资源;issues/git 一文件一操作)
96├── services/ 领域层:DTO、Cmd/Query、校验、SQL、事务、事件发布
97│ └── service.rs DI 根:pub struct XService { pub state: AppState }
98├── share/ 横切:guard.rs(中间件) authz.rs(权限) lookup.rs(装载+校验) crypto.rs redis.rs
99└── 域特有 ws/ worker/ templates/ pow/ context/ smart/ ssh/ sync/ lfs/ graphql/
100```
101
102- 无 `FromRequestParts` 自定义提取器(identity 层为零);`git` 是唯一例外,有 `BasicAuth`(`lib/git/src/share/access.rs:20`)、`RepoPath`/`RepoPathRev`(`handler/share.rs:12-37`)。
103- `handlers/mod.rs` 是扁平 `pub mod` 列表且非字母序(`auth/handlers/mod.rs:1-48`)。
104- DTO 与 service 同文件(`chat/src/services/messages.rs:37` `MessageDto`、`:74` `SendMessageCmd`、`:153` `impl ChatService`)。
105
106---
107
108## 3. 进程拓扑与部署契约
109
1106 个 binary,全部 `#[tokio::main]`,各自 `config::AppConfig::load()` + `validate()`:
111
112| binary | 端口(默认) | 职责 | 依赖 |
113|---|---|---|---|
114| `apihost` | 3000(本地 4081) | 全部 REST + GraphQL + HTML 片段 + WS + Swagger(`/api/docs`);**并在进程内 spawn 两个后台循环**(`chat::worker::run_background_tasks`、`auth::worker::run_deletion_executor`,`app/apihost/src/main.rs:30-31`) | DB+Redis+S3+Email |
115| `githost` | 9418(smart HTTP)/ 22(SSH) | Git 传输专用进程:`smart::handler::router` + `russh` 服务 | DB+Redis+S3+Email |
116| `synchost` | — | push 后元数据同步 worker(消费 `gitdata:tasks`,`concurrency` 个消费者) | DB+Redis+Cache(**不建 AppState**,`app/synchost/src/main.rs:19-33`) |
117| `mails` | — | 邮件队列订阅者进程 | Redis+SMTP(无 DB/S3) |
118| `migrate` | — | 一次性 schema sync | DB |
119| `openapi` | — | 生成/校验 `openapi.json`(`--check` 为漂移门禁) | 无(纯类型) |
120
121### 3.1 部署拓扑
122
123```mermaid
124graph LR
125 U[浏览器/客户端] --> RP[反向代理<br/>同源 TLS 终结]
126 RP -->|"/* 含 /api/v1 /pages /graphql"| A[apihost :3000]
127 RP -->|"/{space}/{repo}.git/*"| G[githost :9418]
128 RP2[git over SSH] -->|":22"| G
129 A <--> DB[(PostgreSQL)]
130 G <--> DB
131 S[synchost] <--> DB
132 A <--> R[(Redis)]
133 G <--> R
134 S <--> R
135 M[mails] <--> R
136 A <--> S3[(S3 兼容存储)]
137 G <--> FS[/opt/gitdata/repos<br/>bare 仓库/]
138 S <--> FS
139 G -->|LPUSH gitdata:tasks| R
140 R -->|BRPOP| S
141 G -->|"spawn git"| GIT[(系统 git 二进制)]
142 S -->|"spawn git"| GIT
143 A -.OTLP 30s.-> OTEL[OTLP Collector]
144```
145
146**反向代理契约(必须满足)**
147
1481. `*.git` 结尾的路径必须路由到 `githost:9418`;`githost` 只挂了根路径 `/{space}/{repo}/{*path}`(`lib/git/src/smart/handler.rs:17-27`),且 `repo` 必须以 `.git` 结尾否则 400。
1492. `apihost` 默认 CORS 关闭(`web.cors_allow_origins = []`,`lib/webapi/src/lib.rs:14-42`),OpenAPI 明确写“期望同源反代”(`lib/webapi/src/openapi.rs:66-68`)。
1503. 客户端 IP 取 `x-forwarded-for` 首跳(`auth/handlers/login.rs:33-40`),故 `X-Forwarded-*` 必须可信。
1514. WS 握手依赖 `Origin` 与 `web_base_url`/`cors_allow_origins` 的 host 比对(`chat/src/ws/mod.rs:43-74`);无 `Origin` 头的请求被放行。
1525. SSH 与 HTTP 推送共用同一 Redis 队列 topic,但**锁 key 不同**(见 §16 第 5 条),跨协议不互斥。
153
154---
155
156## 4. HTTP 请求生命周期
157
158`lib/webapi/src/lib.rs:44-92`:
159
160```
161Request
162 │ AppTraceLayer 生成/复用 x-request-id(uuid v7)、生成 x-log-id;
163 │ 响应头回写,并对**已含 request_id+log_id 键**的 JSON 体回填(全量缓冲响应体)
164 │ metrics_layer axum-otel-metrics HttpMetricsLayer
165 │ TraceLayer(tower-http) INFO,include_headers(false)
166 │ RequestBodyLimitLayer 全局 2 MiB(MAX_REQUEST_BODY_BYTES)
167 │ SessionLayer axum_session + Redis 存储
168 │ CorsLayer 配置为空 ⇒ 拒绝所有跨域
169 ├─ nest("/api/v1") public: auth+chat+issues+space+user+git 六域 merge
170 │ └─ 各域 route_layer: auth_guard / chat_guard / issue_guard / space_guard / user_guard
171 │ 及子路由自带守卫(git handler/share.rs)
172 ├─ nest("/api/v1/internal") git internal_router + admin_guard(config.apihost.admin_uids)
173 ├─ nest("/pages") auth::routes::pages_router(邮件落地页,maud 渲染)
174 └─ merge health::router GET /healthz、GET /readyz(DB+Redis 探测)
175```
176
177- 层叠加顺序决定 `AppTraceLayer` 最外,CORS 最内(`lib/webapi/src/lib.rs:76-90`)。
178- **无 HTTP 层限流中间件**;限流全部在 service 内(`AppRedis::quota_allow` + `auth/share/rate.rs`)。
179- 子路由状态各自 `.with_state(x_service)` 捕获后 merge 成 `Router`;运行期 `State<XService>` 取到的就是捕获值(推导记录见 `.zcode/plans/plan-sess_4f703b21`)。
180- 单路由可收窄 body limit,但**全局 `RequestBodyLimitLayer` 仍然生效**:`/me/avatar` 用 `DefaultBodyLimit::max(6 MiB)`(`user/src/routes.rs:45`)实际拿不到 5 MiB 额度。
181
182### 4.1 响应与错误约定(`lib/export/src/rest.rs`)
183
184```rust
185pub struct AppResponseData<T> {
186 code: i32, message: String, data: Option<T>,
187 error: Option<String>, error_code: Option<String>,
188 request_id: String, log_id: String,
189}
190```
191
192- `code` **直接充当 HTTP status**(`impl IntoResponse`,`rest.rs:117-122`),非法值回落 500。
193- 构造器 `ok/empty/err/err_with_detail` + 链式 `with_code/with_message/with_error/with_error_code`。
194- `request_id`/`log_id` 不由 handler 填写,由 `AppTraceLayer::inject_ids` 事后改写(`middleware.rs:93-132`)。
195- 域错误模式统一(`auth/src/errors.rs` ≡ `space/src/errors.rs` ≡ `user`/`chat`/`issues`):
196 `From<DbErr>` 用字符串嗅探 `"unique"|"duplicate"|"23505"` → `Conflict`;
197 `#[derive(IntoResponses)] enum ApiError` 每个变体绑一个 status,使 utoipa 能输出真实响应体;
198 `From<(StatusCode, &'static str)>` **一律塌成 500** 并 `error!`(故意的绊线);
199 `Internal` 对外固定 `"internal server error"`,不外泄细节。
200 注意 `ApiError::InvalidCredentials` 落在 **400** 而非 401(`auth/errors.rs:106`)。
201- 分页(`export/src/pagination.rs`):`DEFAULT 50 / MAX 100 / MAX_OFFSET 5000`;`paginate()` 是**全量取回后内存 skip/take**(`:39-48`)。`space`/`issues` 用自己的 `max_pagination`/`default_pagination`,`sort` 字段解析后无人消费。
202
203---
204
205## 5. 数据层
206
207### 5.1 读写分离与 SQL 嗅探
208
209```rust
210pub struct AppDatabase { config, primary: DatabaseConnection,
211 replicas: Vec<DatabaseConnection>, round_robin: AtomicUsize }
212```
213(`lib/export/src/database.rs:32-37`)
214
215- `write()` → primary(`:89-91`);`read()` → 副本 round-robin,无副本则 primary(`:93-118`)。
216- 实现 `ConnectionTrait`:按语句首关键字路由(`is_write_sql:152-185`)。剥离 `--`/`/*` 注释后取首个标识符;**未知首词按写处理**;`WITH ...` 递归扫描深度 0 的 `INSERT/UPDATE/DELETE/MERGE`(自研括号/字符串感知扫描器 `:195-233`)。
217- 事务一律 `db.primary()`(auth/space 全部 `db.primary()` 或 `db.write().begin()`)。
218- 副本延迟会直接影响鉴权:`space::share::authz::effective_permissions` 固定走 `db.read()`(`lib/space/src/share/authz.rs:133-144`)。
219
220连接参数由 `config::database::DatabaseConfig` 构造(`lib/config/src/database.rs:201-260`):URL 手工拼装 + 自研 `url_encode`,`ssl_mode` 五档,`max/min_connections`、超时、`sqlx_logging`。
221
222### 5.2 实体与 schema 同步(无版本化迁移)
223
224```
225lib/models/src/<域>/<文件>.rs 114 个实体(97 张表),统一 #[sea_orm::model] + DeriveEntityModel
226lib/models/src/migrate/mod.rs sync_schema(db) = pre SQL → registry.sync(db) → extras SQL
227app/migrate 唯一调用方
228```
229
230- `entity-registry` + `schema-sync` feature(`lib/models/Cargo.toml`),启动前跑一次 `migrate` binary。
231- `schema_pre_sync.sql`:**先 DROP** 那些 sync 层无法表达的部分唯一索引,并对已存在表做 `ADD COLUMN IF NOT EXISTS`(sync 不支持带数据的 NOT NULL DEFAULT 追加)。
232- `schema_extras.sql`:sync 之后再补
233 - 部分唯一索引(多态目标 + WHERE 谓词,`idx_issue_watch_issue_user` 等);
234 - 复合索引;
235 - `DO $$ ... pg_constraint` 保护的 CHECK 约束(`comment_target_xor`、`issue_reaction_target_exactly_one`、`issue_watch_target_xor`、`issue_event_target_xor` …);
236 - **Postgres FTS**:给 `chat_message` 追加 `search_vector tsvector` + GIN 索引 + `BEFORE INSERT OR UPDATE OF content` 触发器(`schema_extras.sql:172-186`)。该列不在实体里,`chat::services::search::search_messages` 用裸 SQL 读写(`chat/src/services/search.rs:77-115`)。
237- 已知冲突已修复:`idx_chat_message_room_nonce` 现仅有一份定义 `(room_id, author_id, nonce)`(`schema_extras.sql`),与代码幂等预检一致(`chat/src/services/messages.rs`)。
238
239### 5.3 表的领域分布
240
241| 领域前缀 | 代表表 |
242|---|---|
243| identity | `users` `user_email` `user_profile` `user_preferences` `user_session` `user_devices` `user_access_key` `user_ssh_key` `user_recovery_codes` `user_agreement_acceptances` `user_email_verification_codes` `user_email_change_ticket` `user_deletion_requests` `user_suspensions` `user_reports` `user_notifications` `user_followers` `user_blocks` `user_push_subscription` `user_saved_message` `user_recent_mention` `user_favorite_meme` |
244| tenancy | `spaces` `space_members` `space_groups` `space_group_permissions` `space_member_groups` `space_invite` `space_invite_groups` `space_bans` `space_warn` `space_join_rule` |
245| 通用树 | `dir` `dir_overwrite_group` `dir_overwrite_user` |
246| git | `repos` `repo_collaboration` `repo_star` `repo_watch` `repo_topic` `repo_relation_topic` `repo_license` `repo_release` `repo_branch_protection_policy/whitelist` `repo_tag_protection_policy/whitelist` `pipeline_run` `git_branch` `git_commit` `git_commit_status` `git_tag` `git_language` `git_lfs` `git_lfs_lock` |
247| issue | `issues`(看板) `issue` `comment` `issue_label` `issue_relation_label` `issue_dependency` `issue_relation_repo` `issue_relation_pull` `issue_member` `issue_user` `issue_watch` `issue_pin` `issue_reaction` `issue_event_line` `issue_content_history` `milestone` `tracked_time` `stopwatch` |
248| chat | `rooms` `room_members` `room_invites` `room_bans` `chat_message` `chat_room_seq` `chat_thread(_member)` `chat_reaction` `chat_read_state` `chat_room_user_setting` `chat_attachment` `chat_emoji` `chat_room_bookmark` `chat_user_draft` `chat_room_webhook` `chat_outbound_webhook` `chat_webhook_delivery` `chat_link_unfurl` `chat_message_edit` `chat_message_reminder` `chat_presence` |
249| 保留 | `products` `product_versions` `product_installs` `product_reviews` `pull_request(_comment/_review/_event_line)` `webhook` `repo_webhook` `webhook_delivery` `system_settings` `system_roles` `system_permissions` `system_announcements` `system_tasks` `system_rate_limits` `system_signing_keys` `system_security_keys` |
250
251### 5.4 ID / 时间约定
252
253- 主键统一 `Uuid`(`auto_increment = false`),时间统一 `time::OffsetDateTime`(`with-time` feature)。
254- 任务/挑战 ID 用 `Uuid::now_v7`(时间有序):`sync/event.rs:44`、`login.rs:107`、`middleware.rs` 的 request-id。
255- 枚举统一 `#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]` + `num_value`;少数枚举用字符串常量(`AccessKeyScope`、`SshKeyType`、`SpaceImageKind.as_str()`)。
256- 计数列为反规范字段:`repos.nums_*`、`spaces.nums_members`、`milestone.nums_issues`(后者从不写,靠 GROUP BY 计算)。
257- 软删/归档靠布尔 + 时间戳对:`is_active`、`is_archived`/`archived_at`/`archived_by`、`deleted_at`、`left_at`、`revoked_at`、`is_deleted`。
258
259---
260
261## 6. 缓存、Redis 键空间、对象存储
262
263### 6.1 两级缓存(`lib/cache/src/tiered.rs`)
264
265```
266CacheClient { chain: Arc<ChainCache> } oxcache core feature
267L1 = MokaMemoryBackend(capacity, ttl?, tti?)
268L2 = RedisBackend(connection_string, mode, pool_size, timeout, db?)
269builder: ChainCache::link(l1).link(l2) [+enable_backfill] [+default_time_to_live]
270API: get::<V>/set::<V>(ttl)/delete/exists/get_bytes/set_bytes/raw()
271```
272仅 `lib/git` 与 `app/synchost` 构造;`git` 侧另有一层 read-through helper `cached_fetch(&ctx.cache, key, ttl, fut)`(`lib/git/src/service/cache.rs`),TTL 常量 `REV_TTL=60s`、`ANALYSIS_TTL=600s`、`IMMUTABLE_TTL=30d`。
273注意 `config/default.toml:21-23` 把 `l1_ttl_secs/l1_tti_secs/default_ttl_secs` 写成 `0` ⇒ figment 得到 `Some(0)`,出厂默认实际是**即时过期的写穿空转缓存**(`lib/config/src/cache.rs:17-26` 的 Rust 默认才是 `None`)。
274缓存键由 git 侧 `sanitize_key` 生成,形如 `repo.{id}.analysis_languages.v1.{revision}`。
275
276### 6.2 Redis 键空间总表(grep 汇总)
277
278| 前缀 | 用途 | 出处 |
279|---|---|---|
280| `session:{sid}` | 会话体(`table_name` 是键前缀,不是表) | `lib/session/src/store.rs:68-74` |
281| `auth:pwd_version` / `auth:token_hash:{uid}` / `auth:2fa` / `auth:code:{purpose}:{temp_id}` / `auth:code_rate:*` / `auth:code_attempts:*` / `auth:login_attempts:*` / `auth:2fa_attempts:*` | 身份与会话 | `auth/src/share/session.rs:7,10`、`services/email.rs`、`services/login.rs:17-20` |
282| `pow:challenge` | PoW 挑战(**存在 axum session 里**) | `auth/src/pow/store.rs:12,59` |
283| `quota:*`、`chat:invite_accept:*`、`chat:webhook:*` | 配额/限流固定窗口 | `export/src/redis.rs:144-187` |
284| `chat:room:{uuid}`、`chat:user:{uuid}` | WS pub/sub | `chat/src/share/realtime.rs:11-17` |
285| `chat:presence:{uid}`、`chat:ws_ticket_used:{jti}` | 在线态、票单去重 | `chat/src/services/presence.rs:33`、`ws/ticket.rs:88-114` |
286| `gitdata:tasks`、`gitdata:dlq` | push 后同步队列(Redis LIST) | `config/default.toml:197-198` |
287| `git:sync:lock:{repo_id}`、`push:{space}:{repo}`、`git_op:{path}`、`git:sync:attempts:*` | 同步锁 / 推送锁 / 重试计数 | `git/src/sync/lock.rs:9`、`smart/push.rs`、`sync/worker.rs:20` |
288| `email.send|batch|render|sent|failed` | 邮件 pub/sub 频道 | `email/src/events.rs:6-10` |
289
290基础原语集中在 `AppRedis`:`set_nx_ex`(`:116`)、`incr_window`(`:144`)、`quota_allow`(`:182`,`max<=0` 即关闭)、`connection_clone()` round-robin 分散到 `pool_size` 条多路复用连接(`:108-112`)、`cluster_master_connections`(`:194-257`,运行时 `CLUSTER SLOTS` 发现 master)。
291
292### 6.3 对象存储(`lib/storage/src/s3.rs`)
293
294- 单桶,扁平前缀命名空间;`endpoint` 非空时用 `Region::Custom`(自建 MinIO/RustFS)。
295- `put_stream` 是**有界内存 multipart**:`CHUNK_SIZE` 读块,首块短则单次 PUT,否则 initiate + **串行** part;所有失败路径 `abort_upload`(自吞错误,`:450-454`)。
296- `delete` 把 404 视为成功(`:254-265`);`head` 返回 `(size, content_type)`,是附件上传后的**权威值来源**。
297- presign 上限 `MAX_PRESIGN_EXPIRY_SECS = 604_800`(7 天);presign 全部是同步函数;工作区内唯一 presign 消费者是聊天上传。
298- DB 存的是间接引用 `storage://{key}`,出 DTO 时由 `resolve_user_avatar_url` / `resolve_space_image_url` 改写为**同源代理 URL** `/api/v1/users/{uid}/avatar`、`/api/v1/spaces/{name}/images/{kind}`,客户端从不拿到 S3 URL(头像/封面路径)。
299
300| 前缀 | key 形状 | 写入方 |
301|---|---|---|
302| `avatars/` | `avatars/{uid}.{jpg\|png\|webp\|gif}` + `{uid}.meta.json` | `user/src/services/avatar.rs:94,112` |
303| `space-images/` | `space-images/{space_id}/{kind}.{ext}` | `space/src/services/images.rs:108` |
304| `space-avatars/` | (死代码路径) | `space/src/services/avatar.rs:26,40` |
305| `chat/attachments/` | `chat/attachments/{uid}/{attachment_id}/{safe_name}` | `chat/src/services/attachments.rs:78-92` |
306| `chat/emojis/` | `chat/emojis/{space_id}/{id}.{ext}` | `chat/src/services/emojis.rs:106-156` |
307| `lfs/` | `lfs/{repo_id.simple()}/{oid}` | `git/src/lfs/object.rs:431` |
308
309---
310
311## 7. 身份、会话与令牌
312
313### 7.1 会话:Redis 存储 + DB 影子表
314
315```rust
316pub type Session = axum_session::Session<SessionRedisPool>; // lib/session/src/store.rs:9-12
317```
318- `SessionRedisPool` 实现 `axum_session::DatabasePool`(`store.rs:130-258`):`store` 用 `SET key val PX ttl_ms`(**相对 TTL**,兼容 Redis < 6.2,`:157-158`);`auto_handles_expiry() = true` ⇒ 由 Redis TTL 负责过期,axum_session 的 purge 循环不生效;`delete_all/count/get_ids` 用 `SCAN {table}:* COUNT 1000`(集群跨 master 合并、排序去重),`UNLINK` 批量 100(集群为 1 以免 CROSSSLOT)。
319- 会话内容键:`auth:uid`、`auth:users`(多账号)、`auth:pwd_version`、`auth:token_hash:{uid}`、`auth:2fa`、`pow:challenge`。**`AuthUser` 把 username/display_name/avatar_url/email 反规范化进 Cookie**,改资料要到下次登录才反映(`session/src/user.rs:10-17`)。
320- Cookie/生命周期参数全部由 `config::session::SessionConfig` 映射(`session/src/config.rs:10-91`);`MIN_KEY_LEN = 64` 字节(32 签名 + 32 加密),base64 解码后超长截断并告警。存在**两套 session 校验**:`config::session::SessionConfig::is_valid()`(较弱,`lib/config/src/session.rs:47-63`)与 `session::config::validate`(较严,`config.rs:94-178`),二者规则不一致且都会跑。
321- `axum_session` 的 `key-store` feature 已启用但**代码零引用**,密钥直接来自配置。
322- 影子表 `user_session` 只用于列表/吊销:登录生成 `token = random_token(32)`,`token_hash = sha256_b64url(token)` 写库并写进会话,**原始 token 从不返回客户端**(`auth/src/services/login.rs:228-290`)。
323- 中间件 `session_guard`(`session/src/share/guard.rs:30-64`):`uid` → `token_hash` → `lookup_session_with_user` → 七条件校验(`!is_revoked && user_id==uid && expires_at>now && is_active && !prohibit_login && !is_restricted && pwd_version_matches`),失败即 `logout_user` + 401。
324- `pwd_version` 是**全端强制登出**开关:改密/找回时 `passwd_version + 1`(`auth/src/services/email.rs:231-241`)。注意换邮箱、换用户名不递增。
325- `lookup_session_with_user` 在 auth/user/space 三处**逐字节重复实现**(`auth/services/mod.rs:71`、`user/services/service.rs:35`、`space/services/service.rs:56`),`.zcode/plans/plan-sess_c60d8a6f` 已记录该漂移(含 auth 版缺 `row.user_id == uid` 校验的历史 bug)。
326- handler 里的 `require_uid(&session)?` 只读 Cookie,不再复核 DB;唯一写活性的地方是 `GET /me` 的 `touch_session`(`auth/src/services/sessions.rs:56-70`)。`logout` 只 revoke DB 行 + 移除会话键,不销毁 Cookie(`services/logout.rs:14-25`)。
327
328### 7.2 凭据形态
329
330| 凭据 | 派生/存储 | 校验点 |
331|---|---|---|
332| 口令 | argon2 `Argon2::default()` PHC 串(随机盐内嵌) | `auth/src/share/crypto.rs:14,21`;强度仅按 `PasswordPolicy` 字符类校验(`validate_password:27-55`) |
333| TOTP 密钥 | AES-256-GCM,`base64(nonce‖ct)`;密钥 = `SHA-256(base64 字符串字节)` | `crypto.rs:96-141`(用**未解码的 base64 串**做密钥材料,与“≥32 解码字节”的配置校验口径不同) |
334| TOTP 算法 | 自实现 RFC 6238,**HMAC-SHA1**、30s、6 位、20 字节、`issuer = "Untitled"`;偏移 [0,-1,+1],`counter <= last_counter` 拒绝(防重放) | `auth/src/share/totp.rs:4,9,106-128` |
335| 恢复码 | `base32(3B)-base32(3B)`,10 个,只存 `sha256_b64url` | `services/two_factor.rs:15,51-57` |
336| Access Key (PAT) | `"{key_id_uuid}.{base64url(32B)}"`,库中 `token_hash = sha256_hex(secret‖salt)` + `token_last_eight`,`scope ∈ read\|write` | 创建/轮换在 auth(`services/access_keys.rs`);**实际校验在 git**(`share/access.rs:46-96`,额外管 `expires_at` 与 `last_used_at` 1 分钟节流);auth 自带的 `verify_access_token:226-257` 零调用 |
337| SSH Key | 仅 RSA / Ed25519 / ECDSA-P256;`SHA256` 指纹全局唯一 | `auth/src/services/ssh_keys.rs:30-89` |
338| 邮件验证码 | 明文进 Redis(校验真源)+ `sha256_b64url` 进 DB(镜像),尝试超限即删键 | `auth/src/services/email.rs:92-259` |
339| 重置/换邮箱票据 | 只存哈希,按哈希比对 | `services/email.rs:380-580` |
340| WS ticket | `base64url(payload).HMAC-SHA256` 两段式(**非 JWT**),`SET chat:ws_ticket_used:{jti} NX EX` 保证单次使用 | `chat/src/ws/ticket.rs:12-114` |
341| 注册挑战 PoW | HKDF-SHA256(ikm=`mac_key`, salt=nonce, info=`pow:v1\n`) → HMAC-SHA256 签 `difficulty‖expires_at`;先验 MAC 再验过期再验答案 | `auth/src/pow/mac.rs:9-25`、`pow/store.rs:63-94` |
342
343PoW 算法本体(`lib/pow-solver/src/challenge.rs`):`SHA-256("{nonce}:{answer}")` 前导零位 ≥ difficulty,答案从 0 线性扫描。默认 difficulty 16(≈6.5 万次哈希),上限 24(≈1670 万次),刻意做成浏览器可承受。`crate-type = ["rlib","cdylib"]` + `#[wasm_bindgen] solve/check`(答案以十进制**字符串**跨 JS 边界),但仓库内**没有任何 wasm 打包接线**(无 wasm-bindgen 步骤、无 package.json、无 `.wasm` 产物)。
344
345### 7.3 注册/登录流程
346
347```
348POST /auth/register : PoW → 口令策略 → 邮箱格式 → dob ≥ min_age(13) → 用户名(邮箱 local-part 去 +)
349 → 唯一性(users.lower_name / user_email.lower_email,冲突返回模糊 Conflict)
350 → [事务] user(is_active=false, passwd_version=1) + user_profiles + user_emails(primary)
351 → issue_code(Signup) + 发信(失败仅 warn)→ 记录已接受协议
352POST /auth/login : PoW → sha256_b64url(UA/IP) → 失败计数 auth:login_attempts:{id}(5/300s)
353 → 标识符含 @ 走邮箱否则 users.lower_name → verify_password
354 → 若 totp_enabled: 把 TwoFactorChallenge 写入 session,返回 {needs_2fa, challenge}
355POST /auth/login/2fa : challenge 串比对 + auth:2fa_attempts:{uid}(5/300s) + TOTP/恢复码
356finish_login : assert_not_suspended → record_device → create_session(DB 影子行)
357 → apply_login(session, AuthUser, passwd_version) → session[token_hash_key]
358```
359
360---
361
362## 8. Git 引擎
363
364### 8.1 三套 Git 设施如何分工
365
366| 设施 | 用在哪 |
367|---|---|
368| **系统 `git` 二进制** | smart HTTP 与 SSH 的整个数据面:`git upload-pack/receive-pack --stateless-rpc`(`smart/rpc.rs:65-83`);`fetch --mirror/--prune`、`gc`、`fsck`、`index-pack`、`repack`、`rev-list --count`(`service/gitcmd.rs`、`sync/fsck.rs`、`service/packs.rs`)。env 加固:`GIT_DIR`、`GIT_PROTOCOL=version=2`、`GIT_TERMINAL_PROMPT=0`、`GIT_CONFIG_NOSYSTEM=1`、`GIT_NO_REPLACE_OBJECTS=1` |
369| **`gitbaby`(私有 crate)** | 仓库级抽象:`GitBaby::new(Arc<GitContext>)`,`list_commits/list_branches/list_tags/walk_tree/find_tag/is_ancestor/set_default_branch`;`GitContext` 实现 `gitbaby::repo::RepositoryFacade`(`context/facade.rs:28-49`) |
370| **`gix`** | 仅 34 处:`service/content.rs`(9) 直写 blob/tree/commit 对象、`graphql/scalars.rs`(5) 的 `ObjectId` 标量、`graphql/query/context.rs`(8)、`context/facade.rs`(2) 的 `gix::open` |
371
372`GitContext.repo_dir() = repo_root/{repo_uuid}`(裸仓目录名 = 仓库 UUID),`git_alternate_object_directories() = [objects_root]`(`facade.rs:23-42`)——**共享对象池靠 git alternates 实现**。注意 `RepoSync::new` 把 `objects_root` 设成了 `repo_root`(`sync/sync.rs:57-63`),同步路径与传输路径看到的 alternates 不一致。
373
374### 8.2 smart HTTP(`lib/git/src/smart/`,仅 githost 进程)
375
376```
377/{space}/{repo}.git/info/refs?service=git-upload-pack → smart_advertisement (pktline '# service=…\n' + run_rpc_bytes)
378/{space}/{repo}.git/info/refs (无 service) → dumb_info_refs (直读 <bare>/info/refs)
379/{space}/{repo}.git/git-upload-pack POST → rpc(UploadPack)
380/{space}/{repo}.git/git-receive-pack POST → pre_receive 校验 → rpc(ReceivePack) → 流尾入队
381/{space}/{repo}.git/info/lfs/** → smart/lfs::dispatch
382/{space}/{repo}.git/objects/… · HEAD · objects/pack/* → smart/object::serve(哑协议只读,白名单 is_served_path,canonicalize 防穿越)
383```
384- 鉴权提取器:`BasicAuth`(只取密码位),无 Authorization 时 `token: None`。
385- 体量:`MAX_RPC_BODY = 512 MiB`(gzip 解压后二次校验,`smart/dispatch.rs:22,191`)、`MAX_LFS_BODY = 64 MiB`、stderr 上限 64 MiB、`git_rpc_timeout_secs` 默认 3600。
386- receive-pack 全流程(`smart/dispatch.rs:58-129`):授权 → 解析 ref 更新行 → **应用层 pre-receive** → `prepare_post_receive`(装捕获钩子 + 清残留)→ 流式执行 git → **响应流排空后**在流尾 `enqueue_post_receive_push` → 整个过程包在 `push_coordinator().run("push:{space}:{repo}")` 内。
387- `PushCoordinator`(`smart/push.rs`):`PushOperations` trait(acquire/renew/release/enqueue/dequeue),Redis 后端用 `SET NX EX` + Lua CAS 续约/释放,并在同一 key 上 `RPUSH/LREM` 做等待者 FIFO;TTL 120s、每 40s 续约、轮询 200ms。**`NoopPushBackend` 对 acquire 返回错误 ⇒ 没配 Redis 时推送是硬失败**(`push.rs:106`)。
388
389### 8.3 应用层 pre-receive 策略引擎(`smart/pre_receive.rs`)
390
391不是磁盘钩子:在 git 运行**之前**从入站 receive-pack 请求里解析 pktline(截断 NUL 丢弃 push-options),逐 ref 判定。
392
393```rust
394pub async fn validate_pre_receive(db, baby: &gitbaby::GitBaby, repo_id, actor_user_id, refs: &[RefUpdate])
395 -> SmartResult<PreReceiveVerdict> // :43
396```
397分支判定(`judge_branch:141`):第一条 globset 命中的 `repo_branch_protection_policy` 生效(只用 `matchers[0]`)→ 删受保护分支无条件拒;`!enable_push` 除非白名单放行;非快进(`is_fast_forward:231` 用 `baby.is_ancestor`)除 `enable_force_push` 或仓库管理员,白名单兜底。标签判定(`judge_tag:204`):命中 tag 保护策略则仅管理员/白名单。`refs/heads`、`refs/tags` 之外的 ref 无条件放行。OID 长度限 40/64(SHA-1/SHA-256,`repos.object_format` 默认 `sha1`)。
398
399磁盘上唯一的钩子是**捕获用 post-receive**(`sync/post_receive.rs:13-78`):`#!/bin/sh` 把 `old new ref` 追加到 `<bare>/_gitdata_post_receive`;worker 侧读取后即删。`ensure_post_receive_hook` 拒绝覆盖非本项目钩子。
400另有 `POST /{space}/{repo}/hooks/{script}`(`routes.rs:244`)可手动执行 bare 里的 pre-/post-receive 脚本,参数受限(≤16 个、单个 ≤1024 字符,`service/hooks.rs:66`);`service::hook_pre_receive` 本身是返回固定结果的桩。
401
402### 8.4 内置 SSH 服务(`lib/git/src/ssh/`,russh 0.63.1)
403
404- 与 smart HTTP 同进程(githost),默认 `0.0.0.0:22`。`GitSshServer { service, session_slots: Semaphore(max_sessions=64) }`,`server_id = "SSH-2.0-gitdata-<ver>"`,**仅 PublicKey** 方法,`max_auth_attempts = 10`,host key 由 `host_key_path` 的 OpenSSH 私钥加载。
405- 认证(`handler/auth.rs`):`auth_none`/`password`/`openssh_certificate` 一律拒绝;`find_user_by_public_key` 先按 `users.lower_name` 找活跃用户,再遍历其 `user_ssh_keys` 用 `ssh_key::PublicKey` 结构化比较(失败回退 trim 后字符串比较),拒绝 `is_restricted || prohibit_login`。
406- 命令派发(`handler/command.rs`):仅 `git-upload-pack` / `git-receive-pack` / `git-upload-archive`;`parse_repo_path` 严格字符集 `[a-z0-9._-/]`、长度 ≤512、拒绝 `.`/`..`/`/../`;`shell/pty/subsystem/env/window-change` 全部 no-op,shell 请求回一句 “git-only server”。
407- 数据面(`handler/channel.rs`):入站缓冲上限 64 MiB,真正的执行发生在 **`channel_eof`**,与 HTTP 路径共用 `validate_pre_receive` + `prepare_post_receive` + `run git --stateless-rpc` + `enqueue_post_receive_push`;输出上限 256 MiB,子进程 stderr 仅在失败时以 extended-data(1) 转发。
408- **Deploy key 未实现**:`models/src/repo/repo_deploy_keys.rs` 存在但**未在 `repo/mod.rs` 声明**(不参与编译),`lib/git/` 内 0 处 `deploy`;仅剩 `repo_branch_protection_policy.push_whitelist_deploy_keys` 列无人读。
409
410### 8.5 push 后同步(`lib/git/src/sync/`,synchost 进程)
411
412**队列是 Redis LIST 而非 Stream**:`LPUSH` 生产、`BRPOP` 消费(`sync/queue.rs:55,86`),每个消费者一条 `MultiplexedConnection` 且 `set_response_timeout(None)`(否则 BRPOP 被本地超时打断,历史故障见 `.zcode/plans/plan-sess_a760d3d4`)。
413
414```
415gitdata:tasks ← LPUSH PostReceiveTask{id, repo_id, channel_id=space_id, actor_id, refs[]}
416 │ BRPOP × concurrency(4)
417 ▼
418 SyncWorker → RepoSync::sync()
419 ├─ SET git:sync:lock:{repo_id} NX EX 120 + 心跳 ttl/3 续约;丢锁 ⇒ Err(Locked)
420 ├─ 单事务:
421 │ commits.new(≤5000, 跨分支去重) → branches upsert/软删(受保护分支豁免)
422 │ → commits 批 100 insert_many on-conflict-do-nothing + email→uid 解析
423 │ → tags upsert/硬删(≤10000, 附注标签补 tagger/message)
424 │ → languages walk_tree(30s 超时, ≤20 语言, basis-point 百分比) + primary_language
425 │ → stats dir_size / rev-list --count / last_pushed_at
426 │ → license 探测 LICENSE|LICENSE.md|LICENSE.txt|COPYING(≤64KiB) + SPDX 映射
427 └─ 事务外: git gc --prune=now → git fsck(stdout 含 error/broken 仅告警)
428 失败 → 计数键 git:sync:attempts:{id}(TTL 1h) > max_lock_retries(5) ? LPUSH gitdata:dlq
429 : sleep 5s → LPUSH gitdata:tasks
430```
431- `Locked` 与其他错误走同一重投路径;**DLQ 没有任何消费者**,也没有重放工具。
432- 完成后 `WebhookTx::dispatch(&task)`,而工作区内**唯一实现是 `NoopWebhookTx`**(打 warn 并丢弃,`sync/webhook.rs`),`app/synchost/src/main.rs:36` 装的正是它;`webhook`/`repo_webhook`/`webhook_delivery` 三张表零消费者。
433
434### 8.6 LFS(`lib/git/src/lfs/`)
435
436- 对象落 S3:`lfs/{repo_id.simple()}/{oid}`;OID 规范形 `sha256:<64hex>`(71 字符,裸 64hex 也接受),大小受 `git.max_blob_size`(默认 100 MiB)约束。
437- `batch` ≤200 对象,逐对象 `exists`;`actions`:下载 `presign_get(3600s)`、上传 `presign_put(3600s)` + `verify`;缺对象以 per-object `error{code:404}` 返回而非整体失败。
438- 上传 `upload_stream` 边写边算 SHA-256(`HashingReader`),事后与声明 oid/size 比对,不符则删除已存对象;`repos.lfs_size` 原子累加,`git_lfs` 行落地。
439- 锁是 **DB 行**(`git_lfs_lock`),非分布式锁;`unlock` 需 owner 或 force;`verify_locks` 分 ours/theirs;游标是上一页最后 `locked_at` 的 RFC3339 串。
440- `cleanup_unreferenced` 两趟:删行(对象已消失)+ 分页列桶删孤儿键。
441- `lfs::handle::router`(完整 LFS HTTP 路由,`lfs/handle.rs:24`)**从未挂载**,线上生效的是 `smart/lfs.rs`。
442
443### 8.7 GraphQL(`lib/git/src/graphql/`)
444
445```rust
446pub type GitSchema = Schema<Query, EmptyMutation, EmptySubscription>; // query/root.rs:13
447.limit_complexity(git.graphql_complexity_limit(默认 1000)).limit_depth(15)
448introspection 由 git.graphql_introspection 控制(default.toml 为 true,代码默认 false)
449```
450挂载在 **`POST/GET /api/v1/graphql`**(`routes.rs:105-111`),`GET` 仅执行 `{ __typename }` 作探活。鉴权在根字段 `repo(space_name, repo_name)` 一次性完成:私有仓库要求 session uid + `authorize_repo_access(..., false, false)`(`query/root.rs:52-61`),后续字段解析器不再校验。查询面(`query/repo.rs`):`info/license/topics/repo_stats/pack_stats/object/commit/blob/tree/tag/tags/ref_/refs/branches/log/diff/diff_short_stat/diff_files_changed/comparison/blame/languages/contributors/code_activity/submodules/conflict_check/patch_generate/commit_statuses/lfs_pointers`。游标是**十进制偏移量**(非不透明游标),`DEFAULT_FIRST 30 / MAX_FIRST 100`。写操作一律走 REST。
451
452### 8.8 HTML 片段(服务端渲染层)
453
454`/api/v1/repos/{space}/{repo}/html/{fragment}`(`routes.rs:303-306`),20 个 GET 片段:`clone-buttons, badge, quickstart, header, last-commit, refs-summary, refs-dropdown, tree, tree-row, breadcrumbs, blob-header, blob-content, readme, commits, commit-diff, blame, languages, star-button, watch-button`。
455
456- 返回 maud `Markup` 片段(`text/html; charset=utf-8`),**Tailwind class + `git-*` 前缀**,由前端 fetch 后 `innerHTML` 注入(设计意图见 `.zcode/plans/plan-sess_4c41ac80`、`plan-sess_90d9952d`)。
457- `response_html(markup, max_age)`:`max_age==0` 用 `no-store`,否则 `private, max-age=N`,恒发 `Vary: Cookie`。
458- 校验集中在 `html/mod.rs:166-351`:`clamp_limit`(≤1000)、`validate_path`(≤4096)、`validate_cursor`(严格 40 hex)、`validate_revision`(≤256)、`resolve`(读权限 + `HtmlCtx`)。
459- **鉴权提取器与其他 JSON handler 不同**:HTML 片段用 `BasicAuth`,JSON 用 `session: Session`。
460- `auth` 侧另有 `pages_router`:`/pages/email/verify`、`/pages/password/reset`、`/pages/agreements/{key}`(maud + pulldown-cmark 渲染协议正文)。
461
462---
463
464## 9. 实时通道(`lib/chat/src/ws/`)
465
466```
467客户端 ── POST /api/v1/chat/ws/ticket ── 拿到 base64url(payload).HMAC-SHA256(单次使用)
468 ── GET /api/v1/chat/ws Sec-WebSocket-Protocol: gitdata.ticket.<t> ──
469后端:消费 ticket(Redis SETNX) → 复核会话行+用户标志 → 升级(max_frame 256 KiB, 回选协议)
470 每房间一个 tokio::broadcast 转发任务;服务端事件源是 Redis SUBSCRIBE
471```
472- 客户端帧用 `op`、服务端帧用 `type`(snake_case):`subscribe/unsubscribe/resync/typing/ping` ⇄ `ready/event/user_event/history/gap/resync_required/error/pong/bye`。
473- 心跳 `heartbeat_secs`(30,下限 5) + `idle_limit = 3×心跳` → `bye:"idle timeout"`;**每 2 个 tick 复核一次所有已订阅房间的读权限**,被撤权即 `resync_required{reason:"access revoked"}`(`ws/mod.rs:318-342`)——权限撤销在 ~60s 内生效,而非仅在收帧时检查。
474- 背压:出站 `mpsc(1024)`,`best_effort_send` 失败重试 1000 次 ×1ms 后丢弃;seq 断档发 `gap{from,to}`,broadcast lag 发 `resync_required{reason:"lagged"}` 并清游标。
475- 订阅上限 64/连接;订阅时回填 `history_limit`(200) 条并播种子游标。
476- 房间事件枚举 `RoomEvent`(`share/realtime.rs:19-101`)11 种,其中只有 message/pin 5 种携带 seq;**user 频道事件是散写的匿名 JSON(5 种形状,无枚举无 schema)**。
477- 集群注意:`PUBLISH` 走完整 `AppRedis`(standalone 或 cluster),`SUBSCRIBE` 只连逗号列表的**第一个地址**(`realtime.rs:123-129`)。
478- 消息序列:`chat_room_seq` 单语句 UPSERT 取号(事务内,`share/notify.rs:14-30`);编辑/置顶/删除用 `current_room_seq` **只读不消耗**,复用房间 tip,故不污染未读数与排序;已读推进是 `WHERE last_read_seq < EXCLUDED` 的单调 upsert,返回 bool 决定是否广播 `read_marker`。
479
480---
481
482## 10. 权限体系(技术视角)
483
484三套彼此独立、互不复用的机制,加上平台管理员:
485
486| 机制 | 载体 | 判定入口 |
487|---|---|---|
488| 平台管理员 | `config.apihost.admin_uids: Vec<String>`(**字符串比较 Uuid**,`lib/config/src/apihost.rs:15-17`) | `auth::share::guard::admin_guard` → `/api/v1/internal/*` |
489| 空间权限 | 9 个字符串常量 + 组并集 | `space::share::authz::effective_permissions`(`authz.rs:76-131`) |
490| 目录可见性 | `dir_overwrite_{group,user}.allow_mask/deny_mask`,目前仅定义 `PERM_DIR_VISIBLE = 1<<0` | `space::share::dir_visibility::is_dir_visible`(沿 `parent_dir_id` 上溯 ≤64 层) |
491| 仓库 ACL | `repo_collaboration.mode ∈ {Read,Write,Admin}` + space owner | `git::share::access::authorize_repo_access`(`access.rs:174-221`,HTTP/SSH/LFS/GraphQL 共用) |
492| 房间 RBAC | `room_members.role: RoomMemberRole{Member,Moderator,Admin,Owner}` | `chat::services::room_admin::require_room_role`(`:56-69`) |
493
494空间权限解析顺序(`authz.rs:76-131`):
495`space.owner_id == uid` ⇒ 全 9 权限短路 → 活跃封禁 ⇒ `Forbidden` → 无 `space_members` 行或非 `Active` ⇒ `Forbidden` → 基线授予 `{view_space, read, message}` → 并集成员所在组的所有 `space_group_permissions.permission`。
496**组权限只增不减、无层级、无 deny**;deny 仅存在于目录层。邀请含提权防护:非 owner 不得授予自己不具备的组(`invites.rs:162-176`)。
497
498聊天读/写闸门收敛为两个函数(`chat/src/share/lookup.rs:120-167`):`require_room_readable`(房间活跃 → 未封禁 → 空间成员 → 私有房需成员或 owner → DM 需 `left_at IS NULL`)、`require_room_writable` = readable + `MESSAGE`。
499
500Issue 的读授权走目录可见性,**不可见一律 404 而非 403**(`issues/src/share/lookup.rs:28-61`);写/管理闸门是 `require_board_manage`(owner 或 `MANAGE_CHANNELS`)。
501
502`space.allow_join` / `allow_invite` / `allow_join_level` 三个字段**从不参与任何授权判定**(仅在 DTO 管道里搬运),也没有 `POST /spaces/{name}/join` 路由;成员来源只有 `add_member`、`accept_invite`、`apply_space_join_rules`(后者由 `lib/issues` 的表情反应触发调用,`issues/src/services/reactions.rs:117-127`)。
503
504---
505
506## 11. 异步任务与后台循环
507
508| 循环 | 所在进程 | 周期 | 职责 |
509|---|---|---|---|
510| `chat::worker::run_background_tasks` | **apihost** | 30s tick,每 120 tick(1h) 清理提及与孤儿附件 | 到期提醒(≤200)、出站 Webhook 投递(≤50)、剪除 90 天前提及、清理过期未用附件;卡死状态 `Delivering → Pending`(10 分钟) |
511| `auth::worker::run_deletion_executor` | **apihost** | 3600s | 到期注销执行(脱敏而非删行) |
512| `SyncWorker` | synchost | BRPOP 阻塞 | push 后元数据同步(§8.5) |
513| `email subscribers` | mails | 常驻 | 订阅 `email.send/batch/render` |
514
515两个 apihost 内的循环都依赖 **DB 原子状态抢占**(`update_many ... WHERE status = Pending`)保证多副本正确性,而非分布式锁。
516出站 Webhook 投递失败按 `60s × attempts` 退避重试,5 次后置 `Failed`;请求带 `X-Gitdata-Signature`(HMAC-SHA256)并拒绝私网/环回地址。
517
518**邮件队列是 Redis Pub/Sub,不是 Stream**(`lib/email/src/queue.rs`):`subscribe` 收到的 `group_id` 立即 `let _ = group_id;` 丢弃(`:122`),配置里的 `subscriber_group_id` 无效。后果:无持久化、无 ack、无消费组、无死信、无重放;**多实例是扇出复制而非竞争消费 ⇒ 水平扩容会重复发信**。重试是进程内包裹 handler(`run_with_retries:267-311`,超过 `max_retries` 直接丢弃)。集群 URL 时 pub/sub 只连第一个节点,该节点故障 ⇒ 投递静默停止。
519TLS 模式仅按端口推断(465 隐式 TLS / 587 STARTTLS / 其他 `builder_dangerous()` 明文,`email/src/transport.rs:74-104`);`response_timeout_ms` 从未应用;凭据存在时强制 `Mechanism::Plain`。
520`email.enabled = false`(默认)时 `EmailService::build()` 返回 `transport: None` 的空转实例,所有调用方只 `warn!` ⇒ **默认安装下验证邮件静默不发**。
521模板是**编译期 maud 函数**(注册名 `verify_code`/`reset_link`/`email_change_confirm`),`config.email.template_dir` 读入后立刻被丢弃(`email/src/runtime.rs:27`)。
522
523---
524
525## 12. 配置体系
526
527```
528dotenvy::dotenv()
529Figment: Toml(config/default.toml)[存在才读]
530 → Toml(config/local.toml)[存在才读]
531 → Env::prefixed("APP_").split("__") ⇒ APP_SESSION__KEY 等
532precedence: APP_* env > local.toml > default.toml > Rust Default
533```
534(`lib/config/src/loader.rs:16-31`;`load_from_env()` 为跳过文件的第二入口)
535
536- 18 个段:`s3 redis cache chat database session pow auth web user quota space issues git email email_queue apihost githost`。
537- `AppConfig` derive 了 `Debug` 且字段无脱敏(`lib/config/src/lib.rs:44`),任何 `{:?}` 都会带出 SMTP/DB/S3/会话密钥。
538- `validate()` 链:`redis → database → pow → auth → cache → chat → email → email_queue → session → apihost → githost`;**`s3/space/user/web/quota/issues/git` 顶层未接入**。正常启动会调用三次 validate(`loader.rs:29`、`export/lib.rs:37-39`、`apihost/main.rs:15`)。
539- `pow.mac_key` 强制(base64 ≥32B)、`session.key` ≥64B、`totp_encryption_key` 若配置则 base64 ≥32B。
540- `config/local.toml`、`.env` 均已 gitignore,但**磁盘上存在含真实密钥的 local.toml**(部署前需轮换/迁出)。
541
542---
543
544## 13. 可观测性与健康
545
546- 日志:`tracing-subscriber` + `EnvFilter`(默认 `info`),各 binary 内联 `init_tracing()`;无 JSON encoder 配置,无结构化字段规范。
547- 关联:`x-request-id`(可复用入站值,≤128)与 `x-log-id`(恒新生,uuid v7)进扩展、回响应头,并对**已含这两个键**的 JSON 响应体改写(会缓冲全部 JSON 响应)。
548- 指标:`axum-otel-metrics` 的 `HttpMetricsLayer`,`PeriodicReader` OTLP/HTTP 推送,间隔 30s,`Temporality::default()`;无自定义业务指标、无 scrape 端点(`lib/webapi/src/metrics.rs`)。
549- 健康:`GET /healthz` 恒 200;`GET /readyz` 做 `SELECT 1` + Redis `PING`,失败 503(`lib/export/src/health.rs`)。
550- 审计:`issue_event_line`(22 种事件类型,实际写入 14 种)、`chat_message_edit`、`issue_content_history` 属业务审计,非系统日志。
551
552---
553
554## 14. API 面与契约工程
555
556| 前缀 | path 数 | 域 |
557|---|---:|---|
558| `/repos` | 64 | 仓库 REST + 20 个 HTML 片段 + pipelines + LFS 锁 |
559| `/chat` | 55 | 57 条路由注册(房间/消息/线程/表情/附件/提醒/草稿/书签/在场/搜索/入站与出站 Webhook/WS) |
560| `/issues` | 39 | 看板/工单/评论/标签/里程碑/工时/依赖/关联 |
561| `/me` | 28 | 当前用户自助 |
562| `/spaces` | 26 | 租户/成员/组/目录/覆写/邀请/封禁/加入规则 |
563| `/auth` | 25 | 认证与安全 |
564| `/users` | 5 | 用户公开面 |
565| `/topics` `/licenses` `/graphql` | 3/2/1 | Git 辅助与图查询 |
566
567`lib/webapi/src/openapi.rs:7-91` 内嵌 84 行 `CONVENTIONS` 说明文,是**事实上的对外规范**(信封、鉴权范围、kebab-case 路径、offset/cursor 双分页、`Option<Option<T>>` 三态 PATCH、2FA challenge、配额 429、幂等 `nonce`、viewer 状态内嵌、Issue 可用 UUID 或看板内编号、二进制用裸 body)。
568
569工程化:`app/openapi` 在 **256 MiB 栈线程**上生成 `openapi.json`(递归类型导致栈深问题,`app/openapi/src/main.rs:7-15`),`--check` 模式供 CI 做漂移门禁;`apihost` 在 `/api/docs` 挂 Swagger UI。仓库内**没有任何 CI 配置、Dockerfile、部署清单**,`openapi.json` 与源码同步只能靠本地执行。
570
571---
572
573## 15. 横切约定(值得沉淀的“规矩”)
574
5751. **不写注释与测试是明确策略**(`AGENTS.md`),仅极少数关键处留注释(如 `ws/mod.rs:353-359` 解释转发任务 join 顺序、`export/database.rs` 解释 SQL 嗅探)。
5762. **零 panic 取向**:全仓 34 处 `.unwrap()`、11 处 `.expect()`,集中在启动路径与常量解析(例:`ChatService::new` 里 `Realtime::new(...).expect("chat realtime client")`)。
5773. **事务边界显式**:写多表的路径统一 `db.write().begin()` 或 `db.primary()` 取事务(注册、建空间、发消息、同步、级联删 issue)。跨事务的“尽力而为”动作用 `warn!` 吞掉(加入空间后补房间成员、发信失败、`last_used_at` 更新)。
5784. **错误折叠**:唯一键冲突 → `Conflict`,外键/查询失败 → `Internal`,不对外暴露 SQL 文本。
5795. **幂等与去重**:消息 `nonce`(房间作用域)、PoW 单次使用、WS ticket 单次使用、LFS 上传短路、`insert_many ... on_conflict(do_nothing)`、reaction 主键冲突当 upsert。
5806. **输入面收紧**:路径穿越(canonicalize + starts_with)、控制字符转 `?`、文件名白名单、危险 content-type 降级为 `application/octet-stream` + `x-content-type-options: nosniff`、`inline` 仅白名单类型(`image/* video/* audio/* text/plain text/markdown application/pdf`)、远程 URL 走 `validate_remote_url`。
5817. **恒定时间比较**用于口令哈希、PoW MAC、WS ticket、access key(`constant_time_eq`,多处重复实现)。
5828. **配额模式统一**:`redis.quota_allow("quota:<action>:{caller}", config.quota.*_per_day, 86_400)` → `XxxError::RateLimited` → 429 + `error_code: rate_limited`;`0` 表示关闭。
583
584---
585
586## 16. 技术债与风险清单(源码核对,按影响排序)
587
588| # | 事实 | 位置 | 影响 |
589|---|---|---|---|
590| 1 | 邮件队列是 Redis Pub/Sub,`subscriber_group_id` 被丢弃 | `lib/email/src/queue.rs:111-183` | 无持久化/ack;多实例重复发信;集群单点订阅静默失效 |
591| 2 | 后台循环寄生在 apihost | `app/apihost/src/main.rs:30-31` | 无法独立扩缩与隔离故障,正确性依赖 DB 原子抢占 |
592| 3 | 仓库 Webhook 三张表 + `WebhookTx` 只有 Noop 实现 | `lib/git/src/sync/webhook.rs`、`app/synchost/src/main.rs:36` | push 事件无法通知外部 |
593| 4 | 无 HTTP 层限流;`system_rate_limits` 表零读写 | `lib/webapi`、`models/src/system/system_rate_limits.rs` | 只有创建类配额,读接口与登录外的写接口无限流 |
594| 5 | HTTP 推送锁 `push:{space}:{repo}` 与 SSH 锁 `git_op:{path}` 不同键 | `smart/dispatch.rs:124`、`ssh/handler/channel.rs` | 两协议并发推同一仓库不互斥 |
595| 6 | `repo_deploy_keys` 未在 `repo/mod.rs` 声明,不参与编译 | `lib/models/src/repo/mod.rs` | Deploy key 功能不存在,白名单列成悬空字段 |
596| 7 | `pull_request` 4 张表无任何服务/路由;`products` 4 张同样 | grep 全库 | PR 与“应用市场”仅数据模型;`issue_relation_pull` 是死路 |
597| 8 | `verify_access_token`(auth)与 `verify_access_key`(git)两套 PAT 校验,前者零调用 | `auth/src/services/access_keys.rs:226`、`git/src/share/access.rs:46` | 过期/作用域语义只在 git 侧生效 |
598| 9 | 邮件内链接与注册路由不匹配(`/auth/verify-email` 等,实际只有 `/pages/*`),落地页还要求邮件不带 `code` | `auth/src/services/email.rs:600-610`、`handlers/verify_email_page.rs:13-18`、`email/templates/*` | 邮件点击链路 404 |
599| 10 | 目录可见性有两份实现且兜底不同(`true` vs `false`) | `space/src/services/dir.rs:155-161`、`share/dir_visibility.rs:40` | 越权可见风险不一致 |
600| 11 | 大量“有字段无行为”:`space.allow_join/allow_invite/allow_join_level`、`space_warn`、`ban.allow_*` 四列、`milestone.nums_*`、`label.exclusive`、`space_members.suppress_roles/mobile_push` | 各文件 | 配置与产品预期不符 |
601| 12 | `context()` 按 `repos.lower_name` 全表取回后线性上溯目录找空间,且每次调用新建 `CacheClient` | `git/src/service/context.rs:15` | 仓库数增长后的热路径放大 |
602| 13 | `is_dir_visible` 单次检查加载整空间节点 + 两张覆写表 | `share/dir_visibility.rs:66-77` | 目录规模大时放大 |
603| 14 | 默认 `cache.l1_ttl_secs=0 / default_ttl_secs=0` | `config/default.toml:21-23` | 出厂缓存近似空转 |
604| 15 | `paginate()` 内存分页;`/me/avatar` 的 6 MiB 被外层 2 MiB 截断 | `export/src/pagination.rs:39-48`、`user/src/routes.rs:45`、`webapi/src/lib.rs:12` | 深翻成本、头像尺寸 |
605| 17 | 注销脱敏的 11 步为逐条语句、非事务;不级联清理空间/仓库/聊天/S3 | `auth/src/services/deletion.rs:20-128` | 半完成状态与残留数据 |
606| 18 | Issue 号段靠 board 行 `lock_exclusive()` 串行 `max+1`,非 Postgres sequence | `issues/src/services/issues.rs:405-415` | 高并发建单串行化 |
607| 19 | Issue 写邮件同步阻塞请求(mentions/reply/assignment 三处 inline),且不查用户偏好 | `issues/src/services/{issues,comments,members}.rs` | SMTP 慢直接拖高 RT;无免打扰 |
608| 20 | 大量复制粘贴未收敛:`RedisConnection` dispatch 宏 5 份、`percent_encode` 3 份、`is_unique_violation` 3 份、`crypto.rs`/`redis.rs` auth 与 user 近似重复、`lookup_session_with_user` 3 份逐字节相同 | Appendix A 各点 | 漂移风险(`openapi.rs` 与 `routes.rs` 同理) |
609| 21 | `AppTraceLayer` 缓冲所有 JSON 响应体只为回填两个字段 | `export/src/middleware.rs:93-132` | 大列表响应额外一份内存拷贝 |
610| 22 | 无 CI/容器化/部署清单;`openapi.json` 漂移无自动门禁;`config/local.toml` 含真实密钥在盘 | 仓库根 | 发布与密钥治理 |
611
612---
613
614## 附:与旧实现的差异提醒
615
616`schema_extras.sql:5-7` 注释提到“历史上在 `lib/db/schema.rs`,该 db crate 已移除,恢复时需自行应用本文件”;`issue_*` 系列表的 `project_id` 列与若干索引是“project 模块移除后保留的悬空列”(`issue_event_line.rs:74-75` 等中文注释)。读源码时不要把这些当活跃功能。