diff --git a/alembic/versions/0004_route_sections.py b/alembic/versions/0004_route_sections.py new file mode 100644 index 0000000..8c2ac9f --- /dev/null +++ b/alembic/versions/0004_route_sections.py @@ -0,0 +1,54 @@ +"""Add route section configuration. + +Revision ID: 0004_route_sections +Revises: 0003_campaign_display_fields +Create Date: 2026-07-02 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +revision = "0004_route_sections" +down_revision = "0003_campaign_display_fields" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + existing_tables = set(inspect(bind).get_table_names()) + + if "RouteSection" not in existing_tables: + op.create_table( + "RouteSection", + 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 "RouteSectionProduct" not in existing_tables: + op.create_table( + "RouteSectionProduct", + sa.Column("sectionId", 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(["sectionId"], ["RouteSection.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("sectionId", "productId"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + existing_tables = set(inspect(bind).get_table_names()) + if "RouteSectionProduct" in existing_tables: + op.drop_table("RouteSectionProduct") + if "RouteSection" in existing_tables: + op.drop_table("RouteSection") diff --git a/app/models.py b/app/models.py index ab42a5f..d450fc2 100644 --- a/app/models.py +++ b/app/models.py @@ -149,6 +149,7 @@ class Product(Base): destination: Mapped[Destination | None] = relationship(back_populates="products") 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") leads: Mapped[list["Lead"]] = relationship(back_populates="sourceProduct") orders: Mapped[list["Order"]] = relationship(back_populates="product") @@ -196,6 +197,31 @@ class CampaignProduct(Base): product: Mapped[Product] = relationship(back_populates="campaignLinks") +class RouteSection(Base): + __tablename__ = "RouteSection" + + 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["RouteSectionProduct"]] = relationship(back_populates="section", cascade="all, delete-orphan") + + +class RouteSectionProduct(Base): + __tablename__ = "RouteSectionProduct" + + sectionId: Mapped[str] = mapped_column(String, ForeignKey("RouteSection.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) + + section: Mapped[RouteSection] = relationship(back_populates="products") + product: Mapped[Product] = relationship(back_populates="routeSectionLinks") + + class Lead(Base): __tablename__ = "Lead" diff --git a/app/route_sections.py b/app/route_sections.py new file mode 100644 index 0000000..8375490 --- /dev/null +++ b/app/route_sections.py @@ -0,0 +1,91 @@ +from fastapi import HTTPException +from sqlalchemy import delete, select +from sqlalchemy.orm import Session, selectinload + +from .models import Product, RouteSection, RouteSectionProduct +from .serializers import model_dict + + +ROUTE_SECTION_DEFAULTS = [ + ("routes", "经典人文打卡线路", "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联", 0, 8), + ("routes-outdoor", "极限山野户外野咖线路", "溶洞、峡谷、漂流、峰林骑行和山野咖啡组合", 8, 16), + ("routes-mix", "人文+户外综合混搭线路", "非遗村寨、古城夜游、自然轻探险和精品住宿同程安排", 16, 24), +] + + +def route_section_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 route_section_dict(item: RouteSection, *, 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 route_section_query(): + return ( + select(RouteSection) + .options(selectinload(RouteSection.products).selectinload(RouteSectionProduct.product)) + .order_by(RouteSection.sortOrder.asc()) + ) + + + + +def validate_route_section_product_ids(db: Session, section_id: str, product_ids: list[str]) -> None: + if len(set(product_ids)) != len(product_ids): + route_section_error(422, "线路商品不能重复", "ROUTE_SECTION_PRODUCT_DUPLICATE", {"sectionId": section_id}) + 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: + route_section_error(422, "线路商品不存在", "ROUTE_SECTION_PRODUCT_NOT_FOUND", {"productIds": missing_ids}) + + conflicts = db.scalars( + select(RouteSectionProduct).where( + RouteSectionProduct.productId.in_(product_ids), + RouteSectionProduct.sectionId != section_id, + ) + ).all() + if conflicts: + conflict = conflicts[0] + route_section_error( + 409, + "同一线路商品不能同时属于多个精选线路子分组", + "ROUTE_SECTION_PRODUCT_CONFLICT", + {"productId": conflict.productId, "sectionId": conflict.sectionId}, + ) + + +def replace_route_section_products(db: Session, section: RouteSection, product_ids: list[str]) -> None: + validate_route_section_product_ids(db, section.id, product_ids) + db.execute(delete(RouteSectionProduct).where(RouteSectionProduct.sectionId == section.id)) + db.flush() + section.products = [] + for index, product_id in enumerate(product_ids): + link = RouteSectionProduct(sectionId=section.id, productId=product_id, sortOrder=index) + section.products.append(link) + db.add(link) + diff --git a/app/routers/admin.py b/app/routers/admin.py index 1bd8977..4c7b1c9 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -25,12 +25,15 @@ from ..models import ( MediaAsset, Product, ProductImage, + RouteSection, + RouteSectionProduct, SiteVersion, ThemeCard, utc_now, ) from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn from ..seed import create_media, reset_guizhou_content +from ..route_sections import replace_route_section_products, route_section_dict, route_section_query from ..serializers import admin_product_dict, destination_dict, encode_value, lead_dict, model_dict from .shared import site_config @@ -91,6 +94,15 @@ SITE_CONFIG_MODULES = { "create_defaults": {"description": None, "coverImage": None, "priceAmount": None, "priceUnit": "起/人", "tags": [], "status": "draft", "startsAt": None, "endsAt": None}, "ordered": False, }, + "routeSections": { + "model": RouteSection, + "entity": "route_section", + "primary": "title", + "fields": {"title", "subtitle", "isActive", "sortOrder", "productIds"}, + "none_to_empty": set(), + "empty_to_none": {"subtitle"}, + "create_defaults": {}, + }, "ctaBanners": { "model": CtaBanner, "entity": "cta_banner", @@ -360,12 +372,16 @@ def site_item_dict(module: str, item) -> dict: return hero_slide_admin_dict(item) if module == "map": return map_image_admin_dict(item) + if module == "routeSections": + return route_section_dict(item) return destination_dict(item) if module == "destinations" else model_dict(item) def module_items(db: Session, config: dict) -> list: model = config["model"] stmt = select(model) + if model is RouteSection: + stmt = stmt.options(selectinload(RouteSection.products).selectinload(RouteSectionProduct.product)) if config.get("ordered", True): stmt = stmt.order_by(model.sortOrder.asc()) else: @@ -383,7 +399,7 @@ def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db: payload = { field: site_field_value(config, field, getattr(body, field)) for field in config["fields"] - if field in fields + if field in fields and field != "productIds" } payload[config["primary"]] = validate_site_primary(config, body) for field, value in config["create_defaults"].items(): @@ -411,6 +427,8 @@ def apply_site_patch(module: str, config: dict, item, body: SiteConfigPatchIn) - for field in config["fields"]: if field not in fields: continue + if field == "productIds": + continue value = site_field_value(config, field, getattr(body, field)) if field == config["primary"] and not value: site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field}) @@ -423,6 +441,35 @@ def apply_site_patch(module: str, config: dict, item, body: SiteConfigPatchIn) - setattr(item, field, value) +def create_route_section_config(body: SiteConfigPatchIn, request: Request, db: Session) -> dict: + config = site_module("routeSections") + items = module_items(db, config) + fields = body.model_fields_set + now = utc_now() + section = RouteSection( + title=validate_site_primary(config, body), + subtitle=site_field_value(config, "subtitle", body.subtitle) if "subtitle" in fields else None, + sortOrder=( + site_field_value(config, "sortOrder", body.sortOrder) + if "sortOrder" in fields and body.sortOrder is not None + else max([item.sortOrder for item in items], default=-1) + 1 + ), + isActive=site_field_value(config, "isActive", body.isActive) if "isActive" in fields else True, + createdAt=now, + updatedAt=now, + ) + section.products = [] + db.add(section) + db.flush() + if "productIds" in fields: + replace_route_section_products(db, section, body.productIds or []) + db.flush() + after = route_section_dict(section) + audit(db, get_actor_id(request), "create", config["entity"], section.id, after) + db.commit() + return after + + def load_product(db: Session, product_id: str) -> Product: return db.scalars( select(Product) @@ -581,6 +628,7 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D model_dict(item) for item in db.scalars(select(Campaign).order_by(Campaign.updatedAt.desc())).all() ], + "routeSections": [route_section_dict(item) for item in db.scalars(route_section_query()).all()], } @@ -593,6 +641,10 @@ def create_site_config( db: Session = Depends(get_db), ): config = site_module(module) + if module == "routeSections": + return create_route_section_config(body, request, db) + 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}) item = config["model"](**site_create_payload(module, config, body, db)) @@ -650,6 +702,8 @@ def update_site_config( site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id}) before = site_item_dict(module, item) 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 []) db.flush() after = site_item_dict(module, item) audit(db, get_actor_id(request), "update", config["entity"], item.id, after, before) @@ -666,6 +720,8 @@ def delete_site_config( db: Session = Depends(get_db), ): config = site_module(module) + if config.get("fixed"): + site_config_error(405, "固定模块不支持删除", "MODULE_CONFIG_DELETE_UNSUPPORTED", {"module": module}) item = db.get(config["model"], item_id) if not item: site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id}) diff --git a/app/routers/shared.py b/app/routers/shared.py index 05087c0..0ec57c2 100644 --- a/app/routers/shared.py +++ b/app/routers/shared.py @@ -1,16 +1,10 @@ from sqlalchemy import select from sqlalchemy.orm import Session, selectinload -from ..models import Campaign, CtaBanner, Destination, HeroSlide, MapImage, Product, ThemeCard +from ..models import Campaign, CtaBanner, Destination, HeroSlide, MapImage, ThemeCard +from ..route_sections import route_section_dict, route_section_query from ..serializers import destination_dict, model_dict -ROUTE_SECTION_LABELS = [ - ("routes", "经典人文打卡线路", 0, 8), - ("routes-outdoor", "极限山野户外野咖线路", 8, 16), - ("routes-mix", "人文+户外综合混搭线路", 16, 24), -] - - 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()) @@ -38,12 +32,9 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr } if include_public_extras: campaigns = db.scalars(select(Campaign).where(Campaign.status == "published").order_by(Campaign.updatedAt.desc())).all() - products = db.scalars( - select(Product).where(Product.status == "published").order_by(Product.sortWeight.asc(), Product.createdAt.asc()).limit(48) - ).all() + route_sections = db.scalars(route_section_query()).all() + if active_only: + route_sections = [section for section in route_sections if section.isActive] result["campaigns"] = [model_dict(item) for item in campaigns] - result["routeSections"] = [ - {"id": section_id, "title": title, "productIds": [item.id for item in products[start:end]]} - for section_id, title, start, end in ROUTE_SECTION_LABELS - ] + result["routeSections"] = [route_section_dict(item, public=True) for item in route_sections] return result diff --git a/app/schemas.py b/app/schemas.py index 9052396..1dca3ed 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -121,6 +121,7 @@ class LeadQuery(BaseModel): class SiteConfigPatchIn(BaseModel): title: str | None = None kicker: str | None = None + subtitle: str | None = None name: str | None = None slug: str | None = None region: str | None = None @@ -132,6 +133,7 @@ class SiteConfigPatchIn(BaseModel): priceAmount: int | None = Field(default=None, ge=0) priceUnit: str | None = None tags: list[str] | None = None + productIds: list[str] | None = None status: str | None = None startsAt: datetime | None = None endsAt: datetime | None = None diff --git a/app/seed.py b/app/seed.py index 0d7bf76..b454958 100644 --- a/app/seed.py +++ b/app/seed.py @@ -21,10 +21,13 @@ from .models import ( MediaAsset, Product, ProductImage, + RouteSection, + RouteSectionProduct, SiteVersion, ThemeCard, utc_now, ) +from .route_sections import ROUTE_SECTION_DEFAULTS ROOT_DIR = Path(__file__).resolve().parent.parent @@ -97,7 +100,7 @@ def load_products() -> list[dict]: def reset_guizhou_content(db: Session) -> dict: products = load_products() - for model in [SiteVersion, CampaignProduct, Campaign, ProductImage, Product, CtaBanner, ThemeCard, HeroSlide, DestinationAlias, Destination, MediaAsset]: + for model in [SiteVersion, CampaignProduct, RouteSectionProduct, Campaign, RouteSection, ProductImage, Product, CtaBanner, ThemeCard, HeroSlide, DestinationAlias, Destination, MediaAsset]: db.execute(delete(model)) db.flush() @@ -178,6 +181,18 @@ def reset_guizhou_content(db: Session) -> dict: for index, product in enumerate(linked_products): db.add(CampaignProduct(campaignId=campaign.id, productId=product.id, sortOrder=index)) + for sort_order, (section_id, title, subtitle, start, end) in enumerate(ROUTE_SECTION_DEFAULTS): + section = RouteSection(id=section_id, title=title, subtitle=subtitle, sortOrder=sort_order) + db.add(section) + db.flush() + section_products = db.scalars( + select(Product) + .where(Product.sourceId >= start + 1, Product.sourceId <= end) + .order_by(Product.sourceId.asc()) + ).all() + for index, product in enumerate(section_products): + db.add(RouteSectionProduct(sectionId=section.id, productId=product.id, sortOrder=index)) + snapshot = SiteVersion( title="guizhou-content-reset", status="published", @@ -187,6 +202,7 @@ def reset_guizhou_content(db: Session) -> dict: "destinations": len(DESTINATIONS), "themeCards": len(THEMES), "products": len(products), + "routeSections": len(ROUTE_SECTION_DEFAULTS), }, ) db.add(snapshot) @@ -197,6 +213,7 @@ def reset_guizhou_content(db: Session) -> dict: "themes": len(THEMES), "ctaBanners": len(CTAS), "products": len(products), + "routeSections": len(ROUTE_SECTION_DEFAULTS), "siteVersionId": snapshot.id, } diff --git a/docs/admin-ui-api-requirements.md b/docs/admin-ui-api-requirements.md index 29759dd..49c79ae 100644 --- a/docs/admin-ui-api-requirements.md +++ b/docs/admin-ui-api-requirements.md @@ -1,4 +1,4 @@ -# WonderQ-Admin-UI Admin API 接口需求 +# WonderQ-Admin-UI Admin API 接口需求 本文档用于指导 `WonderQ-Admin` 后端按当前 `WonderQ-Admin-UI` 管理端完成 Admin API 对接。接口需求来源于前端 `src/api.ts` 与 `src/App.tsx` 的实际类型、请求封装和页面调用。 @@ -34,7 +34,7 @@ Content-Type: application/json | `POST /api/admin/products` | 新建商品 | 已覆盖 | 返回完整 Product | | `PATCH /api/admin/products/{id}` | 编辑商品 | 已覆盖 | 返回完整 Product | | `GET /api/admin/destinations` | 商品目的地下拉、目的地页 | 已覆盖 | 需要返回别名和商品数 | -| `GET /api/admin/site-config` | 首页/目的地/活动结构维护 | 已覆盖 | 需要包含未启用内容 | +| `GET /api/admin/site-config` | 首页/目的地/活动结构维护 | 已覆盖 | 需要包含未启用内容和已保存的 `routeSections` | | `PATCH /api/admin/site-config/{module}/{item_id}` | 模块内容编辑 | 已覆盖 | 模块名需保持一致 | | `GET /api/admin/leads` | 需求线索页 | 已覆盖 | UI 当前不传筛选参数 | | `PATCH /api/admin/leads/{id}/status` | 线索状态流转 | 已覆盖 | UI 更新后会重新拉列表 | @@ -58,7 +58,7 @@ type LeadStatus = "new" | "assigned" | "contacted" | "planning" | "won" | "inval ### SiteModule ```ts -type SiteModule = "heroSlides" | "destinations" | "themes" | "ctaBanners"; +type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "ctaBanners"; ``` ## 公共数据结构 @@ -188,6 +188,27 @@ type SiteConfig = { targetValue?: string | null; isActive: boolean; }>; + campaigns: Array<{ + id: string; + slug: string; + title: string; + description?: string | null; + coverImage?: string | null; + priceAmount?: number | null; + priceUnit?: string | null; + tags: string[]; + status: "draft" | "published"; + startsAt?: string | null; + endsAt?: string | null; + }>; + routeSections: Array<{ + id: string; + title: string; + subtitle?: string | null; + productIds: string[]; + isActive: boolean; + sortOrder: number; + }>; ctaBanners: Array<{ id: string; alt: string; @@ -204,14 +225,28 @@ type SiteConfig = { ```ts type SiteItemPatch = { title?: string; + subtitle?: string | null; kicker?: string; name?: string; + slug?: string; + region?: string | null; label?: string; alt?: string; image?: string | null; + description?: string | null; + coverImage?: string | null; + priceAmount?: number | null; + priceUnit?: string | null; + tags?: string[]; targetType?: string | null; targetValue?: string | null; + isHot?: boolean; isActive?: boolean; + sortOrder?: number; + productIds?: string[]; + status?: "draft" | "published"; + startsAt?: string | null; + endsAt?: string | null; }; ``` @@ -222,6 +257,8 @@ type SiteItemPatch = { | `heroSlides` | `title`、`kicker`、`image`、`targetType`、`targetValue`、`isActive` | | `destinations` | `name`、`image`、`isActive` | | `themes` | `label`、`image`、`targetType`、`targetValue`、`isActive` | +| `campaigns` | `title`、`description`、`coverImage`、`priceAmount`、`priceUnit`、`tags`、`status` | +| `routeSections` | `title`、`subtitle`、`productIds`、`isActive`、`sortOrder` | | `ctaBanners` | `alt`、`image`、`targetType`、`targetValue`、`isActive` | ## 接口明细 @@ -372,9 +409,10 @@ GET /api/admin/site-config 要求: -- 返回 `heroSlides`、`destinations`、`themes`、`ctaBanners` 四个模块。 +- 返回 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`routeSections`、`ctaBanners` 七个模块。 - Admin API 需要返回未启用内容;Public API 才按发布/启用状态过滤。 - 各模块按 `sortOrder` 升序。 +- `routeSections` 返回当前已保存的子分组,包含未启用分组和后台配置的全部 `productIds`;无数据时返回空数组。 ### 更新站点配置项 @@ -386,7 +424,7 @@ PATCH /api/admin/site-config/{module}/{item_id} | 参数 | 类型 | 说明 | | --- | --- | --- | -| `module` | `SiteModule` | 只能为 `heroSlides`、`destinations`、`themes`、`ctaBanners` | +| `module` | `SiteModule` | 只能为 `heroSlides`、`destinations`、`map`、`themes`、`campaigns`、`routeSections`、`ctaBanners` | | `item_id` | `string` | 对应模块内容项 ID | 请求体:`SiteItemPatch` @@ -402,6 +440,26 @@ PATCH /api/admin/site-config/{module}/{item_id} 当前 UI 保存后会重新调用 `GET /api/admin/site-config` 刷新页面,响应体只需保证是合法 JSON。 +#### 精选线路 `routeSections` + +`routeSections` 是首页“精选线路”的动态运营分组配置,不再限制为固定三组。管理端通过新增、更新、删除和排序接口维护闭环;商品本体仍由 `/api/admin/products` 维护,这里只保存首页分组、标题、副文案、启用状态、排序和关联商品 ID 顺序。 + +```http +POST /api/admin/site-config/routeSections +PATCH /api/admin/site-config/routeSections/{section_id} +DELETE /api/admin/site-config/routeSections/{section_id} +PATCH /api/admin/site-config/routeSections/reorder +``` + +新增请求至少包含 `title`,可包含 `subtitle`、`isActive`、`sortOrder`、`productIds`;`id` 由后端生成,不再使用 `routes` / `routes-outdoor` / `routes-mix` 固定槽位。更新请求可包含 `title`、`subtitle`、`isActive`、`sortOrder`、`productIds`。`productIds` 表示该分组关联的线路商品及展示顺序。 + +后端约束: +- `GET /api/admin/site-config` 返回全部后台分组,包含停用分组和后台配置的全部 `productIds`。 +- `productIds` 中的商品必须存在,且同一请求内不能重复。 +- 同一商品不能同时出现在多个精选线路分组;冲突时返回 `409 ROUTE_SECTION_PRODUCT_CONFLICT`。 +- `DELETE /api/admin/site-config/routeSections/{section_id}` 只删除分组配置并解除关联,不删除商品本体;删除后后端重新整理剩余分组 `sortOrder`。 +- `PATCH /api/admin/site-config/routeSections/reorder` 的 `itemIds` 必须完整覆盖当前全部分组 ID,不能缺失、重复或包含未知 ID。 +- `GET /api/public/site-config` 只返回启用分组,且 `productIds` 只包含已发布商品;未发布、归档或不存在的商品不进入 Public 响应。 ### 线索列表 ```http @@ -485,6 +543,7 @@ POST /api/admin/reset-guizhou-content destinations: number; themes: number; ctaBanners: number; + routeSections?: number; products: number; } ``` diff --git a/docs/miniapp-public-api.md b/docs/miniapp-public-api.md index 8c50750..65c7674 100644 --- a/docs/miniapp-public-api.md +++ b/docs/miniapp-public-api.md @@ -78,14 +78,21 @@ ### `RouteSection` -`RouteSection` 用于描述首页“精选线路”下的分组。`经典人文打卡线路`、`极限山野户外野咖线路`、`人文+户外综合混搭线路` 等属于“精选线路”的子集,不是独立一级模块。 +`RouteSection` 用于描述首页“精选线路”下的动态运营分组。后台可按任务新增、编辑、删除和排序分组;MiniAPP 不应依赖固定分组 ID,只按接口返回的分组顺序和 `productIds` 渲染。 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | -| `id` | `string` | 是 | 分组 ID,例如 `routes`、`routes-outdoor`、`routes-mix` | +| `id` | `string` | 是 | 后端生成的分组 ID,客户端只用于列表 key 和商品关联,不作为固定业务枚举 | | `title` | `string` | 是 | 分组标题 | -| `productIds` | `string[]` | 是 | 该分组包含的产品 ID,产品详情来自 `/api/public/products` 的 `items` | +| `subtitle` | `string \| null` | 否 | 分组副文案 | +| `productIds` | `string[]` | 是 | 该分组包含的产品 ID;产品详情来自 `/api/public/products.items` | +| `isActive` | `boolean` | 否 | 是否启用;Public API 通常只返回启用分组 | +Public API 输出规则: +- `GET /api/public/site-config` 只返回启用的 `routeSections`。 +- `routeSections[].productIds` 只包含已发布商品 ID;未发布、归档或不存在的商品不得出现在 Public 响应中。 +- 动态分组按后台 `sortOrder` 升序返回,`productIds` 的顺序就是用户侧商品卡展示顺序。 +- MiniAPP 会按 `productIds` 匹配 `/api/public/products.items[].id`;接口缺失、`routeSections` 为空或没有可匹配商品时回退 `src/content.ts` 的本地精选线路兜底内容。 ### `PublicProduct` | 字段 | 类型 | 必填 | 说明 | @@ -168,11 +175,7 @@ | 首页模块 | 当前接口归属 | 说明 | | --- | --- | --- | | 特价优惠 | `site-config.campaigns` + `/api/public/products` | 当前 Public API 只返回活动元信息,不直接返回“特价优惠结果列表”。MiniAPP 若要展示活动线路,可按活动标题、标签或后续扩展的活动产品关联从 `/api/public/products` 中筛选。 | -| 精选线路 | `site-config.routeSections` + `/api/public/products` | `routeSections` 只返回分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。 | -| 经典人文打卡线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes`。 | -| 极限山野户外野咖线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-outdoor`。 | -| 人文+户外综合混搭线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-mix`。 | - +| 精选线路 | `site-config.routeSections` + `/api/public/products` | `routeSections` 返回动态分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。后台可按任务新增、删除、停用和排序分组,用户侧不假设固定三组。 | #### 响应示例 ```json @@ -224,19 +227,25 @@ ], "routeSections": [ { - "id": "routes", + "id": "route-section-001", "title": "经典人文打卡线路", - "productIds": ["8a6e7c4f-0000-4000-9000-000000000001"] + "subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联", + "productIds": ["8a6e7c4f-0000-4000-9000-000000000001"], + "isActive": true }, { - "id": "routes-outdoor", + "id": "route-section-002", "title": "极限山野户外野咖线路", - "productIds": [] + "subtitle": "溶洞、峡谷、漂流、峰林骑行和山野咖啡组合", + "productIds": [], + "isActive": true }, { - "id": "routes-mix", + "id": "route-section-003", "title": "人文+户外综合混搭线路", - "productIds": [] + "subtitle": "非遗村寨、古城夜游、自然轻探险和精品住宿同程安排", + "productIds": [], + "isActive": true } ] } @@ -414,7 +423,8 @@ - `site-config` 与 `products` 会在应用启动时并行请求;任一请求失败时,MiniAPP 会回退到本地静态内容。 - `products.items` 为空时,MiniAPP 会使用本地产品兜底数据。 -- “精选线路”由 `site-config.routeSections` 定义分组,由 `/api/public/products.items` 提供产品详情;`经典人文打卡线路`、`极限山野户外野咖线路`、`人文+户外综合混搭线路` 是“精选线路”的子集。 +- “精选线路”由 `site-config.routeSections` 定义动态分组标题、副文案和商品 ID 顺序,由 `/api/public/products.items` 提供产品详情;客户端不依赖固定分组 ID 或固定三组数量。 +- `routeSections` 缺失、为空或无法匹配到有效商品时,MiniAPP 使用 `src/content.ts` 的本地精选线路内容回退。 - “特价优惠”当前没有独立 Public 结果列表字段;`site-config.campaigns` 只提供活动元信息,活动线路需通过产品标签/关键词筛选或后续扩展活动产品关联字段。 - 产品搜索当前主要在前端执行,依赖 `title`、`tags`、`destination.name`、`summary`。 - 产品详情页当前使用已加载的产品列表数据;后续可改为进入详情页时请求 `GET /api/public/products/{product_id}`。 @@ -425,7 +435,8 @@ - 为 `GET /health` 增加或保留健康检查测试。 - 为 `GET /api/public/site-config` 验证返回 JSON 包含 `heroSlides`、`destinations`、`map`、`themes`、`ctaBanners`、`campaigns`、`routeSections` 数组字段。 -- 为 `GET /api/public/site-config` 验证 `routeSections` 表达“精选线路”子分组,并包含 `routes`、`routes-outdoor`、`routes-mix` 三个当前约定分组。 +- 为 `GET /api/public/site-config` 验证 `routeSections` 表达“精选线路”子分组;接口返回当前已配置且启用的分组,未配置时返回空数组并由 MiniAPP 本地内容兜底。 +- 为 `GET /api/public/site-config` 验证 `routeSections` 只返回启用分组,且 `productIds` 不包含未发布商品。 - 为 `GET /api/public/products` 验证响应结构为 `{ items: [...] }`,并覆盖 `keyword`、`destinationId`、`status`、`take` 参数。 - 为 `GET /api/public/products/{product_id}` 验证 UUID、数字 `sourceId` 和 404 场景。 - 为 `GET /api/public/destinations` 验证只返回启用目的地及别名字段。 diff --git a/tests/test_api_contracts.py b/tests/test_api_contracts.py index dac804e..653d7b0 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, HeroSlide, Lead, MapImage, MediaAsset, Product, ProductImage, ThemeCard +from app.models import AdminUser, Campaign, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard 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 @@ -208,6 +208,31 @@ def make_campaign(**overrides): ) +def make_route_section(**overrides): + section = RouteSection( + id=overrides.get("id", "routes"), + 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), + ) + section.products = overrides.get("products", []) + return section + + +def make_route_section_product(**overrides): + product = overrides.get("product", make_product(id=overrides.get("productId", "product-test"))) + link = RouteSectionProduct( + sectionId=overrides.get("sectionId", "routes"), + 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", @@ -471,7 +496,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: @@ -489,7 +514,7 @@ def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets(): def test_admin_site_config_includes_map_array_with_dedicated_contract(): map_image = make_map_image() - fake_db = FakeDb(scalar_results=[[], [], [map_image], [], [], []]) + fake_db = FakeDb(scalar_results=[[], [], [map_image], [], [], [], [], []]) app = authenticated_app(fake_db) try: @@ -515,7 +540,7 @@ def test_admin_site_config_includes_map_array_with_dedicated_contract(): def test_admin_site_config_includes_campaigns_for_special_offers(): campaign = make_campaign(status="published") - fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign]]) + fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], [], []]) app = authenticated_app(fake_db) try: @@ -547,6 +572,90 @@ def test_admin_site_config_includes_campaigns_for_special_offers(): assert "targetType" not in body["campaigns"][0] +def test_admin_site_config_includes_route_sections_for_featured_routes(): + section = make_route_section( + products=[ + make_route_section_product(productId="product-a", sortOrder=1), + make_route_section_product(productId="product-b", sortOrder=0), + ], + ) + fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section]]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).get("/api/admin/site-config") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + route_sections = response.json()["routeSections"] + assert route_sections == [ + { + "id": "routes", + "title": "经典人文打卡线路", + "subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联", + "productIds": ["product-b", "product-a"], + "isActive": True, + "sortOrder": 0, + "createdAt": "2026-01-01T00:00:00", + "updatedAt": "2026-01-02T00:00:00", + } + ] + + +def test_site_config_create_route_section_generates_dynamic_id_and_audits(): + fake_db = FakeDb(scalar_results=[[]]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).post( + "/api/admin/site-config/routeSections", + json={"title": " 新人文线路 ", "subtitle": " 新副文案 "}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 201 + body = response.json() + assert body["id"].startswith("routesection-") + assert body["title"] == "新人文线路" + assert body["subtitle"] == "新副文案" + assert body["productIds"] == [] + assert body["isActive"] is True + assert body["sortOrder"] == 0 + created = fake_db.added[0] + assert isinstance(created, RouteSection) + assert created.id.startswith("routesection-") + assert fake_db.added[-1].entity == "route_section" + assert fake_db.committed + + +def test_route_sections_module_is_not_fixed(): + assert admin_router.SITE_CONFIG_MODULES["routeSections"].get("fixed") is not True + + +def test_site_config_create_route_section_allows_more_than_three_groups(): + items = [ + make_route_section(id="route-section-a", title="经典", sortOrder=0), + make_route_section(id="route-section-b", title="户外", sortOrder=1), + make_route_section(id="route-section-c", title="混搭", sortOrder=2), + ] + fake_db = FakeDb(scalar_results=[items]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).post("/api/admin/site-config/routeSections", json={"title": "第四组"}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 201 + body = response.json() + assert body["title"] == "第四组" + assert body["sortOrder"] == 3 + assert body["productIds"] == [] + assert fake_db.committed + + 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) @@ -788,9 +897,67 @@ def test_site_config_patch_campaign_updates_contract_fields_only(): assert fake_db.added[-1].entity == "campaign" +def test_site_config_patch_route_section_updates_copy_and_product_order(): + section = make_route_section() + product_a = make_product(id="product-a") + product_b = make_product(id="product-b") + fake_db = FakeDb( + get_result=section, + scalar_results=[[product_b, product_a], []], + execute_results=[[]], + ) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).patch( + "/api/admin/site-config/routeSections/routes", + 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, RouteSectionProduct)] + assert [(link.productId, link.sortOrder) for link in links] == [("product-b", 0), ("product-a", 1)] + assert fake_db.added[-1].entity == "route_section" + 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") + fake_db = FakeDb( + get_result=section, + scalar_results=[[make_product(id="product-a")], [conflict]], + ) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).patch( + "/api/admin/site-config/routeSections/routes", + json={"productIds": ["product-a"]}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 409 + assert response.json()["code"] == "ROUTE_SECTION_PRODUCT_CONFLICT" + assert not fake_db.committed + + def test_public_site_config_campaigns_include_price_and_tags(): campaign = make_campaign(status="published", tags=["自然奇景", "小众秘境"]) - fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], []]) + fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], [], []]) result = site_config(fake_db, active_only=True) @@ -813,6 +980,30 @@ def test_public_site_config_campaigns_include_price_and_tags(): ] +def test_public_site_config_route_sections_filter_unpublished_products(): + published = make_product(id="product-published", status="published") + draft = make_product(id="product-draft", status="draft") + section = make_route_section( + products=[ + make_route_section_product(product=published, productId=published.id, sortOrder=0), + make_route_section_product(product=draft, productId=draft.id, sortOrder=1), + ] + ) + fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section]]) + + result = site_config(fake_db, active_only=True) + + assert result["routeSections"] == [ + { + "id": "routes", + "title": "经典人文打卡线路", + "subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联", + "productIds": ["product-published"], + "isActive": True, + } + ] + + def test_site_config_patch_updates_destination_contract_fields(): destination = make_destination() fake_db = FakeDb(get_result=destination) @@ -925,6 +1116,32 @@ def test_site_config_reorder_rejects_duplicate_missing_and_unknown_ids(item_ids) assert not fake_db.committed +@pytest.mark.parametrize( + "item_ids", + [ + ["route-section-a", "route-section-a", "route-section-b"], + ["route-section-a", "route-section-b"], + ["route-section-a", "route-section-b", "route-section-x"], + ], +) +def test_site_config_reorder_route_sections_rejects_duplicate_missing_and_unknown_ids(item_ids): + items = [ + make_route_section(id="route-section-a", title="A", sortOrder=0), + make_route_section(id="route-section-b", title="B", sortOrder=1), + make_route_section(id="route-section-c", title="C", sortOrder=2), + ] + fake_db = FakeDb(scalar_results=[items]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).patch("/api/admin/site-config/routeSections/reorder", json={"itemIds": item_ids}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 400 + assert response.json()["code"] == "MODULE_CONFIG_REORDER_INVALID" + assert not fake_db.committed + def test_site_config_reorder_map_is_not_supported(): fake_db = FakeDb() app = authenticated_app(fake_db) @@ -953,6 +1170,49 @@ def test_site_config_reorder_campaigns_is_not_supported(): assert not fake_db.committed +def test_site_config_reorder_route_sections_reassigns_sort_order(): + items = [ + make_route_section(id="route-section-a", title="经典", sortOrder=0), + make_route_section(id="route-section-b", title="户外", sortOrder=1), + make_route_section(id="route-section-c", title="混搭", sortOrder=2), + ] + fake_db = FakeDb(scalar_results=[items]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).patch( + "/api/admin/site-config/routeSections/reorder", + json={"itemIds": ["route-section-b", "route-section-a", "route-section-c"]}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert {item.id: item.sortOrder for item in items} == {"route-section-b": 0, "route-section-a": 1, "route-section-c": 2} + assert [item["id"] for item in response.json()["items"]] == ["route-section-b", "route-section-a", "route-section-c"] + assert fake_db.added[-1].entity == "route_section" + assert fake_db.committed + + +def test_site_config_delete_route_section_returns_json_reorders_and_does_not_delete_products(): + section = make_route_section(id="route-section-delete", title="待删除", sortOrder=1) + remaining_a = make_route_section(id="route-section-a", title="保留 A", sortOrder=0) + remaining_b = make_route_section(id="route-section-b", title="保留 B", sortOrder=2) + fake_db = FakeDb(get_result=section, scalar_results=[[remaining_a, remaining_b]]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).delete("/api/admin/site-config/routeSections/route-section-delete") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert response.json() == {"id": "route-section-delete"} + assert fake_db.deleted == [section] + assert {item.id: item.sortOrder for item in [remaining_a, remaining_b]} == {"route-section-a": 0, "route-section-b": 1} + assert fake_db.added[-1].action == "delete" + assert fake_db.added[-1].entity == "route_section" + assert 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)