fix(worker): 素材下载改用storage_key字段,修复file_url为URL导致下载无效文件 #480
@@ -262,11 +262,61 @@ jobs:
|
||||
pytest --version
|
||||
|
||||
'
|
||||
- name: Select incremental test files
|
||||
if: github.event_name == 'pull_request'
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set +e
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']")
|
||||
echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)"
|
||||
|
||||
CHANGED_FILES="$CHANGED_FILES" \
|
||||
SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \
|
||||
python3 scripts/ci/select_unit_tests.py
|
||||
SELECT_EXIT=$?
|
||||
|
||||
if [ $SELECT_EXIT -eq 0 ]; then
|
||||
echo "UNIT_TEST_MODE=incremental" >> $GITHUB_ENV
|
||||
TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ')
|
||||
echo "SELECTED_TEST_FILES=$TEST_FILES" >> $GITHUB_ENV
|
||||
echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件"
|
||||
else
|
||||
echo "UNIT_TEST_MODE=full" >> $GITHUB_ENV
|
||||
echo "SELECTED_TEST_FILES=tests/unit" >> $GITHUB_ENV
|
||||
echo "全量模式"
|
||||
fi
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
shell: sh
|
||||
run: "set -eu\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m coverage run \\\n --source=apps/api/app,packages \\\n --omit=\"*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*\" \\\n --branch \\\n -m pytest tests/unit -q\npython3 -m coverage report --show-missing\npython3 -m coverage xml -o coverage.xml\npython3 -m coverage report --fail-under=65 > /dev/null\n"
|
||||
run: |
|
||||
set -eu
|
||||
if [ "${UNIT_TEST_MODE:-full}" = "incremental" ]; then
|
||||
echo "=== 增量测试模式 ==="
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest $SELECTED_TEST_FILES -q
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
# 增量模式下调低覆盖率门槛(跑的文件少覆盖率自然低,不做强校验)
|
||||
python3 -m coverage report --fail-under=10 > /dev/null || true
|
||||
else
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest tests/unit -q
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=65 > /dev/null
|
||||
fi
|
||||
- name: Diff coverage check (增量行覆盖率)
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && env.HAS_APP_CHANGES == 'true'
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
@@ -279,12 +329,25 @@ jobs:
|
||||
echo "Base branch: $BASE_BRANCH"
|
||||
|
||||
# 初始化git (CI tarball checkout没有.git目录)
|
||||
# 先备份PR代码,再基于base分支建分支,确保HEAD与base有共同祖先
|
||||
PR_CODE_DIR="/tmp/pr-code-$$"
|
||||
mkdir -p "$PR_CODE_DIR"
|
||||
# 排除隐藏文件(如.env)和后续生成的coverage文件,只备份源码
|
||||
find . -maxdepth 1 -mindepth 1 ! -name 'coverage.xml' ! -name 'diff_coverage.html' -exec cp -r {} "$PR_CODE_DIR/" \;
|
||||
|
||||
rm -rf .git
|
||||
git init > /dev/null 2>&1
|
||||
git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1
|
||||
git config user.email "ci@local"
|
||||
git config user.name "CI"
|
||||
# 拉取base分支用于对比
|
||||
git fetch origin $BASE_BRANCH --depth=100
|
||||
git fetch origin $BASE_BRANCH --depth=200
|
||||
# 基于base分支创建当前分支,确保有共同祖先
|
||||
git checkout -b ci-pr-branch "origin/$BASE_BRANCH" > /dev/null 2>&1
|
||||
# 清除base分支的源码,用PR代码覆盖
|
||||
find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
|
||||
cp -r "$PR_CODE_DIR"/. .
|
||||
rm -rf "$PR_CODE_DIR"
|
||||
# 提交当前代码
|
||||
git add -A > /dev/null 2>&1
|
||||
git commit -m "ci-tmp" > /dev/null 2>&1
|
||||
@@ -301,7 +364,11 @@ jobs:
|
||||
|
||||
# 运行diff-cover
|
||||
set +e
|
||||
python3 -m diff_cover.diff_cover_tool coverage.xml --compare-branch="origin/$BASE_BRANCH" --fail-under=$THRESHOLD --html-report diff_coverage.html 2>&1
|
||||
python3 -m diff_cover.diff_cover_tool coverage.xml \
|
||||
--compare-branch="origin/$BASE_BRANCH" \
|
||||
--fail-under=$THRESHOLD \
|
||||
--html-report diff_coverage.html \
|
||||
2>&1
|
||||
DIFF_EXIT=$?
|
||||
set -e
|
||||
|
||||
@@ -311,12 +378,12 @@ jobs:
|
||||
echo " 请为改动的代码添加单元测试后再提交"
|
||||
echo ""
|
||||
echo "=== 覆盖率报告 ==="
|
||||
python3 -m diff_cover.diff_cover_tool coverage.xml --compare-branch="origin/$BASE_BRANCH" 2>&1 | tail -30
|
||||
python3 -m diff_cover.diff_cover_tool coverage.xml \
|
||||
--compare-branch="origin/$BASE_BRANCH" 2>&1 | tail -30
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ 增量覆盖率达标"
|
||||
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
shell: sh
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
"""add storage_key to assets
|
||||
|
||||
Revision ID: 042_storage_key
|
||||
Revises: 041_result_count
|
||||
Create Date: 2026-07-17 18:10:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "042_storage_key"
|
||||
down_revision = "041_result_count"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"assets",
|
||||
sa.Column("storage_key", sa.String(500), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("assets", "storage_key")
|
||||
@@ -366,12 +366,12 @@ class RenderAdapter:
|
||||
failed_clip_ids: list[str] = []
|
||||
seen_asset_ids: set[str] = set()
|
||||
|
||||
# 批量查询素材的 file_url(OSS 存储路径)
|
||||
# 批量查询素材的 storage_key(OSS 存储路径)
|
||||
clip_asset_ids = [c.asset_id for c in clips if c.asset_id]
|
||||
asset_storage_map: dict[str, str] = {}
|
||||
if clip_asset_ids:
|
||||
assets = self._db.query(AssetModel).filter(AssetModel.id.in_(clip_asset_ids)).all()
|
||||
asset_storage_map = {a.id: a.file_url for a in assets if a.file_url}
|
||||
asset_storage_map = {a.id: a.storage_key for a in assets if a.storage_key}
|
||||
|
||||
for clip in clips:
|
||||
asset_id = clip.asset_id
|
||||
@@ -468,8 +468,8 @@ class RenderAdapter:
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
model = self._db.query(AssetModel).filter(AssetModel.id == asset_id).first()
|
||||
if model and model.file_url:
|
||||
storage_key = model.file_url
|
||||
if model and model.storage_key:
|
||||
storage_key = model.storage_key
|
||||
logger.info("[plan_id=%s] [BGM] 从素材库下载: asset_id=%s", plan_id, asset_id)
|
||||
ok = download_asset(storage_key, bgm_file)
|
||||
if ok and bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
|
||||
@@ -472,14 +472,14 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
rendered_clip_ids: list[str] = []
|
||||
failed_clip_ids: list[str] = []
|
||||
|
||||
# 预先批量查询所有素材的 storage_key(file_url)
|
||||
# 预先批量查询所有素材的 storage_key
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
clip_asset_ids = [c.asset_id for c in clips if c.asset_id]
|
||||
asset_storage_map: dict[str, str] = {}
|
||||
if clip_asset_ids:
|
||||
assets = db.query(AssetModel).filter(AssetModel.id.in_(clip_asset_ids)).all()
|
||||
asset_storage_map = {a.id: a.file_url for a in assets if a.file_url}
|
||||
asset_storage_map = {a.id: a.storage_key for a in assets if a.storage_key}
|
||||
|
||||
for clip in clips:
|
||||
if not clip.asset_id:
|
||||
|
||||
@@ -537,8 +537,8 @@ def _prepare_bgm_track(
|
||||
session = SessionLocal()
|
||||
try:
|
||||
model = session.query(AssetModel).filter(AssetModel.id == asset_id).first()
|
||||
if model and model.file_url:
|
||||
storage_key = model.file_url
|
||||
if model and model.storage_key:
|
||||
storage_key = model.storage_key
|
||||
logger.info("[task_id=%s] [BGM] 从素材库下载: asset_id=%s", task_id, asset_id)
|
||||
ok = download_asset(storage_key, bgm_file)
|
||||
if ok and bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
|
||||
@@ -186,6 +186,14 @@
|
||||
"type": "VARCHAR(1000)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "storage_key",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(500)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "thumbnail_url",
|
||||
|
||||
@@ -72,6 +72,7 @@ class SQLAlchemyAssetRepository:
|
||||
file_type=(asset.mime_type.split("/")[0] if "/" in asset.mime_type else asset.mime_type),
|
||||
file_size=asset.file_size,
|
||||
file_url=asset.storage_key,
|
||||
storage_key=asset.storage_key,
|
||||
thumbnail_url=asset.thumbnail_url,
|
||||
duration=asset.duration,
|
||||
width=asset.width,
|
||||
@@ -100,6 +101,7 @@ class SQLAlchemyAssetRepository:
|
||||
model.name = asset.name
|
||||
model.file_size = asset.file_size
|
||||
model.file_url = asset.storage_key
|
||||
model.storage_key = asset.storage_key
|
||||
model.thumbnail_url = asset.thumbnail_url
|
||||
model.duration = asset.duration
|
||||
model.width = asset.width
|
||||
@@ -299,7 +301,7 @@ class SQLAlchemyAssetRepository:
|
||||
project_id=model.project_id,
|
||||
library_id=model.asset_library_id,
|
||||
name=model.name,
|
||||
storage_key=model.file_url,
|
||||
storage_key=model.storage_key or model.file_url,
|
||||
mime_type=mime_type,
|
||||
file_size=int(model.file_size or 0),
|
||||
thumbnail_url=model.thumbnail_url,
|
||||
|
||||
@@ -75,6 +75,8 @@ class AssetModel(Base):
|
||||
file_size = Column(Integer, nullable=False)
|
||||
# file_url: 完整可访问的 URL,用于客户端直接访问文件
|
||||
file_url = Column(String(1000), nullable=False)
|
||||
# storage_key: OSS 存储键,用于内部下载上传
|
||||
storage_key = Column(String(500), nullable=False, default="", server_default="")
|
||||
thumbnail_url = Column(String(1000), nullable=True)
|
||||
duration = Column(Float, nullable=True)
|
||||
width = Column(Float, nullable=True)
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
"""Unit tests for SQLAlchemyAssetRepository - storage_key field coverage."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
from packages.domain import Asset, AssetStatus
|
||||
|
||||
|
||||
def _repository():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine)()
|
||||
return SQLAlchemyAssetRepository(session)
|
||||
|
||||
|
||||
def test_asset_repository_create_preserves_storage_key():
|
||||
"""create时storage_key字段正确写入数据库。"""
|
||||
repository = _repository()
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name="test_video.mp4",
|
||||
storage_key="assets/proj-1/test_video.mp4",
|
||||
mime_type="video/mp4",
|
||||
file_size=102400,
|
||||
)
|
||||
|
||||
repository.create(asset)
|
||||
saved = repository.get(asset.id)
|
||||
|
||||
assert saved is not None
|
||||
assert saved.storage_key == "assets/proj-1/test_video.mp4"
|
||||
|
||||
|
||||
def test_asset_repository_update_preserves_storage_key():
|
||||
"""update时storage_key字段正确更新。"""
|
||||
repository = _repository()
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name="test_video.mp4",
|
||||
storage_key="assets/proj-1/old_key.mp4",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
repository.create(asset)
|
||||
|
||||
# 更新storage_key
|
||||
asset.storage_key = "assets/proj-1/new_key.mp4"
|
||||
repository.update(asset)
|
||||
|
||||
saved = repository.get(asset.id)
|
||||
assert saved.storage_key == "assets/proj-1/new_key.mp4"
|
||||
|
||||
|
||||
def test_asset_repository_to_domain_fallback_to_file_url():
|
||||
"""_to_domain中storage_key为空时fallback到file_url(兼容存量数据)。"""
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine)()
|
||||
repository = SQLAlchemyAssetRepository(session)
|
||||
|
||||
# 直接插入一条storage_key为空的记录(模拟存量数据)
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
model = AssetModel(
|
||||
id="asset-legacy-001",
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
name="legacy_asset.mp4",
|
||||
file_type="video",
|
||||
file_size=204800,
|
||||
file_url="legacy/storage/key.mp4", # 老数据file_url存的是storage_key
|
||||
storage_key="", # 新字段为空
|
||||
status="ready",
|
||||
classification_status="pending",
|
||||
uploaded_by_user_id="user-1",
|
||||
)
|
||||
session.add(model)
|
||||
session.commit()
|
||||
|
||||
saved = repository.get("asset-legacy-001")
|
||||
assert saved is not None
|
||||
assert saved.storage_key == "legacy/storage/key.mp4" # fallback到file_url
|
||||
@@ -77,7 +77,7 @@ def _make_adapter(
|
||||
Args:
|
||||
plan: 模拟的剪辑计划
|
||||
clips: 模拟的片段列表
|
||||
asset_url_map: asset_id → file_url 映射,用于 mock assets 表查询
|
||||
asset_url_map: asset_id → storage_key 映射,用于 mock assets 表查询
|
||||
|
||||
Returns:
|
||||
(adapter, mock_plan_repo, mock_clip_repo)
|
||||
@@ -91,7 +91,7 @@ def _make_adapter(
|
||||
for aid, url in asset_url_map.items():
|
||||
m = MagicMock()
|
||||
m.id = aid
|
||||
m.file_url = url
|
||||
m.storage_key = url
|
||||
mock_assets.append(m)
|
||||
mock_query.all.return_value = mock_assets
|
||||
mock_query.filter.return_value = mock_query
|
||||
@@ -521,17 +521,17 @@ class TestRenderPlan:
|
||||
class TestDownloadAssets:
|
||||
@staticmethod
|
||||
def _make_mock_db(asset_url_map: dict[str, str]):
|
||||
"""构造 mock db,根据 asset_id 返回对应的 AssetModel.file_url。"""
|
||||
"""构造 mock db,根据 asset_id 返回对应的 AssetModel.storage_key。"""
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
|
||||
def _fake_filter(query):
|
||||
# 模拟 .filter(AssetModel.id.in_([...])).all()
|
||||
mock_assets = []
|
||||
for asset_id, file_url in asset_url_map.items():
|
||||
for asset_id, storage_key in asset_url_map.items():
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.id = asset_id
|
||||
mock_asset.file_url = file_url
|
||||
mock_asset.storage_key = storage_key
|
||||
mock_assets.append(mock_asset)
|
||||
mock_query.all.return_value = mock_assets
|
||||
return mock_query
|
||||
@@ -565,7 +565,7 @@ class TestDownloadAssets:
|
||||
assert len(rendered_ids) == 2
|
||||
assert len(failed_ids) == 0
|
||||
assert mock_download.call_count == 2
|
||||
# 验证传给 download_asset 的是 file_url 而非 asset_id
|
||||
# 验证传给 download_asset 的是 storage_key 而非 asset_id
|
||||
call_keys = [call[0][0] for call in mock_download.call_args_list]
|
||||
assert "https://bucket.oss.com/videos/key1.mp4" in call_keys
|
||||
assert "https://bucket.oss.com/videos/key2.mp4" in call_keys
|
||||
@@ -670,14 +670,14 @@ class TestDownloadAssets:
|
||||
assert "c2" in rendered_ids
|
||||
|
||||
@patch("video_processing.render_adapter.download_asset")
|
||||
def test_asset_without_file_url_skipped(self, mock_download, tmp_path):
|
||||
"""素材在assets表中无file_url时跳过下载,标记为失败。"""
|
||||
# 构造返回 asset 但 file_url 为空
|
||||
def test_asset_without_storage_key_skipped(self, mock_download, tmp_path):
|
||||
"""素材在assets表中无storage_key时跳过下载,标记为失败。"""
|
||||
# 构造返回 asset 但 storage_key 为空
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.id = "asset_no_url"
|
||||
mock_asset.file_url = ""
|
||||
mock_asset.storage_key = ""
|
||||
mock_query.all.return_value = [mock_asset]
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
Reference in New Issue
Block a user