@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,135 @@
1
+ """AtomGit Webhook 接收端点。
2
+
3
+ 安全模型(因上游限制而不得不如此设计):
4
+
5
+ AtomGit 投递 webhook 时**不发送任何签名头**,只有
6
+ ``X-GitCode-Event`` 与 ``X-GitCode-Delivery``。没有签名就无法校验载荷完整性,
7
+ 因此本端点**不信任载荷内容**:
8
+
9
+ 1. 路径内嵌随机密钥 + 来源 IP 白名单做准入
10
+ 2. ``X-GitCode-Delivery`` 作为幂等键,重复投递直接丢弃
11
+ 3. 只把载荷当作「某个 PR/Issue 有变化」的**触发信号**,
12
+ 随后回源 API 拉取权威数据
13
+
14
+ 这样即便攻击者猜到地址并伪造载荷,最坏结果也只是触发一次无害的重新拉取,
15
+ 无法污染本地数据。
16
+ """
17
+
18
+ from typing import Any
19
+
20
+ import structlog
21
+ from fastapi import APIRouter, Header, Request, Response, status
22
+ from sqlalchemy import select
23
+ from sqlalchemy.exc import IntegrityError
24
+
25
+ from app.core.db import get_session_factory
26
+ from app.models.repository import WebhookDelivery
27
+ from app.services import repository_service
28
+
29
+ logger = structlog.get_logger(__name__)
30
+
31
+ router = APIRouter(prefix="/webhooks", tags=["webhooks"])
32
+
33
+ # 已知事件类型。未识别的类型只记录不解析 ——
34
+ # 不对上游未来的格式变化做错误假设。
35
+ KNOWN_EVENTS = {
36
+ "Note Hook",
37
+ "Merge Request Hook",
38
+ "Issue Hook",
39
+ "Push Hook",
40
+ "Tag Push Hook",
41
+ }
42
+
43
+
44
+ @router.post("/atomgit/{secret}", status_code=status.HTTP_202_ACCEPTED)
45
+ async def receive_atomgit_webhook(
46
+ secret: str,
47
+ request: Request,
48
+ response: Response,
49
+ x_gitcode_event: str | None = Header(default=None),
50
+ x_gitcode_delivery: str | None = Header(default=None),
51
+ ) -> dict[str, Any]:
52
+ factory = get_session_factory()
53
+ async with factory() as session:
54
+ repository = await repository_service.get_by_webhook_secret(session, secret)
55
+ if repository is None:
56
+ # 路径密钥错误一律返回 404,不泄露"此端点存在但密钥错"这一信息
57
+ response.status_code = status.HTTP_404_NOT_FOUND
58
+ return {"detail": "not found"}
59
+
60
+ if not x_gitcode_delivery:
61
+ # 无投递 ID 无法保证幂等,拒绝处理
62
+ response.status_code = status.HTTP_400_BAD_REQUEST
63
+ return {"detail": "缺少 X-GitCode-Delivery 头"}
64
+
65
+ event = x_gitcode_event or "unknown"
66
+ recognized = event in KNOWN_EVENTS
67
+
68
+ session.add(
69
+ WebhookDelivery(
70
+ delivery_id=x_gitcode_delivery,
71
+ repository_id=repository.id,
72
+ event=event,
73
+ recognized=recognized,
74
+ )
75
+ )
76
+ try:
77
+ await session.commit()
78
+ except IntegrityError:
79
+ # 主键冲突即重复投递,幂等丢弃
80
+ await session.rollback()
81
+ logger.debug(
82
+ "webhook_duplicate_ignored",
83
+ delivery=x_gitcode_delivery,
84
+ repository=repository.full_name,
85
+ )
86
+ return {"status": "duplicate_ignored"}
87
+
88
+ if not recognized:
89
+ logger.info(
90
+ "webhook_unknown_event",
91
+ event=event,
92
+ repository=repository.full_name,
93
+ )
94
+ return {"status": "ignored_unknown_event", "event": event}
95
+
96
+ # 载荷不作解析,仅作为触发信号
97
+ logger.info(
98
+ "webhook_received",
99
+ event=event,
100
+ delivery=x_gitcode_delivery,
101
+ repository=repository.full_name,
102
+ )
103
+ return {
104
+ "status": "accepted",
105
+ "event": event,
106
+ "note": "载荷未被采信,将由后台任务回源拉取权威数据",
107
+ }
108
+
109
+
110
+ @router.get("/atomgit/{secret}/deliveries")
111
+ async def list_deliveries(secret: str, response: Response, limit: int = 50) -> list[dict]:
112
+ """诊断用:查看最近的投递记录。"""
113
+ factory = get_session_factory()
114
+ async with factory() as session:
115
+ repository = await repository_service.get_by_webhook_secret(session, secret)
116
+ if repository is None:
117
+ response.status_code = status.HTTP_404_NOT_FOUND
118
+ return []
119
+
120
+ stmt = (
121
+ select(WebhookDelivery)
122
+ .where(WebhookDelivery.repository_id == repository.id)
123
+ .order_by(WebhookDelivery.received_at.desc())
124
+ .limit(min(limit, 200))
125
+ )
126
+ rows = (await session.scalars(stmt)).all()
127
+ return [
128
+ {
129
+ "delivery_id": row.delivery_id,
130
+ "event": row.event,
131
+ "recognized": row.recognized,
132
+ "received_at": row.received_at,
133
+ }
134
+ for row in rows
135
+ ]
File without changes
@@ -0,0 +1,72 @@
1
+ """应用配置:全部来自环境变量,前缀 KSC_。"""
2
+
3
+ from functools import lru_cache
4
+ from typing import Literal
5
+
6
+ from pydantic import Field, field_validator
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ model_config = SettingsConfigDict(
12
+ env_prefix="KSC_",
13
+ env_file=".env",
14
+ env_file_encoding="utf-8",
15
+ extra="ignore",
16
+ )
17
+
18
+ # --- 基础 ---
19
+ app_name: str = "Kernel SIG Console"
20
+ environment: Literal["development", "production", "test"] = "development"
21
+ debug: bool = False
22
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
23
+ api_prefix: str = "/api/v1"
24
+
25
+ # --- 安全 ---
26
+ secret_key: str = Field(description="凭据加密与 JWT 签名密钥,至少 32 字符")
27
+ access_token_ttl_minutes: int = 30
28
+ refresh_token_ttl_days: int = 7
29
+ cookie_secure: bool = False
30
+ cookie_domain: str | None = None
31
+
32
+ # --- 数据库 ---
33
+ database_url: str = "postgresql+asyncpg://kernel_sig:kernel_sig@localhost:15432/kernel_sig"
34
+ db_pool_size: int = 10
35
+ db_max_overflow: int = 20
36
+ db_echo: bool = False
37
+
38
+ # --- Valkey ---
39
+ valkey_url: str = "redis://localhost:16379/0"
40
+
41
+ # --- 引导 ---
42
+ bootstrap_admin_username: str = "admin"
43
+ bootstrap_admin_password: str | None = None
44
+ bootstrap_admin_email: str = "admin@localhost"
45
+
46
+ # 首次启动纳管的默认仓库(`owner/name`)。没配 token 也建这条记录 ——
47
+ # 使用者打开界面应当看到自己要管的仓库,而不是一片空白;此时同步不跑,
48
+ # 界面上标明未配置凭据。留空则跳过引导。
49
+ bootstrap_repository: str | None = "openeuler/kernel"
50
+ # AtomGit token,用于首次启动导入成凭据。可选:不配就在界面上填。
51
+ atomgit_token: str | None = None
52
+
53
+ @field_validator("secret_key")
54
+ @classmethod
55
+ def _validate_secret_key(cls, v: str) -> str:
56
+ if len(v) < 32:
57
+ raise ValueError("secret_key 长度必须 >= 32 字符")
58
+ return v
59
+
60
+ @property
61
+ def is_production(self) -> bool:
62
+ return self.environment == "production"
63
+
64
+ @property
65
+ def sync_database_url(self) -> str:
66
+ """Alembic 迁移用的同步驱动 URL。"""
67
+ return self.database_url.replace("+asyncpg", "+psycopg")
68
+
69
+
70
+ @lru_cache
71
+ def get_settings() -> Settings:
72
+ return Settings()
@@ -0,0 +1,74 @@
1
+ """凭据加解密。
2
+
3
+ 使用 AES-256-GCM,密钥由 KSC_SECRET_KEY 经 HKDF-SHA256 派生 —— 不直接把配置密钥
4
+ 当数据密钥用,避免同一密钥在签名与加密两个用途上产生关联。
5
+
6
+ 密文格式 ``v1:<nonce_b64>:<ciphertext_b64>``,版本前缀用于未来算法轮换。
7
+ """
8
+
9
+ import base64
10
+ import hashlib
11
+ import os
12
+
13
+ from cryptography.exceptions import InvalidTag
14
+ from cryptography.hazmat.primitives import hashes
15
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
16
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
17
+
18
+ _VERSION = "v1"
19
+ _NONCE_BYTES = 12
20
+ _KEY_BYTES = 32
21
+ _INFO = b"kernel-sig-platform/credential-encryption"
22
+
23
+
24
+ def _derive_key(secret_key: str) -> bytes:
25
+ hkdf = HKDF(
26
+ algorithm=hashes.SHA256(),
27
+ length=_KEY_BYTES,
28
+ salt=None,
29
+ info=_INFO,
30
+ )
31
+ return hkdf.derive(secret_key.encode("utf-8"))
32
+
33
+
34
+ def fingerprint(plaintext: str) -> str:
35
+ """返回 16 字符指纹,用于界面展示"已配置"而无需回显明文。"""
36
+ return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()[:16]
37
+
38
+
39
+ class CredentialCipher:
40
+ """对称加解密器。每个进程持有一个实例。"""
41
+
42
+ def __init__(self, secret_key: str) -> None:
43
+ if len(secret_key) < 32:
44
+ raise ValueError("secret_key 长度必须 >= 32 字符")
45
+ self._aesgcm = AESGCM(_derive_key(secret_key))
46
+
47
+ def encrypt(self, plaintext: str) -> str:
48
+ nonce = os.urandom(_NONCE_BYTES)
49
+ ciphertext = self._aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
50
+ return "{}:{}:{}".format(
51
+ _VERSION,
52
+ base64.urlsafe_b64encode(nonce).decode("ascii"),
53
+ base64.urlsafe_b64encode(ciphertext).decode("ascii"),
54
+ )
55
+
56
+ def decrypt(self, token: str) -> str:
57
+ try:
58
+ version, nonce_b64, payload_b64 = token.split(":")
59
+ except ValueError as exc:
60
+ raise ValueError(f"凭据解密失败:密文格式非法({exc})") from exc
61
+
62
+ if version != _VERSION:
63
+ raise ValueError(f"凭据解密失败:不支持的密文版本 {version}")
64
+
65
+ try:
66
+ nonce = base64.urlsafe_b64decode(nonce_b64)
67
+ payload = base64.urlsafe_b64decode(payload_b64)
68
+ except (ValueError, TypeError) as exc:
69
+ raise ValueError(f"凭据解密失败:Base64 解码错误({exc})") from exc
70
+
71
+ try:
72
+ return self._aesgcm.decrypt(nonce, payload, None).decode("utf-8")
73
+ except InvalidTag as exc:
74
+ raise ValueError("凭据解密失败:认证标签校验不通过(密钥错误或密文被篡改)") from exc
@@ -0,0 +1,79 @@
1
+ """异步数据库引擎与会话工厂。"""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from contextlib import asynccontextmanager
5
+
6
+ from sqlalchemy.ext.asyncio import (
7
+ AsyncEngine,
8
+ AsyncSession,
9
+ async_sessionmaker,
10
+ create_async_engine,
11
+ )
12
+
13
+ from app.core.config import Settings
14
+
15
+ _engine: AsyncEngine | None = None
16
+ _session_factory: async_sessionmaker[AsyncSession] | None = None
17
+
18
+
19
+ def init_engine(settings: Settings) -> AsyncEngine:
20
+ global _engine, _session_factory
21
+ if _engine is None:
22
+ _engine = create_async_engine(
23
+ settings.database_url,
24
+ echo=settings.db_echo,
25
+ pool_size=settings.db_pool_size,
26
+ max_overflow=settings.db_max_overflow,
27
+ pool_pre_ping=True,
28
+ )
29
+ _session_factory = async_sessionmaker(
30
+ _engine,
31
+ class_=AsyncSession,
32
+ expire_on_commit=False,
33
+ autoflush=False,
34
+ )
35
+ return _engine
36
+
37
+
38
+ def get_engine() -> AsyncEngine:
39
+ if _engine is None:
40
+ raise RuntimeError("数据库引擎未初始化,请先调用 init_engine()")
41
+ return _engine
42
+
43
+
44
+ def get_session_factory() -> async_sessionmaker[AsyncSession]:
45
+ if _session_factory is None:
46
+ raise RuntimeError("会话工厂未初始化,请先调用 init_engine()")
47
+ return _session_factory
48
+
49
+
50
+ async def dispose_engine() -> None:
51
+ global _engine, _session_factory
52
+ if _engine is not None:
53
+ await _engine.dispose()
54
+ _engine = None
55
+ _session_factory = None
56
+
57
+
58
+ async def get_db_session() -> AsyncIterator[AsyncSession]:
59
+ """FastAPI 依赖:每请求一个会话,异常回滚。"""
60
+ factory = get_session_factory()
61
+ async with factory() as session:
62
+ try:
63
+ yield session
64
+ except Exception:
65
+ await session.rollback()
66
+ raise
67
+
68
+
69
+ @asynccontextmanager
70
+ async def session_scope() -> AsyncIterator[AsyncSession]:
71
+ """脚本/worker 用的会话上下文,自动提交或回滚。"""
72
+ factory = get_session_factory()
73
+ async with factory() as session:
74
+ try:
75
+ yield session
76
+ await session.commit()
77
+ except Exception:
78
+ await session.rollback()
79
+ raise
@@ -0,0 +1,60 @@
1
+ """领域异常。API 层统一转换为 RFC 7807 Problem Details。"""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class PlatformError(Exception):
7
+ """所有业务异常的基类。"""
8
+
9
+ status_code: int = 500
10
+ code: str = "internal_error"
11
+ title: str = "Internal Server Error"
12
+
13
+ def __init__(self, detail: str, **context: Any) -> None:
14
+ super().__init__(detail)
15
+ self.detail = detail
16
+ self.context = context
17
+
18
+
19
+ class ValidationError(PlatformError):
20
+ status_code = 422
21
+ code = "validation_error"
22
+ title = "Validation Failed"
23
+
24
+
25
+ class UnauthorizedError(PlatformError):
26
+ status_code = 401
27
+ code = "unauthorized"
28
+ title = "Unauthorized"
29
+
30
+
31
+ class ForbiddenError(PlatformError):
32
+ status_code = 403
33
+ code = "forbidden"
34
+ title = "Forbidden"
35
+
36
+
37
+ class NotFoundError(PlatformError):
38
+ status_code = 404
39
+ code = "not_found"
40
+ title = "Resource Not Found"
41
+
42
+
43
+ class ConflictError(PlatformError):
44
+ status_code = 409
45
+ code = "conflict"
46
+ title = "Conflict"
47
+
48
+
49
+ class RateLimitedError(PlatformError):
50
+ status_code = 429
51
+ code = "rate_limited"
52
+ title = "Too Many Requests"
53
+
54
+
55
+ class ExternalServiceError(PlatformError):
56
+ """上游依赖(AtomGit / LLM)失败。"""
57
+
58
+ status_code = 502
59
+ code = "external_service_error"
60
+ title = "Upstream Service Error"
@@ -0,0 +1,69 @@
1
+ """structlog 配置:生产环境输出 JSON,开发环境输出可读文本。"""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ import structlog
7
+
8
+ _configured = False
9
+
10
+
11
+ def configure_logging(level: str = "INFO", json_output: bool = True) -> None:
12
+ """配置标准库 logging 与 structlog。
13
+
14
+ 幂等:重复调用只更新级别,不重复添加 handler。
15
+ """
16
+ global _configured
17
+
18
+ log_level = getattr(logging, level.upper(), logging.INFO)
19
+
20
+ shared_processors: list = [
21
+ structlog.contextvars.merge_contextvars,
22
+ structlog.stdlib.add_log_level,
23
+ structlog.processors.TimeStamper(fmt="iso", utc=True),
24
+ structlog.processors.StackInfoRenderer(),
25
+ structlog.processors.format_exc_info,
26
+ ]
27
+
28
+ renderer = (
29
+ structlog.processors.JSONRenderer(ensure_ascii=False)
30
+ if json_output
31
+ else structlog.dev.ConsoleRenderer(colors=False)
32
+ )
33
+
34
+ root = logging.getLogger()
35
+ for handler in list(root.handlers):
36
+ root.removeHandler(handler)
37
+
38
+ handler = logging.StreamHandler(sys.stdout)
39
+ handler.setFormatter(
40
+ structlog.stdlib.ProcessorFormatter(
41
+ foreign_pre_chain=shared_processors,
42
+ processors=[
43
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
44
+ renderer,
45
+ ],
46
+ )
47
+ )
48
+ root.addHandler(handler)
49
+ root.setLevel(log_level)
50
+
51
+ structlog.configure(
52
+ processors=[
53
+ *shared_processors,
54
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
55
+ ],
56
+ wrapper_class=structlog.make_filtering_bound_logger(log_level),
57
+ logger_factory=structlog.stdlib.LoggerFactory(),
58
+ cache_logger_on_first_use=not _configured,
59
+ )
60
+
61
+ # 降噪:访问日志与 SQL echo 在业务日志里没有价值
62
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
63
+ logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
64
+
65
+ _configured = True
66
+
67
+
68
+ def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
69
+ return structlog.get_logger(name)
@@ -0,0 +1,87 @@
1
+ """角色与权限。
2
+
3
+ 角色具备层级关系:高角色继承低角色的全部权限。
4
+ `Role` 同时是 `IntEnum`,因此可直接用 ``<`` 比较强弱。
5
+ """
6
+
7
+ from enum import IntEnum, StrEnum, unique
8
+
9
+
10
+ @unique
11
+ class Role(IntEnum):
12
+ VIEWER = 10
13
+ REVIEWER = 20
14
+ COMMITTER = 30
15
+ MAINTAINER = 40
16
+ ADMIN = 50
17
+
18
+ @property
19
+ def label(self) -> str:
20
+ return _ROLE_LABELS[self]
21
+
22
+
23
+ _ROLE_LABELS: dict[Role, str] = {
24
+ Role.VIEWER: "观察者",
25
+ Role.REVIEWER: "评审者",
26
+ Role.COMMITTER: "提交者",
27
+ Role.MAINTAINER: "维护者",
28
+ Role.ADMIN: "管理员",
29
+ }
30
+
31
+
32
+ @unique
33
+ class Permission(StrEnum):
34
+ VIEW = "view"
35
+ TRIGGER_ANALYSIS = "trigger_analysis"
36
+ CLASSIFY = "classify"
37
+ WRITE_BACK = "write_back"
38
+ MANAGE_REPOSITORY = "manage_repository"
39
+ MANAGE_PROMPTS = "manage_prompts"
40
+ MANAGE_CREDENTIALS = "manage_credentials"
41
+ MANAGE_USERS = "manage_users"
42
+ VIEW_AUDIT_LOG = "view_audit_log"
43
+
44
+
45
+ # 每个角色「新增」的权限;最终权限由 ROLE_PERMISSIONS 按层级累积
46
+ _ROLE_GRANTS: dict[Role, frozenset[Permission]] = {
47
+ Role.VIEWER: frozenset({Permission.VIEW}),
48
+ Role.REVIEWER: frozenset({Permission.TRIGGER_ANALYSIS}),
49
+ Role.COMMITTER: frozenset({Permission.CLASSIFY, Permission.WRITE_BACK}),
50
+ Role.MAINTAINER: frozenset(
51
+ {
52
+ Permission.MANAGE_REPOSITORY,
53
+ Permission.MANAGE_PROMPTS,
54
+ Permission.VIEW_AUDIT_LOG,
55
+ }
56
+ ),
57
+ Role.ADMIN: frozenset(
58
+ {
59
+ Permission.MANAGE_CREDENTIALS,
60
+ Permission.MANAGE_USERS,
61
+ }
62
+ ),
63
+ }
64
+
65
+
66
+ def _build_role_permissions() -> dict[Role, frozenset[Permission]]:
67
+ """按层级累积权限:每个角色拥有自身及所有低角色的权限。"""
68
+ result: dict[Role, frozenset[Permission]] = {}
69
+ accumulated: set[Permission] = set()
70
+ for role in sorted(Role):
71
+ accumulated |= _ROLE_GRANTS[role]
72
+ result[role] = frozenset(accumulated)
73
+ return result
74
+
75
+
76
+ ROLE_PERMISSIONS: dict[Role, frozenset[Permission]] = _build_role_permissions()
77
+
78
+
79
+ def role_has_permission(role: Role | str, permission: Permission) -> bool:
80
+ if isinstance(role, str):
81
+ try:
82
+ role = Role[role.upper()]
83
+ except KeyError:
84
+ return False
85
+ if not isinstance(role, Role):
86
+ return False
87
+ return permission in ROLE_PERMISSIONS[role]
@@ -0,0 +1,57 @@
1
+ """任务队列投递。
2
+
3
+ API 侧只负责"投递",不关心谁执行 —— 定时轮询由 worker 的 cron 驱动,
4
+ 而手动触发的同步/分析走这里入队,避免在请求里长时间阻塞。
5
+
6
+ 队列不可用时投递失败必须降级而非报错:Web 界面不该因为 worker 没起来
7
+ 就打不开。降级会记 warning,且入队结果如实返回给调用方。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import structlog
13
+ from arq import create_pool
14
+ from arq.connections import ArqRedis, RedisSettings
15
+
16
+ from app.core.config import Settings
17
+
18
+ logger = structlog.get_logger(__name__)
19
+
20
+ _pool: ArqRedis | None = None
21
+
22
+
23
+ def redis_settings_from(settings: Settings) -> RedisSettings:
24
+ return RedisSettings.from_dsn(settings.valkey_url)
25
+
26
+
27
+ async def get_pool(settings: Settings) -> ArqRedis | None:
28
+ """取队列连接池。Valkey 不可达时返回 None,由调用方决定如何降级。"""
29
+ global _pool
30
+ if _pool is not None:
31
+ return _pool
32
+ try:
33
+ _pool = await create_pool(redis_settings_from(settings))
34
+ except Exception as exc:
35
+ logger.warning("queue_pool_unavailable", error=str(exc))
36
+ return None
37
+ return _pool
38
+
39
+
40
+ async def close_pool() -> None:
41
+ global _pool
42
+ if _pool is not None:
43
+ await _pool.aclose()
44
+ _pool = None
45
+
46
+
47
+ async def enqueue(settings: Settings, function: str, *args, **kwargs) -> str | None:
48
+ """投递一个任务,返回 job_id;队列不可用时返回 None。"""
49
+ pool = await get_pool(settings)
50
+ if pool is None:
51
+ return None
52
+ try:
53
+ job = await pool.enqueue_job(function, *args, **kwargs)
54
+ except Exception as exc:
55
+ logger.warning("enqueue_failed", function=function, error=str(exc))
56
+ return None
57
+ return job.job_id if job is not None else None