@kernel-sig/console 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/.env.example +50 -0
  2. package/LICENSE +127 -0
  3. package/Makefile +66 -0
  4. package/README.md +279 -0
  5. package/backend/.dockerignore +12 -0
  6. package/backend/Dockerfile +43 -0
  7. package/backend/alembic/env.py +75 -0
  8. package/backend/alembic/script.py.mako +25 -0
  9. package/backend/alembic/versions/0001_initial.py +88 -0
  10. package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
  11. package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
  12. package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
  13. package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
  14. package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
  15. package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
  16. package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
  17. package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
  18. package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
  19. package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
  20. package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
  21. package/backend/alembic.ini +40 -0
  22. package/backend/app/__init__.py +0 -0
  23. package/backend/app/api/__init__.py +0 -0
  24. package/backend/app/api/deps.py +74 -0
  25. package/backend/app/api/v1/__init__.py +0 -0
  26. package/backend/app/api/v1/ai.py +386 -0
  27. package/backend/app/api/v1/analytics.py +48 -0
  28. package/backend/app/api/v1/attention.py +142 -0
  29. package/backend/app/api/v1/auth.py +106 -0
  30. package/backend/app/api/v1/classification.py +192 -0
  31. package/backend/app/api/v1/health.py +29 -0
  32. package/backend/app/api/v1/issues.py +143 -0
  33. package/backend/app/api/v1/pulls.py +388 -0
  34. package/backend/app/api/v1/repositories.py +273 -0
  35. package/backend/app/api/v1/router.py +32 -0
  36. package/backend/app/api/v1/sig.py +565 -0
  37. package/backend/app/api/v1/users.py +87 -0
  38. package/backend/app/api/v1/webhooks.py +135 -0
  39. package/backend/app/core/__init__.py +0 -0
  40. package/backend/app/core/config.py +72 -0
  41. package/backend/app/core/crypto.py +74 -0
  42. package/backend/app/core/db.py +79 -0
  43. package/backend/app/core/exceptions.py +60 -0
  44. package/backend/app/core/logging.py +69 -0
  45. package/backend/app/core/permissions.py +87 -0
  46. package/backend/app/core/queue.py +57 -0
  47. package/backend/app/core/security.py +69 -0
  48. package/backend/app/domain/__init__.py +0 -0
  49. package/backend/app/domain/attention.py +618 -0
  50. package/backend/app/domain/classification.py +689 -0
  51. package/backend/app/domain/meeting.py +408 -0
  52. package/backend/app/domain/release.py +163 -0
  53. package/backend/app/domain/review.py +416 -0
  54. package/backend/app/domain/sig.py +178 -0
  55. package/backend/app/domain/sig_info.py +201 -0
  56. package/backend/app/integrations/__init__.py +0 -0
  57. package/backend/app/integrations/atomgit/__init__.py +0 -0
  58. package/backend/app/integrations/atomgit/client.py +505 -0
  59. package/backend/app/integrations/atomgit/models.py +311 -0
  60. package/backend/app/integrations/llm/__init__.py +0 -0
  61. package/backend/app/integrations/llm/prompts.py +222 -0
  62. package/backend/app/integrations/llm/provider.py +326 -0
  63. package/backend/app/main.py +242 -0
  64. package/backend/app/middleware/__init__.py +0 -0
  65. package/backend/app/middleware/audit.py +129 -0
  66. package/backend/app/middleware/request_context.py +42 -0
  67. package/backend/app/models/__init__.py +112 -0
  68. package/backend/app/models/ai.py +214 -0
  69. package/backend/app/models/attention.py +131 -0
  70. package/backend/app/models/attention_settings.py +61 -0
  71. package/backend/app/models/audit.py +53 -0
  72. package/backend/app/models/base.py +35 -0
  73. package/backend/app/models/classification.py +144 -0
  74. package/backend/app/models/credential.py +56 -0
  75. package/backend/app/models/issue.py +132 -0
  76. package/backend/app/models/meeting.py +189 -0
  77. package/backend/app/models/pull_request.py +288 -0
  78. package/backend/app/models/repository.py +196 -0
  79. package/backend/app/models/sig.py +194 -0
  80. package/backend/app/models/user.py +44 -0
  81. package/backend/app/schemas/__init__.py +0 -0
  82. package/backend/app/schemas/ai.py +120 -0
  83. package/backend/app/schemas/attention.py +43 -0
  84. package/backend/app/schemas/auth.py +26 -0
  85. package/backend/app/schemas/classification.py +58 -0
  86. package/backend/app/schemas/common.py +43 -0
  87. package/backend/app/schemas/pull_request.py +214 -0
  88. package/backend/app/schemas/repository.py +94 -0
  89. package/backend/app/schemas/sig.py +246 -0
  90. package/backend/app/schemas/user.py +79 -0
  91. package/backend/app/services/__init__.py +0 -0
  92. package/backend/app/services/ai_service.py +569 -0
  93. package/backend/app/services/analytics_service.py +390 -0
  94. package/backend/app/services/attention_queue.py +451 -0
  95. package/backend/app/services/attention_service.py +432 -0
  96. package/backend/app/services/auth_service.py +74 -0
  97. package/backend/app/services/classification_service.py +658 -0
  98. package/backend/app/services/credential_service.py +105 -0
  99. package/backend/app/services/pull_query.py +273 -0
  100. package/backend/app/services/release_service.py +446 -0
  101. package/backend/app/services/repository_service.py +132 -0
  102. package/backend/app/services/sig_service.py +385 -0
  103. package/backend/app/services/sync_service.py +752 -0
  104. package/backend/app/services/user_service.py +68 -0
  105. package/backend/app/worker.py +389 -0
  106. package/backend/entrypoint.sh +10 -0
  107. package/backend/pyproject.toml +68 -0
  108. package/backend/tests/test_analysis_api.py +154 -0
  109. package/backend/tests/test_atomgit_client.py +360 -0
  110. package/backend/tests/test_atomgit_models.py +257 -0
  111. package/backend/tests/test_attention.py +291 -0
  112. package/backend/tests/test_audit_middleware.py +135 -0
  113. package/backend/tests/test_classification.py +498 -0
  114. package/backend/tests/test_config.py +41 -0
  115. package/backend/tests/test_crypto.py +68 -0
  116. package/backend/tests/test_exceptions.py +62 -0
  117. package/backend/tests/test_health.py +63 -0
  118. package/backend/tests/test_llm_provider.py +320 -0
  119. package/backend/tests/test_meeting_domain.py +169 -0
  120. package/backend/tests/test_permissions.py +69 -0
  121. package/backend/tests/test_pull_query_wiring.py +66 -0
  122. package/backend/tests/test_pull_schemas.py +82 -0
  123. package/backend/tests/test_release_domain.py +82 -0
  124. package/backend/tests/test_review_parser.py +301 -0
  125. package/backend/tests/test_schemas_user.py +97 -0
  126. package/backend/tests/test_security.py +92 -0
  127. package/cli/index.js +338 -0
  128. package/compose.yaml +105 -0
  129. package/frontend/.dockerignore +4 -0
  130. package/frontend/Dockerfile +27 -0
  131. package/frontend/index.html +14 -0
  132. package/frontend/nginx.conf +47 -0
  133. package/frontend/package-lock.json +5020 -0
  134. package/frontend/package.json +35 -0
  135. package/frontend/src/api/ai.ts +124 -0
  136. package/frontend/src/api/analytics.ts +66 -0
  137. package/frontend/src/api/attention.ts +181 -0
  138. package/frontend/src/api/auth.ts +74 -0
  139. package/frontend/src/api/classification.ts +170 -0
  140. package/frontend/src/api/issues.ts +90 -0
  141. package/frontend/src/api/pulls.ts +233 -0
  142. package/frontend/src/api/repositories.ts +95 -0
  143. package/frontend/src/api/sig.ts +232 -0
  144. package/frontend/src/app/antd-theme.ts +94 -0
  145. package/frontend/src/app/providers.tsx +59 -0
  146. package/frontend/src/app/router.tsx +411 -0
  147. package/frontend/src/app/search.ts +30 -0
  148. package/frontend/src/components/ClassificationBadge.tsx +58 -0
  149. package/frontend/src/components/GateBadge.tsx +13 -0
  150. package/frontend/src/components/SeverityBadge.tsx +20 -0
  151. package/frontend/src/components/layout/AppShell.tsx +16 -0
  152. package/frontend/src/components/layout/AuthLayout.tsx +32 -0
  153. package/frontend/src/components/layout/Sidebar.tsx +223 -0
  154. package/frontend/src/components/layout/TopBar.tsx +47 -0
  155. package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
  156. package/frontend/src/components/pulls/FacetRail.tsx +199 -0
  157. package/frontend/src/components/pulls/LabelChips.tsx +87 -0
  158. package/frontend/src/components/ui/alert.tsx +30 -0
  159. package/frontend/src/components/ui/badge.tsx +53 -0
  160. package/frontend/src/components/ui/button.tsx +51 -0
  161. package/frontend/src/components/ui/card.tsx +64 -0
  162. package/frontend/src/components/ui/chart-theme.ts +65 -0
  163. package/frontend/src/components/ui/data-table.tsx +39 -0
  164. package/frontend/src/components/ui/echart.tsx +70 -0
  165. package/frontend/src/components/ui/empty-state.tsx +22 -0
  166. package/frontend/src/components/ui/input.tsx +39 -0
  167. package/frontend/src/components/ui/lazy-chart.tsx +21 -0
  168. package/frontend/src/components/ui/skeleton.tsx +19 -0
  169. package/frontend/src/hooks/use-current-repository.ts +44 -0
  170. package/frontend/src/hooks/use-current-user.ts +38 -0
  171. package/frontend/src/lib/api-client.ts +93 -0
  172. package/frontend/src/lib/css-color.ts +60 -0
  173. package/frontend/src/lib/utils.ts +45 -0
  174. package/frontend/src/main.tsx +22 -0
  175. package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
  176. package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
  177. package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
  178. package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
  179. package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
  180. package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
  181. package/frontend/src/pages/login/LoginPage.tsx +95 -0
  182. package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
  183. package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
  184. package/frontend/src/pages/members/MembersPage.tsx +534 -0
  185. package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
  186. package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
  187. package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
  188. package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
  189. package/frontend/src/pages/setup/SetupPage.tsx +144 -0
  190. package/frontend/src/styles/globals.css +147 -0
  191. package/frontend/tsconfig.json +22 -0
  192. package/frontend/vite.config.ts +57 -0
  193. package/package.json +48 -0
  194. package/scripts/e2e-auth-flow.py +131 -0
  195. package/scripts/e2e-pr-detail.py +128 -0
  196. package/scripts/e2e-verify.py +773 -0
  197. package/scripts/verify-ai-pipeline.py +616 -0
  198. package/scripts/verify-analysis-pipeline.py +303 -0
