From 7b329907f93c65650b55c539d027e77383ab0441 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Thu, 2 Jul 2026 08:57:49 +0800 Subject: [PATCH] feat: add map image site module --- alembic/versions/0002_add_map_image.py | 28 +++++ app/models.py | 10 ++ app/routers/admin.py | 48 ++++++++- app/routers/shared.py | 6 +- tests/test_api_contracts.py | 137 ++++++++++++++++++++++++- 5 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 alembic/versions/0002_add_map_image.py diff --git a/alembic/versions/0002_add_map_image.py b/alembic/versions/0002_add_map_image.py new file mode 100644 index 0000000..cb5bbe6 --- /dev/null +++ b/alembic/versions/0002_add_map_image.py @@ -0,0 +1,28 @@ +"""Add map image module table. + +Revision ID: 0002_add_map_image +Revises: 0001_initial_schema +Create Date: 2026-07-01 +""" + +from alembic import op +from sqlalchemy import inspect +from app.models import MapImage + + +revision = "0002_add_map_image" +down_revision = "0001_initial_schema" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + if "MapImage" not in set(inspect(bind).get_table_names()): + MapImage.__table__.create(bind) + + +def downgrade() -> None: + bind = op.get_bind() + if "MapImage" in set(inspect(bind).get_table_names()): + MapImage.__table__.drop(bind) diff --git a/app/models.py b/app/models.py index ca43e48..d394b6b 100644 --- a/app/models.py +++ b/app/models.py @@ -88,6 +88,16 @@ class DestinationAlias(Base): destination: Mapped[Destination] = relationship(back_populates="aliases") +class MapImage(Base): + __tablename__ = "MapImage" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + image: Mapped[str] = mapped_column(String, nullable=False) + isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + class ThemeCard(Base): __tablename__ = "ThemeCard" diff --git a/app/routers/admin.py b/app/routers/admin.py index a230ab6..50f71ed 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -21,6 +21,7 @@ from ..models import ( Destination, HeroSlide, Lead, + MapImage, MediaAsset, Product, ProductImage, @@ -62,6 +63,16 @@ SITE_CONFIG_MODULES = { "none_to_empty": set(), "create_defaults": {"isHot": False}, }, + "map": { + "model": MapImage, + "entity": "map_image", + "primary": "image", + "fields": {"image", "isActive"}, + "none_to_empty": set(), + "create_defaults": {}, + "ordered": False, + "singleton": True, + }, "themes": { "model": ThemeCard, "entity": "theme_card", @@ -302,14 +313,32 @@ def hero_slide_admin_dict(item: HeroSlide) -> dict: } +def map_image_admin_dict(item: MapImage) -> dict: + return { + "id": item.id, + "image": item.image or None, + "isActive": item.isActive, + "createdAt": encode_value(item.createdAt), + "updatedAt": encode_value(item.updatedAt), + } + + def site_item_dict(module: str, item) -> dict: if module == "heroSlides": return hero_slide_admin_dict(item) + if module == "map": + return map_image_admin_dict(item) return destination_dict(item) if module == "destinations" else model_dict(item) -def module_items(db: Session, model) -> list: - return db.scalars(select(model).order_by(model.sortOrder.asc())).all() +def module_items(db: Session, config: dict) -> list: + model = config["model"] + stmt = select(model) + if config.get("ordered", True): + stmt = stmt.order_by(model.sortOrder.asc()) + else: + stmt = stmt.order_by(model.createdAt.asc()) + return db.scalars(stmt).all() def normalize_site_sort_orders(items: list) -> None: @@ -328,7 +357,7 @@ def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db: for field, value in config["create_defaults"].items(): payload.setdefault(field, value) payload["isActive"] = payload.get("isActive", True) - if payload.get("sortOrder") is None: + if config.get("ordered", True) and payload.get("sortOrder") is None: payload["sortOrder"] = next_site_sort_order(db, config["model"]) if module == "destinations": slug = clean_site_value(payload.get("slug")) @@ -491,6 +520,10 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D for item in db.scalars(select(HeroSlide).order_by(HeroSlide.sortOrder.asc())).all() ], "destinations": [destination_dict(item) for item in destinations], + "map": [ + map_image_admin_dict(item) + for item in db.scalars(select(MapImage).order_by(MapImage.createdAt.asc())).all() + ], "themes": [ model_dict(item) for item in db.scalars(select(ThemeCard).order_by(ThemeCard.sortOrder.asc())).all() @@ -511,6 +544,8 @@ def create_site_config( db: Session = Depends(get_db), ): config = site_module(module) + if config.get("singleton") and module_items(db, config): + site_config_error(409, "地图图片已存在", "MAP_IMAGE_ALREADY_EXISTS", {"module": module}) item = config["model"](**site_create_payload(module, config, body, db)) db.add(item) db.flush() @@ -529,7 +564,9 @@ def reorder_site_config( db: Session = Depends(get_db), ): config = site_module(module) - items = module_items(db, config["model"]) + if not config.get("ordered", True): + site_config_error(400, "模块不支持排序", "MODULE_CONFIG_REORDER_UNSUPPORTED", {"module": module}) + items = module_items(db, config) current_ids = [item.id for item in items] requested_ids = body.itemIds if len(set(requested_ids)) != len(requested_ids) or set(requested_ids) != set(current_ids): @@ -590,7 +627,8 @@ def delete_site_config( before = site_item_dict(module, item) db.delete(item) db.flush() - normalize_site_sort_orders(module_items(db, config["model"])) + if config.get("ordered", True): + normalize_site_sort_orders(module_items(db, config)) result = {"id": item_id} audit(db, get_actor_id(request), "delete", config["entity"], item_id, result, before) db.commit() diff --git a/app/routers/shared.py b/app/routers/shared.py index 0de7b89..05087c0 100644 --- a/app/routers/shared.py +++ b/app/routers/shared.py @@ -1,6 +1,6 @@ from sqlalchemy import select from sqlalchemy.orm import Session, selectinload -from ..models import Campaign, CtaBanner, Destination, HeroSlide, Product, ThemeCard +from ..models import Campaign, CtaBanner, Destination, HeroSlide, MapImage, Product, ThemeCard from ..serializers import destination_dict, model_dict @@ -14,21 +14,25 @@ ROUTE_SECTION_LABELS = [ def site_config(db: Session, active_only: bool, include_public_extras: bool = True) -> dict: hero_stmt = select(HeroSlide).order_by(HeroSlide.sortOrder.asc()) destination_stmt = select(Destination).options(selectinload(Destination.aliases)).order_by(Destination.sortOrder.asc()) + map_stmt = select(MapImage).order_by(MapImage.createdAt.asc()) theme_stmt = select(ThemeCard).order_by(ThemeCard.sortOrder.asc()) cta_stmt = select(CtaBanner).order_by(CtaBanner.sortOrder.asc()) if active_only: hero_stmt = hero_stmt.where(HeroSlide.isActive.is_(True)) destination_stmt = destination_stmt.where(Destination.isActive.is_(True)) + map_stmt = map_stmt.where(MapImage.isActive.is_(True)) theme_stmt = theme_stmt.where(ThemeCard.isActive.is_(True)) cta_stmt = cta_stmt.where(CtaBanner.isActive.is_(True)) hero_slides = db.scalars(hero_stmt).all() destinations = db.scalars(destination_stmt).all() + map_images = db.scalars(map_stmt).all() themes = db.scalars(theme_stmt).all() cta_banners = db.scalars(cta_stmt).all() result = { "heroSlides": [model_dict(item) for item in hero_slides], "destinations": [destination_dict(item) for item in destinations], + "map": [model_dict(item) for item in map_images], "themes": [model_dict(item) for item in themes], "ctaBanners": [model_dict(item) for item in cta_banners], } diff --git a/tests/test_api_contracts.py b/tests/test_api_contracts.py index cebf6f5..043bd98 100644 --- a/tests/test_api_contracts.py +++ b/tests/test_api_contracts.py @@ -8,7 +8,7 @@ from app import serializers from app.auth import hash_password, require_admin from app.database import get_db from app.main import create_app -from app.models import AdminUser, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MediaAsset, Product, ProductImage, ThemeCard +from app.models import AdminUser, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MapImage, MediaAsset, Product, ProductImage, ThemeCard from app.routers import admin as admin_router from app.routers.admin import normalize_detail_sections, normalize_images from app.schemas import ( @@ -179,6 +179,16 @@ def make_cta_banner(**overrides): ) +def make_map_image(**overrides): + return MapImage( + id=overrides.get("id", "map-test"), + image=overrides.get("image", "https://cdn.example.test/map.webp"), + isActive=overrides.get("isActive", True), + createdAt=datetime(2026, 1, 1), + updatedAt=datetime(2026, 1, 2), + ) + + def make_admin_user(): return AdminUser( id="admin-test", @@ -442,7 +452,7 @@ def test_site_config_create_requires_module_primary_field(): def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets(): hero_slide = make_hero_slide(targetType="campaign", targetValue="campaign-test") - fake_db = FakeDb(scalar_results=[[], [hero_slide], [], []]) + fake_db = FakeDb(scalar_results=[[], [hero_slide], [], [], []]) app = authenticated_app(fake_db) try: @@ -458,6 +468,32 @@ def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets(): assert "targetValue" not in hero +def test_admin_site_config_includes_map_array_with_dedicated_contract(): + map_image = make_map_image() + fake_db = FakeDb(scalar_results=[[], [], [map_image], [], []]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).get("/api/admin/site-config") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + body = response.json() + assert body["map"] == [ + { + "id": "map-test", + "image": "https://cdn.example.test/map.webp", + "isActive": True, + "createdAt": "2026-01-01T00:00:00", + "updatedAt": "2026-01-02T00:00:00", + } + ] + assert "sortOrder" not in body["map"][0] + assert "title" not in body["map"][0] + assert "targetType" not in body["map"][0] + + def test_site_config_create_hero_slide_ignores_target_fields_and_returns_dedicated_contract(): fake_db = FakeDb(scalar_values=[0]) app = authenticated_app(fake_db) @@ -487,6 +523,47 @@ def test_site_config_create_hero_slide_ignores_target_fields_and_returns_dedicat assert created.targetValue is None +def test_site_config_create_map_image_defaults_active_and_audits(): + fake_db = FakeDb(scalar_results=[[]]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).post( + "/api/admin/site-config/map", + json={"image": " https://cdn.example.test/guizhou-map.webp ", "title": "ignored", "sortOrder": 99}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 201 + body = response.json() + assert body["image"] == "https://cdn.example.test/guizhou-map.webp" + assert body["isActive"] is True + assert "title" not in body + assert "sortOrder" not in body + created = fake_db.added[0] + assert isinstance(created, MapImage) + assert created.image == "https://cdn.example.test/guizhou-map.webp" + assert created.isActive is True + assert fake_db.added[-1].entity == "map_image" + assert fake_db.committed + + +def test_site_config_create_map_image_rejects_duplicate_singleton(): + fake_db = FakeDb(scalar_results=[[make_map_image()]]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).post("/api/admin/site-config/map", json={"image": "https://cdn.example.test/new.webp"}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 409 + assert response.json()["code"] == "MAP_IMAGE_ALREADY_EXISTS" + assert not fake_db.added + assert not fake_db.committed + + def test_site_config_patch_hero_slide_ignores_target_fields_and_returns_dedicated_contract(): hero_slide = make_hero_slide(targetType="campaign", targetValue="campaign-test") fake_db = FakeDb(get_result=hero_slide) @@ -515,6 +592,30 @@ def test_site_config_patch_hero_slide_ignores_target_fields_and_returns_dedicate assert hero_slide.targetValue == "campaign-test" +def test_site_config_patch_map_image_updates_allowed_fields_only(): + map_image = make_map_image(isActive=True) + fake_db = FakeDb(get_result=map_image) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).patch( + "/api/admin/site-config/map/map-test", + json={"image": " https://cdn.example.test/new-map.webp ", "isActive": False, "targetType": "ignored", "sortOrder": 1}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + body = response.json() + assert body["image"] == "https://cdn.example.test/new-map.webp" + assert body["isActive"] is False + assert "targetType" not in body + assert "sortOrder" not in body + assert not hasattr(map_image, "targetType") + assert fake_db.added[-1].action == "update" + assert fake_db.added[-1].entity == "map_image" + + def test_site_config_patch_updates_destination_contract_fields(): destination = make_destination() fake_db = FakeDb(get_result=destination) @@ -627,6 +728,38 @@ def test_site_config_reorder_rejects_duplicate_missing_and_unknown_ids(item_ids) assert not fake_db.committed +def test_site_config_reorder_map_is_not_supported(): + fake_db = FakeDb() + app = authenticated_app(fake_db) + + try: + response = TestClient(app).patch("/api/admin/site-config/map/reorder", json={"itemIds": ["map-test"]}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 400 + assert response.json()["code"] == "MODULE_CONFIG_REORDER_UNSUPPORTED" + assert not fake_db.committed + + +def test_site_config_delete_map_image_returns_json_and_audits_without_reorder(): + map_image = make_map_image() + fake_db = FakeDb(get_result=map_image) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).delete("/api/admin/site-config/map/map-test") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert response.json() == {"id": "map-test"} + assert fake_db.deleted == [map_image] + assert fake_db.added[-1].action == "delete" + assert fake_db.added[-1].entity == "map_image" + assert fake_db.committed + + def test_admin_media_upload_streams_image_to_oss_records_asset_and_audits(monkeypatch): uploaded = {}