@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.
- package/.env.example +50 -0
- package/LICENSE +127 -0
- package/Makefile +66 -0
- package/README.md +279 -0
- package/backend/.dockerignore +12 -0
- package/backend/Dockerfile +43 -0
- package/backend/alembic/env.py +75 -0
- package/backend/alembic/script.py.mako +25 -0
- package/backend/alembic/versions/0001_initial.py +88 -0
- package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
- package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
- package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
- package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
- package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
- package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
- package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
- package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
- package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
- package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
- package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
- package/backend/alembic.ini +40 -0
- package/backend/app/__init__.py +0 -0
- package/backend/app/api/__init__.py +0 -0
- package/backend/app/api/deps.py +74 -0
- package/backend/app/api/v1/__init__.py +0 -0
- package/backend/app/api/v1/ai.py +386 -0
- package/backend/app/api/v1/analytics.py +48 -0
- package/backend/app/api/v1/attention.py +142 -0
- package/backend/app/api/v1/auth.py +106 -0
- package/backend/app/api/v1/classification.py +192 -0
- package/backend/app/api/v1/health.py +29 -0
- package/backend/app/api/v1/issues.py +143 -0
- package/backend/app/api/v1/pulls.py +388 -0
- package/backend/app/api/v1/repositories.py +273 -0
- package/backend/app/api/v1/router.py +32 -0
- package/backend/app/api/v1/sig.py +565 -0
- package/backend/app/api/v1/users.py +87 -0
- package/backend/app/api/v1/webhooks.py +135 -0
- package/backend/app/core/__init__.py +0 -0
- package/backend/app/core/config.py +72 -0
- package/backend/app/core/crypto.py +74 -0
- package/backend/app/core/db.py +79 -0
- package/backend/app/core/exceptions.py +60 -0
- package/backend/app/core/logging.py +69 -0
- package/backend/app/core/permissions.py +87 -0
- package/backend/app/core/queue.py +57 -0
- package/backend/app/core/security.py +69 -0
- package/backend/app/domain/__init__.py +0 -0
- package/backend/app/domain/attention.py +618 -0
- package/backend/app/domain/classification.py +689 -0
- package/backend/app/domain/meeting.py +408 -0
- package/backend/app/domain/release.py +163 -0
- package/backend/app/domain/review.py +416 -0
- package/backend/app/domain/sig.py +178 -0
- package/backend/app/domain/sig_info.py +201 -0
- package/backend/app/integrations/__init__.py +0 -0
- package/backend/app/integrations/atomgit/__init__.py +0 -0
- package/backend/app/integrations/atomgit/client.py +505 -0
- package/backend/app/integrations/atomgit/models.py +311 -0
- package/backend/app/integrations/llm/__init__.py +0 -0
- package/backend/app/integrations/llm/prompts.py +222 -0
- package/backend/app/integrations/llm/provider.py +326 -0
- package/backend/app/main.py +242 -0
- package/backend/app/middleware/__init__.py +0 -0
- package/backend/app/middleware/audit.py +129 -0
- package/backend/app/middleware/request_context.py +42 -0
- package/backend/app/models/__init__.py +112 -0
- package/backend/app/models/ai.py +214 -0
- package/backend/app/models/attention.py +131 -0
- package/backend/app/models/attention_settings.py +61 -0
- package/backend/app/models/audit.py +53 -0
- package/backend/app/models/base.py +35 -0
- package/backend/app/models/classification.py +144 -0
- package/backend/app/models/credential.py +56 -0
- package/backend/app/models/issue.py +132 -0
- package/backend/app/models/meeting.py +189 -0
- package/backend/app/models/pull_request.py +288 -0
- package/backend/app/models/repository.py +196 -0
- package/backend/app/models/sig.py +194 -0
- package/backend/app/models/user.py +44 -0
- package/backend/app/schemas/__init__.py +0 -0
- package/backend/app/schemas/ai.py +120 -0
- package/backend/app/schemas/attention.py +43 -0
- package/backend/app/schemas/auth.py +26 -0
- package/backend/app/schemas/classification.py +58 -0
- package/backend/app/schemas/common.py +43 -0
- package/backend/app/schemas/pull_request.py +214 -0
- package/backend/app/schemas/repository.py +94 -0
- package/backend/app/schemas/sig.py +246 -0
- package/backend/app/schemas/user.py +79 -0
- package/backend/app/services/__init__.py +0 -0
- package/backend/app/services/ai_service.py +569 -0
- package/backend/app/services/analytics_service.py +390 -0
- package/backend/app/services/attention_queue.py +451 -0
- package/backend/app/services/attention_service.py +432 -0
- package/backend/app/services/auth_service.py +74 -0
- package/backend/app/services/classification_service.py +658 -0
- package/backend/app/services/credential_service.py +105 -0
- package/backend/app/services/pull_query.py +273 -0
- package/backend/app/services/release_service.py +446 -0
- package/backend/app/services/repository_service.py +132 -0
- package/backend/app/services/sig_service.py +385 -0
- package/backend/app/services/sync_service.py +752 -0
- package/backend/app/services/user_service.py +68 -0
- package/backend/app/worker.py +389 -0
- package/backend/entrypoint.sh +10 -0
- package/backend/pyproject.toml +68 -0
- package/backend/tests/test_analysis_api.py +154 -0
- package/backend/tests/test_atomgit_client.py +360 -0
- package/backend/tests/test_atomgit_models.py +257 -0
- package/backend/tests/test_attention.py +291 -0
- package/backend/tests/test_audit_middleware.py +135 -0
- package/backend/tests/test_classification.py +498 -0
- package/backend/tests/test_config.py +41 -0
- package/backend/tests/test_crypto.py +68 -0
- package/backend/tests/test_exceptions.py +62 -0
- package/backend/tests/test_health.py +63 -0
- package/backend/tests/test_llm_provider.py +320 -0
- package/backend/tests/test_meeting_domain.py +169 -0
- package/backend/tests/test_permissions.py +69 -0
- package/backend/tests/test_pull_query_wiring.py +66 -0
- package/backend/tests/test_pull_schemas.py +82 -0
- package/backend/tests/test_release_domain.py +82 -0
- package/backend/tests/test_review_parser.py +301 -0
- package/backend/tests/test_schemas_user.py +97 -0
- package/backend/tests/test_security.py +92 -0
- package/cli/index.js +338 -0
- package/compose.yaml +105 -0
- package/frontend/.dockerignore +4 -0
- package/frontend/Dockerfile +27 -0
- package/frontend/index.html +14 -0
- package/frontend/nginx.conf +47 -0
- package/frontend/package-lock.json +5020 -0
- package/frontend/package.json +35 -0
- package/frontend/src/api/ai.ts +124 -0
- package/frontend/src/api/analytics.ts +66 -0
- package/frontend/src/api/attention.ts +181 -0
- package/frontend/src/api/auth.ts +74 -0
- package/frontend/src/api/classification.ts +170 -0
- package/frontend/src/api/issues.ts +90 -0
- package/frontend/src/api/pulls.ts +233 -0
- package/frontend/src/api/repositories.ts +95 -0
- package/frontend/src/api/sig.ts +232 -0
- package/frontend/src/app/antd-theme.ts +94 -0
- package/frontend/src/app/providers.tsx +59 -0
- package/frontend/src/app/router.tsx +411 -0
- package/frontend/src/app/search.ts +30 -0
- package/frontend/src/components/ClassificationBadge.tsx +58 -0
- package/frontend/src/components/GateBadge.tsx +13 -0
- package/frontend/src/components/SeverityBadge.tsx +20 -0
- package/frontend/src/components/layout/AppShell.tsx +16 -0
- package/frontend/src/components/layout/AuthLayout.tsx +32 -0
- package/frontend/src/components/layout/Sidebar.tsx +223 -0
- package/frontend/src/components/layout/TopBar.tsx +47 -0
- package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
- package/frontend/src/components/pulls/FacetRail.tsx +199 -0
- package/frontend/src/components/pulls/LabelChips.tsx +87 -0
- package/frontend/src/components/ui/alert.tsx +30 -0
- package/frontend/src/components/ui/badge.tsx +53 -0
- package/frontend/src/components/ui/button.tsx +51 -0
- package/frontend/src/components/ui/card.tsx +64 -0
- package/frontend/src/components/ui/chart-theme.ts +65 -0
- package/frontend/src/components/ui/data-table.tsx +39 -0
- package/frontend/src/components/ui/echart.tsx +70 -0
- package/frontend/src/components/ui/empty-state.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +39 -0
- package/frontend/src/components/ui/lazy-chart.tsx +21 -0
- package/frontend/src/components/ui/skeleton.tsx +19 -0
- package/frontend/src/hooks/use-current-repository.ts +44 -0
- package/frontend/src/hooks/use-current-user.ts +38 -0
- package/frontend/src/lib/api-client.ts +93 -0
- package/frontend/src/lib/css-color.ts +60 -0
- package/frontend/src/lib/utils.ts +45 -0
- package/frontend/src/main.tsx +22 -0
- package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
- package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
- package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
- package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
- package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
- package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
- package/frontend/src/pages/login/LoginPage.tsx +95 -0
- package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
- package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
- package/frontend/src/pages/members/MembersPage.tsx +534 -0
- package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
- package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
- package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
- package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
- package/frontend/src/pages/setup/SetupPage.tsx +144 -0
- package/frontend/src/styles/globals.css +147 -0
- package/frontend/tsconfig.json +22 -0
- package/frontend/vite.config.ts +57 -0
- package/package.json +48 -0
- package/scripts/e2e-auth-flow.py +131 -0
- package/scripts/e2e-pr-detail.py +128 -0
- package/scripts/e2e-verify.py +773 -0
- package/scripts/verify-ai-pipeline.py +616 -0
- 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
|