feat(route-sections): add dynamic route sections
Replace hardcoded homepage featured route groups with a fully managed dynamic system: - add database models RouteSection and RouteSectionProduct for storing route groups and their associated products - create Alembic migration 0004_route_sections for the new tables - extend SiteConfigPatchIn schema with subtitle and productIds fields - refactor shared site_config utility to load dynamic route sections instead of fixed groups - implement admin CRUD API with validation for duplicate/conflicting product associations - update public and admin API documentation to reflect the new system - add default route section seed data and comprehensive test coverage
This commit is contained in:
54
alembic/versions/0004_route_sections.py
Normal file
54
alembic/versions/0004_route_sections.py
Normal file
@@ -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")
|
||||||
@@ -149,6 +149,7 @@ class Product(Base):
|
|||||||
destination: Mapped[Destination | None] = relationship(back_populates="products")
|
destination: Mapped[Destination | None] = relationship(back_populates="products")
|
||||||
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")
|
||||||
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")
|
||||||
|
|
||||||
@@ -196,6 +197,31 @@ class CampaignProduct(Base):
|
|||||||
product: Mapped[Product] = relationship(back_populates="campaignLinks")
|
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):
|
class Lead(Base):
|
||||||
__tablename__ = "Lead"
|
__tablename__ = "Lead"
|
||||||
|
|
||||||
|
|||||||
91
app/route_sections.py
Normal file
91
app/route_sections.py
Normal file
@@ -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)
|
||||||
|
|
||||||
@@ -25,12 +25,15 @@ from ..models import (
|
|||||||
MediaAsset,
|
MediaAsset,
|
||||||
Product,
|
Product,
|
||||||
ProductImage,
|
ProductImage,
|
||||||
|
RouteSection,
|
||||||
|
RouteSectionProduct,
|
||||||
SiteVersion,
|
SiteVersion,
|
||||||
ThemeCard,
|
ThemeCard,
|
||||||
utc_now,
|
utc_now,
|
||||||
)
|
)
|
||||||
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 ..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 ..serializers import admin_product_dict, destination_dict, encode_value, lead_dict, model_dict
|
||||||
from .shared import site_config
|
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},
|
"create_defaults": {"description": None, "coverImage": None, "priceAmount": None, "priceUnit": "起/人", "tags": [], "status": "draft", "startsAt": None, "endsAt": None},
|
||||||
"ordered": False,
|
"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": {
|
"ctaBanners": {
|
||||||
"model": CtaBanner,
|
"model": CtaBanner,
|
||||||
"entity": "cta_banner",
|
"entity": "cta_banner",
|
||||||
@@ -360,12 +372,16 @@ def site_item_dict(module: str, item) -> dict:
|
|||||||
return hero_slide_admin_dict(item)
|
return hero_slide_admin_dict(item)
|
||||||
if module == "map":
|
if module == "map":
|
||||||
return map_image_admin_dict(item)
|
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)
|
return destination_dict(item) if module == "destinations" else model_dict(item)
|
||||||
|
|
||||||
|
|
||||||
def module_items(db: Session, config: dict) -> list:
|
def module_items(db: Session, config: dict) -> list:
|
||||||
model = config["model"]
|
model = config["model"]
|
||||||
stmt = select(model)
|
stmt = select(model)
|
||||||
|
if model is RouteSection:
|
||||||
|
stmt = stmt.options(selectinload(RouteSection.products).selectinload(RouteSectionProduct.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:
|
||||||
@@ -383,7 +399,7 @@ def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db:
|
|||||||
payload = {
|
payload = {
|
||||||
field: site_field_value(config, field, getattr(body, field))
|
field: site_field_value(config, field, getattr(body, field))
|
||||||
for field in config["fields"]
|
for field in config["fields"]
|
||||||
if field in fields
|
if field in fields and field != "productIds"
|
||||||
}
|
}
|
||||||
payload[config["primary"]] = validate_site_primary(config, body)
|
payload[config["primary"]] = validate_site_primary(config, body)
|
||||||
for field, value in config["create_defaults"].items():
|
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"]:
|
for field in config["fields"]:
|
||||||
if field not in fields:
|
if field not in fields:
|
||||||
continue
|
continue
|
||||||
|
if field == "productIds":
|
||||||
|
continue
|
||||||
value = site_field_value(config, field, getattr(body, field))
|
value = site_field_value(config, field, getattr(body, field))
|
||||||
if field == config["primary"] and not value:
|
if field == config["primary"] and not value:
|
||||||
site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field})
|
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)
|
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:
|
def load_product(db: Session, product_id: str) -> Product:
|
||||||
return db.scalars(
|
return db.scalars(
|
||||||
select(Product)
|
select(Product)
|
||||||
@@ -581,6 +628,7 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D
|
|||||||
model_dict(item)
|
model_dict(item)
|
||||||
for item in db.scalars(select(Campaign).order_by(Campaign.updatedAt.desc())).all()
|
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),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
config = site_module(module)
|
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):
|
if config.get("singleton") and module_items(db, config):
|
||||||
site_config_error(409, "地图图片已存在", "MAP_IMAGE_ALREADY_EXISTS", {"module": module})
|
site_config_error(409, "地图图片已存在", "MAP_IMAGE_ALREADY_EXISTS", {"module": module})
|
||||||
item = config["model"](**site_create_payload(module, config, body, db))
|
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})
|
site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id})
|
||||||
before = site_item_dict(module, item)
|
before = site_item_dict(module, item)
|
||||||
apply_site_patch(module, config, item, body)
|
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()
|
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)
|
||||||
@@ -666,6 +720,8 @@ def delete_site_config(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
config = site_module(module)
|
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)
|
item = db.get(config["model"], item_id)
|
||||||
if not item:
|
if not item:
|
||||||
site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id})
|
site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id})
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
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, 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
|
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:
|
def site_config(db: Session, active_only: bool, include_public_extras: bool = True) -> dict:
|
||||||
hero_stmt = select(HeroSlide).order_by(HeroSlide.sortOrder.asc())
|
hero_stmt = select(HeroSlide).order_by(HeroSlide.sortOrder.asc())
|
||||||
destination_stmt = select(Destination).options(selectinload(Destination.aliases)).order_by(Destination.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:
|
if include_public_extras:
|
||||||
campaigns = db.scalars(select(Campaign).where(Campaign.status == "published").order_by(Campaign.updatedAt.desc())).all()
|
campaigns = db.scalars(select(Campaign).where(Campaign.status == "published").order_by(Campaign.updatedAt.desc())).all()
|
||||||
products = db.scalars(
|
route_sections = db.scalars(route_section_query()).all()
|
||||||
select(Product).where(Product.status == "published").order_by(Product.sortWeight.asc(), Product.createdAt.asc()).limit(48)
|
if active_only:
|
||||||
).all()
|
route_sections = [section for section in route_sections if section.isActive]
|
||||||
result["campaigns"] = [model_dict(item) for item in campaigns]
|
result["campaigns"] = [model_dict(item) for item in campaigns]
|
||||||
result["routeSections"] = [
|
result["routeSections"] = [route_section_dict(item, public=True) for item in route_sections]
|
||||||
{"id": section_id, "title": title, "productIds": [item.id for item in products[start:end]]}
|
|
||||||
for section_id, title, start, end in ROUTE_SECTION_LABELS
|
|
||||||
]
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ class LeadQuery(BaseModel):
|
|||||||
class SiteConfigPatchIn(BaseModel):
|
class SiteConfigPatchIn(BaseModel):
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
kicker: str | None = None
|
kicker: str | None = None
|
||||||
|
subtitle: str | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
slug: str | None = None
|
slug: str | None = None
|
||||||
region: str | None = None
|
region: str | None = None
|
||||||
@@ -132,6 +133,7 @@ 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
|
||||||
|
productIds: list[str] | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
startsAt: datetime | None = None
|
startsAt: datetime | None = None
|
||||||
endsAt: datetime | None = None
|
endsAt: datetime | None = None
|
||||||
|
|||||||
19
app/seed.py
19
app/seed.py
@@ -21,10 +21,13 @@ from .models import (
|
|||||||
MediaAsset,
|
MediaAsset,
|
||||||
Product,
|
Product,
|
||||||
ProductImage,
|
ProductImage,
|
||||||
|
RouteSection,
|
||||||
|
RouteSectionProduct,
|
||||||
SiteVersion,
|
SiteVersion,
|
||||||
ThemeCard,
|
ThemeCard,
|
||||||
utc_now,
|
utc_now,
|
||||||
)
|
)
|
||||||
|
from .route_sections import ROUTE_SECTION_DEFAULTS
|
||||||
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
@@ -97,7 +100,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, 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.execute(delete(model))
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
@@ -178,6 +181,18 @@ def reset_guizhou_content(db: Session) -> dict:
|
|||||||
for index, product in enumerate(linked_products):
|
for index, product in enumerate(linked_products):
|
||||||
db.add(CampaignProduct(campaignId=campaign.id, productId=product.id, sortOrder=index))
|
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(
|
snapshot = SiteVersion(
|
||||||
title="guizhou-content-reset",
|
title="guizhou-content-reset",
|
||||||
status="published",
|
status="published",
|
||||||
@@ -187,6 +202,7 @@ def reset_guizhou_content(db: Session) -> dict:
|
|||||||
"destinations": len(DESTINATIONS),
|
"destinations": len(DESTINATIONS),
|
||||||
"themeCards": len(THEMES),
|
"themeCards": len(THEMES),
|
||||||
"products": len(products),
|
"products": len(products),
|
||||||
|
"routeSections": len(ROUTE_SECTION_DEFAULTS),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
db.add(snapshot)
|
db.add(snapshot)
|
||||||
@@ -197,6 +213,7 @@ def reset_guizhou_content(db: Session) -> dict:
|
|||||||
"themes": len(THEMES),
|
"themes": len(THEMES),
|
||||||
"ctaBanners": len(CTAS),
|
"ctaBanners": len(CTAS),
|
||||||
"products": len(products),
|
"products": len(products),
|
||||||
|
"routeSections": len(ROUTE_SECTION_DEFAULTS),
|
||||||
"siteVersionId": snapshot.id,
|
"siteVersionId": snapshot.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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` 的实际类型、请求封装和页面调用。
|
本文档用于指导 `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 |
|
| `POST /api/admin/products` | 新建商品 | 已覆盖 | 返回完整 Product |
|
||||||
| `PATCH /api/admin/products/{id}` | 编辑商品 | 已覆盖 | 返回完整 Product |
|
| `PATCH /api/admin/products/{id}` | 编辑商品 | 已覆盖 | 返回完整 Product |
|
||||||
| `GET /api/admin/destinations` | 商品目的地下拉、目的地页 | 已覆盖 | 需要返回别名和商品数 |
|
| `GET /api/admin/destinations` | 商品目的地下拉、目的地页 | 已覆盖 | 需要返回别名和商品数 |
|
||||||
| `GET /api/admin/site-config` | 首页/目的地/活动结构维护 | 已覆盖 | 需要包含未启用内容 |
|
| `GET /api/admin/site-config` | 首页/目的地/活动结构维护 | 已覆盖 | 需要包含未启用内容和已保存的 `routeSections` |
|
||||||
| `PATCH /api/admin/site-config/{module}/{item_id}` | 模块内容编辑 | 已覆盖 | 模块名需保持一致 |
|
| `PATCH /api/admin/site-config/{module}/{item_id}` | 模块内容编辑 | 已覆盖 | 模块名需保持一致 |
|
||||||
| `GET /api/admin/leads` | 需求线索页 | 已覆盖 | UI 当前不传筛选参数 |
|
| `GET /api/admin/leads` | 需求线索页 | 已覆盖 | UI 当前不传筛选参数 |
|
||||||
| `PATCH /api/admin/leads/{id}/status` | 线索状态流转 | 已覆盖 | UI 更新后会重新拉列表 |
|
| `PATCH /api/admin/leads/{id}/status` | 线索状态流转 | 已覆盖 | UI 更新后会重新拉列表 |
|
||||||
@@ -58,7 +58,7 @@ type LeadStatus = "new" | "assigned" | "contacted" | "planning" | "won" | "inval
|
|||||||
### SiteModule
|
### SiteModule
|
||||||
|
|
||||||
```ts
|
```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;
|
targetValue?: string | null;
|
||||||
isActive: boolean;
|
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<{
|
ctaBanners: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
@@ -204,14 +225,28 @@ type SiteConfig = {
|
|||||||
```ts
|
```ts
|
||||||
type SiteItemPatch = {
|
type SiteItemPatch = {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
subtitle?: string | null;
|
||||||
kicker?: string;
|
kicker?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
region?: string | null;
|
||||||
label?: string;
|
label?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
coverImage?: string | null;
|
||||||
|
priceAmount?: number | null;
|
||||||
|
priceUnit?: string | null;
|
||||||
|
tags?: string[];
|
||||||
targetType?: string | null;
|
targetType?: string | null;
|
||||||
targetValue?: string | null;
|
targetValue?: string | null;
|
||||||
|
isHot?: boolean;
|
||||||
isActive?: 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` |
|
| `heroSlides` | `title`、`kicker`、`image`、`targetType`、`targetValue`、`isActive` |
|
||||||
| `destinations` | `name`、`image`、`isActive` |
|
| `destinations` | `name`、`image`、`isActive` |
|
||||||
| `themes` | `label`、`image`、`targetType`、`targetValue`、`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` |
|
| `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 才按发布/启用状态过滤。
|
- Admin API 需要返回未启用内容;Public API 才按发布/启用状态过滤。
|
||||||
- 各模块按 `sortOrder` 升序。
|
- 各模块按 `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 |
|
| `item_id` | `string` | 对应模块内容项 ID |
|
||||||
|
|
||||||
请求体:`SiteItemPatch`
|
请求体:`SiteItemPatch`
|
||||||
@@ -402,6 +440,26 @@ PATCH /api/admin/site-config/{module}/{item_id}
|
|||||||
|
|
||||||
当前 UI 保存后会重新调用 `GET /api/admin/site-config` 刷新页面,响应体只需保证是合法 JSON。
|
当前 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
|
```http
|
||||||
@@ -485,6 +543,7 @@ POST /api/admin/reset-guizhou-content
|
|||||||
destinations: number;
|
destinations: number;
|
||||||
themes: number;
|
themes: number;
|
||||||
ctaBanners: number;
|
ctaBanners: number;
|
||||||
|
routeSections?: number;
|
||||||
products: number;
|
products: number;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -78,14 +78,21 @@
|
|||||||
|
|
||||||
### `RouteSection`
|
### `RouteSection`
|
||||||
|
|
||||||
`RouteSection` 用于描述首页“精选线路”下的分组。`经典人文打卡线路`、`极限山野户外野咖线路`、`人文+户外综合混搭线路` 等属于“精选线路”的子集,不是独立一级模块。
|
`RouteSection` 用于描述首页“精选线路”下的动态运营分组。后台可按任务新增、编辑、删除和排序分组;MiniAPP 不应依赖固定分组 ID,只按接口返回的分组顺序和 `productIds` 渲染。
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `id` | `string` | 是 | 分组 ID,例如 `routes`、`routes-outdoor`、`routes-mix` |
|
| `id` | `string` | 是 | 后端生成的分组 ID,客户端只用于列表 key 和商品关联,不作为固定业务枚举 |
|
||||||
| `title` | `string` | 是 | 分组标题 |
|
| `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`
|
### `PublicProduct`
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
@@ -168,11 +175,7 @@
|
|||||||
| 首页模块 | 当前接口归属 | 说明 |
|
| 首页模块 | 当前接口归属 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 特价优惠 | `site-config.campaigns` + `/api/public/products` | 当前 Public API 只返回活动元信息,不直接返回“特价优惠结果列表”。MiniAPP 若要展示活动线路,可按活动标题、标签或后续扩展的活动产品关联从 `/api/public/products` 中筛选。 |
|
| 特价优惠 | `site-config.campaigns` + `/api/public/products` | 当前 Public API 只返回活动元信息,不直接返回“特价优惠结果列表”。MiniAPP 若要展示活动线路,可按活动标题、标签或后续扩展的活动产品关联从 `/api/public/products` 中筛选。 |
|
||||||
| 精选线路 | `site-config.routeSections` + `/api/public/products` | `routeSections` 只返回分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。 |
|
| 精选线路 | `site-config.routeSections` + `/api/public/products` | `routeSections` 返回动态分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。后台可按任务新增、删除、停用和排序分组,用户侧不假设固定三组。 |
|
||||||
| 经典人文打卡线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes`。 |
|
|
||||||
| 极限山野户外野咖线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-outdoor`。 |
|
|
||||||
| 人文+户外综合混搭线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-mix`。 |
|
|
||||||
|
|
||||||
#### 响应示例
|
#### 响应示例
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -224,19 +227,25 @@
|
|||||||
],
|
],
|
||||||
"routeSections": [
|
"routeSections": [
|
||||||
{
|
{
|
||||||
"id": "routes",
|
"id": "route-section-001",
|
||||||
"title": "经典人文打卡线路",
|
"title": "经典人文打卡线路",
|
||||||
"productIds": ["8a6e7c4f-0000-4000-9000-000000000001"]
|
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
|
||||||
|
"productIds": ["8a6e7c4f-0000-4000-9000-000000000001"],
|
||||||
|
"isActive": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "routes-outdoor",
|
"id": "route-section-002",
|
||||||
"title": "极限山野户外野咖线路",
|
"title": "极限山野户外野咖线路",
|
||||||
"productIds": []
|
"subtitle": "溶洞、峡谷、漂流、峰林骑行和山野咖啡组合",
|
||||||
|
"productIds": [],
|
||||||
|
"isActive": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "routes-mix",
|
"id": "route-section-003",
|
||||||
"title": "人文+户外综合混搭线路",
|
"title": "人文+户外综合混搭线路",
|
||||||
"productIds": []
|
"subtitle": "非遗村寨、古城夜游、自然轻探险和精品住宿同程安排",
|
||||||
|
"productIds": [],
|
||||||
|
"isActive": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -414,7 +423,8 @@
|
|||||||
|
|
||||||
- `site-config` 与 `products` 会在应用启动时并行请求;任一请求失败时,MiniAPP 会回退到本地静态内容。
|
- `site-config` 与 `products` 会在应用启动时并行请求;任一请求失败时,MiniAPP 会回退到本地静态内容。
|
||||||
- `products.items` 为空时,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` 只提供活动元信息,活动线路需通过产品标签/关键词筛选或后续扩展活动产品关联字段。
|
- “特价优惠”当前没有独立 Public 结果列表字段;`site-config.campaigns` 只提供活动元信息,活动线路需通过产品标签/关键词筛选或后续扩展活动产品关联字段。
|
||||||
- 产品搜索当前主要在前端执行,依赖 `title`、`tags`、`destination.name`、`summary`。
|
- 产品搜索当前主要在前端执行,依赖 `title`、`tags`、`destination.name`、`summary`。
|
||||||
- 产品详情页当前使用已加载的产品列表数据;后续可改为进入详情页时请求 `GET /api/public/products/{product_id}`。
|
- 产品详情页当前使用已加载的产品列表数据;后续可改为进入详情页时请求 `GET /api/public/products/{product_id}`。
|
||||||
@@ -425,7 +435,8 @@
|
|||||||
|
|
||||||
- 为 `GET /health` 增加或保留健康检查测试。
|
- 为 `GET /health` 增加或保留健康检查测试。
|
||||||
- 为 `GET /api/public/site-config` 验证返回 JSON 包含 `heroSlides`、`destinations`、`map`、`themes`、`ctaBanners`、`campaigns`、`routeSections` 数组字段。
|
- 为 `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` 验证响应结构为 `{ items: [...] }`,并覆盖 `keyword`、`destinationId`、`status`、`take` 参数。
|
||||||
- 为 `GET /api/public/products/{product_id}` 验证 UUID、数字 `sourceId` 和 404 场景。
|
- 为 `GET /api/public/products/{product_id}` 验证 UUID、数字 `sourceId` 和 404 场景。
|
||||||
- 为 `GET /api/public/destinations` 验证只返回启用目的地及别名字段。
|
- 为 `GET /api/public/destinations` 验证只返回启用目的地及别名字段。
|
||||||
|
|||||||
@@ -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, 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 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
|
||||||
@@ -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():
|
def make_admin_user():
|
||||||
return AdminUser(
|
return AdminUser(
|
||||||
id="admin-test",
|
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():
|
def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
|
||||||
hero_slide = make_hero_slide(targetType="campaign", targetValue="campaign-test")
|
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)
|
app = authenticated_app(fake_db)
|
||||||
|
|
||||||
try:
|
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():
|
def test_admin_site_config_includes_map_array_with_dedicated_contract():
|
||||||
map_image = make_map_image()
|
map_image = make_map_image()
|
||||||
fake_db = FakeDb(scalar_results=[[], [], [map_image], [], [], []])
|
fake_db = FakeDb(scalar_results=[[], [], [map_image], [], [], [], [], []])
|
||||||
app = authenticated_app(fake_db)
|
app = authenticated_app(fake_db)
|
||||||
|
|
||||||
try:
|
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():
|
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], [], []])
|
||||||
app = authenticated_app(fake_db)
|
app = authenticated_app(fake_db)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -547,6 +572,90 @@ def test_admin_site_config_includes_campaigns_for_special_offers():
|
|||||||
assert "targetType" not in body["campaigns"][0]
|
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():
|
def test_site_config_create_hero_slide_ignores_target_fields_and_returns_dedicated_contract():
|
||||||
fake_db = FakeDb(scalar_values=[0])
|
fake_db = FakeDb(scalar_values=[0])
|
||||||
app = authenticated_app(fake_db)
|
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"
|
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():
|
def test_public_site_config_campaigns_include_price_and_tags():
|
||||||
campaign = make_campaign(status="published", 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)
|
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():
|
def test_site_config_patch_updates_destination_contract_fields():
|
||||||
destination = make_destination()
|
destination = make_destination()
|
||||||
fake_db = FakeDb(get_result=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
|
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():
|
def test_site_config_reorder_map_is_not_supported():
|
||||||
fake_db = FakeDb()
|
fake_db = FakeDb()
|
||||||
app = authenticated_app(fake_db)
|
app = authenticated_app(fake_db)
|
||||||
@@ -953,6 +1170,49 @@ def test_site_config_reorder_campaigns_is_not_supported():
|
|||||||
assert not fake_db.committed
|
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():
|
def test_site_config_delete_map_image_returns_json_and_audits_without_reorder():
|
||||||
map_image = make_map_image()
|
map_image = make_map_image()
|
||||||
fake_db = FakeDb(get_result=map_image)
|
fake_db = FakeDb(get_result=map_image)
|
||||||
|
|||||||
Reference in New Issue
Block a user