From 0e41f2876cb0e448ac26572403dc4bdb53c5e53c Mon Sep 17 00:00:00 2001 From: weixin_53033691 Date: Tue, 12 May 2026 10:24:20 +0800 Subject: [PATCH 1/5] =?UTF-8?q?[feature]UT=E9=97=A8=E7=A6=81=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=E7=BB=9F=E8=AE=A1=EF=BC=9A=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E7=94=9F=E6=88=90=E3=80=81UT=E9=97=A8=E7=A6=81=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E4=B8=8A=E6=8A=A5API=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + backend/api/router.py | 3 +- backend/api/v1/ut_gate_run.py | 57 ++++ backend/core/config.py | 3 + backend/core/dependencies.py | 43 ++- backend/models/__init__.py | 2 + backend/models/ut_gate_run.py | 45 +++ backend/schemas/ut_gate_run.py | 63 ++++ backend/services/schema_check_service.py | 1 + backend/services/ut_gate_run_service.py | 82 +++++ backend/tests/test_openapi.py | 2 + backend/tests/test_ut_gate_run_service.py | 86 +++++ database/V1.1.2__create_ut_gate_run.sql | 23 ++ docs/05_technical_architecture.md | 3 + spec/15_ut_gate_jenkins_report_spec.md | 389 ++++++++++++++++++++++ spec/16_ut_gate_report_post_api_spec.md | 212 ++++++++++++ 16 files changed, 1015 insertions(+), 2 deletions(-) create mode 100644 backend/api/v1/ut_gate_run.py create mode 100644 backend/models/ut_gate_run.py create mode 100644 backend/schemas/ut_gate_run.py create mode 100644 backend/services/ut_gate_run_service.py create mode 100644 backend/tests/test_ut_gate_run_service.py create mode 100644 database/V1.1.2__create_ut_gate_run.sql create mode 100644 spec/15_ut_gate_jenkins_report_spec.md create mode 100644 spec/16_ut_gate_report_post_api_spec.md diff --git a/.env.example b/.env.example index 2fc89f2..32a49b8 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,6 @@ AI_ANALYZE_RATE_LIMIT_WINDOW_SECONDS=60 AI_ANALYZE_RATE_LIMIT_MAX_REQUESTS=10 # dt-report -> AIFA 请求超时(秒) AI_ANALYZE_TIMEOUT_SECONDS=180 + +# UT 门禁 Jenkins 上报:与 Jenkins Credentials 注入值一致;留空则 POST /api/v1/ut-gate-runs 返回 401 +UT_GATE_INTEGRATION_TOKEN= diff --git a/backend/api/router.py b/backend/api/router.py index d9647a1..1496531 100644 --- a/backend/api/router.py +++ b/backend/api/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from backend.api.v1 import auth, dashboard, overview, history, analysis, cases, report, notification, admin +from backend.api.v1 import auth, dashboard, overview, history, analysis, cases, report, notification, admin, ut_gate_run api_router = APIRouter(prefix="/api/v1") @@ -13,3 +13,4 @@ api_router.include_router(report.router) api_router.include_router(notification.router) api_router.include_router(admin.router) +api_router.include_router(ut_gate_run.router) diff --git a/backend/api/v1/ut_gate_run.py b/backend/api/v1/ut_gate_run.py new file mode 100644 index 0000000..0bbb2fd --- /dev/null +++ b/backend/api/v1/ut_gate_run.py @@ -0,0 +1,57 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import JSONResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.core.database import get_db +from backend.core.dependencies import verify_ut_gate_integration_token +from backend.schemas.ut_gate_run import UtGateRunCreate, UtGateRunItem +from backend.services.ut_gate_run_service import UtGateIdempotencyConflict, create_ut_gate_run + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/ut-gate-runs", tags=["UT门禁上报"]) + + +@router.post( + "", + response_model=UtGateRunItem, + response_model_exclude_none=False, + responses={ + 200: {"description": "幂等键已存在且内容一致(spec/16 §5.2)"}, + 201: {"description": "新建记录"}, + 409: {"description": "幂等键已存在且请求内容不一致"}, + }, + summary="上报 UT 门禁单次构建结果", + description="实现规约见 `spec/16_ut_gate_report_post_api_spec.md`。", +) +async def post_ut_gate_run( + body: UtGateRunCreate, + db: AsyncSession = Depends(get_db), + _: None = Depends(verify_ut_gate_integration_token), +): + try: + row, http_status = await create_ut_gate_run(db, body) + except UtGateIdempotencyConflict: + logger.warning( + "UT 门禁上报幂等冲突 idempotency_key=%s job_name=%s build_number=%s", + body.idempotency_key, + body.job_name, + body.build_number, + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="幂等键已存在且请求内容不一致", + ) + + logger.info( + "UT 门禁上报成功 http_status=%s id=%s idempotency_key=%s job_name=%s build_number=%s", + http_status, + row.id, + body.idempotency_key, + body.job_name, + body.build_number, + ) + payload = UtGateRunItem.model_validate(row).model_dump(mode="json") + return JSONResponse(status_code=http_status, content=payload) diff --git a/backend/core/config.py b/backend/core/config.py index 6e7c5fc..b4c07b3 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -43,6 +43,9 @@ class Settings(BaseSettings): # dt-report -> AIFA 单次调用超时(秒) AI_ANALYZE_TIMEOUT_SECONDS: int = 180 + # UT 门禁 Jenkins 上报:固定集成 Token(Authorization: Bearer),与 Jenkins Credentials 一致;空则拒绝上报(401) + UT_GATE_INTEGRATION_TOKEN: str = "" + # LDAP 域登录(LDAP_HOST 留空则使用 MVP 密码模式) LDAP_HOST: str = "" LDAP_PORT: int = 389 diff --git a/backend/core/dependencies.py b/backend/core/dependencies.py index 8cc1a0d..b6f5734 100644 --- a/backend/core/dependencies.py +++ b/backend/core/dependencies.py @@ -1,12 +1,20 @@ +import hmac +import logging +from typing import Optional + from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPBearer +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.ext.asyncio import AsyncSession +from backend.core.config import settings from backend.core.database import get_db from backend.core.security import verify_token from backend.services.auth_service import get_user_role +logger = logging.getLogger(__name__) + bearer_scheme = HTTPBearer(auto_error=False) +_ut_gate_bearer = HTTPBearer(auto_error=False) async def get_db_session() -> AsyncSession: # type: ignore[misc] @@ -34,3 +42,36 @@ async def require_apply_failure_reason_permission( if role not in ("user", "admin"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限执行该操作") return payload + + +async def verify_ut_gate_integration_token( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_ut_gate_bearer), +) -> None: + """ + UT 门禁 Jenkins 上报专用:Bearer 与 UT_GATE_INTEGRATION_TOKEN 一致(spec/16 §3)。 + Token 未配置或非 Bearer 时返回 401;不在日志中输出 Token。 + """ + expected = (settings.UT_GATE_INTEGRATION_TOKEN or "").strip() + if not expected: + logger.warning("UT 门禁上报被拒绝:UT_GATE_INTEGRATION_TOKEN 未配置") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="UT 门禁上报未启用或密钥未配置", + ) + if credentials is None or (credentials.scheme or "").lower() != "bearer": + logger.warning("UT 门禁上报鉴权失败:缺少或非法的 Authorization") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效或不存在的认证信息", + ) + received = credentials.credentials or "" + try: + ok = hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8")) + except ValueError: + ok = False + if not ok: + logger.warning("UT 门禁上报鉴权失败:Token 不匹配") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="认证失败", + ) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 8f600c7..6366b62 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -10,6 +10,7 @@ from backend.models.sys_audit_log import SysAuditLog from backend.models.report_snapshot import ReportSnapshot from backend.models.history_search_template import HistorySearchTemplate +from backend.models.ut_gate_run import UtGateRun __all__ = [ "Base", @@ -24,4 +25,5 @@ "SysAuditLog", "ReportSnapshot", "HistorySearchTemplate", + "UtGateRun", ] diff --git a/backend/models/ut_gate_run.py b/backend/models/ut_gate_run.py new file mode 100644 index 0000000..80f2eca --- /dev/null +++ b/backend/models/ut_gate_run.py @@ -0,0 +1,45 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import Boolean, DateTime, Index, Integer, String, UniqueConstraint, text +from sqlalchemy.dialects.mysql import BIGINT, INTEGER +from sqlalchemy.orm import Mapped, mapped_column + +from backend.models.base import Base + + +class UtGateRun(Base): + """Jenkins UT 门禁上报记录,与 database/V1.1.2__create_ut_gate_run.sql 一致。""" + + __tablename__ = "ut_gate_run" + __table_args__ = ( + UniqueConstraint("idempotency_key", name="uk_idempotency"), + Index("idx_created_at", "created_at"), + Index("idx_mr_url_created", "mr_url", "created_at"), + Index("idx_is_intercepted_created", "is_intercepted", "created_at"), + Index("idx_job_build", "job_name", "build_number"), + {"extend_existing": True}, + ) + + id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True, comment="主键") + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="记录创建时间" + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + server_onupdate=text("CURRENT_TIMESTAMP"), + comment="更新时间", + ) + reported_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="门禁结束上报时间" + ) + jenkins_base_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True, comment="Jenkins 根 URL") + job_name: Mapped[str] = mapped_column(String(256), nullable=False, comment="Job 名称") + build_number: Mapped[int] = mapped_column(INTEGER(unsigned=True), nullable=False, comment="构建号") + build_url: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True, comment="本次构建页 URL") + mr_url: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True, comment="MR 页面完整 URL") + idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False, comment="幂等键") + is_intercepted: Mapped[bool] = mapped_column(Boolean, nullable=False, comment="是否拦截到失败用例") + ut_exit_code: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, comment="cargo make test 退出码") diff --git a/backend/schemas/ut_gate_run.py b/backend/schemas/ut_gate_run.py new file mode 100644 index 0000000..f9aa95c --- /dev/null +++ b/backend/schemas/ut_gate_run.py @@ -0,0 +1,63 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class UtGateRunCreate(BaseModel): + """POST /api/v1/ut-gate-runs 请求体;未知字段忽略(spec/16 §4.1)。""" + + model_config = ConfigDict(extra="ignore") + + idempotency_key: str = Field(..., min_length=1, max_length=128) + job_name: str = Field(..., min_length=1, max_length=256) + build_number: int = Field(...) + is_intercepted: bool + ut_exit_code: Optional[int] = None + build_url: Optional[str] = Field(None, max_length=1024) + jenkins_base_url: Optional[str] = Field(None, max_length=512) + mr_url: Optional[str] = Field(None, max_length=1024) + + @field_validator("idempotency_key", "job_name") + @classmethod + def strip_required_strings(cls, v: str) -> str: + s = v.strip() + if not s: + raise ValueError("不能为空或仅空白") + return s + + @field_validator("build_url", "jenkins_base_url", "mr_url", mode="before") + @classmethod + def strip_optional_urls(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + if not isinstance(v, str): + return v + s = v.strip() + return s if s else None + + @field_validator("build_number") + @classmethod + def build_number_unsigned_int(cls, v: int) -> int: + if v < 0 or v > 4294967295: + raise ValueError("build_number 须在 0~4294967295(INT UNSIGNED)范围内") + return v + + +class UtGateRunItem(BaseModel): + """单条 ut_gate_run 响应,与表字段一致(spec/16 §6)。""" + + id: int + created_at: datetime + updated_at: datetime + reported_at: datetime + jenkins_base_url: Optional[str] = None + job_name: str + build_number: int + build_url: Optional[str] = None + mr_url: Optional[str] = None + idempotency_key: str + is_intercepted: bool + ut_exit_code: Optional[int] = None + + model_config = {"from_attributes": True} diff --git a/backend/services/schema_check_service.py b/backend/services/schema_check_service.py index 85f4203..108cfd2 100644 --- a/backend/services/schema_check_service.py +++ b/backend/services/schema_check_service.py @@ -27,6 +27,7 @@ "case_offline_type": "V1.0.8__create_case_offline_type.sql", "sys_audit_log": "V1.0.9__create_sys_audit_log.sql", "report_snapshot": "V1.1.0__create_report_snapshot.sql", + "ut_gate_run": "V1.1.2__create_ut_gate_run.sql", } TABLE_RE = re.compile(r"CREATE\s+TABLE\s+`([^`]+)`\s*\(", re.IGNORECASE) diff --git a/backend/services/ut_gate_run_service.py b/backend/services/ut_gate_run_service.py new file mode 100644 index 0000000..5aa7d31 --- /dev/null +++ b/backend/services/ut_gate_run_service.py @@ -0,0 +1,82 @@ +import logging +from typing import Tuple + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.models.ut_gate_run import UtGateRun +from backend.schemas.ut_gate_run import UtGateRunCreate + +logger = logging.getLogger(__name__) + + +class UtGateIdempotencyConflict(Exception): + """同 idempotency_key 已存在且参与幂等比较的字段与请求不一致。""" + + +def _payload_matches_existing_row(body: UtGateRunCreate, row: UtGateRun) -> bool: + """spec/16 §5.1:逐项比较客户端可写字段(NULL 与缺失等价)。""" + if row.job_name != body.job_name: + return False + if int(row.build_number) != int(body.build_number): + return False + if (row.build_url or None) != (body.build_url or None): + return False + if (row.jenkins_base_url or None) != (body.jenkins_base_url or None): + return False + if (row.mr_url or None) != (body.mr_url or None): + return False + if bool(row.is_intercepted) != bool(body.is_intercepted): + return False + if (row.ut_exit_code if row.ut_exit_code is not None else None) != ( + body.ut_exit_code if body.ut_exit_code is not None else None + ): + return False + return True + + +async def create_ut_gate_run(db: AsyncSession, body: UtGateRunCreate) -> Tuple[UtGateRun, int]: + """ + 插入或幂等返回已有行。 + 返回 (UtGateRun, http_status),status 为 201 或 200。 + 冲突时抛出 UtGateIdempotencyConflict。 + """ + key = body.idempotency_key + res = await db.execute(select(UtGateRun).where(UtGateRun.idempotency_key == key)) + existing = res.scalar_one_or_none() + if existing is not None: + if _payload_matches_existing_row(body, existing): + return existing, status.HTTP_200_OK + raise UtGateIdempotencyConflict() + + row = UtGateRun( + idempotency_key=body.idempotency_key, + job_name=body.job_name, + build_number=body.build_number, + build_url=body.build_url, + jenkins_base_url=body.jenkins_base_url, + mr_url=body.mr_url, + is_intercepted=body.is_intercepted, + ut_exit_code=body.ut_exit_code, + ) + db.add(row) + try: + await db.commit() + await db.refresh(row) + return row, status.HTTP_201_CREATED + except IntegrityError: + await db.rollback() + logger.warning("UT 门禁上报 INSERT 唯一键冲突,进入重试比对: idempotency_key=%s", key) + res2 = await db.execute(select(UtGateRun).where(UtGateRun.idempotency_key == key)) + row2 = res2.scalar_one_or_none() + if row2 is None: + logger.exception("UT 门禁上报唯一键冲突后未查询到记录: idempotency_key=%s", key) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="写入失败,请稍后重试", + ) + if _payload_matches_existing_row(body, row2): + return row2, status.HTTP_200_OK + raise UtGateIdempotencyConflict() diff --git a/backend/tests/test_openapi.py b/backend/tests/test_openapi.py index 05b1c3d..39b7aeb 100644 --- a/backend/tests/test_openapi.py +++ b/backend/tests/test_openapi.py @@ -13,3 +13,5 @@ async def test_openapi_json_available(): body = response.json() assert body.get("openapi") is not None assert "paths" in body + assert "/api/v1/ut-gate-runs" in body["paths"] + assert "post" in body["paths"]["/api/v1/ut-gate-runs"] diff --git a/backend/tests/test_ut_gate_run_service.py b/backend/tests/test_ut_gate_run_service.py new file mode 100644 index 0000000..122e9c0 --- /dev/null +++ b/backend/tests/test_ut_gate_run_service.py @@ -0,0 +1,86 @@ +from unittest.mock import MagicMock + +from backend.schemas.ut_gate_run import UtGateRunCreate +from backend.services.ut_gate_run_service import _payload_matches_existing_row + + +def _make_row(**kwargs): + row = MagicMock() + defaults = { + "job_name": "job", + "build_number": 1, + "build_url": None, + "jenkins_base_url": None, + "mr_url": None, + "is_intercepted": False, + "ut_exit_code": None, + } + defaults.update(kwargs) + for k, v in defaults.items(): + setattr(row, k, v) + return row + + +def test_payload_matches_equal_minimal(): + body = UtGateRunCreate( + idempotency_key="k1", + job_name="job", + build_number=1, + is_intercepted=False, + ) + assert _payload_matches_existing_row(body, _make_row()) is True + + +def test_payload_matches_with_urls_and_exit_code(): + body = UtGateRunCreate( + idempotency_key="k1", + job_name="job", + build_number=2, + is_intercepted=True, + build_url="https://j.example/job/2/", + jenkins_base_url="https://j.example", + mr_url="https://c.example/mr/1", + ut_exit_code=0, + ) + row = _make_row( + build_number=2, + is_intercepted=True, + build_url="https://j.example/job/2/", + jenkins_base_url="https://j.example", + mr_url="https://c.example/mr/1", + ut_exit_code=0, + ) + assert _payload_matches_existing_row(body, row) is True + + +def test_payload_mismatch_job_name(): + body = UtGateRunCreate( + idempotency_key="k1", + job_name="other", + build_number=1, + is_intercepted=False, + ) + assert _payload_matches_existing_row(body, _make_row()) is False + + +def test_payload_mismatch_ut_exit_code_none_vs_int(): + body = UtGateRunCreate( + idempotency_key="k1", + job_name="job", + build_number=1, + is_intercepted=False, + ut_exit_code=1, + ) + assert _payload_matches_existing_row(body, _make_row(ut_exit_code=None)) is False + + +def test_unknown_json_keys_ignored_by_schema(): + data = { + "idempotency_key": "k1", + "job_name": "job", + "build_number": 1, + "is_intercepted": False, + "error_message": "should be ignored", + } + m = UtGateRunCreate.model_validate(data) + assert "error_message" not in m.model_dump() diff --git a/database/V1.1.2__create_ut_gate_run.sql b/database/V1.1.2__create_ut_gate_run.sql new file mode 100644 index 0000000..29493ce --- /dev/null +++ b/database/V1.1.2__create_ut_gate_run.sql @@ -0,0 +1,23 @@ +-- 新建 UT 门禁上报记录表 ut_gate_run(见 spec/15_ut_gate_jenkins_report_spec.md §5) +-- MySQL 5.7,字符集 utf8mb4,排序规则 utf8mb4_unicode_ci + +CREATE TABLE `ut_gate_run` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `reported_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '门禁结束上报时间', + `jenkins_base_url` varchar(512) DEFAULT NULL COMMENT 'Jenkins 根 URL(由 BUILD_URL 解析)', + `job_name` varchar(256) NOT NULL COMMENT 'Job 名称', + `build_number` int(10) unsigned NOT NULL COMMENT '构建号', + `build_url` varchar(1024) DEFAULT NULL COMMENT '本次构建页 URL', + `mr_url` varchar(1024) DEFAULT NULL COMMENT 'MR 页面完整 URL', + `idempotency_key` varchar(128) NOT NULL COMMENT '幂等键(同一次 Jenkins 构建)', + `is_intercepted` tinyint(1) NOT NULL COMMENT '是否拦截到:1=可判定且存在失败用例,0=其它', + `ut_exit_code` int(11) DEFAULT NULL COMMENT 'cargo make test 退出码', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_idempotency` (`idempotency_key`), + KEY `idx_created_at` (`created_at`), + KEY `idx_mr_url_created` (`mr_url`, `created_at`), + KEY `idx_is_intercepted_created` (`is_intercepted`, `created_at`), + KEY `idx_job_build` (`job_name`, `build_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/05_technical_architecture.md b/docs/05_technical_architecture.md index 3f83a77..063bf69 100644 --- a/docs/05_technical_architecture.md +++ b/docs/05_technical_architecture.md @@ -224,6 +224,7 @@ pipeline_cases case_offline_type sys_audit_log (新增) | case_offline_type | 全表 | 全字段 CRUD | 管理员操作 | | sys_audit_log | 全表 | INSERT only | 系统自动写入 | | report_snapshot | 全表 | INSERT / SELECT | 管理员生成报告时写入 | +| ut_gate_run | 全表 | INSERT(幂等) | Jenkins 经 `POST /api/v1/ut-gate-runs` 写入,鉴权为 `UT_GATE_INTEGRATION_TOKEN`(见 `spec/16_ut_gate_report_post_api_spec.md`) | --- @@ -236,6 +237,7 @@ pipeline_cases case_offline_type sys_audit_log (新增) | Epic 1 | 数据看板 | `/api/v1/dashboard` | DashboardPage | pipeline_overview | | Epic 1 | 分组概览 | `/api/v1/overview` | OverviewPage | pipeline_overview | | Epic 1 | 执行明细 | `/api/v1/history` | HistoryPage | pipeline_history | +| Epic 1 | UT 门禁上报 | `POST /api/v1/ut-gate-runs`(列表页见 Story 规划) | (「UT门禁历史」页面对接 `GET` 待实现) | ut_gate_run | | Epic 2 | 失败分析 | `/api/v1/analysis` | (HistoryPage 内交互) | pipeline_failure_reason | | Epic 3 | 总结报告 | `/api/v1/report` | ReportPage | report_snapshot | | Epic 4 | 消息通知 | `/api/v1/notification` | NotificationPage | WeLink API | @@ -274,6 +276,7 @@ pipeline_cases case_offline_type sys_audit_log (新增) - 认证方式:JWT (HS256),token 有效期 8 小时 - 角色判定:token 中的 `sub`(员工工号)是否在 `ADMIN_EMPLOYEE_IDS` 列表中 - 权限校验:后端 `Depends(require_admin)` 拦截管理员接口 +- **Jenkins UT 门禁上报**:`POST /api/v1/ut-gate-runs` 使用 **`Authorization: Bearer`** 与配置项 **`UT_GATE_INTEGRATION_TOKEN`** 校验,**不**使用用户 JWT;规约见 `spec/16_ut_gate_report_post_api_spec.md` --- diff --git a/spec/15_ut_gate_jenkins_report_spec.md b/spec/15_ut_gate_jenkins_report_spec.md new file mode 100644 index 0000000..011cb29 --- /dev/null +++ b/spec/15_ut_gate_jenkins_report_spec.md @@ -0,0 +1,389 @@ +# UT 门禁结果自动上报至看板系统(Spec) + +本文档描述将 CodeHub MR 触发的 Jenkins UT 门禁执行结果**采集、持久化、查询与展示**的方案设计,供 Jenkins 侧脚本改造与 QualityBoard(dt-report)后端/前端实现时对照。本文档**不**包含 `build/.cloudbuild/gate/root.sh` 的具体实现(该文件不在本仓),仅约定对接契约与集成点。 + +**关联约束**(实现本需求时需遵守项目规则):新建表须先有 `database/Vx.y.z__*.sql` 迁移;MySQL 5.7 语法;禁止改动既有 8 张保护表;上报接口认证方式与日志规范见本文档与 `docs/06_logging_guide.md`。 + +--- + +## 1. 背景与业务目标 + +### 1.1 背景 + +- CodeHub 上 MR 在合入前触发 Jenkins UT 门禁任务。 +- 任务核心步骤:拉取代码 → 执行 `cargo make test` → 在 Console Output 中出现汇总行,例如: + `Summary [ 3.178s] 83 tests run: 83 passed, 3 skipped` +- **UT 拦截到问题**:以**合法 Summary** 为准——若存在 **失败用例**(例如日志中 **`failed` 且失败数大于 0** 或等价表达),则 **`is_intercepted=true`**。 +- **其余一切情况**(含:**合法 Summary 且无失败**、**无合法 Summary**、**前置失败**、**解析失败** 等)均 **`is_intercepted=false`**。**注意**:`false` **不**等价于「未检出问题的健康构建」——仅表示「未满足『可判定且存在失败用例』」;细分语义须结合 Jenkins 日志或后续扩展字段(见 §8.1、§8.4)。 + +### 1.2 业务目标 + +- 记录**哪次 MR / 哪次构建**触发了 UT 门禁。 +- 用单一布尔字段 **`is_intercepted`** 表示 **UT 是否拦截到**:**仅当** Summary **可判定**且**存在失败用例**时为 **`true`**;**其它所有情况**均为 **`false`**(见 §3.3、§5.2)。 +- 通过 **「UT门禁历史」列表页**(见 §8.3)展示上报记录与 **拦截效果**(**`is_intercepted`** 等列);**`false` 为混合集合**(含未拦截、不可判定等)。**本期不做**首页图表、**不做** ECharts 趋势/饼柱图(见 §8.2);若需按「无 Summary / 前置失败」单独出图或趋势分析,**二期**加字段或扩展页面。**本期库表不存** MR 源/目标分支、MR 编号、**Git 仓库 URL、commit**;若要看代码分支或提交维度,须 **二期扩展字段** 或仅从 **`mr_url` 路径** 做展示层解析(非库表字段)。 + +### 1.3 非目标(本期可不做) + +- 替换或重写 Jenkins 门禁判定逻辑(仍以现有脚本/退出码为准)。 +- 存储完整 Console Output 或单条用例级明细(除非后续单独立项)。 +- 在 Jenkins 内嵌 QualityBoard 页面(本期以 API + Web 看板为主)。 + +--- + +## 2. 需求与约束 + +| 编号 | 需求/约束 | 说明 | +|------|-----------|------| +| R1 | 不破坏现有 UT 门禁 | 上报为**旁路**;不得改变 `cargo make test` 的调用方式与失败判定主路径。 | +| R2 | 上报失败不阻断任务 | 上报须包在「忽略非零退出」或独立子 shell 中;失败仅打日志,**不得** `exit 1`。 | +| R3 | 网络 | Jenkins 执行节点到 QualityBoard 后端需可达;若不通须走代理或内网 DNS,**在部署文档中明确**。 | +| R4 | 敏感信息 | 禁止在脚本中硬编码密码、长期 Token;使用 Jenkins Credentials / 环境变量注入。 | +| R5 | **不依赖未定义环境变量** | Jenkins 侧上报逻辑**仅允许**使用 §5.2.1 所列 **A / B 类**与**脚本可计算值**;**禁止**裸引用未列入 §5.2.1、未判空的变量;可选字段在无法可靠得到时 **省略 JSON 键或传 `null`**(见 §5.2.1)。 | + +--- + +## 3. 问题 1:如何获取 UT 执行结果? + +### 3.1 方案对比与本期结论 + +**本期规约:仅采用方案 B**——在 **root.sh**(或门禁脚本最外层)用 **`tee`** 捕获 `cargo make test` 输出并在**同一次构建**内解析 Summary;**不**采用任务结束后依赖 **Jenkins API(或插件)拉取 Console Output** 再解析的方案 A,也**不**并行部署 QualityBoard 侧「拉日志对账」类同步任务。 + +| 方案 | 做法 | 优点 | 缺点 | 本期 | +|------|------|------|------|------| +| **A. 任务结束后解析 Console Output** | 用 Jenkins API(或插件)拉取构建日志,服务端/独立 Job 解析 Summary | 不改门禁脚本;集中解析 | 依赖 Jenkins API 与权限;有延迟;日志量大时需截断策略 | **不采用** | +| **B. root.sh 内捕获测试输出** | 将 `cargo make test` 输出经管道 **`tee`** 写入工作区文件,同脚本内解析 Summary | 实时、不依赖事后拉日志;易与构建号、MR 元数据对齐 | 需改脚本(仍可为旁路);需约定输出编码与文件路径;日志极大时注意磁盘空间 | **采用** | +| **C. 测试框架/JUnit 报告** | 若 `cargo make test` 可生成 JUnit XML,解析 XML 上报 | 结构化、可扩展用例级统计 | 依赖工具链是否产出报告;改造面可能大于解析一行 Summary | **不采用**(后续若要用例级统计再评估) | + +### 3.2 本期实现要点(方案 B) + +- 在 **root.sh**(或门禁脚本最外层)对测试命令使用 **`tee`**,将 stdout/stderr 写入**构建工作目录下**固定相对路径文件(如 `.ci/ut_console.log`),在同一脚本末尾解析 **`Summary ...`** 行(**本期业务约定**:日志中**不出现多行** Summary,见 §3.3、§7.2)。 +- 构建节点须具备常见 Linux 自带的 **`tee`**(属 coreutils);极简无 `tee` 的镜像需在流水线镜像或脚本中另行保证可用性。 + +### 3.3 Summary 行解析规约(逻辑) + +- **模式(建议,待与真实日志对齐)**: + `Summary [] tests run:

