From e5bfb0cefc78a537a9974077259bd88d064ac61f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 10 Aug 2026 10:27:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E5=B0=81=E9=9D=A2=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=20API=20=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=EF=BC=8C=E8=A1=A8=E4=B8=8D=E5=AD=98=E5=9C=A8=E6=97=B6=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E8=BF=94=E5=9B=9E=E7=A9=BA=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /cover-templates 捕获 OperationalError/ProgrammingError,返回空列表+warning日志 - POST/PUT/DELETE 捕获同类异常,返回 503 而非 500 - 新增 4 个错误处理单元测试(32 passed) --- apps/api/app/api/routes/cover_templates.py | 32 ++++++-- tests/unit/test_cover_templates_crud.py | 91 ++++++++++++++++++++++ 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/apps/api/app/api/routes/cover_templates.py b/apps/api/app/api/routes/cover_templates.py index 7f2c3b80b..f42cbfda2 100644 --- a/apps/api/app/api/routes/cover_templates.py +++ b/apps/api/app/api/routes/cover_templates.py @@ -20,6 +20,7 @@ from app.schemas.cover_template import ( UpdateCoverTemplateRequest, ) from fastapi import APIRouter, Depends, HTTPException, Response +from sqlalchemy.exc import OperationalError, ProgrammingError from packages.domain.cover_template import CoverTemplate @@ -35,10 +36,17 @@ def list_cover_templates( authenticated_user: AuthenticatedUser = Depends(get_current_user), repo: Any = Depends(get_cover_template_repository), ) -> ListCoverTemplatesResponse: - """列出当前用户可见的封面模板(系统模板 + 用户自定义模板)。""" + """列出当前用户可见的封面模板(系统模板 + 用户自定义模板)。 + + 当数据库表不存在时(迁移未执行),降级返回空列表而非 500。 + """ user_id = authenticated_user.user.id - items = repo.list_for_user(user_id, skip=skip, limit=limit) - total = repo.count_for_user(user_id) + try: + items = repo.list_for_user(user_id, skip=skip, limit=limit) + total = repo.count_for_user(user_id) + except (OperationalError, ProgrammingError) as exc: + logger.warning("cover_templates 表查询失败(可能未迁移),返回空列表: %s", exc) + return ListCoverTemplatesResponse(items=[], total=0) return ListCoverTemplatesResponse( items=[ CoverTemplateResponse( @@ -70,7 +78,11 @@ def create_cover_template( config=config_dict, thumbnail_url=request.thumbnail_url, ) - created = repo.create(template) + try: + created = repo.create(template) + except (OperationalError, ProgrammingError) as exc: + logger.warning("cover_templates 表不可用(可能未迁移): %s", exc) + raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None return CoverTemplateResponse( id=created.id, name=created.name, @@ -90,7 +102,11 @@ def update_cover_template( ) -> CoverTemplateResponse: """更新封面模板(仅允许更新自己的模板)。""" user_id = authenticated_user.user.id - template = repo.get(template_id) + try: + template = repo.get(template_id) + except (OperationalError, ProgrammingError) as exc: + logger.warning("cover_templates 表不可用: %s", exc) + raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None if template is None: raise HTTPException(status_code=404, detail="模板不存在") if template.is_system: @@ -124,7 +140,11 @@ def delete_cover_template( ) -> None: """删除用户自定义封面模板(系统模板不可删除)。""" user_id = authenticated_user.user.id - template = repo.get(template_id) + try: + template = repo.get(template_id) + except (OperationalError, ProgrammingError) as exc: + logger.warning("cover_templates 表不可用: %s", exc) + raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None if template is None: raise HTTPException(status_code=404, detail="模板不存在") if template.is_system: diff --git a/tests/unit/test_cover_templates_crud.py b/tests/unit/test_cover_templates_crud.py index c6a61c80c..acf57f326 100644 --- a/tests/unit/test_cover_templates_crud.py +++ b/tests/unit/test_cover_templates_crud.py @@ -307,3 +307,94 @@ class TestInMemoryCoverTemplateRepository: system = repo.list_system_templates() assert len(system) == 2 assert all(t.is_system for t in system) + + + +class TestCoverTemplatesErrorHandling: + """测试 API 错误处理加固 - 数据库表不存在时降级处理。""" + + def test_list_raises_operational_error_when_table_missing(self): + """当 cover_templates 表不存在时,repository 应抛出 OperationalError。""" + from sqlalchemy.exc import OperationalError + from packages.adapters.sqlalchemy_impl.cover_template_repository import SQLAlchemyCoverTemplateRepository + + mock_session = MagicMock() + mock_session.query.side_effect = OperationalError( + "statement", {}, Exception('relation "cover_templates" does not exist') + ) + repo = SQLAlchemyCoverTemplateRepository(mock_session) + + with pytest.raises(OperationalError): + repo.list_for_user("user-1") + + def test_route_catches_operational_error_on_list(self): + """路由层应捕获 OperationalError 并返回空列表。""" + from sqlalchemy.exc import OperationalError + from app.api.routes.cover_templates import list_cover_templates + from app.schemas.cover_template import ListCoverTemplatesResponse + + mock_repo = MagicMock() + mock_repo.list_for_user.side_effect = OperationalError( + "statement", {}, Exception('relation "cover_templates" does not exist') + ) + + mock_user = MagicMock() + mock_user.user.id = "user-1" + + # 调用路由函数,验证它不会抛出异常 + result = list_cover_templates( + skip=0, limit=100, + authenticated_user=mock_user, + repo=mock_repo, + ) + assert isinstance(result, ListCoverTemplatesResponse) + assert result.items == [] + assert result.total == 0 + + def test_route_catches_programming_error_on_list(self): + """路由层应捕获 ProgrammingError 并返回空列表。""" + from sqlalchemy.exc import ProgrammingError + from app.api.routes.cover_templates import list_cover_templates + from app.schemas.cover_template import ListCoverTemplatesResponse + + mock_repo = MagicMock() + mock_repo.list_for_user.side_effect = ProgrammingError( + "statement", {}, Exception("no such table: cover_templates") + ) + + mock_user = MagicMock() + mock_user.user.id = "user-1" + + result = list_cover_templates( + skip=0, limit=100, + authenticated_user=mock_user, + repo=mock_repo, + ) + assert isinstance(result, ListCoverTemplatesResponse) + assert result.items == [] + assert result.total == 0 + + def test_create_returns_503_when_table_missing(self): + """创建模板时,如果表不存在应抛出 HTTPException(503)。""" + from sqlalchemy.exc import OperationalError + from fastapi import HTTPException + from app.api.routes.cover_templates import create_cover_template + from app.schemas.cover_template import CreateCoverTemplateRequest + + mock_repo = MagicMock() + mock_repo.create.side_effect = OperationalError( + "statement", {}, Exception('relation "cover_templates" does not exist') + ) + + mock_user = MagicMock() + mock_user.user.id = "user-1" + + request = CreateCoverTemplateRequest(name="test") + + with pytest.raises(HTTPException) as exc_info: + create_cover_template( + request=request, + authenticated_user=mock_user, + repo=mock_repo, + ) + assert exc_info.value.status_code == 503 -- 2.54.0 From 2e93bf437c14bc63952d6c6db1a937645449c83c Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 10 Aug 2026 02:30:45 +0000 Subject: [PATCH 2/2] style: auto-format with black + isort + prettier [skip ci-format-check] --- tests/unit/test_cover_templates_crud.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_cover_templates_crud.py b/tests/unit/test_cover_templates_crud.py index acf57f326..9ef2f88a1 100644 --- a/tests/unit/test_cover_templates_crud.py +++ b/tests/unit/test_cover_templates_crud.py @@ -309,13 +309,13 @@ class TestInMemoryCoverTemplateRepository: assert all(t.is_system for t in system) - class TestCoverTemplatesErrorHandling: """测试 API 错误处理加固 - 数据库表不存在时降级处理。""" def test_list_raises_operational_error_when_table_missing(self): """当 cover_templates 表不存在时,repository 应抛出 OperationalError。""" from sqlalchemy.exc import OperationalError + from packages.adapters.sqlalchemy_impl.cover_template_repository import SQLAlchemyCoverTemplateRepository mock_session = MagicMock() @@ -329,9 +329,9 @@ class TestCoverTemplatesErrorHandling: def test_route_catches_operational_error_on_list(self): """路由层应捕获 OperationalError 并返回空列表。""" - from sqlalchemy.exc import OperationalError from app.api.routes.cover_templates import list_cover_templates from app.schemas.cover_template import ListCoverTemplatesResponse + from sqlalchemy.exc import OperationalError mock_repo = MagicMock() mock_repo.list_for_user.side_effect = OperationalError( @@ -343,7 +343,8 @@ class TestCoverTemplatesErrorHandling: # 调用路由函数,验证它不会抛出异常 result = list_cover_templates( - skip=0, limit=100, + skip=0, + limit=100, authenticated_user=mock_user, repo=mock_repo, ) @@ -353,9 +354,9 @@ class TestCoverTemplatesErrorHandling: def test_route_catches_programming_error_on_list(self): """路由层应捕获 ProgrammingError 并返回空列表。""" - from sqlalchemy.exc import ProgrammingError from app.api.routes.cover_templates import list_cover_templates from app.schemas.cover_template import ListCoverTemplatesResponse + from sqlalchemy.exc import ProgrammingError mock_repo = MagicMock() mock_repo.list_for_user.side_effect = ProgrammingError( @@ -366,7 +367,8 @@ class TestCoverTemplatesErrorHandling: mock_user.user.id = "user-1" result = list_cover_templates( - skip=0, limit=100, + skip=0, + limit=100, authenticated_user=mock_user, repo=mock_repo, ) @@ -376,10 +378,10 @@ class TestCoverTemplatesErrorHandling: def test_create_returns_503_when_table_missing(self): """创建模板时,如果表不存在应抛出 HTTPException(503)。""" - from sqlalchemy.exc import OperationalError - from fastapi import HTTPException from app.api.routes.cover_templates import create_cover_template from app.schemas.cover_template import CreateCoverTemplateRequest + from fastapi import HTTPException + from sqlalchemy.exc import OperationalError mock_repo = MagicMock() mock_repo.create.side_effect = OperationalError( -- 2.54.0