@@ -0,0 +1,326 @@
1
+ """LLM 提供方抽象。
2
+
3
+ 统一走 OpenAI 兼容的 /chat/completions 协议,即可对接 DeepSeek、通义千问、
4
+ 智谱、Kimi、OpenAI 以及本地 vLLM / Ollama —— 换供应商只需改 base_url 与模型名。
5
+
6
+ 不依赖任何内部模块,便于独立测试与复用。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import random
14
+ import re
15
+ from collections.abc import AsyncIterator
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Protocol
18
+
19
+ import httpx
20
+ import structlog
21
+
22
+ logger = structlog.get_logger(__name__)
23
+
24
+ RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
25
+ MAX_ATTEMPTS = 3
26
+ BASE_BACKOFF_SECONDS = 1.5
27
+
28
+
29
+ class LLMError(Exception):
30
+ """LLM 调用异常基类。"""
31
+
32
+
33
+ class LLMAuthError(LLMError):
34
+ """凭据无效或权限不足。"""
35
+
36
+
37
+ class LLMRateLimitError(LLMError):
38
+ """触发上游限流。"""
39
+
40
+
41
+ class LLMUpstreamError(LLMError):
42
+ """上游服务错误(5xx 或网络故障)。"""
43
+
44
+
45
+ class LLMResponseError(LLMError):
46
+ """响应内容不符合预期(如要求 JSON 却返回了非 JSON)。"""
47
+
48
+
49
+ @dataclass
50
+ class ChatMessage:
51
+ role: str # system | user | assistant
52
+ content: str
53
+
54
+
55
+ @dataclass
56
+ class CompletionRequest:
57
+ messages: list[ChatMessage]
58
+ model: str
59
+ temperature: float = 0.1
60
+ max_tokens: int = 4096
61
+ # 要求上游返回 JSON。并非所有供应商都支持,不支持时由 prompt 兜底。
62
+ json_mode: bool = False
63
+ timeout: float = 180.0
64
+
65
+
66
+ @dataclass
67
+ class CompletionResult:
68
+ content: str
69
+ model: str
70
+ prompt_tokens: int = 0
71
+ completion_tokens: int = 0
72
+ finish_reason: str | None = None
73
+ raw: dict[str, Any] = field(default_factory=dict)
74
+
75
+ @property
76
+ def total_tokens(self) -> int:
77
+ return self.prompt_tokens + self.completion_tokens
78
+
79
+
80
+ class LLMProvider(Protocol):
81
+ """任意模型提供方需实现的接口。"""
82
+
83
+ name: str
84
+
85
+ async def complete(self, request: CompletionRequest) -> CompletionResult: ...
86
+
87
+ def stream(self, request: CompletionRequest) -> AsyncIterator[str]: ...
88
+
89
+ async def aclose(self) -> None: ...
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # OpenAI 兼容实现
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ class OpenAICompatibleProvider:
98
+ """OpenAI 兼容协议的客户端。
99
+
100
+ 这是目前唯一需要的实现 —— DeepSeek、Qwen、GLM、Kimi、OpenAI、
101
+ vLLM、Ollama 均提供该协议。若将来遇到不兼容的供应商,
102
+ 只需新增一个实现 ``LLMProvider`` 的类,上层无需改动。
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ name: str,
108
+ base_url: str,
109
+ api_key: str,
110
+ *,
111
+ default_model: str = "",
112
+ timeout: float = 180.0,
113
+ transport: httpx.AsyncBaseTransport | None = None,
114
+ max_concurrency: int = 3,
115
+ ) -> None:
116
+ if not base_url:
117
+ raise ValueError("必须提供 base_url")
118
+ self.name = name
119
+ self._default_model = default_model
120
+ self._client = httpx.AsyncClient(
121
+ base_url=base_url.rstrip("/"),
122
+ timeout=timeout,
123
+ headers={
124
+ # 各家兼容实现的鉴权头基本一致;未配置 key 时省略该头以支持本地服务
125
+ **({"Authorization": f"Bearer {api_key}"} if api_key else {}),
126
+ "Content-Type": "application/json",
127
+ },
128
+ transport=transport,
129
+ follow_redirects=True,
130
+ )
131
+ # 并发上限:多数供应商按并发限流,串行化比触发 429 再退避更划算
132
+ self._semaphore = asyncio.Semaphore(max_concurrency)
133
+ self.requests = 0
134
+ self.retries = 0
135
+ self.errors = 0
136
+
137
+ async def __aenter__(self) -> OpenAICompatibleProvider:
138
+ return self
139
+
140
+ async def __aexit__(self, *exc_info: object) -> None:
141
+ await self.aclose()
142
+
143
+ async def aclose(self) -> None:
144
+ await self._client.aclose()
145
+
146
+ # ------------------------------------------------------------------
147
+
148
+ def _payload(self, request: CompletionRequest, *, stream: bool) -> dict[str, Any]:
149
+ payload: dict[str, Any] = {
150
+ "model": request.model or self._default_model,
151
+ "messages": [
152
+ {"role": message.role, "content": message.content} for message in request.messages
153
+ ],
154
+ "temperature": request.temperature,
155
+ "max_tokens": request.max_tokens,
156
+ "stream": stream,
157
+ }
158
+ if request.json_mode:
159
+ payload["response_format"] = {"type": "json_object"}
160
+ return payload
161
+
162
+ async def complete(self, request: CompletionRequest) -> CompletionResult:
163
+ payload = self._payload(request, stream=False)
164
+ response = await self._post("/chat/completions", payload, request.timeout)
165
+
166
+ try:
167
+ body = response.json()
168
+ choice = body["choices"][0]
169
+ usage = body.get("usage") or {}
170
+ except (ValueError, KeyError, IndexError) as exc:
171
+ raise LLMResponseError(f"响应结构不符合预期:{exc}") from exc
172
+
173
+ return CompletionResult(
174
+ content=(choice.get("message") or {}).get("content") or "",
175
+ model=body.get("model") or payload["model"],
176
+ prompt_tokens=usage.get("prompt_tokens", 0),
177
+ completion_tokens=usage.get("completion_tokens", 0),
178
+ finish_reason=choice.get("finish_reason"),
179
+ raw=body,
180
+ )
181
+
182
+ async def stream(self, request: CompletionRequest) -> AsyncIterator[str]:
183
+ """流式输出。用于前端 SSE 实时展示分析过程。"""
184
+ payload = self._payload(request, stream=True)
185
+ async with (
186
+ self._semaphore,
187
+ self._client.stream(
188
+ "POST", "/chat/completions", json=payload, timeout=request.timeout
189
+ ) as response,
190
+ ):
191
+ self.requests += 1
192
+ if response.status_code >= 400:
193
+ await response.aread()
194
+ self._raise_for_status(response)
195
+ async for line in response.aiter_lines():
196
+ if not line.startswith("data:"):
197
+ continue
198
+ data = line[5:].strip()
199
+ if data == "[DONE]":
200
+ return
201
+ try:
202
+ chunk = json.loads(data)
203
+ delta = chunk["choices"][0].get("delta") or {}
204
+ if content := delta.get("content"):
205
+ yield content
206
+ except (ValueError, KeyError, IndexError):
207
+ # 个别供应商会插入心跳或注释行,忽略即可
208
+ continue
209
+
210
+ async def _post(self, path: str, payload: dict[str, Any], timeout: float) -> httpx.Response:
211
+ last_error: LLMError | None = None
212
+
213
+ async with self._semaphore:
214
+ for attempt in range(1, MAX_ATTEMPTS + 1):
215
+ self.requests += 1
216
+ try:
217
+ response = await self._client.post(path, json=payload, timeout=timeout)
218
+ except httpx.HTTPError as exc:
219
+ last_error = LLMUpstreamError(f"网络请求失败:{exc}")
220
+ if attempt == MAX_ATTEMPTS:
221
+ break
222
+ await self._backoff(attempt)
223
+ continue
224
+
225
+ if response.status_code in RETRYABLE_STATUS:
226
+ detail = _error_detail(response)
227
+ last_error = (
228
+ LLMRateLimitError(f"触发限流:{detail}")
229
+ if response.status_code == 429
230
+ else LLMUpstreamError(f"上游返回 {response.status_code}:{detail}")
231
+ )
232
+ if attempt == MAX_ATTEMPTS:
233
+ break
234
+ self.retries += 1
235
+ await self._backoff(attempt, response)
236
+ continue
237
+
238
+ self._raise_for_status(response)
239
+ return response
240
+
241
+ self.errors += 1
242
+ assert last_error is not None
243
+ raise last_error
244
+
245
+ @staticmethod
246
+ def _raise_for_status(response: httpx.Response) -> None:
247
+ code = response.status_code
248
+ if code < 400:
249
+ return
250
+ detail = _error_detail(response)
251
+ if code in (401, 403):
252
+ raise LLMAuthError(f"凭据无效或无权访问:{detail}")
253
+ if code == 429:
254
+ raise LLMRateLimitError(f"触发限流:{detail}")
255
+ raise LLMUpstreamError(f"上游返回 {code}:{detail}")
256
+
257
+ @staticmethod
258
+ async def _backoff(attempt: int, response: httpx.Response | None = None) -> None:
259
+ delay = BASE_BACKOFF_SECONDS * (2 ** (attempt - 1))
260
+ if response is not None:
261
+ retry_after = response.headers.get("Retry-After")
262
+ if retry_after and retry_after.isdigit():
263
+ delay = max(delay, float(retry_after))
264
+ await asyncio.sleep(min(delay + random.uniform(0, delay * 0.25), 30.0))
265
+
266
+
267
+ def _error_detail(response: httpx.Response) -> str:
268
+ try:
269
+ payload = response.json()
270
+ except ValueError:
271
+ return response.text[:200]
272
+ if isinstance(payload, dict):
273
+ error = payload.get("error")
274
+ if isinstance(error, dict) and isinstance(error.get("message"), str):
275
+ return error["message"][:300]
276
+ if isinstance(error, str):
277
+ return error[:300]
278
+ for key in ("message", "detail"):
279
+ if isinstance(payload.get(key), str):
280
+ return payload[key][:300]
281
+ return response.text[:200]
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # 结构化输出解析
286
+ # ---------------------------------------------------------------------------
287
+
288
+ _FENCE_PATTERN = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL)
289
+
290
+
291
+ def extract_json(text: str) -> dict[str, Any]:
292
+ """从模型输出中稳健地取出 JSON 对象。
293
+
294
+ 模型经常不顾"只输出 JSON"的指示,包上 ```json 围栏或加一段前言。
295
+ 直接 json.loads 会失败,因此这里逐层退让:
296
+ 原样解析 → 剥离代码围栏 → 截取首个 { 到末个 }。
297
+
298
+ 全部失败时抛 LLMResponseError —— 上层据此重试或标记失败,
299
+ 而不是把一段散文塞进结构化字段。
300
+ """
301
+ candidate = (text or "").strip()
302
+ if not candidate:
303
+ raise LLMResponseError("模型返回了空内容")
304
+
305
+ for attempt in (candidate, _strip_fence(candidate), _slice_braces(candidate)):
306
+ if not attempt:
307
+ continue
308
+ try:
309
+ parsed = json.loads(attempt)
310
+ except ValueError:
311
+ continue
312
+ if isinstance(parsed, dict):
313
+ return parsed
314
+ raise LLMResponseError(f"期望 JSON 对象,实际为 {type(parsed).__name__}")
315
+
316
+ raise LLMResponseError(f"无法从模型输出中解析出 JSON:{candidate[:200]}")
317
+
318
+
319
+ def _strip_fence(text: str) -> str:
320
+ match = _FENCE_PATTERN.search(text)
321
+ return match.group(1) if match else ""
322
+
323
+
324
+ def _slice_braces(text: str) -> str:
325
+ start, end = text.find("{"), text.rfind("}")
326
+ return text[start : end + 1] if start != -1 and end > start else ""
@@ -0,0 +1,242 @@
1
+ """FastAPI 应用装配。"""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from contextlib import asynccontextmanager
5
+
6
+ import structlog
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.exceptions import RequestValidationError
9
+ from fastapi.responses import JSONResponse
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+ from starlette.middleware.cors import CORSMiddleware
12
+
13
+ from app.api.v1.router import router as v1_router
14
+ from app.core.config import Settings, get_settings
15
+ from app.core.db import dispose_engine, init_engine, session_scope
16
+ from app.core.exceptions import PlatformError
17
+ from app.core.logging import configure_logging
18
+ from app.core.permissions import Role
19
+ from app.middleware.audit import AuditMiddleware
20
+ from app.middleware.request_context import RequestContextMiddleware
21
+ from app.models.credential import Credential
22
+
23
+ logger = structlog.get_logger(__name__)
24
+
25
+
26
+ async def _bootstrap_admin(settings: Settings) -> None:
27
+ """按环境变量创建首个管理员(仅当系统无用户时)。
28
+
29
+ 这是容器化部署的便利手段 —— 无需手工打开浏览器完成初始化。
30
+ 已初始化则静默跳过。
31
+ """
32
+ if not settings.bootstrap_admin_password:
33
+ return
34
+
35
+ from app.services import user_service
36
+
37
+ async with session_scope() as session:
38
+ if await user_service.count_users(session) > 0:
39
+ return
40
+ await user_service.create_user(
41
+ session,
42
+ username=settings.bootstrap_admin_username,
43
+ display_name=settings.bootstrap_admin_username,
44
+ email=settings.bootstrap_admin_email,
45
+ password=settings.bootstrap_admin_password,
46
+ role=Role.ADMIN,
47
+ )
48
+ logger.info("bootstrap_admin_created", username=settings.bootstrap_admin_username)
49
+
50
+
51
+ # 首次启动导入的凭据在界面上的名字,让人一眼看出它是哪来的
52
+ BOOTSTRAP_CREDENTIAL_NAME = "AtomGit(首次启动导入)"
53
+
54
+
55
+ async def _bootstrap_credential(
56
+ session: AsyncSession, settings: Settings, token: str
57
+ ) -> Credential:
58
+ """取或建引导用的 AtomGit 凭据。
59
+
60
+ 已存在就复用:重启容器时 token 一般还在,不该因此再建一条重名凭据
61
+ (凭据名唯一,会撞约束)。
62
+ """
63
+ from sqlalchemy import select
64
+
65
+ from app.models.credential import CredentialKind
66
+ from app.services import credential_service
67
+
68
+ credential = await session.scalar(
69
+ select(Credential).where(Credential.name == BOOTSTRAP_CREDENTIAL_NAME)
70
+ )
71
+ if credential is None:
72
+ credential = await credential_service.create_credential(
73
+ session,
74
+ settings,
75
+ name=BOOTSTRAP_CREDENTIAL_NAME,
76
+ kind=CredentialKind.ATOMGIT_TOKEN,
77
+ plaintext=token,
78
+ )
79
+ return credential
80
+
81
+
82
+ async def _bootstrap_repository(settings: Settings) -> None:
83
+ """首次启动纳管默认仓库。
84
+
85
+ 使用者打开界面时不该是一片空白,所以**没配 token 也照样建这条记录** ——
86
+ 此时同步不跑(同步层会跳过无凭据的仓库),界面上标明未配置凭据、
87
+ 指引去补 token。
88
+
89
+ 配了 token 就一并导入成凭据并关联上,数据随即开始拉取。令牌在库里是
90
+ AES-256-GCM 加密的,明文只存在于环境变量。
91
+
92
+ 已纳管则静默跳过,所以重启容器不会重复建。
93
+ """
94
+ target = settings.bootstrap_repository
95
+ if not target:
96
+ return
97
+
98
+ owner, _, name = target.partition("/")
99
+ if not owner or not name:
100
+ logger.warning("bootstrap_repository_malformed", value=target)
101
+ return
102
+
103
+ from app.models.repository import Repository
104
+ from app.services import repository_service
105
+
106
+ token = settings.atomgit_token
107
+
108
+ async with session_scope() as session:
109
+ existing = await repository_service.get_by_full_name(session, owner, name)
110
+
111
+ if existing is not None:
112
+ # 已纳管。但如果它是上次**没带 token**时建的,凭据还是空的 ——
113
+ # 这次配上了就补上去。否则"先启动、后给 token"这条路走不通:
114
+ # 使用者得自己去界面上再关联一次,而他已经把 token 给了命令行。
115
+ if existing.credential_id is None and token:
116
+ credential = await _bootstrap_credential(session, settings, token)
117
+ existing.credential_id = credential.id
118
+ logger.info("bootstrap_repository_credential_linked", repository=target)
119
+ return
120
+
121
+ credential_id = None
122
+ if token:
123
+ credential_id = (await _bootstrap_credential(session, settings, token)).id
124
+
125
+ session.add(
126
+ Repository(
127
+ owner=owner,
128
+ name=name,
129
+ display_name=target,
130
+ credential_id=credential_id,
131
+ )
132
+ )
133
+ logger.info(
134
+ "bootstrap_repository_created",
135
+ repository=target,
136
+ has_credential=credential_id is not None,
137
+ )
138
+
139
+
140
+ @asynccontextmanager
141
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
142
+ settings = get_settings()
143
+ configure_logging(level=settings.log_level, json_output=settings.is_production)
144
+ init_engine(settings)
145
+
146
+ try:
147
+ await _bootstrap_admin(settings)
148
+ except Exception:
149
+ logger.exception("bootstrap_admin_failed")
150
+
151
+ try:
152
+ await _bootstrap_repository(settings)
153
+ except Exception:
154
+ # 引导失败不该让服务起不来:仓库可以之后在界面上手动纳管
155
+ logger.exception("bootstrap_repository_failed")
156
+
157
+ logger.info(
158
+ "application_started",
159
+ environment=settings.environment,
160
+ app_name=settings.app_name,
161
+ )
162
+ try:
163
+ yield
164
+ finally:
165
+ await dispose_engine()
166
+ logger.info("application_stopped")
167
+
168
+
169
+ def _problem_response(
170
+ status_code: int,
171
+ title: str,
172
+ detail: str,
173
+ instance: str | None,
174
+ code: str,
175
+ context: dict | None = None,
176
+ ) -> JSONResponse:
177
+ return JSONResponse(
178
+ status_code=status_code,
179
+ content={
180
+ "type": f"https://kernel-sig.local/errors/{code}",
181
+ "title": title,
182
+ "status": status_code,
183
+ "detail": detail,
184
+ "instance": instance,
185
+ "code": code,
186
+ "context": context or None,
187
+ },
188
+ )
189
+
190
+
191
+ def create_app() -> FastAPI:
192
+ settings = get_settings()
193
+
194
+ app = FastAPI(
195
+ title=settings.app_name,
196
+ version="0.1.0",
197
+ docs_url=None if settings.is_production else "/api/docs",
198
+ redoc_url=None,
199
+ openapi_url="/api/openapi.json",
200
+ lifespan=lifespan,
201
+ )
202
+
203
+ # 中间件注册顺序:后添加的先执行。
204
+ # 实际执行序:RequestContext → Audit → CORS → 路由
205
+ app.add_middleware(
206
+ CORSMiddleware,
207
+ allow_origins=["http://localhost:15173", "http://localhost:18080"],
208
+ allow_credentials=True,
209
+ allow_methods=["*"],
210
+ allow_headers=["*"],
211
+ )
212
+ app.add_middleware(AuditMiddleware)
213
+ app.add_middleware(RequestContextMiddleware)
214
+
215
+ @app.exception_handler(PlatformError)
216
+ async def _handle_platform_error(request: Request, exc: PlatformError) -> JSONResponse:
217
+ return _problem_response(
218
+ exc.status_code, exc.title, exc.detail, request.url.path, exc.code, exc.context
219
+ )
220
+
221
+ @app.exception_handler(RequestValidationError)
222
+ async def _handle_validation_error(
223
+ request: Request, exc: RequestValidationError
224
+ ) -> JSONResponse:
225
+ errors = [
226
+ {"field": ".".join(str(p) for p in err["loc"][1:]), "message": err["msg"]}
227
+ for err in exc.errors()
228
+ ]
229
+ return _problem_response(
230
+ 422,
231
+ "Validation Failed",
232
+ "请求参数校验失败",
233
+ request.url.path,
234
+ "validation_error",
235
+ {"errors": errors},
236
+ )
237
+
238
+ app.include_router(v1_router, prefix=settings.api_prefix)
239
+ return app
240
+
241
+
242
+ app = create_app()
File without changes
@@ -0,0 +1,129 @@
1
+ """自动审计中间件:记录所有写操作。
2
+
3
+ GET/HEAD/OPTIONS 不记录(读操作量大且审计价值低)。
4
+ 敏感字段在写入前脱敏 —— 审计日志本身不能成为凭据泄露点。
5
+ """
6
+
7
+ import json
8
+ from typing import Any
9
+
10
+ import structlog
11
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
12
+ from starlette.requests import Request
13
+ from starlette.responses import Response
14
+
15
+ from app.core.db import get_session_factory
16
+ from app.models.audit import AuditLog, AuditResult
17
+
18
+ AUDITED_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
19
+ REDACTED_KEYS = {
20
+ "password",
21
+ "current_password",
22
+ "new_password",
23
+ "token",
24
+ "secret",
25
+ "api_key",
26
+ "private_token",
27
+ "refresh_token",
28
+ "access_token",
29
+ }
30
+ # 登录/刷新/初始化的载荷本身是凭据,整体不记录。
31
+ # 但登录**尝试**必须可归属,否则无法从审计日志发现暴力破解,
32
+ # 因此单独提取 username(绝非 password)作为行为归属。
33
+ CREDENTIAL_PATHS = {
34
+ "/api/v1/auth/login",
35
+ "/api/v1/auth/refresh",
36
+ "/api/v1/auth/setup",
37
+ }
38
+
39
+
40
+ def _redact(value: Any) -> Any:
41
+ if isinstance(value, dict):
42
+ return {k: ("***" if k.lower() in REDACTED_KEYS else _redact(v)) for k, v in value.items()}
43
+ if isinstance(value, list):
44
+ return [_redact(item) for item in value]
45
+ return value
46
+
47
+
48
+ def _username_from(body: Any) -> str | None:
49
+ if isinstance(body, dict):
50
+ username = body.get("username")
51
+ if isinstance(username, str) and username:
52
+ return username
53
+ return None
54
+
55
+
56
+ class AuditMiddleware(BaseHTTPMiddleware):
57
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
58
+ if request.method not in AUDITED_METHODS:
59
+ return await call_next(request)
60
+
61
+ raw_body = await self._read_json(request)
62
+ payload = self._extract_payload(raw_body, request.url.path)
63
+ claimed_actor = _username_from(raw_body) if request.url.path in CREDENTIAL_PATHS else None
64
+
65
+ try:
66
+ response = await call_next(request)
67
+ except Exception as exc:
68
+ await self._write_log(request, payload, AuditResult.FAILURE, claimed_actor, str(exc))
69
+ raise
70
+
71
+ result = AuditResult.SUCCESS if response.status_code < 400 else AuditResult.FAILURE
72
+ await self._write_log(request, payload, result, claimed_actor)
73
+ return response
74
+
75
+ @staticmethod
76
+ async def _read_json(request: Request) -> Any:
77
+ try:
78
+ body = await request.body()
79
+ except Exception:
80
+ return None
81
+ if not body:
82
+ return None
83
+ try:
84
+ return json.loads(body)
85
+ except (ValueError, UnicodeDecodeError):
86
+ return None
87
+
88
+ @staticmethod
89
+ def _extract_payload(body: Any, path: str) -> dict[str, Any] | None:
90
+ if path in CREDENTIAL_PATHS:
91
+ username = _username_from(body)
92
+ # 记录尝试登录的账号名(非机密),不记录密码
93
+ return {"username": username, "_note": "凭据类请求,密码不记录"}
94
+ if body is None:
95
+ return None
96
+ return _redact(body) if isinstance(body, dict) else {"body": _redact(body)}
97
+
98
+ async def _write_log(
99
+ self,
100
+ request: Request,
101
+ payload: dict[str, Any] | None,
102
+ result: AuditResult,
103
+ claimed_actor: str | None = None,
104
+ error: str | None = None,
105
+ ) -> None:
106
+ # 只读取原始值:此处数据库会话已关闭,触碰 ORM 实例会触发
107
+ # DetachedInstanceError。认证依赖已把归属信息存为标量。
108
+ actor_user_id = getattr(request.state, "actor_user_id", None)
109
+ actor_username = getattr(request.state, "actor_username", None) or claimed_actor
110
+
111
+ try:
112
+ factory = get_session_factory()
113
+ async with factory() as session:
114
+ session.add(
115
+ AuditLog(
116
+ actor_user_id=actor_user_id,
117
+ actor_username=actor_username,
118
+ actor_ip=request.client.host if request.client else None,
119
+ action=f"{request.method} {request.url.path}",
120
+ after=payload,
121
+ result=result,
122
+ error=error,
123
+ request_id=getattr(request.state, "request_id", None),
124
+ )
125
+ )
126
+ await session.commit()
127
+ except Exception:
128
+ # 审计写入失败绝不能影响主请求,但必须留痕便于排查
129
+ structlog.get_logger(__name__).exception("audit_log_write_failed")