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
This commit is contained in:
duanshuwen
2026-07-07 22:03:57 +08:00
parent d11c361aa2
commit fe85197e68
9 changed files with 749 additions and 15 deletions

View File

@@ -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)

View File

@@ -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 = [ CAMPAIGNS = [
{"slug": "classic-deal", "title": "经典打卡特惠", "start": 0, "end": 8, "coverImage": HERO_SLIDES[0]["image"]}, {"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"]}, {"slug": "outdoor-deal", "title": "山野野咖特惠", "start": 8, "end": 16, "coverImage": HERO_SLIDES[1]["image"]},

View File

@@ -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)

View File

@@ -207,6 +207,7 @@ class Product(Base):
images: Mapped[list["ProductImage"]] = relationship(back_populates="product", cascade="all, delete-orphan") images: Mapped[list["ProductImage"]] = relationship(back_populates="product", cascade="all, delete-orphan")
campaignLinks: Mapped[list["CampaignProduct"]] = 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") 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") leads: Mapped[list["Lead"]] = relationship(back_populates="sourceProduct")
orders: Mapped[list["Order"]] = relationship(back_populates="product") orders: Mapped[list["Order"]] = relationship(back_populates="product")
@@ -279,6 +280,74 @@ class RouteSectionProduct(Base):
product: Mapped[Product] = relationship(back_populates="routeSectionLinks") 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): class Lead(Base):
__tablename__ = "Lead" __tablename__ = "Lead"

View File

