From fe85197e68987a1006c71a0baa356f291ef71076 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Tue, 7 Jul 2026 22:03:57 +0800 Subject: [PATCH] feat: add demand page modules and admin endpoints Add full backend support for a demand request page, including: - New database models for demand hero, feature cards, form configuration, and product recommendations - Alembic migration for the new tables - Default seed data for demand page components - Public site config endpoint exposing demand data - Admin CRUD operations and configuration for demand modules - Extended lead query filters for source page, keyword, and date range - Updated Pydantic schemas for lead query parameters - Comprehensive test coverage for all new endpoints --- alembic/versions/0008_demand_page_modules.py | 106 ++++++++ app/content.py | 35 +++ app/demand_recommendations.py | 66 +++++ app/models.py | 69 +++++ app/routers/admin.py | 130 +++++++++- app/routers/shared.py | 16 +- app/schemas.py | 28 +- app/seed.py | 56 +++- tests/test_api_contracts.py | 258 ++++++++++++++++++- 9 files changed, 749 insertions(+), 15 deletions(-) create mode 100644 alembic/versions/0008_demand_page_modules.py create mode 100644 app/demand_recommendations.py diff --git a/alembic/versions/0008_demand_page_modules.py b/alembic/versions/0008_demand_page_modules.py new file mode 100644 index 0000000..574f9c6 --- /dev/null +++ b/alembic/versions/0008_demand_page_modules.py @@ -0,0 +1,106 @@ +"""Add demand page configurable modules. + +Revision ID: 0008_demand_page_modules +Revises: 0007_destination_page_modules +Create Date: 2026-07-07 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect +from sqlalchemy.dialects import postgresql + + +revision = "0008_demand_page_modules" +down_revision = "0007_destination_page_modules" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + existing_tables = set(inspect(bind).get_table_names()) + + if "DemandHero" not in existing_tables: + op.create_table( + "DemandHero", + sa.Column("id", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("kicker", sa.String(), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("steps", postgresql.ARRAY(sa.String()), nullable=False, server_default=sa.text("'{}'")), + sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), + sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.Column("updatedAt", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + if "DemandFeatureCard" not in existing_tables: + op.create_table( + "DemandFeatureCard", + sa.Column("id", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), + sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.Column("updatedAt", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + if "DemandForm" not in existing_tables: + op.create_table( + "DemandForm", + sa.Column("id", sa.String(), nullable=False), + sa.Column("destinationLabel", sa.String(), nullable=False, server_default="目的地/玩法"), + sa.Column("destinationPlaceholder", sa.String(), nullable=True), + sa.Column("phoneLabel", sa.String(), nullable=False, server_default="联系方式"), + sa.Column("phonePlaceholder", sa.String(), nullable=True), + sa.Column("noteLabel", sa.String(), nullable=False, server_default="补充说明"), + sa.Column("notePlaceholder", sa.String(), nullable=True), + sa.Column("submitLabel", sa.String(), nullable=False, server_default="提交出行需求"), + sa.Column("chips", postgresql.ARRAY(sa.String()), nullable=False, server_default=sa.text("'{}'")), + sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.Column("updatedAt", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + if "DemandRecommendation" not in existing_tables: + op.create_table( + "DemandRecommendation", + sa.Column("id", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("subtitle", sa.Text(), nullable=True), + sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), + sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.Column("updatedAt", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + if "DemandRecommendationProduct" not in existing_tables: + op.create_table( + "DemandRecommendationProduct", + sa.Column("recommendationId", sa.String(), nullable=False), + sa.Column("productId", sa.String(), nullable=False), + sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), + sa.ForeignKeyConstraint(["productId"], ["Product.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["recommendationId"], ["DemandRecommendation.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("recommendationId", "productId"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + existing_tables = set(inspect(bind).get_table_names()) + for table_name in [ + "DemandRecommendationProduct", + "DemandRecommendation", + "DemandForm", + "DemandFeatureCard", + "DemandHero", + ]: + if table_name in existing_tables: + op.drop_table(table_name) diff --git a/app/content.py b/app/content.py index 7638e07..ed2da25 100644 --- a/app/content.py +++ b/app/content.py @@ -120,6 +120,41 @@ VEHICLE_OPTIONS = [ }, ] +DEMAND_HERO = [ + { + "kicker": "3步定制", + "title": "告诉我们日期、人数和想法", + "description": "管家会按同行人、预算和体力强度,重新组合酒店、用车和景点节奏。", + "steps": ["提交需求", "管家沟通", "确认方案"], + } +] + +DEMAND_FEATURE_CARDS = [ + {"title": "动线", "description": "按天数顺路排"}, + {"title": "住宿", "description": "城市与山野组合"}, + {"title": "用车", "description": "人数行李匹配"}, +] + +DEMAND_FORM = { + "destinationLabel": "目的地/玩法", + "destinationPlaceholder": "例如:贵州、黄果树、西江苗寨", + "phoneLabel": "联系方式", + "phonePlaceholder": "手机号 / 微信号", + "noteLabel": "补充说明", + "notePlaceholder": "出行日期、人数、酒店偏好、预算范围", + "submitLabel": "提交出行需求", + "chips": ["贵州", "黄果树", "荔波小七孔", "西江苗寨", "梵净山", "万峰林"], +} + +DEMAND_RECOMMENDATIONS = [ + { + "title": "热门推荐", + "subtitle": "也可以先挑一条线路沟通", + "start": 16, + "end": 22, + } +] + CAMPAIGNS = [ {"slug": "classic-deal", "title": "经典打卡特惠", "start": 0, "end": 8, "coverImage": HERO_SLIDES[0]["image"]}, {"slug": "outdoor-deal", "title": "山野野咖特惠", "start": 8, "end": 16, "coverImage": HERO_SLIDES[1]["image"]}, diff --git a/app/demand_recommendations.py b/app/demand_recommendations.py new file mode 100644 index 0000000..fc181e5 --- /dev/null +++ b/app/demand_recommendations.py @@ -0,0 +1,66 @@ +from fastapi import HTTPException +from sqlalchemy import delete, select +from sqlalchemy.orm import Session, selectinload + +from .models import DemandRecommendation, DemandRecommendationProduct, Product +from .serializers import model_dict + + +def demand_recommendation_error(status_code: int, message: str, code: str, details: dict | None = None) -> None: + raise HTTPException(status_code=status_code, detail={"message": message, "code": code, "details": details or {}}) + + +def demand_recommendation_dict(item: DemandRecommendation, *, public: bool = False) -> dict: + links = sorted(item.products or [], key=lambda link: (link.sortOrder, link.productId)) + if public: + links = [link for link in links if link.product and link.product.status == "published"] + + data = { + "id": item.id, + "title": item.title, + "subtitle": item.subtitle, + "productIds": [link.productId for link in links], + "isActive": item.isActive, + } + if not public: + base = model_dict(item) + data.update( + { + "sortOrder": item.sortOrder, + "createdAt": base["createdAt"], + "updatedAt": base["updatedAt"], + } + ) + return data + + +def demand_recommendation_query(): + return ( + select(DemandRecommendation) + .options(selectinload(DemandRecommendation.products).selectinload(DemandRecommendationProduct.product)) + .order_by(DemandRecommendation.sortOrder.asc()) + ) + + +def validate_demand_recommendation_product_ids(db: Session, product_ids: list[str]) -> None: + if len(set(product_ids)) != len(product_ids): + demand_recommendation_error(422, "推荐线路商品不能重复", "DEMAND_RECOMMENDATION_PRODUCT_DUPLICATE") + if not product_ids: + return + + products = db.scalars(select(Product).where(Product.id.in_(product_ids))).all() + found_ids = {product.id for product in products} + missing_ids = [product_id for product_id in product_ids if product_id not in found_ids] + if missing_ids: + demand_recommendation_error(422, "推荐线路商品不存在", "DEMAND_RECOMMENDATION_PRODUCT_NOT_FOUND", {"productIds": missing_ids}) + + +def replace_demand_recommendation_products(db: Session, recommendation: DemandRecommendation, product_ids: list[str]) -> None: + validate_demand_recommendation_product_ids(db, product_ids) + db.execute(delete(DemandRecommendationProduct).where(DemandRecommendationProduct.recommendationId == recommendation.id)) + db.flush() + recommendation.products = [] + for index, product_id in enumerate(product_ids): + link = DemandRecommendationProduct(recommendationId=recommendation.id, productId=product_id, sortOrder=index) + recommendation.products.append(link) + db.add(link) diff --git a/app/models.py b/app/models.py index fc7e520..a982afc 100644 --- a/app/models.py +++ b/app/models.py @@ -207,6 +207,7 @@ class Product(Base): images: Mapped[list["ProductImage"]] = relationship(back_populates="product", cascade="all, delete-orphan") campaignLinks: Mapped[list["CampaignProduct"]] = relationship(back_populates="product", cascade="all, delete-orphan") routeSectionLinks: Mapped[list["RouteSectionProduct"]] = relationship(back_populates="product", cascade="all, delete-orphan") + demandRecommendationLinks: Mapped[list["DemandRecommendationProduct"]] = relationship(back_populates="product", cascade="all, delete-orphan") leads: Mapped[list["Lead"]] = relationship(back_populates="sourceProduct") orders: Mapped[list["Order"]] = relationship(back_populates="product") @@ -279,6 +280,74 @@ class RouteSectionProduct(Base): product: Mapped[Product] = relationship(back_populates="routeSectionLinks") +class DemandHero(Base): + __tablename__ = "DemandHero" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + title: Mapped[str] = mapped_column(String, nullable=False) + kicker: Mapped[str | None] = mapped_column(String) + description: Mapped[str | None] = mapped_column(Text) + steps: Mapped[list[str]] = mapped_column(ARRAY(String), default=list, nullable=False) + sortOrder: Mapped[int] = mapped_column(Integer, default=0, 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 DemandFeatureCard(Base): + __tablename__ = "DemandFeatureCard" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + title: Mapped[str] = mapped_column(String, nullable=False) + description: Mapped[str | None] = mapped_column(Text) + sortOrder: Mapped[int] = mapped_column(Integer, default=0, 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 DemandForm(Base): + __tablename__ = "DemandForm" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + destinationLabel: Mapped[str] = mapped_column(String, default="目的地/玩法", nullable=False) + destinationPlaceholder: Mapped[str | None] = mapped_column(String) + phoneLabel: Mapped[str] = mapped_column(String, default="联系方式", nullable=False) + phonePlaceholder: Mapped[str | None] = mapped_column(String) + noteLabel: Mapped[str] = mapped_column(String, default="补充说明", nullable=False) + notePlaceholder: Mapped[str | None] = mapped_column(String) + submitLabel: Mapped[str] = mapped_column(String, default="提交出行需求", nullable=False) + chips: Mapped[list[str]] = mapped_column(ARRAY(String), default=list, 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 DemandRecommendation(Base): + __tablename__ = "DemandRecommendation" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + title: Mapped[str] = mapped_column(String, nullable=False) + subtitle: Mapped[str | None] = mapped_column(Text) + sortOrder: Mapped[int] = mapped_column(Integer, default=0, 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) + + products: Mapped[list["DemandRecommendationProduct"]] = relationship(back_populates="recommendation", cascade="all, delete-orphan") + + +class DemandRecommendationProduct(Base): + __tablename__ = "DemandRecommendationProduct" + + recommendationId: Mapped[str] = mapped_column(String, ForeignKey("DemandRecommendation.id", ondelete="CASCADE"), primary_key=True) + productId: Mapped[str] = mapped_column(String, ForeignKey("Product.id", ondelete="CASCADE"), primary_key=True) + sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + recommendation: Mapped[DemandRecommendation] = relationship(back_populates="products") + product: Mapped[Product] = relationship(back_populates="demandRecommendationLinks") + + class Lead(Base): __tablename__ = "Lead" diff --git a/app/routers/admin.py b/app/routers/admin.py index cd44065..3ec6f57 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -18,6 +18,11 @@ from ..models import ( AuditLog, Campaign, CtaBanner, + DemandFeatureCard, + DemandForm, + DemandHero, + DemandRecommendation, + DemandRecommendationProduct, Destination, DestinationHero, DestinationRegion, @@ -37,6 +42,7 @@ from ..models import ( ) from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn from ..seed import create_media, reset_guizhou_content +from ..demand_recommendations import demand_recommendation_dict, demand_recommendation_query, replace_demand_recommendation_products from ..route_sections import replace_route_section_products, route_section_dict, route_section_query from ..serializers import admin_product_dict, destination_dict, encode_value, hotel_group_dict, lead_dict, model_dict from .shared import site_config @@ -88,6 +94,63 @@ SITE_CONFIG_MODULES = { "empty_to_none": {"keyword", "spots"}, "create_defaults": {"keyword": None, "spots": None}, }, + "demandHero": { + "model": DemandHero, + "entity": "demand_hero", + "primary": "title", + "fields": {"title", "kicker", "description", "steps", "isActive", "sortOrder"}, + "none_to_empty": set(), + "empty_to_none": {"kicker", "description"}, + "create_defaults": {"kicker": None, "description": None, "steps": []}, + }, + "demandFeatureCards": { + "model": DemandFeatureCard, + "entity": "demand_feature_card", + "primary": "title", + "fields": {"title", "description", "isActive", "sortOrder"}, + "none_to_empty": set(), + "empty_to_none": {"description"}, + "create_defaults": {"description": None}, + }, + "demandForm": { + "model": DemandForm, + "entity": "demand_form", + "primary": "submitLabel", + "fields": { + "destinationLabel", + "destinationPlaceholder", + "phoneLabel", + "phonePlaceholder", + "noteLabel", + "notePlaceholder", + "submitLabel", + "chips", + "isActive", + }, + "none_to_empty": set(), + "empty_to_none": {"destinationPlaceholder", "phonePlaceholder", "notePlaceholder"}, + "create_defaults": { + "destinationLabel": "目的地/玩法", + "destinationPlaceholder": "例如:贵州、黄果树、西江苗寨", + "phoneLabel": "联系方式", + "phonePlaceholder": "手机号 / 微信号", + "noteLabel": "补充说明", + "notePlaceholder": "出行日期、人数、酒店偏好、预算范围", + "submitLabel": "提交出行需求", + "chips": ["贵州", "黄果树", "荔波小七孔", "西江苗寨", "梵净山", "万峰林"], + }, + "ordered": False, + "singleton": True, + }, + "demandRecommendations": { + "model": DemandRecommendation, + "entity": "demand_recommendation", + "primary": "title", + "fields": {"title", "subtitle", "productIds", "isActive", "sortOrder"}, + "none_to_empty": set(), + "empty_to_none": {"subtitle"}, + "create_defaults": {"subtitle": None}, + }, "map": { "model": MapImage, "entity": "map_image", @@ -353,9 +416,17 @@ def normalize_campaign_tags(value) -> list[str]: return tags +def normalize_string_list(value) -> list[str]: + if value is None: + return [] + return [item.strip() for item in value if isinstance(item, str) and item.strip()] + + def site_field_value(config: dict, field: str, value): if field == "tags": return normalize_campaign_tags(value) + if field in {"steps", "chips"}: + return normalize_string_list(value) value = clean_site_value(value) if value == "" and field in config.get("empty_to_none", set()): return None @@ -414,6 +485,8 @@ def site_item_dict(module: str, item) -> dict: return map_image_admin_dict(item) if module == "routeSections": return route_section_dict(item) + if module == "demandRecommendations": + return demand_recommendation_dict(item) if module == "hotelGroups": return hotel_group_dict(item) return destination_dict(item) if module == "destinations" else model_dict(item) @@ -424,6 +497,8 @@ def module_items(db: Session, config: dict) -> list: stmt = select(model) if model is RouteSection: stmt = stmt.options(selectinload(RouteSection.products).selectinload(RouteSectionProduct.product)) + if model is DemandRecommendation: + stmt = stmt.options(selectinload(DemandRecommendation.products).selectinload(DemandRecommendationProduct.product)) if config.get("ordered", True): stmt = stmt.order_by(model.sortOrder.asc()) else: @@ -708,6 +783,19 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D model_dict(item) for item in db.scalars(select(VehicleOption).order_by(VehicleOption.sortOrder.asc())).all() ], + "demandHero": [ + model_dict(item) + for item in db.scalars(select(DemandHero).order_by(DemandHero.sortOrder.asc())).all() + ], + "demandFeatureCards": [ + model_dict(item) + for item in db.scalars(select(DemandFeatureCard).order_by(DemandFeatureCard.sortOrder.asc())).all() + ], + "demandForm": [ + model_dict(item) + for item in db.scalars(select(DemandForm).order_by(DemandForm.createdAt.asc())).all() + ], + "demandRecommendations": [demand_recommendation_dict(item) for item in db.scalars(demand_recommendation_query()).all()], } @@ -725,10 +813,15 @@ def create_site_config( if config.get("fixed"): site_config_error(405, "固定模块不支持新增", "MODULE_CONFIG_CREATE_UNSUPPORTED", {"module": module}) if config.get("singleton") and module_items(db, config): - site_config_error(409, "地图图片已存在", "MAP_IMAGE_ALREADY_EXISTS", {"module": module}) + if module == "map": + site_config_error(409, "地图图片已存在", "MAP_IMAGE_ALREADY_EXISTS", {"module": module}) + site_config_error(409, "单例配置已存在", "MODULE_CONFIG_SINGLETON_EXISTS", {"module": module}) item = config["model"](**site_create_payload(module, config, body, db)) db.add(item) db.flush() + if module == "demandRecommendations" and "productIds" in body.model_fields_set: + replace_demand_recommendation_products(db, item, body.productIds or []) + db.flush() after = site_item_dict(module, item) audit(db, get_actor_id(request), "create", config["entity"], item.id, after) db.commit() @@ -783,6 +876,8 @@ def update_site_config( apply_site_patch(module, config, item, body) if module == "routeSections" and "productIds" in body.model_fields_set: replace_route_section_products(db, item, body.productIds or []) + if module == "demandRecommendations" and "productIds" in body.model_fields_set: + replace_demand_recommendation_products(db, item, body.productIds or []) db.flush() after = site_item_dict(module, item) audit(db, get_actor_id(request), "update", config["entity"], item.id, after, before) @@ -822,11 +917,22 @@ def delete_site_config( @router.get("/leads") def list_leads( status_value: LeadStatus | None = Query(default=None, alias="status"), + sourcePage: str | None = None, + keyword: str | None = None, + createdFrom: str | None = None, + createdTo: str | None = None, take: int = Query(default=100, ge=1, le=200), _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db), ): - query = LeadQuery(status=status_value, take=take) + query = LeadQuery( + status=status_value, + sourcePage=sourcePage, + keyword=keyword, + createdFrom=createdFrom, + createdTo=createdTo, + take=take, + ) stmt = ( select(Lead) .options(selectinload(Lead.sourceProduct), selectinload(Lead.assignedUser)) @@ -835,6 +941,26 @@ def list_leads( ) if query.status: stmt = stmt.where(Lead.status == query.status) + if query.sourcePage: + stmt = stmt.where(Lead.sourcePage == query.sourcePage.strip()) + if query.createdFrom: + stmt = stmt.where(Lead.createdAt >= query.createdFrom) + if query.createdTo: + stmt = stmt.where(Lead.createdAt < query.createdTo) + if query.keyword and query.keyword.strip(): + pattern = f"%{query.keyword.strip()}%" + stmt = ( + stmt.outerjoin(Lead.sourceProduct) + .where( + or_( + Lead.phone.ilike(pattern), + Lead.destination.ilike(pattern), + Lead.note.ilike(pattern), + Product.title.ilike(pattern), + ) + ) + .distinct() + ) leads = db.scalars(stmt).all() return {"items": [lead_dict(lead) for lead in leads]} diff --git a/app/routers/shared.py b/app/routers/shared.py index 8020c92..70d6df5 100644 --- a/app/routers/shared.py +++ b/app/routers/shared.py @@ -1,6 +1,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session, selectinload -from ..models import Campaign, CtaBanner, Destination, DestinationHero, DestinationRegion, HeroSlide, HotelGroup, MapImage, ThemeCard, VehicleOption +from ..models import Campaign, CtaBanner, DemandFeatureCard, DemandForm, DemandHero, Destination, DestinationHero, DestinationRegion, HeroSlide, HotelGroup, MapImage, ThemeCard, VehicleOption +from ..demand_recommendations import demand_recommendation_dict, demand_recommendation_query from ..route_sections import route_section_dict, route_section_query from ..serializers import destination_dict, hotel_group_dict, model_dict @@ -15,6 +16,9 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr cta_stmt = select(CtaBanner).order_by(CtaBanner.sortOrder.asc()) hotel_stmt = select(HotelGroup).order_by(HotelGroup.sortOrder.asc()) vehicle_stmt = select(VehicleOption).order_by(VehicleOption.sortOrder.asc()) + demand_hero_stmt = select(DemandHero).order_by(DemandHero.sortOrder.asc()) + demand_feature_card_stmt = select(DemandFeatureCard).order_by(DemandFeatureCard.sortOrder.asc()) + demand_form_stmt = select(DemandForm).order_by(DemandForm.createdAt.asc()) if active_only: hero_stmt = hero_stmt.where(HeroSlide.isActive.is_(True)) destination_stmt = destination_stmt.where(Destination.isActive.is_(True)) @@ -25,6 +29,9 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr cta_stmt = cta_stmt.where(CtaBanner.isActive.is_(True)) hotel_stmt = hotel_stmt.where(HotelGroup.isActive.is_(True), HotelGroup.status == "published") vehicle_stmt = vehicle_stmt.where(VehicleOption.isActive.is_(True)) + demand_hero_stmt = demand_hero_stmt.where(DemandHero.isActive.is_(True)) + demand_feature_card_stmt = demand_feature_card_stmt.where(DemandFeatureCard.isActive.is_(True)) + demand_form_stmt = demand_form_stmt.where(DemandForm.isActive.is_(True)) hero_slides = db.scalars(hero_stmt).all() destinations = db.scalars(destination_stmt).all() @@ -51,4 +58,11 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr result["routeSections"] = [route_section_dict(item, public=True) for item in route_sections] result["hotelGroups"] = [hotel_group_dict(item) for item in db.scalars(hotel_stmt).all()] result["vehicleOptions"] = [model_dict(item) for item in db.scalars(vehicle_stmt).all()] + result["demandHero"] = [model_dict(item) for item in db.scalars(demand_hero_stmt).all()] + result["demandFeatureCards"] = [model_dict(item) for item in db.scalars(demand_feature_card_stmt).all()] + result["demandForm"] = [model_dict(item) for item in db.scalars(demand_form_stmt).all()] + demand_recommendations = db.scalars(demand_recommendation_query()).all() + if active_only: + demand_recommendations = [item for item in demand_recommendations if item.isActive] + result["demandRecommendations"] = [demand_recommendation_dict(item, public=active_only) for item in demand_recommendations] return result diff --git a/app/schemas.py b/app/schemas.py index 1656e79..c1c7650 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,6 +1,6 @@ -from datetime import datetime +from datetime import datetime, timedelta from typing import Literal -from pydantic import BaseModel, EmailStr, Field, field_validator +from pydantic import BaseModel, EmailStr, Field, ValidationInfo, field_validator ProductStatus = Literal["draft", "published", "archived"] @@ -115,8 +115,23 @@ class AdminProductQuery(BaseModel): class LeadQuery(BaseModel): status: LeadStatus | None = None + sourcePage: str | None = None + keyword: str | None = None + createdFrom: datetime | None = None + createdTo: datetime | None = None take: int = Field(default=100, ge=1, le=200) + @field_validator("createdFrom", "createdTo", mode="before") + @classmethod + def parse_date_only_range(cls, value, info: ValidationInfo): + if isinstance(value, str) and len(value) == 10: + try: + parsed = datetime.strptime(value, "%Y-%m-%d") + return parsed + timedelta(days=1) if info.field_name == "createdTo" else parsed + except ValueError: + return value + return value + class SiteConfigPatchIn(BaseModel): title: str | None = None @@ -135,6 +150,15 @@ class SiteConfigPatchIn(BaseModel): priceAmount: int | None = Field(default=None, ge=0) priceUnit: str | None = None tags: list[str] | None = None + steps: list[str] | None = None + destinationLabel: str | None = None + destinationPlaceholder: str | None = None + phoneLabel: str | None = None + phonePlaceholder: str | None = None + noteLabel: str | None = None + notePlaceholder: str | None = None + submitLabel: str | None = None + chips: list[str] | None = None productIds: list[str] | None = None status: str | None = None startsAt: datetime | None = None diff --git a/app/seed.py b/app/seed.py index 9efc4b2..4b59a38 100644 --- a/app/seed.py +++ b/app/seed.py @@ -8,13 +8,18 @@ from urllib.parse import quote from sqlalchemy import delete, select from sqlalchemy.orm import Session from .auth import hash_password -from .content import ALIASES, CAMPAIGNS, CTAS, DESTINATION_HERO, DESTINATION_REGIONS, DESTINATIONS, HERO_SLIDES, HOTEL_GROUPS, THEMES, VEHICLE_OPTIONS +from .content import ALIASES, CAMPAIGNS, CTAS, DEMAND_FEATURE_CARDS, DEMAND_FORM, DEMAND_HERO, DEMAND_RECOMMENDATIONS, DESTINATION_HERO, DESTINATION_REGIONS, DESTINATIONS, HERO_SLIDES, HOTEL_GROUPS, THEMES, VEHICLE_OPTIONS from .database import Base, SessionLocal, engine from .models import ( AdminUser, Campaign, CampaignProduct, CtaBanner, + DemandFeatureCard, + DemandForm, + DemandHero, + DemandRecommendation, + DemandRecommendationProduct, Destination, DestinationAlias, DestinationHero, @@ -104,7 +109,7 @@ def load_products() -> list[dict]: def reset_guizhou_content(db: Session) -> dict: products = load_products() - for model in [SiteVersion, CampaignProduct, RouteSectionProduct, Campaign, RouteSection, ProductImage, Product, VehicleOption, HotelGroup, CtaBanner, ThemeCard, DestinationRegion, DestinationHero, HeroSlide, DestinationAlias, Destination, MediaAsset]: + for model in [SiteVersion, DemandRecommendationProduct, DemandRecommendation, DemandForm, DemandFeatureCard, DemandHero, CampaignProduct, RouteSectionProduct, Campaign, RouteSection, ProductImage, Product, VehicleOption, HotelGroup, CtaBanner, ThemeCard, DestinationRegion, DestinationHero, HeroSlide, DestinationAlias, Destination, MediaAsset]: db.execute(delete(model)) db.flush() @@ -188,6 +193,29 @@ def reset_guizhou_content(db: Session) -> dict: sortOrder=index, ) ) + + for index, item in enumerate(DEMAND_HERO): + db.add( + DemandHero( + title=item["title"], + kicker=item.get("kicker"), + description=item.get("description"), + steps=item.get("steps", []), + sortOrder=index, + ) + ) + + for index, item in enumerate(DEMAND_FEATURE_CARDS): + db.add( + DemandFeatureCard( + title=item["title"], + description=item.get("description"), + sortOrder=index, + ) + ) + + db.add(DemandForm(**DEMAND_FORM)) + for product in products: create_media(db, product.get("image"), "product", product["title"]) matched_destination = product.get("destinationName") if product.get("destinationName") in destination_map else None @@ -245,6 +273,22 @@ def reset_guizhou_content(db: Session) -> dict: for index, product in enumerate(section_products): db.add(RouteSectionProduct(sectionId=section.id, productId=product.id, sortOrder=index)) + for sort_order, seed in enumerate(DEMAND_RECOMMENDATIONS): + recommendation = DemandRecommendation( + title=seed["title"], + subtitle=seed.get("subtitle"), + sortOrder=sort_order, + ) + db.add(recommendation) + db.flush() + linked_products = db.scalars( + select(Product) + .where(Product.sourceId >= seed["start"] + 1, Product.sourceId <= seed["end"]) + .order_by(Product.sourceId.asc()) + ).all() + for index, product in enumerate(linked_products): + db.add(DemandRecommendationProduct(recommendationId=recommendation.id, productId=product.id, sortOrder=index)) + snapshot = SiteVersion( title="guizhou-content-reset", status="published", @@ -259,6 +303,10 @@ def reset_guizhou_content(db: Session) -> dict: "routeSections": len(ROUTE_SECTION_DEFAULTS), "hotelGroups": len(HOTEL_GROUPS), "vehicleOptions": len(VEHICLE_OPTIONS), + "demandHero": len(DEMAND_HERO), + "demandFeatureCards": len(DEMAND_FEATURE_CARDS), + "demandForm": 1, + "demandRecommendations": len(DEMAND_RECOMMENDATIONS), }, ) db.add(snapshot) @@ -274,6 +322,10 @@ def reset_guizhou_content(db: Session) -> dict: "routeSections": len(ROUTE_SECTION_DEFAULTS), "hotelGroups": len(HOTEL_GROUPS), "vehicleOptions": len(VEHICLE_OPTIONS), + "demandHero": len(DEMAND_HERO), + "demandFeatureCards": len(DEMAND_FEATURE_CARDS), + "demandForm": 1, + "demandRecommendations": len(DEMAND_RECOMMENDATIONS), "siteVersionId": snapshot.id, } diff --git a/tests/test_api_contracts.py b/tests/test_api_contracts.py index 4a6fe76..64b001f 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, Campaign, CtaBanner, Destination, DestinationAlias, DestinationHero, DestinationRegion, HeroSlide, HotelGroup, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard, VehicleOption +from app.models import AdminUser, Campaign, CtaBanner, DemandFeatureCard, DemandForm, DemandHero, DemandRecommendation, DemandRecommendationProduct, Destination, DestinationAlias, DestinationHero, DestinationRegion, HeroSlide, HotelGroup, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard, VehicleOption from app.routers import admin as admin_router from app.routers.admin import normalize_detail_sections, normalize_images from app.routers.shared import site_config @@ -52,19 +52,24 @@ class FakeDb: self.scalar_results = list(scalar_results or []) self.scalar_values = list(scalar_values or []) self.execute_results = list(execute_results or []) + self.scalar_statements = [] + self.execute_statements = [] self.get_result = get_result self.added = [] self.deleted = [] self.committed = False - def scalars(self, _stmt): - return FakeScalarResult(self.scalar_results.pop(0)) + def scalars(self, stmt): + self.scalar_statements.append(stmt) + return FakeScalarResult(self.scalar_results.pop(0) if self.scalar_results else []) - def scalar(self, _stmt): - return self.scalar_values.pop(0) + def scalar(self, stmt): + self.scalar_statements.append(stmt) + return self.scalar_values.pop(0) if self.scalar_values else None - def execute(self, _stmt): - return FakeExecuteResult(self.execute_results.pop(0)) + def execute(self, stmt): + self.execute_statements.append(stmt) + return FakeExecuteResult(self.execute_results.pop(0) if self.execute_results else []) def get(self, _model, _item_id): return self.get_result @@ -291,6 +296,74 @@ def make_route_section_product(**overrides): return link +def make_demand_hero(**overrides): + return DemandHero( + id=overrides.get("id", "demand-hero-test"), + title=overrides.get("title", "告诉我们日期、人数和想法"), + kicker=overrides.get("kicker", "3步定制"), + description=overrides.get("description", "管家会按同行人、预算和体力强度,重新组合酒店、用车和景点节奏。"), + steps=overrides.get("steps", ["提交需求", "管家沟通", "确认方案"]), + sortOrder=overrides.get("sortOrder", 0), + isActive=overrides.get("isActive", True), + createdAt=datetime(2026, 1, 1), + updatedAt=datetime(2026, 1, 2), + ) + + +def make_demand_feature_card(**overrides): + return DemandFeatureCard( + id=overrides.get("id", "demand-feature-card-test"), + title=overrides.get("title", "动线"), + description=overrides.get("description", "按天数顺路排"), + sortOrder=overrides.get("sortOrder", 0), + isActive=overrides.get("isActive", True), + createdAt=datetime(2026, 1, 1), + updatedAt=datetime(2026, 1, 2), + ) + + +def make_demand_form(**overrides): + return DemandForm( + id=overrides.get("id", "demand-form-test"), + destinationLabel=overrides.get("destinationLabel", "目的地/玩法"), + destinationPlaceholder=overrides.get("destinationPlaceholder", "例如:贵州、黄果树、西江苗寨"), + phoneLabel=overrides.get("phoneLabel", "联系方式"), + phonePlaceholder=overrides.get("phonePlaceholder", "手机号 / 微信号"), + noteLabel=overrides.get("noteLabel", "补充说明"), + notePlaceholder=overrides.get("notePlaceholder", "出行日期、人数、酒店偏好、预算范围"), + submitLabel=overrides.get("submitLabel", "提交出行需求"), + chips=overrides.get("chips", ["贵州", "黄果树", "荔波小七孔", "西江苗寨", "梵净山", "万峰林"]), + isActive=overrides.get("isActive", True), + createdAt=datetime(2026, 1, 1), + updatedAt=datetime(2026, 1, 2), + ) + + +def make_demand_recommendation(**overrides): + item = DemandRecommendation( + id=overrides.get("id", "demand-recommendation-test"), + title=overrides.get("title", "热门推荐"), + subtitle=overrides.get("subtitle", "也可以先挑一条线路沟通"), + sortOrder=overrides.get("sortOrder", 0), + isActive=overrides.get("isActive", True), + createdAt=datetime(2026, 1, 1), + updatedAt=datetime(2026, 1, 2), + ) + item.products = overrides.get("products", []) + return item + + +def make_demand_recommendation_product(**overrides): + product = overrides.get("product", make_product(id=overrides.get("productId", "product-test"))) + link = DemandRecommendationProduct( + recommendationId=overrides.get("recommendationId", "demand-recommendation-test"), + productId=overrides.get("productId", product.id), + sortOrder=overrides.get("sortOrder", 0), + ) + link.product = product + return link + + def make_admin_user(): return AdminUser( id="admin-test", @@ -504,6 +577,26 @@ def test_site_config_patch_ignores_fields_not_allowed_for_module_and_audits(): ("ctaBanners", {"alt": "新运营入口", "image": None, "targetType": None}, {"alt": "新运营入口", "image": "", "targetType": ""}), ("hotelGroups", {"title": "经典酒店", "description": "酒店文案", "image": None}, {"title": "经典酒店", "description": "酒店文案", "image": None}), ("vehicleOptions", {"title": "5座舒适用车", "description": "用车文案", "image": None}, {"title": "5座舒适用车", "description": "用车文案", "image": None}), + ( + "demandHero", + {"title": "需求页主视觉", "kicker": "3步定制", "description": "说明", "steps": ["提交", "沟通", "确认"]}, + {"title": "需求页主视觉", "kicker": "3步定制", "description": "说明", "steps": ["提交", "沟通", "确认"]}, + ), + ( + "demandFeatureCards", + {"title": "动线", "description": "按天数顺路排"}, + {"title": "动线", "description": "按天数顺路排"}, + ), + ( + "demandForm", + {"submitLabel": "提交出行需求", "destinationLabel": "目的地/玩法", "chips": [" 贵州 ", "", "黄果树"]}, + {"submitLabel": "提交出行需求", "destinationLabel": "目的地/玩法", "chips": ["贵州", "黄果树"]}, + ), + ( + "demandRecommendations", + {"title": "热门推荐", "subtitle": "也可以先挑一条线路沟通"}, + {"title": "热门推荐", "subtitle": "也可以先挑一条线路沟通", "productIds": []}, + ), ], ) def test_site_config_create_modules_defaults_fields_and_audits(module, payload, expected): @@ -520,7 +613,10 @@ def test_site_config_create_modules_defaults_fields_and_audits(module, payload, for key, value in expected.items(): assert body[key] == value assert body["isActive"] is True - assert body["sortOrder"] == 5 + if module == "demandForm": + assert "sortOrder" not in body + else: + assert body["sortOrder"] == 5 assert fake_db.committed assert fake_db.added[-1].action == "create" @@ -615,6 +711,23 @@ def test_admin_site_config_includes_destination_page_modules(): assert body["destinationRegions"] == [] +def test_admin_site_config_includes_demand_page_modules(): + fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [], [], [], [], [], [], [], []]) + 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["demandHero"] == [] + assert body["demandFeatureCards"] == [] + assert body["demandForm"] == [] + assert body["demandRecommendations"] == [] + + def test_public_site_config_includes_destination_page_modules(): destination_hero = make_destination_hero() destination_region = make_destination_region() @@ -648,6 +761,53 @@ def test_public_site_config_includes_destination_page_modules(): ] +def test_public_site_config_includes_demand_modules_and_filters_unpublished_recommendation_products(): + published = make_product(id="product-published", status="published") + draft = make_product(id="product-draft", status="draft") + recommendation = make_demand_recommendation( + products=[ + make_demand_recommendation_product(product=published, productId=published.id, sortOrder=1), + make_demand_recommendation_product(product=draft, productId=draft.id, sortOrder=0), + ] + ) + fake_db = FakeDb( + scalar_results=[ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [make_demand_hero()], + [make_demand_feature_card()], + [make_demand_form()], + [recommendation], + ] + ) + + result = site_config(fake_db, active_only=True) + + assert result["demandHero"][0]["title"] == "告诉我们日期、人数和想法" + assert result["demandHero"][0]["steps"] == ["提交需求", "管家沟通", "确认方案"] + assert result["demandFeatureCards"][0]["title"] == "动线" + assert result["demandForm"][0]["submitLabel"] == "提交出行需求" + assert result["demandForm"][0]["chips"] == ["贵州", "黄果树", "荔波小七孔", "西江苗寨", "梵净山", "万峰林"] + assert result["demandRecommendations"] == [ + { + "id": "demand-recommendation-test", + "title": "热门推荐", + "subtitle": "也可以先挑一条线路沟通", + "productIds": ["product-published"], + "isActive": True, + } + ] + + def test_admin_site_config_includes_campaigns_for_special_offers(): campaign = make_campaign(status="published") fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [campaign], [], [], []]) @@ -880,6 +1040,21 @@ def test_site_config_create_map_image_rejects_duplicate_singleton(): assert not fake_db.committed +def test_site_config_create_demand_form_rejects_duplicate_singleton(): + fake_db = FakeDb(scalar_results=[[make_demand_form()]]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).post("/api/admin/site-config/demandForm", json={"submitLabel": "提交"}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 409 + assert response.json()["code"] == "MODULE_CONFIG_SINGLETON_EXISTS" + assert not fake_db.added + assert not fake_db.committed + + def test_site_config_create_campaign_defaults_draft_and_audits(): fake_db = FakeDb() app = authenticated_app(fake_db) @@ -1087,6 +1262,42 @@ def test_site_config_patch_route_section_updates_copy_and_product_order(): assert fake_db.committed +def test_site_config_patch_demand_recommendation_updates_product_order_without_route_conflict(): + recommendation = make_demand_recommendation() + product_a = make_product(id="product-a") + product_b = make_product(id="product-b") + fake_db = FakeDb( + get_result=recommendation, + scalar_results=[[product_b, product_a]], + execute_results=[[]], + ) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).patch( + "/api/admin/site-config/demandRecommendations/demand-recommendation-test", + json={ + "title": " 新热门推荐 ", + "subtitle": " 新副文案 ", + "isActive": False, + "productIds": ["product-b", "product-a"], + }, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + body = response.json() + assert body["title"] == "新热门推荐" + assert body["subtitle"] == "新副文案" + assert body["isActive"] is False + assert body["productIds"] == ["product-b", "product-a"] + links = [item for item in fake_db.added if isinstance(item, DemandRecommendationProduct)] + assert [(link.productId, link.sortOrder) for link in links] == [("product-b", 0), ("product-a", 1)] + assert fake_db.added[-1].entity == "demand_recommendation" + assert fake_db.committed + + def test_site_config_patch_route_section_rejects_product_used_by_another_section(): section = make_route_section(id="routes") conflict = make_route_section_product(sectionId="route-section-other", productId="product-a") @@ -1478,6 +1689,37 @@ def test_admin_media_upload_rejects_non_image_file(): assert not fake_db.committed +def test_admin_leads_endpoint_accepts_source_keyword_and_created_range_filters(): + fake_db = FakeDb(scalar_results=[[]]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).get( + "/api/admin/leads", + params={ + "status": "new", + "sourcePage": "demand_page", + "keyword": "荔波", + "createdFrom": "2026-01-01", + "createdTo": "2026-01-31", + "take": 50, + }, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert response.json() == {"items": []} + stmt = str(fake_db.scalar_statements[0]) + assert '"Lead".status' in stmt + assert '"Lead"."sourcePage"' in stmt + assert '"Lead"."createdAt"' in stmt + assert '"Product".title' in stmt + assert '"Lead".phone' in stmt + assert '"Lead".destination' in stmt + assert '"Lead".note' in stmt + + def test_admin_lead_status_update_returns_updated_status_and_audits(): lead = Lead( id="lead-test",