diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index d5c2ebd6..0505948a 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -1,7 +1,7 @@ from typing import Annotated, AsyncGenerator, Callable, Iterable import uuid -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession @@ -139,11 +139,71 @@ async def get_cra_site_scope( def require_study_not_locked(): """检查项目是否被锁定,如果锁定则拒绝所有修改请求""" from app.crud import study as study_crud + from app.crud import faq_category as faq_category_crud + from app.crud import faq_item as faq_item_crud + + async def resolve_study_id(request: Request, db: AsyncSession) -> uuid.UUID: + raw_study_id = request.path_params.get("study_id") or request.query_params.get("study_id") + if raw_study_id: + try: + return uuid.UUID(str(raw_study_id)) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="项目 ID 格式错误", + ) from exc + + try: + body = await request.json() + except Exception: + body = None + if isinstance(body, dict) and body.get("study_id"): + try: + return uuid.UUID(str(body["study_id"])) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="项目 ID 格式错误", + ) from exc + + raw_category_id = request.path_params.get("category_id") + if raw_category_id: + try: + category_id = uuid.UUID(str(raw_category_id)) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="分类 ID 格式错误", + ) from exc + category = await faq_category_crud.get_category(db, category_id) + if not category or not category.study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") + return category.study_id + + raw_item_id = request.path_params.get("item_id") + if raw_item_id: + try: + item_id = uuid.UUID(str(raw_item_id)) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="FAQ ID 格式错误", + ) from exc + item = await faq_item_crud.get_item(db, item_id) + if not item or not item.study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") + return item.study_id + + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="缺少项目 ID", + ) async def dependency( - study_id: uuid.UUID, + request: Request, db: AsyncSession = Depends(get_db_session), ): + study_id = await resolve_study_id(request, db) study = await study_crud.get(db, study_id) if not study: raise HTTPException( diff --git a/backend/app/main.py b/backend/app/main.py index 118b273d..28304e0d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,7 +15,6 @@ from app.core.exceptions import register_exception_handlers from app.crud.user import ensure_admin_exists from app.db.base import Base from app.db.session import SessionLocal, engine -from app.services.fee_seed import seed_demo_fees from app.services.visit_scheduler import run_daily_lost_visit_job logger = logging.getLogger("ctms.setup_config") @@ -38,7 +37,6 @@ async def lifespan(_: FastAPI): await conn.run_sync(Base.metadata.create_all) async with SessionLocal() as session: await ensure_admin_exists(session) - await seed_demo_fees(session) yield stop_event.set() await scheduler_task diff --git a/backend/tests/test_app_startup.py b/backend/tests/test_app_startup.py new file mode 100644 index 00000000..f31f4bb0 --- /dev/null +++ b/backend/tests/test_app_startup.py @@ -0,0 +1,78 @@ +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI + +from app import main + + +class _DummyTask: + def __init__(self): + self.awaited = False + + def __await__(self): + async def _wait(): + self.awaited = True + + return _wait().__await__() + + +class _DummySession: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _DummyBegin: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def run_sync(self, fn): + return None + + +class _DummyEngine: + def begin(self): + return _DummyBegin() + + +@pytest.mark.asyncio +async def test_development_startup_does_not_seed_demo_fees(monkeypatch): + ensure_admin_exists = AsyncMock() + seed_demo_fees = AsyncMock() + create_all = AsyncMock() + legacy_pk = AsyncMock() + scheduler_task = _DummyTask() + + async def fake_scheduler(stop_event): + return None + + def fake_create_task(coroutine): + coroutine.close() + return scheduler_task + + @asynccontextmanager + async def fake_session_local(): + yield _DummySession() + + monkeypatch.setattr(main.settings, "ENV", "development") + monkeypatch.setattr(main, "ensure_admin_exists", ensure_admin_exists) + monkeypatch.setattr(main, "seed_demo_fees", seed_demo_fees, raising=False) + monkeypatch.setattr(main, "run_daily_lost_visit_job", fake_scheduler) + monkeypatch.setattr(main.asyncio, "create_task", fake_create_task) + monkeypatch.setattr(main, "engine", _DummyEngine()) + monkeypatch.setattr(main, "SessionLocal", fake_session_local) + monkeypatch.setattr(main, "_ensure_legacy_primary_keys", legacy_pk) + monkeypatch.setattr(main.Base.metadata, "create_all", create_all) + + async with main.lifespan(FastAPI()): + pass + + ensure_admin_exists.assert_awaited_once() + seed_demo_fees.assert_not_awaited() diff --git a/backend/tests/test_study_lock_dependency.py b/backend/tests/test_study_lock_dependency.py new file mode 100644 index 00000000..805df935 --- /dev/null +++ b/backend/tests/test_study_lock_dependency.py @@ -0,0 +1,105 @@ +import uuid + +import pytest +from fastapi import HTTPException +from starlette.requests import Request + +from app.core import deps + + +class _Study: + def __init__(self, study_id: uuid.UUID, is_locked: bool = False): + self.id = study_id + self.is_locked = is_locked + + +class _Category: + def __init__(self, study_id: uuid.UUID | None): + self.study_id = study_id + + +class _FaqItem: + def __init__(self, study_id: uuid.UUID | None): + self.study_id = study_id + + +def _make_request(*, method: str = "POST", path: str = "/", query_string: bytes = b"", json_body: bytes = b"", path_params=None): + async def receive(): + return {"type": "http.request", "body": json_body, "more_body": False} + + scope = { + "type": "http", + "http_version": "1.1", + "method": method, + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "path_params": path_params or {}, + } + return Request(scope, receive) + + +@pytest.mark.asyncio +async def test_require_study_not_locked_accepts_study_id_from_request_body(monkeypatch): + study_id = uuid.uuid4() + + async def fake_get(_db, requested_study_id): + return _Study(requested_study_id, is_locked=False) + + monkeypatch.setattr("app.crud.study.get", fake_get) + + dependency = deps.require_study_not_locked() + request = _make_request(json_body=f'{{"study_id":"{study_id}"}}'.encode()) + + study = await dependency(request=request, db=object()) + + assert study.id == study_id + + +@pytest.mark.asyncio +async def test_require_study_not_locked_resolves_study_id_from_category_path(monkeypatch): + study_id = uuid.uuid4() + category_id = uuid.uuid4() + + async def fake_get_category(_db, requested_category_id): + assert requested_category_id == category_id + return _Category(study_id) + + async def fake_get_study(_db, requested_study_id): + return _Study(requested_study_id, is_locked=False) + + monkeypatch.setattr("app.crud.faq_category.get_category", fake_get_category) + monkeypatch.setattr("app.crud.study.get", fake_get_study) + + dependency = deps.require_study_not_locked() + request = _make_request(path=f"/api/v1/faqs/categories/{category_id}", path_params={"category_id": str(category_id)}) + + study = await dependency(request=request, db=object()) + + assert study.id == study_id + + +@pytest.mark.asyncio +async def test_require_study_not_locked_rejects_locked_study_resolved_from_item_path(monkeypatch): + study_id = uuid.uuid4() + item_id = uuid.uuid4() + + async def fake_get_item(_db, requested_item_id): + assert requested_item_id == item_id + return _FaqItem(study_id) + + async def fake_get_study(_db, requested_study_id): + return _Study(requested_study_id, is_locked=True) + + monkeypatch.setattr("app.crud.faq_item.get_item", fake_get_item) + monkeypatch.setattr("app.crud.study.get", fake_get_study) + + dependency = deps.require_study_not_locked() + request = _make_request(path=f"/api/v1/faqs/{item_id}", path_params={"item_id": str(item_id)}) + + with pytest.raises(HTTPException) as exc_info: + await dependency(request=request, db=object()) + + assert exc_info.value.status_code == 403 + assert "项目已锁定" in exc_info.value.detail diff --git a/docs/plans/2026-03-30-remove-demo-study-seed-implementation.md b/docs/plans/2026-03-30-remove-demo-study-seed-implementation.md new file mode 100644 index 00000000..a4ccd499 --- /dev/null +++ b/docs/plans/2026-03-30-remove-demo-study-seed-implementation.md @@ -0,0 +1,48 @@ +# Remove Demo Study Seed Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Stop development initialization from auto-creating any demo study or fee data. + +**Architecture:** Remove the application startup hook that seeds demo fee data, while keeping schema setup and protected admin initialization intact. Add a regression test that proves the lifespan startup no longer calls the demo seed helper. + +**Tech Stack:** FastAPI, pytest, unittest.mock + +--- + +### Task 1: Lock the startup behavior with a failing test + +**Files:** +- Create: `backend/tests/test_app_startup.py` +- Modify: `backend/app/main.py` + +**Step 1: Write the failing test** + +```python +async def test_development_startup_does_not_seed_demo_fees(): + ... +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest backend/tests/test_app_startup.py -q` +Expected: FAIL because startup still calls `seed_demo_fees`. + +**Step 3: Write minimal implementation** + +Remove the `seed_demo_fees` import and startup call from `backend/app/main.py`. + +**Step 4: Run test to verify it passes** + +Run: `pytest backend/tests/test_app_startup.py -q` +Expected: PASS. + +### Task 2: Verify no regression in admin bootstrap + +**Files:** +- Test: `backend/tests/test_protected_admin.py` + +**Step 1: Run targeted regression tests** + +Run: `pytest backend/tests/test_app_startup.py backend/tests/test_protected_admin.py -q` +Expected: PASS. diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index f73115b5..4ba7d7d6 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -1,24 +1,26 @@ {{ formatAxisLaneLabel(lane.branch) }} @@ -1566,7 +1567,7 @@ v-for="lane in rollbackAxisLanes.filter(l => l.branch === 'main')" :key="`${row.id}-${lane.branch}`" class="rollback-axis-lane-segment is-main-lane" - :style="{ left: `${lane.x}px` }" + :style="getRollbackAxisLeftStyle(lane.x)" />
@@ -1661,6 +1662,7 @@ { const ROLLBACK_AXIS_LANE_GAP = 34; const ROLLBACK_AXIS_LANE_START = 18; +const getRollbackAxisLeftStyle = (axisX?: number | null) => ({ + left: `${Number.isFinite(axisX) ? axisX : ROLLBACK_AXIS_LANE_START}px`, +}); const normalizeAxisBranch = (branchName?: string | null): string => (branchName || "main").trim() || "main"; const formatAxisLaneLabel = (branchName: string): string => branchName === "main" ? "main" : branchName.replace(/^release\//, ""); @@ -6546,23 +6554,6 @@ onBeforeUnmount(() => { padding: 6px 0; } -.rollback-axis-scroll::-webkit-scrollbar { - width: 5px; -} - -.rollback-axis-scroll::-webkit-scrollbar-track { - background: transparent; -} - -.rollback-axis-scroll::-webkit-scrollbar-thumb { - background: #c8d6e5; - border-radius: 10px; -} - -.rollback-axis-scroll::-webkit-scrollbar-thumb:hover { - background: #a0b4cc; -} - /* SVG 覆盖层 */ .rollback-axis-overlay { position: absolute; diff --git a/frontend/src/views/admin/ProjectMembers.vue b/frontend/src/views/admin/ProjectMembers.vue index 03d15ccc..f6621ef1 100644 --- a/frontend/src/views/admin/ProjectMembers.vue +++ b/frontend/src/views/admin/ProjectMembers.vue @@ -64,7 +64,7 @@
- + diff --git a/frontend/src/views/admin/Projects.vue b/frontend/src/views/admin/Projects.vue index 84960d04..5f4d5481 100644 --- a/frontend/src/views/admin/Projects.vue +++ b/frontend/src/views/admin/Projects.vue @@ -58,7 +58,7 @@ - + @@ -139,7 +139,11 @@ const handleDelete = async (study: Study) => { return; } + const isDeletingCurrentStudy = studyStore.currentStudy?.id === study.id; await deleteStudy(study.id); + if (isDeletingCurrentStudy) { + await studyStore.rehydrateStudyForLastUser({ preferActive: true }); + } ElMessage.success("项目已删除"); await loadProjects(); } catch (err: any) { diff --git a/frontend/src/views/admin/SiteCraBinding.vue b/frontend/src/views/admin/SiteCraBinding.vue index 6dd7b9b9..3eb75b39 100644 --- a/frontend/src/views/admin/SiteCraBinding.vue +++ b/frontend/src/views/admin/SiteCraBinding.vue @@ -1,5 +1,5 @@ - + - + @@ -383,7 +383,7 @@ - + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 44bdf546..34916c6f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,7 +2,24 @@ import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; export default defineConfig({ - plugins: [vue()], + plugins: [ + vue(), + { + name: "sanitize-legacy-css", + generateBundle(_, bundle) { + for (const asset of Object.values(bundle)) { + if (asset.type !== "asset" || !asset.fileName.endsWith(".css") || typeof asset.source !== "string") { + continue; + } + asset.source = asset.source + .replace(/\.el-button::\-moz-focus-inner\{border:0\}/g, "") + .replace(/\.el-input__inner\[type=password\]::\-ms-reveal\{display:none\}/g, "") + .replace(/filter:alpha\(opacity=0\);/g, "") + .replace(/[^{}]*::\-webkit-scrollbar[^{}]*\{[^{}]*\}/g, ""); + } + }, + }, + ], server: { host: true, port: 5173,