@@ -18,6 +18,11 @@ from ..models import (
AuditLog, AuditLog,
Campaign, Campaign,
CtaBanner, CtaBanner,
DemandFeatureCard,
DemandForm,
DemandHero,
DemandRecommendation,
DemandRecommendationProduct,
Destination, Destination,
DestinationHero, DestinationHero,
DestinationRegion, DestinationRegion,
@@ -37,6 +42,7 @@ from ..models import (
) )
from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn
from ..seed import create_media, reset_guizhou_content 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 ..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 ..serializers import admin_product_dict, destination_dict, encode_value, hotel_group_dict, lead_dict, model_dict
from .shared import site_config from .shared import site_config
@@ -88,6 +94,63 @@ SITE_CONFIG_MODULES = {
"empty_to_none": {"keyword", "spots"}, "empty_to_none": {"keyword", "spots"},
"create_defaults": {"keyword": None, "spots": None}, "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": { "map": {
"model": MapImage, "model": MapImage,
"entity": "map_image", "entity": "map_image",
@@ -353,9 +416,17 @@ def normalize_campaign_tags(value) -> list[str]:
return tags 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): def site_field_value(config: dict, field: str, value):
if field == "tags": if field == "tags":
return normalize_campaign_tags(value) return normalize_campaign_tags(value)
if field in {"steps", "chips"}:
return normalize_string_list(value)
value = clean_site_value(value) value = clean_site_value(value)
if value == "" and field in config.get("empty_to_none", set()): if value == "" and field in config.get("empty_to_none", set()):
return None return None
@@ -414,6 +485,8 @@ def site_item_dict(module: str, item) -> dict:
return map_image_admin_dict(item) return map_image_admin_dict(item)
if module == "routeSections": if module == "routeSections":
return route_section_dict(item) return route_section_dict(item)
if module == "demandRecommendations":
return demand_recommendation_dict(item)
if module == "hotelGroups": if module == "hotelGroups":
return hotel_group_dict(item) return hotel_group_dict(item)
return destination_dict(item) if module == "destinations" else model_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) stmt = select(model)
if model is RouteSection: if model is RouteSection:
stmt = stmt.options(selectinload(RouteSection.products).selectinload(RouteSectionProduct.product)) 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): if config.get("ordered", True):
stmt = stmt.order_by(model.sortOrder.asc()) stmt = stmt.order_by(model.sortOrder.asc())
else: else:
@@ -708,6 +783,19 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D
model_dict(item) model_dict(item)
for item in db.scalars(select(VehicleOption).order_by(VehicleOption.sortOrder.asc())).all() 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"): if config.get("fixed"):
site_config_error(405, "固定模块不支持新增", "MODULE_CONFIG_CREATE_UNSUPPORTED", {"module": module}) site_config_error(405, "固定模块不支持新增", "MODULE_CONFIG_CREATE_UNSUPPORTED", {"module": module})
if config.get("singleton") and module_items(db, config): 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)) item = config["model"](**site_create_payload(module, config, body, db))
db.add(item) db.add(item)
db.flush() 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) after = site_item_dict(module, item)
audit(db, get_actor_id(request), "create", config["entity"], item.id, after) audit(db, get_actor_id(request), "create", config["entity"], item.id, after)
db.commit() db.commit()
@@ -783,6 +876,8 @@ def update_site_config(
apply_site_patch(module, config, item, body) apply_site_patch(module, config, item, body)
if module == "routeSections" and "productIds" in body.model_fields_set: if module == "routeSections" and "productIds" in body.model_fields_set:
replace_route_section_products(db, item, body.productIds or []) 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() db.flush()
after = site_item_dict(module, item) after = site_item_dict(module, item)
audit(db, get_actor_id(request), "update", config["entity"], item.id, after, before) audit(db, get_actor_id(request), "update", config["entity"], item.id, after, before)
@@ -822,11 +917,22 @@ def delete_site_config(
@router.get("/leads") @router.get("/leads")
def list_leads( def list_leads(
status_value: LeadStatus | None = Query(default=None, alias="status"), 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), take: int = Query(default=100, ge=1, le=200),
_user: AdminUser = Depends(require_admin), _user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db), 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 = ( stmt = (
select(Lead) select(Lead)
.options(selectinload(Lead.sourceProduct), selectinload(Lead.assignedUser)) .options(selectinload(Lead.sourceProduct), selectinload(Lead.assignedUser))
@@ -835,6 +941,26 @@ def list_leads(
) )
if query.status: if query.status:
stmt = stmt.where(Lead.status == 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() leads = db.scalars(stmt).all()
return {"items": [lead_dict(lead) for lead in leads]} return {"items": [lead_dict(lead) for lead in leads]}

View File

@@ -1,6 +1,7 @@
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload 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 ..route_sections import route_section_dict, route_section_query
from ..serializers import destination_dict, hotel_group_dict, model_dict 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()) cta_stmt = select(CtaBanner).order_by(CtaBanner.sortOrder.asc())
hotel_stmt = select(HotelGroup).order_by(HotelGroup.sortOrder.asc()) hotel_stmt = select(HotelGroup).order_by(HotelGroup.sortOrder.asc())
vehicle_stmt = select(VehicleOption).order_by(VehicleOption.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: if active_only:
hero_stmt = hero_stmt.where(HeroSlide.isActive.is_(True)) hero_stmt = hero_stmt.where(HeroSlide.isActive.is_(True))
destination_stmt = destination_stmt.where(Destination.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)) cta_stmt = cta_stmt.where(CtaBanner.isActive.is_(True))
hotel_stmt = hotel_stmt.where(HotelGroup.isActive.is_(True), HotelGroup.status == "published") hotel_stmt = hotel_stmt.where(HotelGroup.isActive.is_(True), HotelGroup.status == "published")
vehicle_stmt = vehicle_stmt.where(VehicleOption.isActive.is_(True)) 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() hero_slides = db.scalars(hero_stmt).all()
destinations = db.scalars(destination_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["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["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["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 return result

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime, timedelta
from typing import Literal 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"] ProductStatus = Literal["draft", "published", "archived"]
@@ -115,8 +115,23 @@ class AdminProductQuery(BaseModel):
class LeadQuery(BaseModel): class LeadQuery(BaseModel):
status: LeadStatus | None = None 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) 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): class SiteConfigPatchIn(BaseModel):
title: str | None = None title: str | None = None
@@ -135,6 +150,15 @@ class SiteConfigPatchIn(BaseModel):
priceAmount: int | None = Field(default=None, ge=0) priceAmount: int | None = Field(default=None, ge=0)
priceUnit: str | None = None priceUnit: str | None = None
tags: list[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 productIds: list[str] | None = None
status: str | None = None status: str | None = None
startsAt: datetime | None = None startsAt: datetime | None = None

View File

@@ -8,13 +8,18 @@ from urllib.parse import quote
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .auth import hash_password 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 .database import Base, SessionLocal, engine
from .models import ( from .models import (
AdminUser, AdminUser,
Campaign, Campaign,
CampaignProduct, CampaignProduct,
CtaBanner, CtaBanner,
DemandFeatureCard,
DemandForm,
DemandHero,
DemandRecommendation,
DemandRecommendationProduct,
Destination, Destination,
DestinationAlias, DestinationAlias,
DestinationHero, DestinationHero,
@@ -104,7 +109,7 @@ def load_products() -> list[dict]:
def reset_guizhou_content(db: Session) -> dict: def reset_guizhou_content(db: Session) -> dict:
products = load_products() 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.execute(delete(model))
db.flush() db.flush()
@@ -188,6 +193,29 @@ def reset_guizhou_content(db: Session) -> dict:
sortOrder=index, 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: for product in products:
create_media(db, product.get("image"), "product", product["title"]) create_media(db, product.get("image"), "product", product["title"])
matched_destination = product.get("destinationName") if product.get("destinationName") in destination_map else None 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): for index, product in enumerate(section_products):
db.add(RouteSectionProduct(sectionId=section.id, productId=product.id, sortOrder=index)) 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( snapshot = SiteVersion(
title="guizhou-content-reset", title="guizhou-content-reset",
status="published", status="published",
@@ -259,6 +303,10 @@ def reset_guizhou_content(db: Session) -> dict:
"routeSections": len(ROUTE_SECTION_DEFAULTS), "routeSections": len(ROUTE_SECTION_DEFAULTS),
"hotelGroups": len(HOTEL_GROUPS), "hotelGroups": len(HOTEL_GROUPS),
"vehicleOptions": len(VEHICLE_OPTIONS), "vehicleOptions": len(VEHICLE_OPTIONS),
"demandHero": len(DEMAND_HERO),
"demandFeatureCards": len(DEMAND_FEATURE_CARDS),
"demandForm": 1,
"demandRecommendations": len(DEMAND_RECOMMENDATIONS),
}, },
) )
db.add(snapshot) db.add(snapshot)
@@ -274,6 +322,10 @@ def reset_guizhou_content(db: Session) -> dict:
"routeSections": len(ROUTE_SECTION_DEFAULTS), "routeSections": len(ROUTE_SECTION_DEFAULTS),
"hotelGroups": len(HOTEL_GROUPS), "hotelGroups": len(HOTEL_GROUPS),
"vehicleOptions": len(VEHICLE_OPTIONS), "vehicleOptions": len(VEHICLE_OPTIONS),
"demandHero": len(DEMAND_HERO),
"demandFeatureCards": len(DEMAND_FEATURE_CARDS),
"demandForm": 1,
"demandRecommendations": len(DEMAND_RECOMMENDATIONS),
"siteVersionId": snapshot.id, "siteVersionId": snapshot.id,
} }

View File

@@ -8,7 +8,7 @@ from app import serializers
from app.auth import hash_password, require_admin from app.auth import hash_password, require_admin
from app.database import get_db from app.database import get_db
from app.main import create_app 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 import admin as admin_router
from app.routers.admin import normalize_detail_sections, normalize_images from app.routers.admin import normalize_detail_sections, normalize_images
from app.routers.shared import site_config from app.routers.shared import site_config
@@ -52,19 +52,24 @@ class FakeDb:
self.scalar_results = list(scalar_results or []) self.scalar_results = list(scalar_results or [])
self.scalar_values = list(scalar_values or []) self.scalar_values = list(scalar_values or [])
self.execute_results = list(execute_results or []) self.execute_results = list(execute_results or [])
self.scalar_statements = []
self.execute_statements = []
self.get_result = get_result self.get_result = get_result
self.added = [] self.added = []
self.deleted = [] self.deleted = []
self.committed = False self.committed = False
def scalars(self, _stmt): def scalars(self, stmt):
return FakeScalarResult(self.scalar_results.pop(0)) self.scalar_statements.append(stmt)
return FakeScalarResult(self.scalar_results.pop(0) if self.scalar_results else [])
def scalar(self, _stmt): def scalar(self, stmt):
return self.scalar_values.pop(0) self.scalar_statements.append(stmt)
return self.scalar_values.pop(0) if self.scalar_values else None
def execute(self, _stmt): def execute(self, stmt):
return FakeExecuteResult(self.execute_results.pop(0)) self.execute_statements.append(stmt)
return FakeExecuteResult(self.execute_results.pop(0) if self.execute_results else [])
def get(self, _model, _item_id): def get(self, _model, _item_id):
return self.get_result return self.get_result
@@ -291,6 +296,74 @@ def make_route_section_product(**overrides):
return link 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(): def make_admin_user():
return AdminUser( return AdminUser(
id="admin-test", 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": ""}), ("ctaBanners", {"alt": "新运营入口", "image": None, "targetType": None}, {"alt": "新运营入口", "image": "", "targetType": ""}),
("hotelGroups", {"title": "经典酒店", "description": "酒店文案", "image": None}, {"title": "经典酒店", "description": "酒店文案", "image": None}), ("hotelGroups", {"title": "经典酒店", "description": "酒店文案", "image": None}, {"title": "经典酒店", "description": "酒店文案", "image": None}),
("vehicleOptions", {"title": "5座舒适用车", "description": "用车文案", "image": None}, {"title": "5座舒适用车", "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): 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(): for key, value in expected.items():
assert body[key] == value assert body[key] == value
assert body["isActive"] is True 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.committed
assert fake_db.added[-1].action == "create" assert fake_db.added[-1].action == "create"
@@ -615,6 +711,23 @@ def test_admin_site_config_includes_destination_page_modules():
assert body["destinationRegions"] == [] 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(): def test_public_site_config_includes_destination_page_modules():
destination_hero = make_destination_hero() destination_hero = make_destination_hero()
destination_region = make_destination_region() 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(): def test_admin_site_config_includes_campaigns_for_special_offers():
campaign = make_campaign(status="published") campaign = make_campaign(status="published")
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [campaign], [], [], []]) 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 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(): def test_site_config_create_campaign_defaults_draft_and_audits():
fake_db = FakeDb() fake_db = FakeDb()
app = authenticated_app(fake_db) 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 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(): def test_site_config_patch_route_section_rejects_product_used_by_another_section():
section = make_route_section(id="routes") section = make_route_section(id="routes")
conflict = make_route_section_product(sectionId="route-section-other", productId="product-a") 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 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(): def test_admin_lead_status_update_returns_updated_status_and_audits():
lead = Lead( lead = Lead(
id="lead-test", id="lead-test",