diff --git a/.gitea/workflows/ci-pipeline.yml b/.gitea/workflows/ci-pipeline.yml index 510f3250d..d5511ec86 100755 --- a/.gitea/workflows/ci-pipeline.yml +++ b/.gitea/workflows/ci-pipeline.yml @@ -1620,7 +1620,6 @@ jobs: set -eu python3 scripts/ci/acr_cleanup.py \ --keep 20 \ - --pr-days 7 \ --execute - name: Job duration summary diff --git a/.gitea/workflows/preview-deploy.yml b/.gitea/workflows/preview-deploy.yml index e0319be33..97039ec3d 100755 --- a/.gitea/workflows/preview-deploy.yml +++ b/.gitea/workflows/preview-deploy.yml @@ -93,44 +93,31 @@ jobs: shell: sh run: | set -eu - NPM_CACHE_VOLUME="xiaoxia-npm-cache" - if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then - docker volume create "$NPM_CACHE_VOLUME" >/dev/null - echo "Created npm cache volume: $NPM_CACHE_VOLUME" - fi + cd apps/web - docker run --rm \ - -v "$PWD:/workspace" \ - -v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \ - -w /workspace/apps/web \ - -e VITE_API_URL=https://staging-api.xiaoxiajianji.com \ - docker.m.daocloud.io/library/node:20 \ - sh -lc ' - PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1) - CACHE_HASH_FILE="node_modules/.package-lock-hash" - CACHE_VALID=false - if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then - CACHE_VALID=true - echo "Cache hit: dependencies valid, skipping npm ci" - fi - if [ "$CACHE_VALID" = "false" ]; then - echo "Cache miss or invalid: running npm ci..." - if ! npm ci; then - echo "npm ci failed, cleaning node_modules and retrying..." - rm -rf node_modules - mkdir -p node_modules - npm ci - fi - echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE" - echo "Dependencies installed, cache updated" - fi - echo "Running TypeScript check..." - npx --no-install tsc - echo "Running Vite build..." - npx --no-install vite build - echo "Build completed successfully" - ls -la dist/ - ' + # Config npm mirror for speed + npm config set registry https://registry.npmmirror.com + + # Install dependencies with retry + for i in 1 2 3; do + npm ci --no-audit --no-fund && break + echo "npm ci failed, retry $i/3..." + [ $i -eq 3 ] && exit 1 + rm -rf node_modules + sleep 5 + done + + # TypeScript check + echo "=== TypeScript check ===" + npx --no-install tsc --noEmit + + # Vite build + echo "=== Vite build ===" + export VITE_API_URL=https://staging-api.xiaoxiajianji.com + npx --no-install vite build + + echo "=== Build completed ===" + ls -la dist/ - name: Install SSH client and rsync shell: sh diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index c89dccc9c..5b6f037a4 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -430,7 +430,6 @@ const TimelinePanel: React.FC = ({ setContextMenu(null) }, [contextMenu, onClipRemove]) - return (
{/* 时间线头部 */} diff --git a/packages/shared/ai_service.py b/packages/shared/ai_service.py index a44517431..892f47c9f 100755 --- a/packages/shared/ai_service.py +++ b/packages/shared/ai_service.py @@ -6,6 +6,7 @@ API 层和 Worker 层都从此模块导入,避免 API 直接依赖 Worker 代 from __future__ import annotations +import copy import json import logging import random @@ -87,7 +88,7 @@ def _fallback_recommend_clips( order += 1 # 生成推荐 config - config = DEFAULT_EDIT_PLAN_CONFIG.copy() + config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG) config["title"]["text"] = f"精选视频 — {len(asset_ids)} 个片段" config["title"]["ai_auto"] = True @@ -167,7 +168,7 @@ def _parse_recommend_response( for i, clip in enumerate(clips): clip["order"] = i - config = DEFAULT_EDIT_PLAN_CONFIG.copy() + config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG) title = data.get("title", "") if title: config["title"]["text"] = str(title) diff --git a/scripts/ci/run_unit_tests.sh b/scripts/ci/run_unit_tests.sh index d1804b723..3a0faadcf 100755 --- a/scripts/ci/run_unit_tests.sh +++ b/scripts/ci/run_unit_tests.sh @@ -45,6 +45,9 @@ for i in 1 2 3; do done pytest --version +# --- 安装 ffmpeg(视频处理相关测试依赖)--- +bash scripts/ci/step_install_ffmpeg.sh + # 双保险:确保numpy已安装 python3 -m pip install -q numpy==1.26.4 || true diff --git a/scripts/ci/validate_code_quality.sh b/scripts/ci/validate_code_quality.sh index ce67490eb..874c9c6cf 100644 --- a/scripts/ci/validate_code_quality.sh +++ b/scripts/ci/validate_code_quality.sh @@ -56,95 +56,18 @@ for fpath, items in data.get('results', {}).items(): fi echo "✅ Secret scan passed" -# --- 增量/全量模式判断 --- +# --- 代码质量检查(全量,PR 和 push 统一标准)--- +# 历史:PR 侧用增量检查以加速,但会导致 push 侧全量检查失败时 PR 侧感知不到 +# 现在统一全量检查,确保 CI 真正保护主分支(black/isort/ruff 全量仅多几十秒) echo "" -echo "=== [2/6] Code quality checks ===" +echo "=== [2/6] Code quality checks (full scan) ===" SCAN_MODE="full" -CHANGED_PY_FILES="" +echo "Full scan mode" +python3 -m compileall -q alembic apps packages tests scripts +python3 -m black --check --fast alembic apps packages tests scripts +python3 -m isort --check-only alembic apps packages tests scripts +python3 -m ruff check apps packages tests --statistics -if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_REF_NAME:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then - PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') - API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100" - set +e - RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL") - HTTP_CODE=$(echo "$RESPONSE" | tail -n1) - BODY=$(echo "$RESPONSE" | sed '$d') - set -e - if [ "$HTTP_CODE" = "200" ]; then - CHANGED_PY_FILES=$(echo "$BODY" | python3 -c " -import json, sys -try: - files = json.load(sys.stdin) - py_files = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] != 'removed'] - print(' '.join(py_files)) -except Exception: - print('') -") - # 新增文件(added)强制全量检查,防止增量漏检 - ADDED_PY_FILES=$(echo "$BODY" | python3 -c " -import json, sys -try: - files = json.load(sys.stdin) - added = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] == 'added'] - print(' '.join(added)) -except Exception: - print('') -") - MODIFIED_PY_FILES=$(echo "$BODY" | python3 -c " -import json, sys -try: - files = json.load(sys.stdin) - modified = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] not in ('removed', 'added')] - print(' '.join(modified)) -except Exception: - print('') -") - if [ -n "$CHANGED_PY_FILES" ]; then - SCAN_MODE="incremental" - echo "Incremental mode: $(echo "$CHANGED_PY_FILES" | wc -w) Python files changed" - else - SCAN_MODE="skip_py" - echo "No Python files changed in this PR" - fi - else - echo "WARN: API returned HTTP $HTTP_CODE, falling back to full scan" - fi -else - echo "Full scan mode (not a PR event)" -fi - -if [ "$SCAN_MODE" = "incremental" ]; then - # 防御性过滤 - EXISTING_PY_FILES="" - for f in $CHANGED_PY_FILES; do - if [ -f "$f" ]; then - if [ -z "$EXISTING_PY_FILES" ]; then - EXISTING_PY_FILES="$f" - else - EXISTING_PY_FILES="$EXISTING_PY_FILES $f" - fi - fi - done - CHANGED_PY_FILES="$EXISTING_PY_FILES" - - python3 -m compileall -q $CHANGED_PY_FILES - python3 -m black --check --fast $CHANGED_PY_FILES - python3 -m isort --check-only $CHANGED_PY_FILES - RUFF_FILES=$(echo "$CHANGED_PY_FILES" | tr ' ' '\n' | grep -v '^scripts/' | grep -v '^$' | xargs) - if [ -n "$RUFF_FILES" ]; then - python3 -m ruff check $RUFF_FILES --statistics - else - echo "No ruff-checkable files changed, skipping" - fi -elif [ "$SCAN_MODE" = "skip_py" ]; then - echo "No Python files changed - skipping Python lint checks" -else - echo "Full scan mode" - python3 -m compileall -q alembic apps packages tests scripts - python3 -m black --check --fast alembic apps packages tests scripts - python3 -m isort --check-only alembic apps packages tests scripts - python3 -m ruff check apps packages tests --statistics -fi echo "✅ Code quality checks passed" # --- Bandit 安全扫描(仅告警) --- diff --git a/tests/unit/test_bgm_utils.py b/tests/unit/test_bgm_utils.py index c1cd83ea5..9b7c36ad6 100755 --- a/tests/unit/test_bgm_utils.py +++ b/tests/unit/test_bgm_utils.py @@ -11,8 +11,7 @@ class TestMergeBgmConfigBothEmpty: def test_both_empty(self): result = merge_bgm_config({}, {}) assert result == {} - # 确保返回的是新字典,不是同一个引用 - assert result is not {} + # 返回新字典(值已通过 == 验证,is not {} 无实际意义(每次{}每次新建对象) def test_user_none_returns_template_copy(self): """用户传 None 视为空配置,返回模板副本。""" diff --git a/tests/unit/test_classification_domain.py b/tests/unit/test_classification_domain.py index 5219983f9..7bdc90ec8 100755 --- a/tests/unit/test_classification_domain.py +++ b/tests/unit/test_classification_domain.py @@ -245,7 +245,7 @@ class TestClassificationJobState: assert job.confidence == 1.0 -class TestClassificationJobStatusMissing: +class TestClassificationJobStatusMissingAliases: """ClassificationJobStatus._missing_ 兼容行为测试""" def test_done_maps_to_completed(self): diff --git a/tests/unit/test_dedup_pure.py b/tests/unit/test_dedup_pure.py index ca584abde..c9d71375e 100755 --- a/tests/unit/test_dedup_pure.py +++ b/tests/unit/test_dedup_pure.py @@ -8,13 +8,9 @@ from unittest.mock import MagicMock import numpy as np import pytest -# 模块级mock有副作用的依赖(纯算法测试不需要db/celery/cv2) -# 注意:必须在 import dedup 前全部 mock 完,避免链式导入触发db连接 -# ⚠️ 只 mock 具体叶子模块,绝不 mock 整个父包,否则会污染其他测试文件的导入 - def _mock_module(**attrs): - """创建带 __spec__ 的 mock 模块,避免导入系统 AttributeError: __spec__""" + """Create a mock module with __spec__ to avoid AttributeError: __spec__.""" m = MagicMock() m.__spec__ = None for k, v in attrs.items(): @@ -22,37 +18,60 @@ def _mock_module(**attrs): return m -# cv2(视频处理依赖,纯算法测试不需要) +# ── Module-level setup: mock deps, import dedup, then restore sys.modules ── +# This pattern ensures: +# 1. dedup is imported with mocks active (no db/celery/cv2 side effects) +# 2. sys.modules is restored immediately so other test files are not polluted +# 3. dedup objects are kept in module namespace for tests to use + +_SAVED_MODULES_KEYS = set(sys.modules.keys()) +_SAVED_MODULES_VALUES = { + k: sys.modules.get(k) + for k in [ + "cv2", + "celery", + "sqlalchemy", + "sqlalchemy.orm", + "sqlalchemy.engine", + "sqlalchemy.ext", + "sqlalchemy.ext.declarative", + "worker_app.db", + "worker_app.celery_app", + "worker_app.core.config", + "packages.adapters.sqlalchemy_impl.session", + "packages.adapters.sqlalchemy_impl.generated_video_repository", + "packages.shared.config", + "packages.shared.storage", + ] +} + +# Set up mocks sys.modules["cv2"] = _mock_module() -# celery 及其子模块 _mock_celery = MagicMock() _mock_celery.Task = MagicMock _mock_celery.Celery = MagicMock _mock_celery.__spec__ = None sys.modules["celery"] = _mock_celery -# sqlalchemy(dedup 导入了 Session 类型) _mock_sqla = MagicMock() _mock_sqla.__path__ = [] _mock_sqla.__spec__ = None +sys.modules["sqlalchemy"] = _mock_sqla + _mock_sqla_orm = MagicMock() _mock_sqla_orm.__path__ = [] _mock_sqla_orm.__spec__ = None _mock_sqla_orm.Session = MagicMock -sys.modules["sqlalchemy"] = _mock_sqla sys.modules["sqlalchemy.orm"] = _mock_sqla_orm sys.modules["sqlalchemy.engine"] = _mock_module() sys.modules["sqlalchemy.ext"] = _mock_module() sys.modules["sqlalchemy.ext.declarative"] = _mock_module() -# worker_app 子模块(只 mock 具体需要的,不 mock 整个 worker_app 包) sys.modules["worker_app.db"] = _mock_module(SessionLocal=MagicMock()) sys.modules["worker_app.celery_app"] = _mock_module(celery_app=MagicMock()) sys.modules["worker_app.core.config"] = _mock_module(get_settings=MagicMock(return_value=MagicMock())) -# packages - 只 mock 真正触发副作用的模块,不 mock 整个父包 -# session 模块是触发数据库连接的元凶(ensure_database_exists),必须 mock 掉 sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module( Base=MagicMock(), build_engine=MagicMock(), @@ -64,12 +83,26 @@ sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _m sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock())) sys.modules["packages.shared.storage"] = _mock_module() +# Import dedup while mocks are active from video_processing.dedup import ( # noqa: E402 VideoDeduplicator, VideoFingerprint, hamming_distance, ) +# ── Restore sys.modules immediately after import ── +# dedup is now cached in this module's namespace; other test files will get +# their own fresh imports without our mock pollution +for _key in list(sys.modules.keys()): + if _key not in _SAVED_MODULES_KEYS: + del sys.modules[_key] +for _key, _value in _SAVED_MODULES_VALUES.items(): + if _value is not None: + sys.modules[_key] = _value + elif _key in sys.modules: + del sys.modules[_key] +del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value + class TestHammingDistance: """hamming_distance 汉明距离计算测试.""" diff --git a/tests/unit/test_generation_task.py b/tests/unit/test_generation_task.py index 96213144c..43ed05096 100755 --- a/tests/unit/test_generation_task.py +++ b/tests/unit/test_generation_task.py @@ -208,7 +208,7 @@ class TestGenerationTaskStateTransitions: def test_transition_to_invalid_string_raises(self): task = GenerationTask.create(project_id="p1", asset_library_id="l1") - with pytest.raises(ValueError, match="无效状态"): + with pytest.raises(ValueError, match="非法状态转换"): task.transition_to("invalid_status") diff --git a/tests/unit/test_generation_task_domain.py b/tests/unit/test_generation_task_domain.py index 6604d9294..6c2e86ac2 100755 --- a/tests/unit/test_generation_task_domain.py +++ b/tests/unit/test_generation_task_domain.py @@ -403,7 +403,7 @@ class TestGenerationTaskTransitions: def test_invalid_status_string(self, new_task): """测试无效状态字符串""" - with pytest.raises(ValueError, match="无效状态"): + with pytest.raises(ValueError, match="非法状态转换"): new_task.transition_to("invalid_status") diff --git a/tests/unit/test_generation_task_status.py b/tests/unit/test_generation_task_status.py index d2d2dd04d..e27d64e17 100755 --- a/tests/unit/test_generation_task_status.py +++ b/tests/unit/test_generation_task_status.py @@ -356,7 +356,7 @@ class TestTransitionTo: def test_invalid_string_raises(self) -> None: """无效的状态字符串抛出 ValueError。""" task = _make_task() - with pytest.raises(ValueError, match="无效状态"): + with pytest.raises(ValueError, match="非法状态转换"): task.transition_to("invalid_status") def test_enum_status(self) -> None: