diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py old mode 100755 new mode 100644 index d624b7e75..146a5efe1 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -22,6 +22,7 @@ from datetime import datetime from typing import Any, List, Optional from app.auth import AuthenticatedUser, get_current_user +from app.core.storage import get_storage_service from app.dependencies import ( get_asset_library_repository, get_asset_repository, @@ -251,6 +252,16 @@ class GenerateFromTemplateResponse(BaseModel): def _to_response(p: EditPlan) -> EditPlanResponse: + # 对 config 中的 rendered_url 做签名转换(私有bucket裸URL会403) + config = dict(p.config) if p.config else {} + raw_rendered_url = config.get("rendered_url", "") + if raw_rendered_url: + try: + storage = get_storage_service() + config["rendered_url"] = storage.get_download_url(raw_rendered_url, expires_seconds=86400) + except Exception as e: + logger.warning("剪辑计划rendered_url签名失败,返回原始URL: plan_id=%s error=%s", p.id, e) + return EditPlanResponse( id=p.id, template_id=p.template_id, @@ -260,7 +271,7 @@ def _to_response(p: EditPlan) -> EditPlanResponse: result_count=getattr(p, "result_count", 0), project_id=p.project_id or "", created_by_user_id=p.created_by_user_id or "", - config=p.config, + config=config, created_at=p.created_at, updated_at=p.updated_at, ) diff --git a/tests/unit/test_edit_plans_api.py b/tests/unit/test_edit_plans_api.py index 5e44e6575..1dab5494c 100755 --- a/tests/unit/test_edit_plans_api.py +++ b/tests/unit/test_edit_plans_api.py @@ -487,6 +487,59 @@ class TestGetPlan: assert resp.status_code == 404 assert "剪辑计划不存在" in resp.json()["detail"] + def test_get_plan_rendered_url_is_signed(self, client): + """plan详情接口返回的rendered_url应该是签名URL,不是裸OSS URL""" + c, repo = client + raw_url = "https://bucket.oss-cn-hangzhou.aliyuncs.com/rendered/plan-001/output.mp4" + signed_url = raw_url + "?OSSAccessKeyId=xxx&Expires=123456&Signature=yyy" + plan = EditPlan.create("tpl-001", "测试计划", config={"rendered_url": raw_url, "other": "val"}) + plan.status = EditPlanStatus.COMPLETED + repo.create(plan) + + mock_storage = MagicMock() + mock_storage.get_download_url.return_value = signed_url + with patch("app.api.routes.edit_plans.get_storage_service", return_value=mock_storage): + resp = c.get(f"/api/v1/edit-plans/{plan.id}") + + assert resp.status_code == 200 + data = resp.json() + assert data["config"]["rendered_url"] == signed_url + assert data["config"]["other"] == "val" + mock_storage.get_download_url.assert_called_once_with(raw_url, expires_seconds=86400) + + def test_list_plans_rendered_url_is_signed(self, client): + """plan列表接口返回的rendered_url也应该是签名URL""" + c, repo = client + raw_url = "https://bucket.oss-cn-hangzhou.aliyuncs.com/rendered/plan-list/output.mp4" + signed_url = raw_url + "?OSSAccessKeyId=xxx&Expires=123456&Signature=yyy" + plan = EditPlan.create("tpl-001", "测试计划", config={"rendered_url": raw_url}) + plan.status = EditPlanStatus.COMPLETED + repo.create(plan) + + mock_storage = MagicMock() + mock_storage.get_download_url.return_value = signed_url + with patch("app.api.routes.edit_plans.get_storage_service", return_value=mock_storage): + resp = c.get("/api/v1/edit-plans") + + assert resp.status_code == 200 + data = resp.json() + assert data["items"][0]["config"]["rendered_url"] == signed_url + + def test_get_plan_no_rendered_url_skips_signing(self, client): + """没有rendered_url的计划不调用签名服务""" + c, repo = client + plan = EditPlan.create("tpl-001", "测试计划", config={"key": "val"}) + repo.create(plan) + + mock_storage = MagicMock() + with patch("app.api.routes.edit_plans.get_storage_service", return_value=mock_storage): + resp = c.get(f"/api/v1/edit-plans/{plan.id}") + + assert resp.status_code == 200 + data = resp.json() + assert data["config"] == {"key": "val"} + mock_storage.get_download_url.assert_not_called() + # --------------------------------------------------------------------------- # 更新测试