passed, skipped` + 若存在 **`, failed`** 或 **`0 failed` 以外的 failed 片段**,且 **failed > 0**,则 **`is_intercepted=true`**。 + **其它任意情况**(含:合法 Summary 且无失败、无合法 Summary、仅凭退出码非 0、解析失败等)一律 **`is_intercepted=false`**。 +- **本期业务约定**:UT 门禁日志中 **Summary 仅一行**(**不出现**多 crate / 多模块导致的**多行** Summary;若将来工具链变化出现多行,须修订 §7.2 解析策略)。 +- **本期解析目标**:产出 **`is_intercepted`(布尔)** 即可;**不得**仅凭「`cargo make test` 退出码非 0」置 **`true`**(须以 Summary 可判定且存在失败用例为准)。 +- **`ut_exit_code`**(见 §5.2)建议随请求上报并落库,便于在 **`is_intercepted=false`** 时结合 Jenkins 排查。 + +--- + +## 4. 问题 2:如何持久化存储结果? + +| 方案 | 说明 | 推荐 | +|------|------|------| +| **A. 调用后端 API 写入数据库** | Jenkins 脚本 `curl` POST JSON;服务端校验后 INSERT | **推荐**;与现有 dt-report 架构一致,查询简单 | +| **B. 写文件由外部同步** | 写 NDJSON/SQLite,由 Filebeat/定时任务导入 | 适合强隔离网络;增加同步组件与延迟 | +| **C. 消息队列** | 推 Kafka/RabbitMQ,消费者落库 | 适合极大规模;本期通常过重 | + +**本期规约**:采用 **A**;若网络不可靠,可在 B 中 **仅作本地落盘备份**(同一路径 append 一行 JSON),不替代 API 成功路径的定义。 + +--- + +## 5. 问题 3:数据表结构设计 + +### 5.1 表名(建议) + +`ut_gate_run`(新建表;DDL 单独迁移文件,与 ORM 字段一致)。 + +### 5.2 字段(建议) + +**本期持久化**:Jenkins 构建维度 + **`mr_url`**(**唯一 MR 相关字段**)+ **`idempotency_key`** + **`is_intercepted`** + **`ut_exit_code`**;**不存** `git_remote_url`、`git_commit_sha`、`mr_id`、`source_branch`、`target_branch`、`mr_title`、`summary_line`、各 `tests_*`、`duration_sec`、`reporter_version` 等。 + +| 字段名 | 类型(MySQL 5.7) | 可空 | 说明 | +|--------|-------------------|------|------| +| `id` | BIGINT UNSIGNED AI PK | 否 | 主键 | +| `created_at` | DATETIME | 否 | 记录创建时间(默认 CURRENT_TIMESTAMP) | +| `updated_at` | DATETIME | 否 | 更新时间 ON UPDATE CURRENT_TIMESTAMP | +| `reported_at` | DATETIME | 否 | 门禁结束上报时间(可由服务端写 `NOW()`,或与客户端 `finished_at` 二选一) | +| `jenkins_base_url` | VARCHAR(512) | 是 | Jenkins 根 URL;**由 `BUILD_URL` 解析**,不依赖 `JENKINS_URL`(见 §5.2.1) | +| `job_name` | VARCHAR(256) | 否 | Job 名称(或 fullName,约定一种) | +| `build_number` | INT UNSIGNED | 否 | 构建号 | +| `build_url` | VARCHAR(1024) | 是 | 本次构建页 URL | +| `mr_url` | VARCHAR(1024) | 是 | **MR 页面完整 URL**(如 CodeHub `.../merge_requests/4647`);作为**逻辑 MR 唯一标识**用于去重与列表跳转;**无 MR 场景**(非 MR 触发)可空 | +| `idempotency_key` | VARCHAR(128) | 否 | **幂等键**:标识「同一次 Jenkins 构建」的唯一键,防止网络重试或脚本重复执行导致**同一次构建写入多行**(详见下文 **idempotency_key 说明**) | +| `is_intercepted` | TINYINT(1) | 否 | **`1`(true)**:Summary **可判定**且**存在失败用例**;**`0`(false)**:其它**所有**情况(含未拦截、无 Summary、前置失败等) | +| `ut_exit_code` | INT | 是 | `cargo make test` 退出码,便于排查;与 `is_intercepted` 无简单一一对应 | + +**为何表内没有 `WORKSPACE`?** +**§5.2 仅列落库字段**。**`WORKSPACE`** 在 **§5.2.1 A 类**中出现,是因为上报脚本需要用它(或 **`${WORKSPACE:-.}`**)拼 **`tee` 日志路径**(见 §7),属于**运行时路径**,随 Agent/任务变化,对看板无稳定业务语义;**`build_url` 已能唯一定位本次构建**,故**不**把 `WORKSPACE` 设计成表字段。若二期要做「工作区审计」再单独加列。 + +**唯一约束**:`UNIQUE KEY uk_idempotency (idempotency_key)`,避免同一构建重复 INSERT。 + +**`idempotency_key` 说明(做什么用)** + +- Jenkins 上报可能因 **超时重试、网络抖动、脚本重复调用** 而多次 `POST` **同一构建**;若无幂等设计,库内会出现多条「同一 `job_name` + `build_number`」的记录,统计会被放大。 +- 客户端为**每一次构建**生成一个稳定字符串:**优先**使用 Jenkins **`BUILD_TAG`**(通常为 `job_name-build_number`,在单控制器内可唯一标识一次构建);或使用 `sha256(job_name + "\0" + str(build_number))` 的 hex(**不**依赖 commit,与本期表结构一致)。 +- 服务端以 **`idempotency_key`** 做 **UNIQUE**:第二次相同 key 的请求返回 **200** 且返回已有记录(或 **409**,见 §6.1),**不**再插入新行。 + +**`is_intercepted` 与业务用语** + +| `is_intercepted` | 含义 | +|------------------|------| +| **true**(1) | **拦截到**:合法 Summary,且存在失败用例 | +| **false**(0) | **非拦截**(混合):含「未拦截」、无 Summary、前置失败、解析失败等,**库内不区分** | + +### 5.2.1 数据来源规约:100% 不依赖「未定义 / 未约定」环境变量(本期) + +**目标**:上报脚本在任何 Agent 上不因「变量未注入」而报错或写入脏数据;**拿不到就不传或可空**,**绝不**假设 `VAR` 一定存在。 + +**A 类——Jenkins 核心变量(视为默认可用;若极端环境缺失须有降级)** + +| 变量 | 用途 | 缺失时 | +|------|------|--------| +| `JOB_NAME` | `job_name` | 视为异常,不应继续上报(或记录错误日志) | +| `BUILD_NUMBER` | `build_number` | 同上 | +| `BUILD_URL` | `build_url`;并可**解析**出 `jenkins_base_url`(见下) | 同上 | +| `BUILD_TAG` | **`idempotency_key` 首选** | 改用 `sha256(JOB_NAME + "\0" + BUILD_NUMBER)` 等**纯 A 类变量**计算 | +| `WORKSPACE` | `tee` 日志路径(如 `"$WORKSPACE/.ci/ut_console.log"`) | 降级为 `"${WORKSPACE:-.}/.ci/..."` 或当前目录(须在试点验证) | + +**禁止**将下列变量当作「一定存在」写入上报逻辑(除非落入 B 类且已判空):`JENKINS_URL`、`GIT_*`、`CI_*` 等未列入本节的通用名。 + +**B 类——CodeHub 插件变量(本期已固化,仅此一项)** + +| Jenkins 变量名 | 映射到请求体 / 表字段 | 使用前 | +|----------------|----------------------|--------| +| **`codehubMergeRequestUrl`** | **`mr_url`** | **`[ -n "${codehubMergeRequestUrl:-}" ]`** 为真则赋值;否则 **省略 `mr_url` 键** 或 **`null`**(非 MR 触发等) | + +- **本期**:**仅**允许通过 **`codehubMergeRequestUrl`** 填充 **`mr_url`**;**不采用** `.ci/mr_url.txt`、**不采用**从 Console / `consoleText` 解析 MR 链接作为默认路径(二期若变更须改本节)。 +- **禁止**在脚本中再引用其它未列入 **A / B 类**的变量名填充 `mr_url`。 + +**`jenkins_base_url`(可空)** + +- **不得**依赖 `JENKINS_URL`。 +- **推荐**:从 **`BUILD_URL`** 用 shell 解析出 **scheme + host(+ 固定 port)**(例如 `https://jenkins.example.com`),解析失败则 **JSON 中省略 `jenkins_base_url`** 或显式 `null`。 + +**`mr_url`(可空)** + +- **本期**:**仅**来自 **`codehubMergeRequestUrl`**(见上表),与 Webhook **`object_attributes.url`** 语义一致,由 CodeHub 插件注入。 +- **非 MR / 变量为空**:不传 `mr_url` 或 `null`。 + +**`is_intercepted` / `ut_exit_code`** + +- **仅**依赖 **`tee` 落盘日志**与 **`PIPESTATUS[0]`**(或等价),**不**依赖任何 MR/Git 环境变量。 + +**API / 后端** + +- `id`、`created_at`、`updated_at`、`reported_at` 由服务端或数据库生成,**不要求**客户端从环境变量推导。 + +### 5.3 索引(支持看板查询) + +| 索引 | 字段 | 用途 | +|------|------|------| +| `idx_created_at` | `created_at` | 时间范围筛选、列表排序;**二期**若做趋势统计可复用 | +| `idx_mr_url_created` | `mr_url`, `created_at` | 按 MR 链接聚合、列表筛选(`mr_url` 可空时索引仍可用,查询注意 IS NOT NULL) | +| `idx_is_intercepted_created` | `is_intercepted`, `created_at` | 列表按拦截状态筛选;**二期**若做分布/趋势可复用 | +| `idx_job_build` | `job_name`, `build_number` | 对账、去重辅助 | + +### 5.4 MR 标识(规约) + +- **本期仅使用 `mr_url`**:完整 MR 页面 URL 一般已包含 **项目路径 + `merge_requests/`**,**全局可区分不同 MR**,无需再存 `mr_id`、源/目标分支。 +- **多仓(已确认)**:不同仓库、不同 MR 的 URL **路径不同**,**仅凭 `mr_url` 即可区分多仓与多 MR**,**不需要**再增加仓库键、`job_name` 等与「仓」绑定的额外字段作区分;每个仓库各自 MR → **各自一条 `mr_url`**,多条构建多行上报;看板按 **`mr_url` 去重** 即按「单仓 MR」统计;若需把多个 MR URL 合成「同一需求」,属 **二期或平台侧关联**,本期不存额外字段。 +- **取值来源**:**仅** §5.2.1 **B 类** **`codehubMergeRequestUrl`**(判非空后写入 `mr_url`)。 +- **`mr_url` 为空**:非 MR 触发的构建可不报;看板「按 MR」统计时 **排除** `mr_url` 为空的记录,或单独展示「无 MR 关联」。 + +--- + +## 6. 问题 4:API 接口设计 + +### 6.1 写入:上报单次门禁结果 + +**POST 实现细则**(请求/响应字段、幂等 200/409、鉴权配置、日志)见 **`spec/16_ut_gate_report_post_api_spec.md`**。 + +| 项目 | 规约 | +|------|------| +| 路径 | `POST /api/v1/ut-gate-runs` | +| Content-Type | `application/json` | +| 认证 | **必须**;**本期仅采用** **固定集成 Token**:HTTP 头 **`Authorization: Bearer `**。**`token`** 由 QualityBoard 配置(如环境变量),Jenkins 经 **Credentials** 注入为环境变量后写入请求头;**禁止**硬编码进仓库脚本。**本期不采用** HMAC + 时间戳、OAuth 用户态;**禁止**将 UT 门禁写入接口与用户登录 Cookie 混用。 | +| 幂等 | 请求体带 `idempotency_key`(**含义与生成**见 §5.2);**实现口径**见 **`spec/16_ut_gate_report_post_api_spec.md` §5**(重复且一致 → **200**;同键不同内容 → **409**)。 | + +**`idempotency_key` 生成建议**:**首选** Jenkins **`BUILD_TAG`**;否则使用 `sha256(job_name + "\0" + str(build_number))` 的 hex(同一 Job 下 **`build_number` 单调递增**,与 `job_name` 组合可区分每次构建)。 + +**请求体(JSON)示例字段**(与表字段对应,蛇形命名,与前端/后端 Schema 一致): + +- 必填:`idempotency_key`, `job_name`, `build_number`, `is_intercepted`(布尔:`true` / `false`) +- **建议**:`mr_url`(**仅当** **`codehubMergeRequestUrl`** 非空时**等于该变量值**)、`ut_exit_code`(整数,可空)、`build_url`(来自 `BUILD_URL`)、`jenkins_base_url`(来自 **`BUILD_URL` 解析**,失败则省略);**须满足 R5 / §5.2.1** + +**响应**:`201 Created` 返回写入记录 ID 与主要字段;幂等命中返回 `200 OK`。 + +### 6.2 查询:列表与聚合(看板) + +**认证**:与 **§6.1** 相同——**`Authorization: Bearer <固定集成Token>`**(**同一**或**独立第二枚** Token 由运维约定;独立时写入/查询权限可拆分)。**本期不采用** HMAC、**不单独**依赖用户 Cookie 作为 UT 门禁 GET 鉴权。 +**看板前端**:**不得**把集成 Token 写进浏览器脚本;由 **QualityBoard 后端**(已走现有用户登录态)**服务端代调** GET,或使用内网仅可达的 BFF。**本期**以内网 + 固定 Token 为最简单闭环。 +**「UT门禁历史」权限(已确认)**:**全员可见**——凡**已登录**本系统的用户均可访问该菜单及列表数据(不因角色隐藏);仍依赖应用整体登录与内网部署;后端代调 UT 列表接口时使用集成 Token。 + +| 接口 | 方法 | 说明 | +|------|------|------| +| `/api/v1/ut-gate-runs` | GET | 分页列表;筛选:`start_time`, `end_time`, `is_intercepted`, `mr_url`(精确或前缀,**待实现约定**), `job_name` | +| `/api/v1/ut-gate-runs/stats` | GET | 聚合:按日/周 **`is_intercepted=true` 次数**、**`false` 次数**(`false` 为混合口径);可选 **按 `job_name`** 分布(参数:`granularity`, `start_time`, `end_time`);**本期不按仓库 URL / commit / MR 分支** 维度存库(无 `git_remote_url`、`git_commit_sha`、`source_branch`/`target_branch`) | + +### 6.3 异常与错误码 + +| HTTP | 场景 | +|------|------| +| 400 | JSON 非法、缺少必填字段、枚举非法 | +| 401/403 | 认证失败 | +| 409 | 幂等键冲突且载荷不一致(若采用严格策略) | +| 422 | 业务校验失败(如字段类型非法、与服务端约定规则冲突等;**本期**对 `is_intercepted` 的真假以 **Jenkins 脚本解析结果为准**,服务端可不做强校验) | +| 500 | 服务端/数据库异常(须 `logger.exception`,不落敏感信息) | + +**Jenkins 侧**:任意 4xx/5xx **仅记录日志**,不改变门禁退出码。 + +--- + +## 7. 问题 5:root.sh 改造要点 + +### 7.1 插入位置(逻辑顺序) + +1. 在调用 `cargo make test` **之前**:确保目录存在,准备日志文件路径。 +2. **执行测试**:`cargo make test 2>&1 | tee "${WORKSPACE:-.}/.ci/ut_console.log"`(路径须与 §5.2.1 **`WORKSPACE` 降级**一致);**同时**保存 **`PIPESTATUS[0]`**(或等价)为 `UT_EXIT_CODE`。 +3. **在现有成功/失败判定与 `exit` 之前或之后**(须在**同一 shell** 可拿到退出码处):调用上报函数 `report_ut_gate`(内部 `curl`,`|| true`)。 +4. **保持原有** `exit` 逻辑**不变**(仍以门禁规则为准)。 + +### 7.2 Summary 解析 + +- 从 **`${WORKSPACE:-.}/.ci/ut_console.log`**(与 §7.1 路径一致)读取匹配 **`Summary [`** 的行(**本期约定**:日志中**仅一行** Summary,直接取该行即可;实现上亦可保留「取最后一行匹配」以防御偶然重复输出)。 +- 根据该行:**存在失败用例** → 上报 **`is_intercepted: true`**;**否则**(含合法 Summary 无失败)→ **`is_intercepted: false`**。 +- 若**无合法 Summary** 或无法解析:**一律** **`is_intercepted: false`**;**不得**仅凭退出码非 0 置 **`true`**。同时上报 **`ut_exit_code`**(`PIPESTATUS[0]`)便于排查。 + +### 7.3 失败时是否上报 + +**是**。只要门禁流程走到「测试已执行完毕」,均应上报 **`is_intercepted`**(及 **`idempotency_key`**、**`job_name`**、**`build_number`** 等必填项)与建议的 **`ut_exit_code`**;**`mr_url`** 仅当 **`codehubMergeRequestUrl`** 非空时填写。 +**未执行测试就中断**(如克隆失败):是否仍 `POST` 由 **Jenkins 侧策略**自定(**不影响**门禁结论);**本期不提供** **`error_message`** 上报字段,亦**不在** `ut_gate_run` 表扩展该列。 + +--- + +## 8. 问题 6:看板展示维度 + +### 8.1 维度(本期以列表呈现为主) + +| 维度 | 本期在「UT门禁历史」页的用法 | +|------|------------------------------| +| 时间 | 表格列 **`created_at` / `reported_at`**(与 §5.2 一致),支持时间范围 **筛选**、分页排序;**不做**按日/周聚合图表 | +| MR | 列 **`mr_url`**(可外链 CodeHub)、列 **`is_intercepted`**;同一 MR 多行构建以多行展示,**不做** MR 维度合并小计图表 | +| Job | 列 **`job_name`**、**`build_number`** 等;**不做** Job 占比饼图 | + +### 8.2 图表(本期不做) + +- **本期**:**不实现** ECharts(**无**趋势图、柱状图、饼图等);**不在系统首页**展示 UT 门禁相关图表或汇总卡片。 +- **二期(可选)**:可恢复趋势/分布类图表(例如按日拦截次数、`job_name` 占比等),与 §6.2 `stats` 接口能力配套后再做。 + +### 8.3 与现有系统关系:菜单与路由 + +- **菜单位置**:与 **「详细执行历史」**(现有 `/history` 所在主导航层级)**同级**,新增一项,菜单文案:**「UT门禁历史」**。 +- **路由**:建议 **`/ut-gate-history`**(与 **`/history`** 并列顶层路径;实现时若需微调须保持「与详细执行历史同级」语义,并在路由表中登记)。 +- **页面内容**:**仅** Ant Design **``** + 筛选条件 + 分页,对接 **`GET /api/v1/ut-gate-runs`**;行内可链 **`build_url`** / **`mr_url`** 跳转 Jenkins / CodeHub。**本期页面不引入 ECharts**。 +- **首页**:**不**增加 UT 门禁图表或专用卡片;用户经 **「UT门禁历史」** 菜单进入列表即可。 + +### 8.4 核心指标:按 MR 去重(不依赖是否合入) + +- **指标语义(推荐)**:时间窗内,**至少出现过一次 `is_intercepted=true`** 的 MR 占比——**分子** = 曾 **`true`** 的 **不同 `mr_url`** 数(**须** `mr_url` 非空);**分母** = 时间窗内 **`mr_url` 非空** 且至少有一条上报记录的 **不同 `mr_url`** 数。 +- **局限**:因 **`is_intercepted=false` 混合多种语义**,分母**包含**「从未 Summary 可判定仅 false」的 MR 时,比例解读偏「宽」;若业务要求分母仅限「Summary 可判定」的 MR,须**二期**增加「可判定」标志字段,或**约定**仅在测试跑完且可解析时上报。 +- **逻辑 MR 键**:**仅** **`mr_url`**(建议服务端或上报端做 **URL 规范化**:去 fragment、统一 host 大小写规则等,**待实现约定**)。 +- **比例**:分子 ÷ 分母。同一 **`mr_url`** 多次构建仅影响「是否曾 **`true`**」,**每个 `mr_url` 在分母中最多计一次**。 +- **`mr_url` 为空** 的构建:不参与本 MR 指标分子/分母,或单独统计「无 MR 关联构建」。 + +--- + +## 9. 安全与运维 + +- **鉴权方式**:**固定集成 Token** + **`Authorization: Bearer`**(见 §6.1、§6.2);**本期不采用** HMAC。 +- **「UT门禁历史」**:**全员可见**(见 §6.2);应用仍须登录、内网访问。 +- Token 存放在 **Jenkins Credentials**,注入为环境变量(如 `QUALITYBOARD_UT_REPORT_TOKEN`)。 +- 服务端对 Token **轮换**友好:支持双 Token 过渡期(**可选实现**)。 +- 限流:按 IP 或 Token **QPS 限制**,防止误配置死循环打满服务。 +- 审计:上报成功打 **INFO**(含 `idempotency_key`、`job_name`、`build_number`,不含 Token)。 + +--- + +## 10. 已确认决策(原开放问题关闭) + +| 原编号 | 决策 | +|--------|------| +| 1(B 类 / `mr_url`) | **`mr_url` 仅由 `codehubMergeRequestUrl` 赋值**(判非空),已写入 **§5.2.1**、§5.4、§6.1、§7.3。 | +| 2(`mr_url` 备选) | **不采用** `.ci/mr_url.txt` 等文件备选;**仅用 B 类变量**。 | +| 3(多仓) | **仅凭 `mr_url` 区分多仓 / 多 MR**即可,**不增加**其它区分字段(见 §5.4)。 | +| 4(Summary) | **约定仅一行 Summary**,**不出现多行**;解析见 §3.3、§7.2。 | +| 5(权限) | **「UT门禁历史」全员可见**(已登录用户均可访问菜单与数据);见 §6.2、§9。 | +| 6(`error_message`) | **不需要**;**本期不提供**该上报字段,**不扩展**表字段(见 §7.3)。 | + +**本期无未决开放项**;若工具链或 CodeHub 插件行为变更,须修订 §3.3、§5.2.1、§7.2 并更新本表。 + +--- + +## 11. 文档与实现检查清单(后续迭代用) + +**推荐实现顺序**见 **§12**(分阶段计划与 PR 切分)。 + +- [ ] 新增 `database/V*.*.*__create_ut_gate_run.sql` 与 ORM/Schema/Service/API +- [ ] Jenkins 侧:Credentials、`curl` 示例、`tee` + `PIPESTATUS` 试点;**`codehubMergeRequestUrl` → `mr_url`**(§5.2.1 **B 类**)按规约接入 +- [ ] 联调:幂等、超时(`curl --max-time`)、DNS +- [ ] 前端:**「UT门禁历史」**菜单(与详细执行历史同级)+ 路由 **`/ut-gate-history`** + 列表页(**无图表**);**全员可见**(已登录用户);`utGateApi` 服务封装 +- [ ] 更新 `docs/` 中架构/接口说明(若有对外部署) + +--- + +## 12. 分阶段实现计划(推荐) + +本节约定 **QualityBoard 与 Jenkins 侧** 的落地顺序,与项目分层 **Model → Schema → Service → API** 一致;**不必**引入新的顶层工程子项目,在现有 `backend/models`、`schemas`、`services`、`api/v1` 下为本需求新增一组文件并注册路由即可。 + +### 12.1 为何分阶段 + +- **依赖链固定**:须先有迁移 SQL 与 ORM,再写 Service/API;前端依赖稳定接口。 +- **验收清晰**:每阶段有可独立验证的交付物(库表、POST 幂等、GET 分页、页面、端到端联调)。 +- **与检查清单对应**:§11 勾选项可按 §12 阶段逐项完成。 + +### 12.2 阶段与验收标准 + +| 阶段 | 内容 | 验收标准(建议) | +|------|------|------------------| +| **1. 数据层** | `database/V*.*.*__create_ut_gate_run.sql`;`UtGateRun`(或等价命名)ORM;字段与本文 **§5** 一致;**禁止** `create_all` | 迁移在目标环境执行成功 | +| **2. 上报 API** | 请求 Schema、Service(含 **`idempotency_key` 幂等**)、`POST` 路由;**集成 Bearer** 校验;日志符合 `docs/06_logging_guide.md` | 同 key 重复上报不产生重复行;未授权/参数错误返回 4xx | +| **3. 查询 API** | 列表 Query Schema、`select` 分页与计数、`GET` + `PageResponse` | 与项目内其它列表接口行为一致 | +| **4. 前端** | `frontend/src/services` 下 **`utGateApi`**(字段 **snake_case** 与后端一致);路由 **`/ut-gate-history`**;**「UT门禁历史」**菜单(全员可见,见 §6.2、§9);列表页,**本期无图表** | 已登录用户可访问列表与分页 | +| **5. Jenkins 侧** | Credentials、**`curl --max-time`**、**`tee` + `PIPESTATUS`**(或等价);**`codehubMergeRequestUrl` → `mr_url`**(§5.2.1 **B 类**) | 试点 Job 端到端产生一条符合预期的库记录 | + +**文档**:`docs/` 中架构/接口说明在**功能对外可用**的版本与代码同步更新即可,无需每个小改动都改文档。 + +### 12.3 PR 切分建议(可压缩) + +| PR | 范围 | +|----|------| +| **PR1** | 迁移 SQL + ORM(+ 若需 Pydantic 仅用于内部校验可随 PR2,避免空转) | +| **PR2** | POST 上报(认证、幂等、日志) | +| **PR3** | GET 分页列表 | +| **PR4** | 前端 + `pnpm build` / 部署流程按仓库脚本执行 | + +单人开发时可合并为 **「数据库 + 后端」** 与 **「前端」** 两个 PR,但**不建议**省略迁移或把迁移与大量无关逻辑混在同一提交。 + +### 12.4 不必单独成「模块」的部分 + +- **Summary 解析**:放在 **Service** 层(或同目录下小工具函数文件),无需单独 Python 包。 +- **Jenkins 流水线脚本**:若不在本仓,以本文 **§7** 与运维侧仓库/片段为准,QualityBoard 仓内不强制新建脚本目录。 + +--- + +## 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v0.1 | 2026-05-07 | 初稿:采集、存储、API、表结构、看板与 root.sh 集成要点 | +| v0.2 | 2026-05-07 | §1.1:无合法 Summary 且退出码非 0 不等同于 UT 未通过;同步 §1.2、§3.3、§5.2、§6.3、§7.2、§8 口径 | +| v0.3 | 2026-05-07 | 新增 §5.4(多仓与 MR ID 作用域)、§8.4(按逻辑 MR 去重指标);`mr_id` 字段说明与 §10 引用更新 | +| v0.4 | 2026-05-07 | §1.2/§1.3:业务以「是否出现失败用例」为主,用例计数改为可选;同步 §3.3、§5.2、§6.1、§6.3、§7 | +| v0.5 | 2026-05-07 | 业务语义改为「拦截/未拦截」:`ut_status` 枚举 **`intercepted`** / **`not_intercepted`** 替代 passed/failed;全文与 §8.4 指标语义对齐 | +| v0.6 | 2026-05-07 | §3:本期**仅采用方案 B**,取消与方案 A 的配合及对账同步;§3.1 增「本期」列、§3.2 改为实现要点 | +| v0.7 | 2026-05-07 | §5.2:`mr_title` 及 `ut_exit_code` 之后字段删除;`ut_status` 改为布尔 **`is_intercepted`**(仅 Summary 可判定且存在失败为 true,其余 false);**`idempotency_key`** 前移并专段说明;全文与 §8 统计局限同步 | +| v0.8 | 2026-05-07 | MR 维度**仅保留 `mr_url`**;删除 `mr_id`、`source_branch`、`target_branch`;§5.3/§5.4、§6、§8、§10 同步 | +| v0.9 | 2026-05-07 | 删除 **`git_remote_url`**、**`git_commit_sha`**;**`idempotency_key`** 改为依赖 **`BUILD_TAG`** 或 **`job_name`+`build_number`**;§1.2、§6、§8 与幂等说明同步 | +| v1.0 | 2026-05-07 | 新增 **R5**、**§5.2.1**:**100% 不依赖未定义环境变量**;`jenkins_base_url` 从 **`BUILD_URL` 解析**;**B 类**契约白名单;§5.4、§6.1、§7、§10、§11 同步 | +| v1.0.1 | 2026-05-07 | §5.2:补充说明 **`WORKSPACE` 仅 §5.2.1 / §7 使用、不入库** | +| v1.1 | 2026-05-07 | **认证定稿**:**固定集成 Token** + **`Authorization: Bearer`**;写入/查询一致;**不采用** HMAC;§6.1、§6.2、§9 同步 | +| v1.2 | 2026-05-07 | §8:**本期不做图表**、**首页不展示** UT 图表;菜单 **「UT门禁历史」** 与详细执行历史**同级**,路由建议 **`/ut-gate-history`**;§8.1–§8.3 重写;§1.2、§5.3 索引说明、§11 同步 | +| v1.3 | 2026-05-07 | **B 类**固化为 **`codehubMergeRequestUrl`→`mr_url`**;多仓仅 **`mr_url`**;Summary **单行**;**全员可见**;**无 `error_message`**;§10 改为已确认表;§3.2–§3.3、§5.2.1、§5.4、§6–§9、§11 同步 | +| v1.3.1 | 2026-05-07 | **R5**、表字段 `jenkins_base_url` 与 §5.2.1 对齐(不引用外部「CI 契约」);§11 前端项补充 **全员可见** | +| v1.4 | 2026-05-07 | 新增 **§12 分阶段实现计划**(阶段表、验收、PR 切分、非单独模块说明);§11 增加对 §12 的引用 | +| v1.5 | 2026-05-07 | §6.1:POST 细则引用 **`spec/16_ut_gate_report_post_api_spec.md`**;幂等行为与 §16 对齐 | diff --git a/spec/16_ut_gate_report_post_api_spec.md b/spec/16_ut_gate_report_post_api_spec.md new file mode 100644 index 0000000..c965b4a --- /dev/null +++ b/spec/16_ut_gate_report_post_api_spec.md @@ -0,0 +1,212 @@ +# UT 门禁结果上报 API(POST)实现规约 + +本文档为 **`POST /api/v1/ut-gate-runs`** 的**实现级**规约,供后端开发、联调与 Jenkins 脚本对照。上位需求、业务语义、表结构字段含义见 **`spec/15_ut_gate_jenkins_report_spec.md`**;数据库 DDL 见 **`database/V1.1.2__create_ut_gate_run.sql`**。 + +**本文档范围**:仅 **写入(上报)** 接口;**不包含** `GET` 列表/统计、前端页面。 + +--- + +## 1. 接口概要 + +| 项目 | 规约 | +|------|------| +| 方法 / 路径 | **`POST /api/v1/ut-gate-runs`** | +| Content-Type | **`application/json`**(UTF-8) | +| FastAPI `tags` | **`["UT门禁上报"]`**(或等价中文标签,与项目其它路由风格一致) | +| 鉴权 | 见 **§3** | + +--- + +## 2. 关联与约束 + +- **分层**:`Schema`(请求/响应体校验)→ `Service`(幂等、INSERT)→ `API`(路由、依赖注入)。禁止在路由函数内手写 SQL 大块逻辑。 +- **Python**:类型标注使用 `Optional[X]`(Python 3.8);**禁止** `X | None`。 +- **日志**:遵循 `docs/06_logging_guide.md`;**禁止**在日志中输出 Token 或 `Authorization` 头全文。 +- **ORM**:写入目标表 **`ut_gate_run`**,模型 **`UtGateRun`**(`backend/models/ut_gate_run.py`),字段与 DDL **逐列一致**。 + +--- + +## 3. 认证与安全 + +### 3.1 方式(本期定稿) + +- 请求头 **`Authorization: Bearer `**。 +- **``** 与服务器配置项一致;部署时由运维配置 **QualityBoard `.env`**,与 **Jenkins Credentials** 注入的变量**共用同一密钥值**(Jenkins 侧变量名可为 `QUALITYBOARD_UT_REPORT_TOKEN` 等,**不在本文档强制 Jenkins 变量名**,仅要求请求头格式正确)。 + +### 3.2 服务端配置项(建议) + +| 环境变量 / Settings 字段 | 说明 | +|---------------------------|------| +| **`UT_GATE_INTEGRATION_TOKEN`**(建议命名;实现时写入 `backend/core/config.py`) | 非空字符串时启用校验:与 `Authorization` 中 Bearer 值**按常量时间比较**(或框架推荐方式),相等则通过。 | +| **未配置或为空** | **实现二选一须在 PR 描述中写明**:**(A)** 拒绝所有上报(`401`/`503` + 明确 `detail`);**(B)** 仅开发环境放行(**禁止**生产默认可匿名写入)。**推荐 (A)**,避免误部署空 Token。 | + +### 3.3 失败响应 + +| HTTP | 场景 | +|------|------| +| **401** | 缺失 `Authorization`、非 `Bearer`、Token 不匹配或配置未启用有效 Token。 | +| **403** | 本期可与 **401** 合并实现(统一 **401** 即可),**不**单独引入 RBAC。 | + +--- + +## 4. 请求体(JSON) + +### 4.1 字段总表 + +所有键名 **蛇形命名(snake_case)**,与表字段、前端后续对接一致。 + +| JSON 键 | 必填 | 类型 | 长度 / 范围 | 映射列 | 说明 | +|---------|------|------|---------------|--------|------| +| `idempotency_key` | **是** | `string` | `1~128` | `idempotency_key` | 同 §5.2 主 spec;**禁止**仅空白字符。 | +| `job_name` | **是** | `string` | `1~256` | `job_name` | Jenkins `JOB_NAME` 等,与主 spec 一致。 | +| `build_number` | **是** | `integer` | `≥ 0` 且与 DB **`INT UNSIGNED`** 一致 | `build_number` | 超出 unsigned 范围则 **422**。 | +| `is_intercepted` | **是** | `boolean` | — | `is_intercepted` | **`true`/`false`**;服务端落库为 `1`/`0`(tinyint)。 | +| `ut_exit_code` | 否 | `integer` 或省略 / `null` | 与 MySQL **`INT`** 一致 | `ut_exit_code` | 建议 Jenkins 始终上报;缺省则 **`NULL`**。 | +| `build_url` | 否 | `string` 或 `null` | `≤ 1024` | `build_url` | 通常 `BUILD_URL`。 | +| `jenkins_base_url` | 否 | `string` 或 `null` | `≤ 512` | `jenkins_base_url` | 由 `BUILD_URL` 解析;缺省 **`NULL`**。 | +| `mr_url` | 否 | `string` 或 `null` | `≤ 1024` | `mr_url` | **仅当** Jenkins **`codehubMergeRequestUrl`** 非空时等于该值;否则省略或 **`null`**。 | + +**禁止**出现的键(本期):`error_message`、`git_remote_url`、`git_commit_sha`、`mr_id`、`summary_line` 等未在 **`ut_gate_run`** 表定义的字段;若客户端传入,**建议**服务端 **忽略**(不报错)或 **422**——实现时选一种并在 OpenAPI 说明中写死;**推荐忽略未知键**(Pydantic 默认 `extra="ignore"`)。 + +### 4.2 服务端校验规则 + +1. **Content-Type** 非 JSON 或 body 非法 JSON → **400**(可由框架抛出,**detail** 中文简述即可)。 +2. **必填缺失 / 类型错误 / 超长度** → **422**(Pydantic `RequestValidationError` 统一处理时,对外仍应为可读中文或结构化 `detail`,与项目现有全局异常处理一致)。 +3. **`idempotency_key` / `job_name` 去首尾空白**后若为空 → **422**。 +4. **`mr_url` / `build_url` / `jenkins_base_url`**:可选 **去首尾空白**;全空白视为 **`null`**。 +5. **不在服务端重算 `is_intercepted`**:以请求体为准(与主 spec §6.3「以 Jenkins 脚本解析结果为准」一致)。 + +### 4.3 请求示例 + +```json +{ + "idempotency_key": "my-job-123", + "job_name": "folder/my-job", + "build_number": 123, + "is_intercepted": false, + "ut_exit_code": 101, + "build_url": "https://jenkins.example.com/job/folder/job/my-job/123/", + "jenkins_base_url": "https://jenkins.example.com", + "mr_url": "https://codehub.example.com/group/project/-/merge_requests/4647" +} +``` + +--- + +## 5. 幂等语义(本期固化) + +以 **`idempotency_key`** 对应 **`UNIQUE KEY uk_idempotency`**。 + +### 5.1 比较字段集(客户端可写、参与幂等比较) + +以下字段若与库中已有行**全部相同**,视为**同一语义重复上报**: + +`job_name`, `build_number`, `build_url`, `jenkins_base_url`, `mr_url`, `is_intercepted`, `ut_exit_code` + +**不参与比较**:`id`, `created_at`, `updated_at`, `reported_at`(由库或服务端维护)。 + +### 5.2 行为 + +| 条件 | HTTP | 响应体 | +|------|------|--------| +| `idempotency_key` **不存在** | **201 Created** | 新建行,返回 **完整记录**(见 **§6**),含生成的 `id` 与时间字段。 | +| `idempotency_key` **已存在**,且 §5.1 字段与已存行 **逐项相等**(`NULL` 与缺失均与 **`NULL`** 等价) | **200 OK** | 返回 **当前库中该行完整记录**(**不**修改 `updated_at`/`reported_at` 亦可,**不**再 INSERT)。 | +| `idempotency_key` **已存在**,且 §5.1 中 **任一项不等** | **409 Conflict** | `detail` 中文说明「幂等键已存在且请求内容不一致」;**不**改库中已有行。 | + +> 说明:主 spec §6.1 中「200 或 409 二选一」本期按上表 **同时采用**:重复且一致 → **200**;冲突 → **409**。 + +--- + +## 6. 成功响应体(201 / 200) + +### 6.1 格式 + +- **与表字段一致**的 **JSON 对象**(蛇形命名),至少包含: + +`id`, `created_at`, `updated_at`, `reported_at`, `jenkins_base_url`, `job_name`, `build_number`, `build_url`, `mr_url`, `idempotency_key`, `is_intercepted`, `ut_exit_code` + +### 6.2 类型与序列化 + +| 字段 | JSON 类型 | 说明 | +|------|-------------|------| +| `id` | `number` | 大整数;前端若用 JS 需注意精度,本期列表可用字符串化策略 **留待 GET spec**。 | +| `is_intercepted` | `boolean` | 由 ORM `tinyint` 映射为 `true`/`false`。 | +| `ut_exit_code` | `number` 或 `null` | | +| `*_at` | `string`(ISO 8601) | 与时区策略一致:推荐 **UTC** 带 `Z` 或显式 offset,与项目其它 API 一致。 | +| 可空字符串列 | `string` 或 `null` | | + +### 6.3 Pydantic 响应模型 + +- 须设置 **`model_config = {"from_attributes": True}`**(与项目 Schema 契约一致)。 + +--- + +## 7. 错误响应与 HTTP 表 + +| HTTP | 场景 | +|------|------| +| **400** | Body 非合法 JSON。 | +| **401** | 鉴权失败(见 §3)。 | +| **409** | 幂等键冲突且载荷不一致(§5.2)。 | +| **422** | 参数校验失败(§4.2)。 | +| **500** | 未预期异常;**必须** `logger.exception()`,**detail** 对用户简短中文,**不**返回栈信息。 | + +**Jenkins 侧**:主 spec 约定 4xx/5xx **不改变**门禁退出码;与本文档无关但联调时需知晓。 + +--- + +## 8. 服务端写库规则 + +| 列 | 规则 | +|----|------| +| `id` | 自增,插入后返回。 | +| `created_at` / `updated_at` | 以 **数据库默认值 / ON UPDATE** 为准;**不在应用层覆盖**(除非项目统一用 `server_default` 已对齐)。 | +| `reported_at` | **插入时**写 **`NOW()`**(或服务端等价 UTC);**幂等 200** 时**不更新**该列。 | + +**事务**:单条 INSERT 或「SELECT by key + 比较 + INSERT/返回」须在**同一事务**内完成,避免并发双插;并发下第二条应命中唯一键异常后转为「读已有行 + 比较」分支(实现细节由 Service 层处理)。 + +--- + +## 9. 日志 + +| 级别 | 时机 | 内容 | +|------|------|------| +| **INFO** | **201** 或 **200**(幂等命中)成功落库/返回 | **`idempotency_key`、`job_name`、`build_number`**;可含 **`id`**;**不含** Token。 | +| **WARNING** | **401**、**422**、**409** | 原因简述(**不**打完整 body)。 | +| **ERROR** | **500** | `logger.exception()`,**不**记录敏感信息。 | + +--- + +## 10. 路由注册与 OpenAPI + +- 在 `backend/api/v1/` 新增模块(如 **`ut_gate_run.py`**),`router = APIRouter(prefix="/ut-gate-runs", tags=["UT门禁上报"])`。 +- 在 `backend/main.py`(或统一 `api/v1/__init__.py`)**include_router**,前缀与现有 **`/api/v1`** 拼接后为 **`/api/v1/ut-gate-runs`**。 +- OpenAPI 中本接口 **description** 可简短引用本文档路径。 + +--- + +## 11. 非目标(本期) + +- **GET** / 分页 / 筛选:见后续专项或主 spec §6.2。 +- **限流**:主 spec §9 建议按 IP/Token QPS;可在首版 **TODO** 或二期实现;**不阻塞** POST 首版合入。 +- **双 Token 过渡期**:主 spec 为可选;首版单 Token 即可。 + +--- + +## 12. 实现检查清单 + +- [x] `Settings` 增加 **`UT_GATE_INTEGRATION_TOKEN`**(或最终命名)及 `.env.example` 说明 +- [x] 依赖:`verify_ut_gate_integration_token`(或等价)仅作用于本路由 +- [x] `UtGateRunCreate` / `UtGateRunItem` Schema(命名以代码为准,字段与 §4、§6 一致) +- [x] `create_ut_gate_run`(或等价)`async` Service:`INSERT` / 幂等分支 / 事务与唯一键冲突处理 +- [x] 路由:`POST`、`response_model`、**201** 与 **200** 用 `JSONResponse`/`Response` 区分状态码(FastAPI 默认单 `response_model` 时需显式处理多状态码) +- [x] 单元测试:幂等比较逻辑、OpenAPI 路径登记(**鉴权 / 422 / 201 / 200 / 409** 的 HTTP 联测可在有 MySQL 的 CI 或本地库上补全) + +--- + +## 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v1.0 | 2026-05-07 | 初稿:`POST /api/v1/ut-gate-runs`、鉴权、请求/响应、幂等 200/409、日志与检查清单 | +| v1.1 | 2026-05-07 | 后端已按 §12 落地;§12 勾选同步;补充联测说明 | From 2982b0be9d1e4669a62d08b3cbb6713eaf65505c Mon Sep 17 00:00:00 2001 From: weixin_53033691 Date: Tue, 12 May 2026 11:00:32 +0800 Subject: [PATCH 2/5] =?UTF-8?q?[feature]UT=E9=97=A8=E7=A6=81=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=E7=BB=9F=E8=AE=A1=EF=BC=9AUT=E9=97=A8=E7=A6=81?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=9F=A5=E8=AF=A2API=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/v1/ut_gate_run.py | 27 +++- backend/schemas/ut_gate_run.py | 99 +++++++++++++- backend/services/ut_gate_run_service.py | 48 ++++++- backend/tests/test_openapi.py | 4 +- backend/tests/test_ut_gate_run_query.py | 40 ++++++ docs/05_technical_architecture.md | 2 +- spec/15_ut_gate_jenkins_report_spec.md | 15 +- spec/16_ut_gate_report_post_api_spec.md | 3 +- spec/17_ut_gate_runs_get_api_spec.md | 173 ++++++++++++++++++++++++ 9 files changed, 395 insertions(+), 16 deletions(-) create mode 100644 backend/tests/test_ut_gate_run_query.py create mode 100644 spec/17_ut_gate_runs_get_api_spec.md diff --git a/backend/api/v1/ut_gate_run.py b/backend/api/v1/ut_gate_run.py index 0bbb2fd..2215d6b 100644 --- a/backend/api/v1/ut_gate_run.py +++ b/backend/api/v1/ut_gate_run.py @@ -5,15 +5,36 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend.core.database import get_db -from backend.core.dependencies import verify_ut_gate_integration_token -from backend.schemas.ut_gate_run import UtGateRunCreate, UtGateRunItem -from backend.services.ut_gate_run_service import UtGateIdempotencyConflict, create_ut_gate_run +from backend.core.dependencies import get_current_user, verify_ut_gate_integration_token +from backend.schemas.common import PageResponse +from backend.schemas.ut_gate_run import UtGateRunCreate, UtGateRunItem, UtGateRunQuery +from backend.services.ut_gate_run_service import UtGateIdempotencyConflict, create_ut_gate_run, list_ut_gate_runs logger = logging.getLogger(__name__) router = APIRouter(prefix="/ut-gate-runs", tags=["UT门禁上报"]) +@router.get( + "", + response_model=PageResponse[UtGateRunItem], + summary="分页查询 UT 门禁上报记录", + description="筛选 `reported_at` 使用 `start_time`/`end_time`(与 History 批次 `start_time` 无关)。规约见 `spec/17_ut_gate_runs_get_api_spec.md`。", +) +async def get_ut_gate_runs( + query: UtGateRunQuery = Depends(), + db: AsyncSession = Depends(get_db), + _payload: dict = Depends(get_current_user), +): + rows, total = await list_ut_gate_runs(db, query) + return PageResponse( + items=[UtGateRunItem.model_validate(r) for r in rows], + total=total, + page=query.page, + page_size=query.page_size, + ) + + @router.post( "", response_model=UtGateRunItem, diff --git a/backend/schemas/ut_gate_run.py b/backend/schemas/ut_gate_run.py index f9aa95c..a3029d1 100644 --- a/backend/schemas/ut_gate_run.py +++ b/backend/schemas/ut_gate_run.py @@ -1,7 +1,10 @@ -from datetime import datetime +from datetime import datetime, time, timezone +import re from typing import Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from backend.schemas.common import PageRequest class UtGateRunCreate(BaseModel): @@ -61,3 +64,95 @@ class UtGateRunItem(BaseModel): ut_exit_code: Optional[int] = None model_config = {"from_attributes": True} + + +_DATE_ONLY_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _is_date_only_string(s: str) -> bool: + return bool(_DATE_ONLY_RE.match(s.strip())) + + +class UtGateRunQuery(PageRequest): + """GET /api/v1/ut-gate-runs 查询参数(spec/17)。""" + + model_config = ConfigDict(extra="ignore") + + page_size: int = Field(20, ge=1, le=100, description="每页条数,最大 100") + start_time: Optional[str] = Field( + None, + description="reported_at 下限(闭区间);YYYY-MM-DD 或 ISO8601,须与 end_time 同为日期或同为带时间格式", + ) + end_time: Optional[str] = Field( + None, + description="reported_at 上限(闭区间);YYYY-MM-DD 或 ISO8601", + ) + is_intercepted: Optional[bool] = Field(None, description="是否拦截到(true/false);省略则不过滤") + mr_url: Optional[str] = Field(None, max_length=1024, description="mr_url 精确匹配(与 mr_url_contains 互斥)") + mr_url_contains: Optional[str] = Field(None, max_length=200, description="mr_url 子串匹配(LIKE 转义)") + job_name_contains: Optional[str] = Field(None, max_length=200, description="job_name 子串匹配(LIKE 转义)") + sort_field: Optional[str] = Field( + None, + description="排序列:reported_at(默认)、created_at、id", + ) + sort_order: Optional[str] = Field(None, description="asc / desc,默认 desc") + + parsed_reported_at_start: Optional[datetime] = Field(default=None, exclude=True) + parsed_reported_at_end: Optional[datetime] = Field(default=None, exclude=True) + + @field_validator("mr_url", "mr_url_contains", "job_name_contains", mode="before") + @classmethod + def strip_optional_query_strings(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + if not isinstance(v, str): + return v + s = v.strip() + return s if s else None + + @model_validator(mode="after") + def validate_mr_mutual_and_times_and_sort(self) -> "UtGateRunQuery": + mu = self.mr_url + mc = self.mr_url_contains + if (mu or "").strip() and (mc or "").strip(): + raise ValueError("mr_url 与 mr_url_contains 互斥,不能同时指定") + + st_raw = self.start_time + en_raw = self.end_time + if (st_raw or "").strip() and (en_raw or "").strip(): + if _is_date_only_string(st_raw) != _is_date_only_string(en_raw): + raise ValueError("start_time 与 end_time 须同为 YYYY-MM-DD 日期或同为带时间的 ISO8601") + + def _parse_one(raw: Optional[str], *, is_end: bool) -> Optional[datetime]: + if raw is None or not str(raw).strip(): + return None + s = str(raw).strip() + if _is_date_only_string(s): + d = datetime.strptime(s, "%Y-%m-%d").date() + if is_end: + return datetime.combine(d, time(23, 59, 59)) + return datetime.combine(d, time.min) + iso = s.replace("Z", "+00:00") if s.endswith("Z") else s + try: + dt = datetime.fromisoformat(iso) + except ValueError as e: + raise ValueError("时间格式无效,请使用 YYYY-MM-DD 或 ISO8601") from e + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + + ps = _parse_one(st_raw, is_end=False) + pe = _parse_one(en_raw, is_end=True) + if ps is not None and pe is not None and ps > pe: + raise ValueError("start_time 不能晚于 end_time") + + sf = (self.sort_field or "").strip() or None + if sf is not None and sf not in ("reported_at", "created_at", "id"): + raise ValueError("sort_field 仅支持 reported_at、created_at、id") + so = (self.sort_order or "").strip().lower() or "desc" + if so not in ("asc", "desc"): + raise ValueError("sort_order 仅支持 asc 或 desc") + + object.__setattr__(self, "parsed_reported_at_start", ps) + object.__setattr__(self, "parsed_reported_at_end", pe) + return self diff --git a/backend/services/ut_gate_run_service.py b/backend/services/ut_gate_run_service.py index 5aa7d31..64b8953 100644 --- a/backend/services/ut_gate_run_service.py +++ b/backend/services/ut_gate_run_service.py @@ -1,13 +1,14 @@ import logging -from typing import Tuple +from typing import List, Tuple from fastapi import HTTPException, status -from sqlalchemy import select +from sqlalchemy import and_, func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from backend.models.ut_gate_run import UtGateRun -from backend.schemas.ut_gate_run import UtGateRunCreate +from backend.schemas.ut_gate_run import UtGateRunCreate, UtGateRunQuery +from backend.services.history_service import _like_substring logger = logging.getLogger(__name__) @@ -80,3 +81,44 @@ async def create_ut_gate_run(db: AsyncSession, body: UtGateRunCreate) -> Tuple[U if _payload_matches_existing_row(body, row2): return row2, status.HTTP_200_OK raise UtGateIdempotencyConflict() + + +async def list_ut_gate_runs(db: AsyncSession, query: UtGateRunQuery) -> Tuple[List[UtGateRun], int]: + """ + 分页列表,单表 ut_gate_run,条件 AND(spec/17)。 + """ + u = UtGateRun + conds = [] + if query.parsed_reported_at_start is not None: + conds.append(u.reported_at >= query.parsed_reported_at_start) + if query.parsed_reported_at_end is not None: + conds.append(u.reported_at <= query.parsed_reported_at_end) + if query.is_intercepted is not None: + conds.append(u.is_intercepted == query.is_intercepted) + if query.mr_url: + conds.append(u.mr_url == query.mr_url) + mr_like = _like_substring(u.mr_url, query.mr_url_contains) + if mr_like is not None: + conds.append(mr_like) + jn_like = _like_substring(u.job_name, query.job_name_contains) + if jn_like is not None: + conds.append(jn_like) + + stmt = select(u) + if conds: + stmt = stmt.where(and_(*conds)) + + count_stmt = select(func.count()).select_from(stmt.subquery()) + total = (await db.execute(count_stmt)).scalar() or 0 + + sf = (query.sort_field or "").strip() or "reported_at" + so = (query.sort_order or "").strip().lower() or "desc" + sort_col = getattr(u, sf) + asc = so == "asc" + primary = sort_col.asc() if asc else sort_col.desc() + stmt = stmt.order_by(primary, u.id.desc()) + stmt = stmt.offset((query.page - 1) * query.page_size).limit(query.page_size) + + result = await db.execute(stmt) + rows = result.scalars().all() + return list(rows), int(total) diff --git a/backend/tests/test_openapi.py b/backend/tests/test_openapi.py index 39b7aeb..0664dc7 100644 --- a/backend/tests/test_openapi.py +++ b/backend/tests/test_openapi.py @@ -14,4 +14,6 @@ async def test_openapi_json_available(): assert body.get("openapi") is not None assert "paths" in body assert "/api/v1/ut-gate-runs" in body["paths"] - assert "post" in body["paths"]["/api/v1/ut-gate-runs"] + ut_paths = body["paths"]["/api/v1/ut-gate-runs"] + assert "post" in ut_paths + assert "get" in ut_paths diff --git a/backend/tests/test_ut_gate_run_query.py b/backend/tests/test_ut_gate_run_query.py new file mode 100644 index 0000000..1b2bd03 --- /dev/null +++ b/backend/tests/test_ut_gate_run_query.py @@ -0,0 +1,40 @@ +import pytest +from pydantic import ValidationError + +from backend.schemas.ut_gate_run import UtGateRunQuery + + +def test_mr_url_mutual_exclusive(): + with pytest.raises(ValidationError) as ei: + UtGateRunQuery(mr_url="https://a/mr/1", mr_url_contains="mr") + assert "互斥" in str(ei.value) + + +def test_start_end_date_mix_with_iso_fails(): + with pytest.raises(ValidationError) as ei: + UtGateRunQuery(start_time="2026-01-01", end_time="2026-01-02T00:00:00") + assert "同为" in str(ei.value) + + +def test_start_after_end_fails(): + with pytest.raises(ValidationError) as ei: + UtGateRunQuery(start_time="2026-01-03", end_time="2026-01-01") + assert "不能晚于" in str(ei.value) + + +def test_sort_field_invalid(): + with pytest.raises(ValidationError) as ei: + UtGateRunQuery(sort_field="job_name") + assert "sort_field" in str(ei.value).lower() or "仅支持" in str(ei.value) + + +def test_iso8601_with_z_normalized(): + q = UtGateRunQuery(start_time="2026-05-07T12:00:00Z", end_time="2026-05-07T15:00:00Z") + assert q.parsed_reported_at_start is not None + assert q.parsed_reported_at_end is not None + assert q.parsed_reported_at_start <= q.parsed_reported_at_end + + +def test_page_size_max(): + with pytest.raises(ValidationError): + UtGateRunQuery(page_size=101) diff --git a/docs/05_technical_architecture.md b/docs/05_technical_architecture.md index 063bf69..2e5deba 100644 --- a/docs/05_technical_architecture.md +++ b/docs/05_technical_architecture.md @@ -237,7 +237,7 @@ pipeline_cases case_offline_type sys_audit_log (新增) | Epic 1 | 数据看板 | `/api/v1/dashboard` | DashboardPage | pipeline_overview | | Epic 1 | 分组概览 | `/api/v1/overview` | OverviewPage | pipeline_overview | | Epic 1 | 执行明细 | `/api/v1/history` | HistoryPage | pipeline_history | -| Epic 1 | UT 门禁上报 | `POST /api/v1/ut-gate-runs`(列表页见 Story 规划) | (「UT门禁历史」页面对接 `GET` 待实现) | ut_gate_run | +| Epic 1 | UT 门禁上报 | `POST` / **`GET`** `/api/v1/ut-gate-runs`(列表页见 Story 规划) | (「UT门禁历史」页面对接 `GET`) | ut_gate_run | | Epic 2 | 失败分析 | `/api/v1/analysis` | (HistoryPage 内交互) | pipeline_failure_reason | | Epic 3 | 总结报告 | `/api/v1/report` | ReportPage | report_snapshot | | Epic 4 | 消息通知 | `/api/v1/notification` | NotificationPage | WeLink API | diff --git a/spec/15_ut_gate_jenkins_report_spec.md b/spec/15_ut_gate_jenkins_report_spec.md index 011cb29..2a75e25 100644 --- a/spec/15_ut_gate_jenkins_report_spec.md +++ b/spec/15_ut_gate_jenkins_report_spec.md @@ -211,14 +211,16 @@ ### 6.2 查询:列表与聚合(看板) -**认证**:与 **§6.1** 相同——**`Authorization: Bearer <固定集成Token>`**(**同一**或**独立第二枚** Token 由运维约定;独立时写入/查询权限可拆分)。**本期不采用** HMAC、**不单独**依赖用户 Cookie 作为 UT 门禁 GET 鉴权。 -**看板前端**:**不得**把集成 Token 写进浏览器脚本;由 **QualityBoard 后端**(已走现有用户登录态)**服务端代调** GET,或使用内网仅可达的 BFF。**本期**以内网 + 固定 Token 为最简单闭环。 -**「UT门禁历史」权限(已确认)**:**全员可见**——凡**已登录**本系统的用户均可访问该菜单及列表数据(不因角色隐藏);仍依赖应用整体登录与内网部署;后端代调 UT 列表接口时使用集成 Token。 +**列表 GET 实现细则**(查询参数、时间语义、`reported_at` 筛选、`mr_url`/`job_name` 匹配、分页、权限)见 **`spec/17_ut_gate_runs_get_api_spec.md`**。 + +**认证(分场景)**:**Jenkins → `POST`** 使用 **`Authorization: Bearer <固定集成Token>`**(见 §6.1、**`spec/16`**)。**浏览器 → `GET` 列表** 使用 **用户登录态(JWT)**,**不得**在浏览器持有集成 Token;细则见 **`spec/17` §3**。**本期不采用** HMAC。 +**看板前端**:列表数据经 **`GET /api/v1/ut-gate-runs`** 由已登录前端调用后端,后端直读库;与主 spec 原「服务端代调」表述等价(**不**经浏览器携带集成 Token)。 +**「UT门禁历史」权限(已确认)**:**全员可见**——凡**已登录**本系统的用户均可访问该菜单及列表数据(不因角色隐藏);仍依赖应用整体登录与内网部署。 | 接口 | 方法 | 说明 | |------|------|------| -| `/api/v1/ut-gate-runs` | GET | 分页列表;筛选:`start_time`, `end_time`, `is_intercepted`, `mr_url`(精确或前缀,**待实现约定**), `job_name` | -| `/api/v1/ut-gate-runs/stats` | GET | 聚合:按日/周 **`is_intercepted=true` 次数**、**`false` 次数**(`false` 为混合口径);可选 **按 `job_name`** 分布(参数:`granularity`, `start_time`, `end_time`);**本期不按仓库 URL / commit / MR 分支** 维度存库(无 `git_remote_url`、`git_commit_sha`、`source_branch`/`target_branch`) | +| `/api/v1/ut-gate-runs` | GET | 分页列表;筛选与排序见 **`spec/17_ut_gate_runs_get_api_spec.md` §4~§5**(`start_time`/`end_time` 绑定 **`reported_at`**;`is_intercepted`;`mr_url` 精确与 `mr_url_contains` 子串互斥;`job_name_contains`) | +| `/api/v1/ut-gate-runs/stats` | GET | 聚合:按日/周 **`is_intercepted=true` 次数**、**`false` 次数**(`false` 为混合口径);可选 **按 `job_name`** 分布(参数:`granularity`, `start_time`, `end_time`);**本期不按仓库 URL / commit / MR 分支** 维度存库(无 `git_remote_url`、`git_commit_sha`、`source_branch`/`target_branch`);**stats 首期可不实现**,见 **`spec/17` §11** 与 §8.2 | ### 6.3 异常与错误码 @@ -319,6 +321,7 @@ **推荐实现顺序**见 **§12**(分阶段计划与 PR 切分)。 - [ ] 新增 `database/V*.*.*__create_ut_gate_run.sql` 与 ORM/Schema/Service/API +- [x] **`GET /api/v1/ut-gate-runs`** 分页列表:见 **`spec/17_ut_gate_runs_get_api_spec.md`** - [ ] Jenkins 侧:Credentials、`curl` 示例、`tee` + `PIPESTATUS` 试点;**`codehubMergeRequestUrl` → `mr_url`**(§5.2.1 **B 类**)按规约接入 - [ ] 联调:幂等、超时(`curl --max-time`)、DNS - [ ] 前端:**「UT门禁历史」**菜单(与详细执行历史同级)+ 路由 **`/ut-gate-history`** + 列表页(**无图表**);**全员可见**(已登录用户);`utGateApi` 服务封装 @@ -387,3 +390,5 @@ | v1.3.1 | 2026-05-07 | **R5**、表字段 `jenkins_base_url` 与 §5.2.1 对齐(不引用外部「CI 契约」);§11 前端项补充 **全员可见** | | v1.4 | 2026-05-07 | 新增 **§12 分阶段实现计划**(阶段表、验收、PR 切分、非单独模块说明);§11 增加对 §12 的引用 | | v1.5 | 2026-05-07 | §6.1:POST 细则引用 **`spec/16_ut_gate_report_post_api_spec.md`**;幂等行为与 §16 对齐 | +| v1.6 | 2026-05-07 | 新增 **`spec/17_ut_gate_runs_get_api_spec.md`**(GET 列表);§6.2 认证分场景(POST 集成 Token / GET 用户 JWT)、筛选与 stats 说明对齐 §17 | +| v1.7 | 2026-05-07 | §11:`GET` 列表检查项已落地;与 **`spec/17` v1.1** 同步 | diff --git a/spec/16_ut_gate_report_post_api_spec.md b/spec/16_ut_gate_report_post_api_spec.md index c965b4a..c0fda1a 100644 --- a/spec/16_ut_gate_report_post_api_spec.md +++ b/spec/16_ut_gate_report_post_api_spec.md @@ -2,7 +2,7 @@ 本文档为 **`POST /api/v1/ut-gate-runs`** 的**实现级**规约,供后端开发、联调与 Jenkins 脚本对照。上位需求、业务语义、表结构字段含义见 **`spec/15_ut_gate_jenkins_report_spec.md`**;数据库 DDL 见 **`database/V1.1.2__create_ut_gate_run.sql`**。 -**本文档范围**:仅 **写入(上报)** 接口;**不包含** `GET` 列表/统计、前端页面。 +**本文档范围**:仅 **写入(上报)** 接口;**不包含** `GET` 列表/统计、前端页面(**`GET` 见 `spec/17_ut_gate_runs_get_api_spec.md`**)。 --- @@ -210,3 +210,4 @@ |------|------|------| | v1.0 | 2026-05-07 | 初稿:`POST /api/v1/ut-gate-runs`、鉴权、请求/响应、幂等 200/409、日志与检查清单 | | v1.1 | 2026-05-07 | 后端已按 §12 落地;§12 勾选同步;补充联测说明 | +| v1.2 | 2026-05-07 | 文首范围补充 **`GET` 见 `spec/17_ut_gate_runs_get_api_spec.md`** | diff --git a/spec/17_ut_gate_runs_get_api_spec.md b/spec/17_ut_gate_runs_get_api_spec.md new file mode 100644 index 0000000..711a2c0 --- /dev/null +++ b/spec/17_ut_gate_runs_get_api_spec.md @@ -0,0 +1,173 @@ +# UT 门禁记录查询 API(GET 列表)实现规约 + +本文档为 **`GET /api/v1/ut-gate-runs`** 的**实现级**规约,供后端与「UT门禁历史」前端联调对照。上位需求见 **`spec/15_ut_gate_jenkins_report_spec.md`**(§6.2、§8);表结构与字段含义见 **§5** 及 **`database/V1.1.2__create_ut_gate_run.sql`**;单条记录 JSON 形状与 **`spec/16_ut_gate_report_post_api_spec.md` §6** 的 **`UtGateRunItem`** 对齐。 + +**本文档范围**:**分页列表** `GET /api/v1/ut-gate-runs`。**不包含** `GET /api/v1/ut-gate-runs/stats`(见 **§11**);**不包含** Jenkins `POST`(见 **`spec/16_ut_gate_report_post_api_spec.md`**)。 + +--- + +## 1. 接口概要 + +| 项目 | 规约 | +|------|------| +| 方法 / 路径 | **`GET /api/v1/ut-gate-runs`** | +| FastAPI `tags` | **`["UT门禁上报"]`**(与 POST 同路由模块时可复用,或 **`["UT门禁历史"]`**,实现时二选一保持 OpenAPI 分组清晰) | +| 鉴权 | 见 **§3** | +| 响应包装 | **`PageResponse[UtGateRunItem]`**(与 `backend/schemas/common.py` 一致:`items`, `total`, `page`, `page_size`) | + +--- + +## 2. 关联与约束 + +- **分层**:`Schema`(`UtGateRunQuery` 等)→ `Service`(`select` + 动态 `where` + 分页 + count)→ `API`(`Depends(get_db)`、`response_model=PageResponse[UtGateRunItem]`)。 +- **Python**:类型标注使用 `Optional[X]`(Python 3.8);**禁止** `X | None`。 +- **查询策略**:**默认禁止 JOIN**(项目规则);本接口仅查 **`ut_gate_run`** 单表。 +- **日志**:列表接口**不在每条查询后打 INFO**(见项目日志规范);**可**在参数明显非法时 **WARNING**;未预期异常 **ERROR** + `logger.exception()`。 +- **ORM → Schema**:`UtGateRunItem.model_validate(row)`,`model_config = {"from_attributes": True}`。 + +--- + +## 3. 认证与权限 + +### 3.1 与集成 Token、浏览器的关系 + +- **`UT_GATE_INTEGRATION_TOKEN` / `Authorization: Bearer`**:用于 **Jenkins → `POST /api/v1/ut-gate-runs`**(见 **`spec/16`**)。**浏览器不得持有该 Token。** +- **本 `GET` 接口**:使用现有用户登录态 **`Depends(get_current_user)`**(JWT),与 **`/api/v1/history`** 等列表接口一致;由后端直接读库,**不要求**再用集成 Token「代调」第二跳。 + +### 3.2 「全员可见」(已确认) + +- 凡 **已登录** 用户(`user` / `admin` 等现有角色)均可调用本接口;**不得**因非管理员返回 **403**(除非未登录 **401**)。 +- 与主 spec §6.2、§9 一致:菜单与数据对登录用户开放,**不**做额外 RBAC 裁剪。 + +### 3.3 失败响应 + +| HTTP | 场景 | +|------|------| +| **401** | 未登录或 JWT 无效 | +| **422** | 查询参数校验失败(时间格式、互斥参数、非法 `sort_order` 等) | +| **500** | 数据库或其它未预期异常;`logger.exception()`,响应 **detail** 简短中文 | + +--- + +## 4. 查询参数(Query) + +继承 **`PageRequest`**:`page`(默认 `1`)、`page_size`(默认 `20`,**建议上限** `100`,与项目其它列表一致)。 + +| 参数名 | 必填 | 类型 | 说明 | +|--------|------|------|------| +| `start_time` | 否 | `string` | **闭区间左端**,作用于 **`reported_at`**(见 §4.1)。 | +| `end_time` | 否 | `string` | **闭区间右端**,作用于 **`reported_at`**。 | +| `is_intercepted` | 否 | `boolean` | 为 `true` / `false` 时仅返回对应行;**省略**则不过滤。 | +| `mr_url` | 否 | `string` | **精确匹配**:`TRIM(mr_url 参数)` 与列 **`mr_url`** 相等;**不**使用 `LIKE`。 | +| `mr_url_contains` | 否 | `string` | **子串匹配**:`mr_url IS NOT NULL AND mr_url LIKE '%…%'`;**转义**字面量 `%`、`_`、`!`(与 `history_service._like_substring` 语义一致,防通配符注入)。最大长度 **200**(与 History 子串参数一致)。 | +| `job_name_contains` | 否 | `string` | **`job_name`** 子串匹配,同上转义规则;最大长度 **200**。 | +| `sort_field` | 否 | `string` | 允许值:`reported_at`(默认)、`created_at`、`id`。其它值 → **422**。 | +| `sort_order` | 否 | `string` | `asc` / `desc`(大小写不敏感),默认 **`desc`**。其它值 → **422**。 | + +### 4.1 时间参数语义(固化) + +- 筛选列固定为 **`reported_at`**(门禁结束上报时间,与主 spec §5.2、§8.1 列表语义一致)。 +- **`start_time` / `end_time`** 与主 spec §6.2 命名对齐;**语义**为本接口上的 **`reported_at` 范围**,**不是** Jenkins `JOB_NAME` 的 `start_time`。 +- **格式**(二选一,实现须统一解析): + 1. **ISO 8601** 日期时间字符串(推荐带时区;若无时区则按服务器本地或统一按 **UTC** 解析,**须在实现 PR 中写死一种**);或 + 2. **`YYYY-MM-DD`**:视为该日 **00:00:00**~**23:59:59** 的本地日界(仅当 `start_time`、`end_time` **均为**日期格式时适用;与 ISO 混用规则在实现中 **422** 或按文档写死)。 +- **闭区间**:`reported_at >= 解析(start_time)` 且 `reported_at <= 解析(end_time)`;若只传一端则只施加一端条件。 +- **`start_time` 晚于 `end_time`** → **422**。 + +### 4.2 `mr_url` 与 `mr_url_contains` 互斥 + +- 若 **`mr_url` 与 `mr_url_contains` 同时非空**(去空白后)→ **422**,`detail` 说明二者互斥。 +- 若均为空,则不对 `mr_url` 列加条件。 + +### 4.3 多条件组合 + +- 所有条件 **AND** 关系。 + +--- + +## 5. 排序与分页 + +- **默认排序**:`reported_at DESC`,`id DESC`(第二排序键保证稳定顺序,**建议**写入 Service)。 +- **`sort_field` + `sort_order`** 覆盖第一排序键;**第二键**仍为 **`id DESC`**(推荐)。 + +### 5.1 计数 + +- `total` 使用 **`select(func.count()).select_from(与列表相同 where 条件的子查询/别名)`** 或与项目现有 `history_service` 等一致的 **count 语句**,**禁止**对大结果集仅 `LIMIT` 后数行当 `total`。 + +--- + +## 6. 响应体 + +- 顶层:`PageResponse[UtGateRunItem]`。 +- **`UtGateRunItem`** 字段与 **`spec/16` §6**、表 **`ut_gate_run`** 一致;若 POST 已定义该 Schema,**GET 直接复用**,避免两套模型漂移。 + +### 6.1 日期时间序列化 + +- 与 **`spec/16` §6.2** 及项目现有列表 API 一致(推荐 **ISO 8601** 字符串)。 + +### 6.2 大整数 `id` + +- JSON **number** 与 POST 一致;若前端需字符串化,在 **前端类型** 或 **二期** 扩展字段中处理,**本期**不强制改 Schema。 + +--- + +## 7. 索引与性能 + +- 已有索引:`idx_created_at`、`idx_mr_url_created`、`idx_is_intercepted_created`、`idx_job_build`(见 `V1.1.2` DDL)。 +- **`reported_at` 范围 + `ORDER BY reported_at`**:若线上执行计划不佳,**二期**可加 **`idx_reported_at`**(须新迁移,**禁止** ALTER 保护表;仅允许**新增**迁移加索引)。 + +--- + +## 8. 路由与模块组织 + +- 与 **`POST /api/v1/ut-gate-runs`** 同属 **`backend/api/v1/ut_gate_run.py`**(推荐),或拆文件但 **同一 `prefix="/ut-gate-runs"`** 的 `APIRouter`。 +- 在 **`backend/api/router.py`** 已 `include_router` 的前提下,仅新增 **`GET`** 处理函数即可。 + +--- + +## 9. OpenAPI + +- 为各 Query 参数补充 **description**(中文):尤其说明 **`start_time`/`end_time` 绑定 `reported_at`**,避免与 History 的「批次 start_time」混淆。 + +--- + +## 10. 错误与校验小结 + +| 场景 | HTTP | +|------|--------| +| 未登录 | **401** | +| 互斥参数、`sort_field`/`sort_order` 非法、时间解析失败、start>end | **422** | +| 数据库异常 | **500** | + +--- + +## 11. `GET /api/v1/ut-gate-runs/stats`(非本期必做) + +主 spec §6.2 已列出 **stats** 接口(按日/周聚合 `is_intercepted`、可选 `job_name`)。**主 spec §8.2** 明确 **本期前端不做图表**。 + +| 项 | 规约 | +|----|------| +| **本期实现** | **可不实现** stats;若实现,**不得**被「UT门禁历史」列表页 **v1** 强依赖。 | +| **鉴权** | 与列表 **GET** 相同:**`get_current_user`**。 | +| **二期** | 与 ECharts 或看板汇总一并对接时再固化请求/响应字段表。 | + +若本期跳过 stats,**OpenAPI** 可不登记该路径,直至二期 spec 更新。 + +--- + +## 12. 实现检查清单 + +- [x] `UtGateRunQuery`(或等价)继承 `PageRequest`,含 §4 字段与校验器 +- [x] `list_ut_gate_runs(db, query) -> Tuple[List[UtGateRun], int]`(与项目 Service 签名风格一致) +- [x] `GET` 路由:`Depends(get_current_user)`、`response_model=PageResponse[UtGateRunItem]` +- [x] `mr_url_contains` / `job_name_contains` 与 History 一致的 **LIKE 转义** +- [x] 单测:Schema(互斥、时间、排序)、OpenAPI 含 `GET`(DB 联测可后续补) + +--- + +## 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v1.0 | 2026-05-07 | 初稿:`GET` 列表、鉴权、筛选、排序、分页、`stats` 非必做说明 | +| v1.1 | 2026-05-07 | 后端已按 §12 实现列表接口;§12 勾选同步 | From f1ee55bcc2d0c6a5e92631204791852ad10b27f6 Mon Sep 17 00:00:00 2001 From: weixin_53033691 Date: Tue, 12 May 2026 11:16:04 +0800 Subject: [PATCH 3/5] =?UTF-8?q?[feature]UT=E9=97=A8=E7=A6=81=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=E7=BB=9F=E8=AE=A1=EF=BC=9AUT=E9=97=A8=E7=A6=81?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=20=E5=89=8D=E7=AB=AF=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/05_technical_architecture.md | 2 +- frontend/src/layouts/MainLayout.tsx | 2 + .../ut-gate-history/UtGateHistoryPage.tsx | 290 ++++++++++++++++++ frontend/src/routes/index.tsx | 2 + frontend/src/services/index.ts | 3 + frontend/src/services/utGate.ts | 58 ++++ spec/15_ut_gate_jenkins_report_spec.md | 8 +- spec/17_ut_gate_runs_get_api_spec.md | 3 +- spec/18_ut_gate_history_frontend_spec.md | 170 ++++++++++ 9 files changed, 533 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/ut-gate-history/UtGateHistoryPage.tsx create mode 100644 frontend/src/services/utGate.ts create mode 100644 spec/18_ut_gate_history_frontend_spec.md diff --git a/docs/05_technical_architecture.md b/docs/05_technical_architecture.md index 2e5deba..8fb4f50 100644 --- a/docs/05_technical_architecture.md +++ b/docs/05_technical_architecture.md @@ -237,7 +237,7 @@ pipeline_cases case_offline_type sys_audit_log (新增) | Epic 1 | 数据看板 | `/api/v1/dashboard` | DashboardPage | pipeline_overview | | Epic 1 | 分组概览 | `/api/v1/overview` | OverviewPage | pipeline_overview | | Epic 1 | 执行明细 | `/api/v1/history` | HistoryPage | pipeline_history | -| Epic 1 | UT 门禁上报 | `POST` / **`GET`** `/api/v1/ut-gate-runs`(列表页见 Story 规划) | (「UT门禁历史」页面对接 `GET`) | ut_gate_run | +| Epic 1 | UT 门禁上报 | `POST` / **`GET`** `/api/v1/ut-gate-runs` | **`UtGateHistoryPage`**(路由 **`/ut-gate-history`**,`spec/18`) | ut_gate_run | | Epic 2 | 失败分析 | `/api/v1/analysis` | (HistoryPage 内交互) | pipeline_failure_reason | | Epic 3 | 总结报告 | `/api/v1/report` | ReportPage | report_snapshot | | Epic 4 | 消息通知 | `/api/v1/notification` | NotificationPage | WeLink API | diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 3c7d224..555dafc 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -5,6 +5,7 @@ import { DashboardOutlined, UnorderedListOutlined, HistoryOutlined, + SafetyCertificateOutlined, FileTextOutlined, FileSearchOutlined, UserOutlined, @@ -23,6 +24,7 @@ const allMenuItems: MenuProps["items"] = [ { key: "/", icon: , label: "首页大盘" }, { key: "/overview", icon: , label: "分组执行历史" }, { key: "/history", icon: , label: "详细执行历史" }, + { key: "/ut-gate-history", icon: , label: "UT门禁历史" }, { key: "/cases", icon: , label: "用例管理" }, { key: "/report", icon: , label: "总结报告" }, { diff --git a/frontend/src/pages/ut-gate-history/UtGateHistoryPage.tsx b/frontend/src/pages/ut-gate-history/UtGateHistoryPage.tsx new file mode 100644 index 0000000..dd3eff5 --- /dev/null +++ b/frontend/src/pages/ut-gate-history/UtGateHistoryPage.tsx @@ -0,0 +1,290 @@ +import { useCallback, useEffect, useState } from "react"; +import { + Alert, + Button, + Card, + DatePicker, + Input, + message, + Select, + Space, + Spin, + Table, + Tag, + Tooltip, + Typography, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import dayjs from "dayjs"; +import type { Dayjs } from "dayjs"; +import { utGateApi, type UtGateRunItem, type UtGateRunListParams, type PageResponse } from "../../services"; + +const { RangePicker } = DatePicker; +const { Link, Text } = Typography; + +function extractApiDetail(err: unknown): string { + const ax = err as { response?: { data?: { detail?: unknown } } }; + const d = ax?.response?.data?.detail; + if (typeof d === "string") return d; + if (Array.isArray(d)) { + const first = d[0] as { msg?: string } | undefined; + if (first?.msg) return String(first.msg); + } + return "加载失败"; +} + +function formatDt(s: string | null | undefined): string { + if (!s) return "—"; + const d = dayjs(s); + return d.isValid() ? d.format("YYYY-MM-DD HH:mm:ss") : s; +} + +export default function UtGateHistoryPage() { + const [loading, setLoading] = useState(false); + const [data, setData] = useState>({ + items: [], + total: 0, + page: 1, + page_size: 20, + }); + + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null); + const [interceptFilter, setInterceptFilter] = useState<"all" | "yes" | "no">("all"); + const [mrUrlExact, setMrUrlExact] = useState(""); + const [mrUrlContains, setMrUrlContains] = useState(""); + const [jobNameContains, setJobNameContains] = useState(""); + + const fetchList = useCallback(async (nextPage: number, nextPageSize: number) => { + if (mrUrlExact.trim() && mrUrlContains.trim()) { + message.warning("MR 精确与 MR 子串互斥,请只填其一"); + return; + } + setLoading(true); + try { + const params: UtGateRunListParams = { + page: nextPage, + page_size: nextPageSize, + sort_field: "reported_at", + sort_order: "desc", + }; + if (dateRange?.[0]) params.start_time = dateRange[0].format("YYYY-MM-DD"); + if (dateRange?.[1]) params.end_time = dateRange[1].format("YYYY-MM-DD"); + if (interceptFilter === "yes") params.is_intercepted = true; + if (interceptFilter === "no") params.is_intercepted = false; + const m = mrUrlExact.trim(); + const mc = mrUrlContains.trim(); + if (m) params.mr_url = m; + if (mc) params.mr_url_contains = mc; + const j = jobNameContains.trim(); + if (j) params.job_name_contains = j; + const res = await utGateApi.list(params); + setData(res); + setPage(res.page); + setPageSize(res.page_size); + } catch (e) { + message.error(extractApiDetail(e)); + } finally { + setLoading(false); + } + }, [dateRange, interceptFilter, mrUrlExact, mrUrlContains, jobNameContains]); + + useEffect(() => { + void fetchList(1, pageSize); + // 首次进入:默认排序与空筛选 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onSearch = () => { + void fetchList(1, pageSize); + }; + + const onReset = () => { + setDateRange(null); + setInterceptFilter("all"); + setMrUrlExact(""); + setMrUrlContains(""); + setJobNameContains(""); + setPageSize(20); + void fetchList(1, 20); + }; + + const columns: ColumnsType = [ + { + title: "ID", + dataIndex: "id", + key: "id", + width: 120, + ellipsis: true, + render: (v: number) => {String(v)}, + }, + { title: "上报时间", dataIndex: "reported_at", key: "reported_at", width: 170, render: (v) => formatDt(v) }, + { title: "创建时间", dataIndex: "created_at", key: "created_at", width: 170, render: (v) => formatDt(v) }, + { title: "Job", dataIndex: "job_name", key: "job_name", ellipsis: true }, + { title: "构建号", dataIndex: "build_number", key: "build_number", width: 90 }, + { + title: ( + + 是否拦截 + + + (?) + + + + ), + dataIndex: "is_intercepted", + key: "is_intercepted", + width: 110, + render: (v: boolean) => + v ? 已拦截 : 未拦截, + }, + { + title: "MR 链接", + dataIndex: "mr_url", + key: "mr_url", + ellipsis: true, + render: (url: string | null) => + url ? ( + + 打开 + + ) : ( + "—" + ), + }, + { + title: "构建链接", + dataIndex: "build_url", + key: "build_url", + width: 90, + render: (url: string | null) => + url ? ( + + Jenkins + + ) : ( + "—" + ), + }, + { + title: "退出码", + dataIndex: "ut_exit_code", + key: "ut_exit_code", + width: 80, + render: (v: number | null) => (v === null || v === undefined ? "—" : String(v)), + }, + { + title: "幂等键", + dataIndex: "idempotency_key", + key: "idempotency_key", + ellipsis: true, + render: (t: string) => ( + + {t} + + ), + }, + ]; + + return ( +
+
+ + UT 门禁历史 + + + 数据来自 Jenkins 上报;列表接口为 GET /api/v1/ut-gate-runs。 + + + + +
+
上报时间
+ setDateRange(v)} /> +
+
+
是否拦截
+ setMrUrlExact(e.target.value)} + allowClear + /> +
+
+
MR 子串
+ setMrUrlContains(e.target.value)} + allowClear + /> +
+
+
Job 子串
+ setJobNameContains(e.target.value)} + allowClear + /> +
+
+ + + + +
+
+
+
+ + + rowKey="id" + columns={columns} + dataSource={data.items} + scroll={{ x: "max-content" }} + pagination={{ + current: page, + pageSize, + total: data.total, + showSizeChanger: true, + pageSizeOptions: ["10", "20", "50", "100"], + showTotal: (t) => `共 ${t} 条`, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + void fetchList(p, ps); + }, + }} + locale={{ emptyText: "暂无数据" }} + /> + +
+ ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index e1c8bdc..d0a937b 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -6,6 +6,7 @@ import DashboardPage from "../pages/dashboard/DashboardPage"; import OverviewPage from "../pages/overview/OverviewPage"; import HistoryPage from "../pages/history/HistoryPage"; import CaseExecutionsHistoryPage from "../pages/history/CaseExecutionsHistoryPage"; +import UtGateHistoryPage from "../pages/ut-gate-history/UtGateHistoryPage"; import CasesPage from "../pages/cases/CasesPage"; import ReportPage from "../pages/report/ReportPage"; import UsersPage from "../pages/admin/UsersPage"; @@ -55,6 +56,7 @@ export default function AppRoutes() { }, { path: "history", element: }, { path: "history/case-executions", element: }, + { path: "ut-gate-history", element: }, { path: "cases", element: ( diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 6de8bd2..3872eb8 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -452,3 +452,6 @@ export const overviewApi = { return request.get("/overview/options") as any; }, }; + +export { utGateApi } from "./utGate"; +export type { UtGateRunItem, UtGateRunListParams } from "./utGate"; diff --git a/frontend/src/services/utGate.ts b/frontend/src/services/utGate.ts new file mode 100644 index 0000000..c1872fd --- /dev/null +++ b/frontend/src/services/utGate.ts @@ -0,0 +1,58 @@ +import request from "./request"; +import type { PageResponse } from "../types"; + +/** 与后端 `UtGateRunItem` / 表字段一致,snake_case(spec/18) */ +export interface UtGateRunItem { + id: number; + created_at: string | null; + updated_at: string | null; + reported_at: string | null; + jenkins_base_url: string | null; + job_name: string; + build_number: number; + build_url: string | null; + mr_url: string | null; + idempotency_key: string; + is_intercepted: boolean; + ut_exit_code: number | null; +} + +/** GET /ut-gate-runs 查询参数(spec/17 §4) */ +export interface UtGateRunListParams { + page?: number; + page_size?: number; + start_time?: string; + end_time?: string; + is_intercepted?: boolean; + mr_url?: string; + mr_url_contains?: string; + job_name_contains?: string; + sort_field?: string; + sort_order?: string; +} + +function toSearchParams(params?: UtGateRunListParams): Record { + if (!params) return {}; + const out: Record = {}; + const setIf = (key: string, v: string | number | boolean | undefined | null) => { + if (v === undefined || v === null || v === "") return; + out[key] = v; + }; + setIf("page", params.page ?? 1); + setIf("page_size", params.page_size ?? 20); + setIf("start_time", params.start_time); + setIf("end_time", params.end_time); + if (params.is_intercepted !== undefined) setIf("is_intercepted", params.is_intercepted); + setIf("mr_url", params.mr_url); + setIf("mr_url_contains", params.mr_url_contains); + setIf("job_name_contains", params.job_name_contains); + setIf("sort_field", params.sort_field); + setIf("sort_order", params.sort_order); + return out; +} + +export const utGateApi = { + list(params?: UtGateRunListParams): Promise> { + return request.get("/ut-gate-runs", { params: toSearchParams(params) }) as Promise>; + }, +}; diff --git a/spec/15_ut_gate_jenkins_report_spec.md b/spec/15_ut_gate_jenkins_report_spec.md index 2a75e25..126ab6b 100644 --- a/spec/15_ut_gate_jenkins_report_spec.md +++ b/spec/15_ut_gate_jenkins_report_spec.md @@ -277,7 +277,7 @@ - **菜单位置**:与 **「详细执行历史」**(现有 `/history` 所在主导航层级)**同级**,新增一项,菜单文案:**「UT门禁历史」**。 - **路由**:建议 **`/ut-gate-history`**(与 **`/history`** 并列顶层路径;实现时若需微调须保持「与详细执行历史同级」语义,并在路由表中登记)。 -- **页面内容**:**仅** Ant Design **`
`** + 筛选条件 + 分页,对接 **`GET /api/v1/ut-gate-runs`**;行内可链 **`build_url`** / **`mr_url`** 跳转 Jenkins / CodeHub。**本期页面不引入 ECharts**。 +- **页面内容**:**仅** Ant Design **`
`** + 筛选条件 + 分页,对接 **`GET /api/v1/ut-gate-runs`**;行内可链 **`build_url`** / **`mr_url`** 跳转 Jenkins / CodeHub。**本期页面不引入 ECharts**。**前端实现级规约**见 **`spec/18_ut_gate_history_frontend_spec.md`**。 - **首页**:**不**增加 UT 门禁图表或专用卡片;用户经 **「UT门禁历史」** 菜单进入列表即可。 ### 8.4 核心指标:按 MR 去重(不依赖是否合入) @@ -324,7 +324,7 @@ - [x] **`GET /api/v1/ut-gate-runs`** 分页列表:见 **`spec/17_ut_gate_runs_get_api_spec.md`** - [ ] Jenkins 侧:Credentials、`curl` 示例、`tee` + `PIPESTATUS` 试点;**`codehubMergeRequestUrl` → `mr_url`**(§5.2.1 **B 类**)按规约接入 - [ ] 联调:幂等、超时(`curl --max-time`)、DNS -- [ ] 前端:**「UT门禁历史」**菜单(与详细执行历史同级)+ 路由 **`/ut-gate-history`** + 列表页(**无图表**);**全员可见**(已登录用户);`utGateApi` 服务封装 +- [x] 前端:**「UT门禁历史」**菜单(与详细执行历史同级)+ 路由 **`/ut-gate-history`** + 列表页(**无图表**);**全员可见**(已登录用户);`utGateApi` 服务封装(**细则见 `spec/18_ut_gate_history_frontend_spec.md`**,**§10 已实现**) - [ ] 更新 `docs/` 中架构/接口说明(若有对外部署) --- @@ -346,7 +346,7 @@ | **1. 数据层** | `database/V*.*.*__create_ut_gate_run.sql`;`UtGateRun`(或等价命名)ORM;字段与本文 **§5** 一致;**禁止** `create_all` | 迁移在目标环境执行成功 | | **2. 上报 API** | 请求 Schema、Service(含 **`idempotency_key` 幂等**)、`POST` 路由;**集成 Bearer** 校验;日志符合 `docs/06_logging_guide.md` | 同 key 重复上报不产生重复行;未授权/参数错误返回 4xx | | **3. 查询 API** | 列表 Query Schema、`select` 分页与计数、`GET` + `PageResponse` | 与项目内其它列表接口行为一致 | -| **4. 前端** | `frontend/src/services` 下 **`utGateApi`**(字段 **snake_case** 与后端一致);路由 **`/ut-gate-history`**;**「UT门禁历史」**菜单(全员可见,见 §6.2、§9);列表页,**本期无图表** | 已登录用户可访问列表与分页 | +| **4. 前端** | `frontend/src/services` 下 **`utGateApi`**(字段 **snake_case** 与后端一致);路由 **`/ut-gate-history`**;**「UT门禁历史」**菜单(全员可见,见 §6.2、§9);列表页,**本期无图表** | 已登录用户可访问列表与分页;**实现见 `spec/18_ut_gate_history_frontend_spec.md` §10(v1.1)** | | **5. Jenkins 侧** | Credentials、**`curl --max-time`**、**`tee` + `PIPESTATUS`**(或等价);**`codehubMergeRequestUrl` → `mr_url`**(§5.2.1 **B 类**) | 试点 Job 端到端产生一条符合预期的库记录 | **文档**:`docs/` 中架构/接口说明在**功能对外可用**的版本与代码同步更新即可,无需每个小改动都改文档。 @@ -392,3 +392,5 @@ | v1.5 | 2026-05-07 | §6.1:POST 细则引用 **`spec/16_ut_gate_report_post_api_spec.md`**;幂等行为与 §16 对齐 | | v1.6 | 2026-05-07 | 新增 **`spec/17_ut_gate_runs_get_api_spec.md`**(GET 列表);§6.2 认证分场景(POST 集成 Token / GET 用户 JWT)、筛选与 stats 说明对齐 §17 | | v1.7 | 2026-05-07 | §11:`GET` 列表检查项已落地;与 **`spec/17` v1.1** 同步 | +| v1.8 | 2026-05-07 | §8.3、§11、§12.2:**前端**细则引用 **`spec/18_ut_gate_history_frontend_spec.md`** | +| v1.9 | 2026-05-07 | §11:前端项注明 **`spec/18` §10** 已落地代码;部署侧仍需 `pnpm build` | diff --git a/spec/17_ut_gate_runs_get_api_spec.md b/spec/17_ut_gate_runs_get_api_spec.md index 711a2c0..e9064a8 100644 --- a/spec/17_ut_gate_runs_get_api_spec.md +++ b/spec/17_ut_gate_runs_get_api_spec.md @@ -2,7 +2,7 @@ 本文档为 **`GET /api/v1/ut-gate-runs`** 的**实现级**规约,供后端与「UT门禁历史」前端联调对照。上位需求见 **`spec/15_ut_gate_jenkins_report_spec.md`**(§6.2、§8);表结构与字段含义见 **§5** 及 **`database/V1.1.2__create_ut_gate_run.sql`**;单条记录 JSON 形状与 **`spec/16_ut_gate_report_post_api_spec.md` §6** 的 **`UtGateRunItem`** 对齐。 -**本文档范围**:**分页列表** `GET /api/v1/ut-gate-runs`。**不包含** `GET /api/v1/ut-gate-runs/stats`(见 **§11**);**不包含** Jenkins `POST`(见 **`spec/16_ut_gate_report_post_api_spec.md`**)。 +**本文档范围**:**分页列表** `GET /api/v1/ut-gate-runs`。**不包含** `GET /api/v1/ut-gate-runs/stats`(见 **§11**);**不包含** Jenkins `POST`(见 **`spec/16_ut_gate_report_post_api_spec.md`**)。**浏览器列表页**实现见 **`spec/18_ut_gate_history_frontend_spec.md`**。 --- @@ -171,3 +171,4 @@ |------|------|------| | v1.0 | 2026-05-07 | 初稿:`GET` 列表、鉴权、筛选、排序、分页、`stats` 非必做说明 | | v1.1 | 2026-05-07 | 后端已按 §12 实现列表接口;§12 勾选同步 | +| v1.2 | 2026-05-07 | 文首补充 **`spec/18`** 浏览器列表页引用 | diff --git a/spec/18_ut_gate_history_frontend_spec.md b/spec/18_ut_gate_history_frontend_spec.md new file mode 100644 index 0000000..11ba3f6 --- /dev/null +++ b/spec/18_ut_gate_history_frontend_spec.md @@ -0,0 +1,170 @@ +# 「UT门禁历史」前端实现规约 + +本文档对应 **`spec/15_ut_gate_jenkins_report_spec.md` §12.2 阶段 4(前端)** 的实现级说明,与 **§8.3** 菜单/路由/页面形态、**§6.2**(全员可见)、**`spec/17_ut_gate_runs_get_api_spec.md`**(列表 GET)对齐。**不包含** Jenkins 脚本、后端 POST、**`stats`** 图表。 + +--- + +## 1. 文档范围与关联 + +| 关联 | 说明 | +|------|------| +| 上位需求 | **`spec/15_ut_gate_jenkins_report_spec.md`** §8.1~§8.3、§9、§11 前端勾选项 | +| 列表 API | **`GET /api/v1/ut-gate-runs`**,见 **`spec/17_ut_gate_runs_get_api_spec.md`** | +| 项目前端契约 | `.cursor/rules/project.mdc`:`frontend/src/services/`、`frontend/src/pages/`、`request.ts`、`pnpm build` | + +**非目标(本期)**:ECharts、首页 UT 卡片、`GET /api/v1/ut-gate-runs/stats`、在浏览器中持有 **`UT_GATE_INTEGRATION_TOKEN`**(仅 Jenkins 使用 POST)。 + +--- + +## 2. 路由与导航 + +### 2.1 路由 + +| 项 | 规约 | +|----|------| +| **浏览器路径** | **`/ut-gate-history`**(与 **`/history`** 同级顶层路径,见 §15 §8.3) | +| **注册位置** | `frontend/src/routes/index.tsx`:在 **`RequireAuth`** 包裹的 `MainLayout` 子路由中新增一条 **`{ path: "ut-gate-history", element: }`** | +| **鉴权** | **仅** `RequireAuth`(与「详细执行历史」一致);**不得**使用 `RequireAdmin`(全员可见) | + +### 2.2 侧栏菜单 + +| 项 | 规约 | +|----|------| +| **文案** | **「UT门禁历史」** | +| **位置** | 与 **「详细执行历史」**(`/history`)**同级**:紧挨在 **`/history`** 项之后(或之前,二选一固定即可) | +| **`key`** | **`/ut-gate-history`**(与路由 path 一致,便于 `navigate`) | +| **图标** | 选用 Ant Design Icons 中与「门禁/检查」语义接近且与 `HistoryOutlined` 可区分的图标(如 **`SafetyCertificateOutlined`** 或 **`AuditOutlined`**,实现时选定一种) | +| **全员可见** | **`getMenuItemsByRole`**(`MainLayout.tsx`)中:**`user` 与 `admin` 均须包含本菜单项**;**不得**随「用例管理 / 管理员后台」等非管理员隐藏逻辑一并过滤掉 | + +--- + +## 3. 页面与组件 + +### 3.1 文件与导出 + +| 项 | 规约 | +|----|------| +| **页面文件** | `frontend/src/pages/ut-gate-history/UtGateHistoryPage.tsx`(目录名 **kebab-case** 与路由语义一致) | +| **组件形式** | **默认导出**函数组件:`export default function UtGateHistoryPage()` | +| **数据加载** | `useState` + `useEffect` + 异步函数调用 `utGateApi.list`(与项目其它列表页习惯一致) | + +### 3.2 页面结构(本期) + +- **仅** Ant Design **`
`** + **筛选区** + **分页**;**不**引入 ECharts、**不**做首页嵌入。 +- **表格 `rowKey`**:**`"id"`**(与项目规则一致)。 +- **分页**:对接后端 **`PageResponse`**:`pagination.current`、`pagination.pageSize`、`pagination.total`,与 **`spec/17`** 的 `page` / `page_size` / `total` 一致;翻页时重新请求列表。 + +### 3.3 建议列(与 §15 §5 / §8.1 对齐) + +以下列名均为 **中文表头**;数据字段 **snake_case** 与后端一致。 + +| 列(建议顺序) | 字段 | 说明 | +|----------------|------|------| +| ID | `id` | 大整数;若担心 JS 精度,**可**格式化为 **字符串** 展示(见 §6.2) | +| 上报时间 | `reported_at` | 可格式化为本地可读时间字符串 | +| 创建时间 | `created_at` | 可选列 | +| Job | `job_name` | — | +| 构建号 | `build_number` | — | +| 是否拦截 | `is_intercepted` | **`Tag`** 等:`true` → 建议文案 **「已拦截」**(或「是」);`false` → **「未拦截」**(或「否」),并在列说明或 Tooltip 中注明 **false 为混合语义**(见 §15 §1.2 / §8.4,简短即可) | +| MR 链接 | `mr_url` | 可空;非空时 **`Link`/`Typography.Link`** `target="_blank"` `rel="noopener noreferrer"` | +| 构建链接 | `build_url` | 可空;非空时同上外链 Jenkins | +| 退出码 | `ut_exit_code` | 可空,展示 `-` 或 `—` | +| 幂等键 | `idempotency_key` | 可选列;过长可 `ellipsis` + `Tooltip` | + +**不要求**本期展示:`jenkins_base_url`、`updated_at`(除非产品希望对齐 DBA 排障,可列为可选隐藏列二期再做)。 + +--- + +## 4. 筛选与查询参数 + +筛选条件映射 **`spec/17` §4** Query 参数;请求时 **snake_case** 与后端一致。 + +| UI 建议 | 对应 Query 参数 | 说明 | +|---------|------------------|------| +| **上报时间**范围(`RangePicker` 或两个日期选择) | `start_time`、`end_time` | 绑定 **`reported_at`**;推荐传 **`YYYY-MM-DD`**(与 §17 日期模式一致);**须在页面旁用中文提示**:「时间筛选对应上报时间 `reported_at`,与详细执行历史的批次时间不同」 | +| **是否拦截**下拉(全部 / 是 / 否) | `is_intercepted` | 选「全部」则**不传**该参数 | +| **MR 精确**输入框 | `mr_url` | 与 **MR 子串**互斥(见下) | +| **MR 子串**输入框 | `mr_url_contains` | 与 **MR 精确**互斥;若两者均有非空值,**提交前校验**并 **`message.warning`** 或表单错误提示,**不**发请求 | +| **Job 子串**输入框 | `job_name_contains` | 可选 | +| **排序**(可选简化) | `sort_field`、`sort_order` | 默认 **`reported_at` + `desc`**;可提供简单下拉或固定不写死由后端默认 | + +**查询按钮**:点击后 `setState` 页码为 **1** 再请求,避免筛选后仍停留在超大页码无数据。 + +**重置按钮**:清空筛选、`page=1`、重新拉取。 + +--- + +## 5. Service 层(`utGateApi`) + +### 5.1 文件位置 + +- **推荐**:新建 **`frontend/src/services/utGate.ts`**,导出 **`utGateApi`** 对象;在 **`frontend/src/services/index.ts`** 增加 **`export { utGateApi } from "./utGate"`**(或等价聚合导出),便于其它模块按需引用。 +- **禁止**:在页面内手写完整 `axios` URL 字符串散落多处;**须**通过 **`request`**(`./request.ts`)实例调用。 + +### 5.2 `request` 与鉴权 + +- 使用 **`import request from "./request"`**(或与 `historyApi` 相同的 project's request 路径)。 +- **JWT**:依赖现有 **`request` 拦截器**从 `localStorage` 注入 **`Authorization: Bearer`**;**禁止**把 **`UT_GATE_INTEGRATION_TOKEN`** 写入前端代码或 `localStorage`。 + +### 5.3 TypeScript 类型(与后端一致 **snake_case**) + +在 **`utGate.ts`**(或紧邻的 `types` 片段)中定义,字段与 **`UtGateRunItem`** / **`UtGateRunQuery`** 对齐,例如: + +- **`UtGateRunItem`**:`id`, `created_at`, `updated_at`, `reported_at`, `jenkins_base_url`, `job_name`, `build_number`, `build_url`, `mr_url`, `idempotency_key`, `is_intercepted`, `ut_exit_code`(日期时间字段类型为 **`string | null`** 等与 `HistoryItem` 风格一致即可)。 +- **`UtGateRunListParams`**(可选命名):`page`, `page_size`, `start_time`, `end_time`, `is_intercepted`, `mr_url`, `mr_url_contains`, `job_name_contains`, `sort_field`, `sort_order`;全部为 **可选**除分页默认值由页面传入。 + +### 5.4 API 方法 + +```ts +utGateApi.list(params?: UtGateRunListParams): Promise> +``` + +- **HTTP**:`request.get("/ut-gate-runs", { params })`。 +- **Query 序列化**:与 `historyApi` 类似,**仅附加非 `undefined` / 非空字符串** 的键;`boolean` 的 `is_intercepted` 需能序列化为后端可解析形式(与 FastAPI 行为一致,一般为 `true`/`false` 字符串)。 + +--- + +## 6. 大整数 `id` 与日期 + +- **`id`**:`BIGINT` 可能超过 **`Number.MAX_SAFE_INTEGER`**。表格展示推荐 **`String(row.id)`** 或使用 **`BigInt`** 再转字符串;**避免**对大 `id` 做依赖精度的数值运算。 +- **日期时间**:后端返回 ISO 字符串;展示层可用 **`dayjs`**(若项目已用)或 **`toLocaleString`** 格式化,**不**强制与时区策略改动后端。 + +--- + +## 7. 错误与空态 + +- **401**:由 **`request` 响应拦截器**统一跳转登录(现有行为),页面无需重复实现跳转逻辑。 +- **列表为空**:`` 或表格 `locale.emptyText`。 +- **非 401 错误**:`message.error` 展示简短中文(可从 `error.response?.data?.detail` 读取数组或字符串)。 + +--- + +## 8. 构建与部署 + +- 修改前端后须执行 **`pnpm build`**(或通过 **`scripts/deploy.sh`**),并**重启**后端以托管新静态资源(见项目规则 §五)。 +- **不**新增 `npm`/`yarn` 依赖除非产品必须;若新增须 **`pnpm add`** 并说明用途。 + +--- + +## 9. 文档与 Epic 表 + +- 功能对用户可用后,同步 **`docs/05_technical_architecture.md`** 中 Epic 表「UT 门禁」一行:前端路由 **`/ut-gate-history`** 与页面组件名(与 §15 §11「更新 docs」一致)。 + +--- + +## 10. 实现检查清单 + +- [x] `frontend/src/services/utGate.ts`:`utGateApi` + **`UtGateRunItem`** / 列表请求参数类型 +- [x] `frontend/src/pages/ut-gate-history/UtGateHistoryPage.tsx`:表格 + 筛选 + 分页 + 外链 +- [x] `routes/index.tsx`:注册 **`/ut-gate-history`**,**仅** `RequireAuth` +- [x] `MainLayout.tsx`:菜单项 **「UT门禁历史」**,**`user`/`admin` 均可见** +- [ ] 部署环境执行 **`pnpm build`** 并重启后端(见项目规则) + +--- + +## 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v1.0 | 2026-05-07 | 初稿:对应 §15 §12.2 阶段 4;路由、菜单全员可见、`utGateApi`、表格与筛选、非目标范围 | +| v1.1 | 2026-05-07 | 仓库已按 §10 落地前端实现;构建请在本地执行 `pnpm build` | From ecbf9f408f7ad77f4f0ec035275f93ec7702e1cf Mon Sep 17 00:00:00 2001 From: weixin_53033691 Date: Fri, 15 May 2026 16:20:55 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(db):=20ut=5Fgate=5Frun=20=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=20mr=5Furl=20=E5=89=8D=E7=BC=80=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=20MySQL=201071?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit utf8mb4 下 VARCHAR(1024) 全列与 created_at 联合索引超过 3072 字节上限。 改为 mr_url(191) 前缀索引,ORM mysql_length 与 spec §5.3 同步。 Co-authored-by: Cursor --- backend/models/ut_gate_run.py | 2 +- database/V1.1.2__create_ut_gate_run.sql | 3 ++- spec/15_ut_gate_jenkins_report_spec.md | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/models/ut_gate_run.py b/backend/models/ut_gate_run.py index 80f2eca..48a4788 100644 --- a/backend/models/ut_gate_run.py +++ b/backend/models/ut_gate_run.py @@ -15,7 +15,7 @@ class UtGateRun(Base): __table_args__ = ( UniqueConstraint("idempotency_key", name="uk_idempotency"), Index("idx_created_at", "created_at"), - Index("idx_mr_url_created", "mr_url", "created_at"), + Index("idx_mr_url_created", "mr_url", "created_at", mysql_length={"mr_url": 191}), Index("idx_is_intercepted_created", "is_intercepted", "created_at"), Index("idx_job_build", "job_name", "build_number"), {"extend_existing": True}, diff --git a/database/V1.1.2__create_ut_gate_run.sql b/database/V1.1.2__create_ut_gate_run.sql index 29493ce..97512c7 100644 --- a/database/V1.1.2__create_ut_gate_run.sql +++ b/database/V1.1.2__create_ut_gate_run.sql @@ -17,7 +17,8 @@ CREATE TABLE `ut_gate_run` ( PRIMARY KEY (`id`), UNIQUE KEY `uk_idempotency` (`idempotency_key`), KEY `idx_created_at` (`created_at`), - KEY `idx_mr_url_created` (`mr_url`, `created_at`), + -- mr_url 为 VARCHAR(1024),utf8mb4 全列索引超过 767/3072 字节限制,须使用前缀索引(与 ORM mysql_length 一致) + KEY `idx_mr_url_created` (`mr_url`(191), `created_at`), KEY `idx_is_intercepted_created` (`is_intercepted`, `created_at`), KEY `idx_job_build` (`job_name`, `build_number`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/spec/15_ut_gate_jenkins_report_spec.md b/spec/15_ut_gate_jenkins_report_spec.md index 126ab6b..e1e08e9 100644 --- a/spec/15_ut_gate_jenkins_report_spec.md +++ b/spec/15_ut_gate_jenkins_report_spec.md @@ -174,7 +174,7 @@ | 索引 | 字段 | 用途 | |------|------|------| | `idx_created_at` | `created_at` | 时间范围筛选、列表排序;**二期**若做趋势统计可复用 | -| `idx_mr_url_created` | `mr_url`, `created_at` | 按 MR 链接聚合、列表筛选(`mr_url` 可空时索引仍可用,查询注意 IS NOT NULL) | +| `idx_mr_url_created` | `mr_url`(191), `created_at` | 按 MR 链接聚合、列表筛选;**`mr_url` 为前缀索引**(MySQL 5.7 utf8mb4 单索引字节上限 3072,全列 `VARCHAR(1024)` 会报 ERROR 1071) | | `idx_is_intercepted_created` | `is_intercepted`, `created_at` | 列表按拦截状态筛选;**二期**若做分布/趋势可复用 | | `idx_job_build` | `job_name`, `build_number` | 对账、去重辅助 | @@ -394,3 +394,4 @@ | v1.7 | 2026-05-07 | §11:`GET` 列表检查项已落地;与 **`spec/17` v1.1** 同步 | | v1.8 | 2026-05-07 | §8.3、§11、§12.2:**前端**细则引用 **`spec/18_ut_gate_history_frontend_spec.md`** | | v1.9 | 2026-05-07 | §11:前端项注明 **`spec/18` §10** 已落地代码;部署侧仍需 `pnpm build` | +| v1.10 | 2026-05-12 | §5.3、`V1.1.2` DDL:**`idx_mr_url_created`** 改为 **`mr_url`(191)** 前缀索引,避免 MySQL 5.7 utf8mb4 **ERROR 1071** | From d72fa8a72a7bd1bfafd1c396efc8054224742534 Mon Sep 17 00:00:00 2001 From: weixin_53033691 Date: Fri, 15 May 2026 16:30:58 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(frontend):=20RangePicker=20=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E8=8C=83=E5=9B=B4=E7=B1=BB=E5=9E=8B=E4=B8=8E=20Ant=20?= =?UTF-8?q?Design=20=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 TS2345:onChange 为 [Dayjs|null, Dayjs|null]|null,state 不可写死为 [Dayjs,Dayjs]。 Co-authored-by: Cursor --- frontend/src/pages/ut-gate-history/UtGateHistoryPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/ut-gate-history/UtGateHistoryPage.tsx b/frontend/src/pages/ut-gate-history/UtGateHistoryPage.tsx index dd3eff5..e38d9c7 100644 --- a/frontend/src/pages/ut-gate-history/UtGateHistoryPage.tsx +++ b/frontend/src/pages/ut-gate-history/UtGateHistoryPage.tsx @@ -39,6 +39,9 @@ function formatDt(s: string | null | undefined): string { return d.isValid() ? d.format("YYYY-MM-DD HH:mm:ss") : s; } +/** 与 Ant Design RangePicker value/onChange 一致(两端可为 null) */ +type UtGateDateRange = [Dayjs | null, Dayjs | null] | null; + export default function UtGateHistoryPage() { const [loading, setLoading] = useState(false); const [data, setData] = useState>({ @@ -50,7 +53,7 @@ export default function UtGateHistoryPage() { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); - const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null); + const [dateRange, setDateRange] = useState(null); const [interceptFilter, setInterceptFilter] = useState<"all" | "yes" | "no">("all"); const [mrUrlExact, setMrUrlExact] = useState(""); const [mrUrlContains, setMrUrlContains] = useState("");