refactor: 清理废弃业务模块并更新全栈配置

- 移除后端产品、目的地、活动专题等废弃模块的数据库表与业务代码,删除冗余API接口
- 删除小程序端详情页、线路组件等冗余代码,移除搜索工具与测试用例,调整导航逻辑
- 清理管理端废弃的类型定义、编辑器与测试代码
- 更新项目文档,修正模块维护说明与接口文档内容
This commit is contained in:
duanshuwen committed 2026-08-17 22:43:49 +08:00
1 parent 72e6071bb1
commit 548f91c37f
80 files changed
+953 -12702

No files matched your search

+13 -405
View File
@@ -8,7 +8,7 @@ from email.utils import formatdate
from uuid import uuid4
from urllib.parse import quote, urlsplit, urlunsplit
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status
from sqlalchemy import delete, func, or_, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from ..auth import create_token, get_actor_id, require_admin, verify_password
from ..config import get_settings
@@ -16,35 +16,20 @@ from ..database import get_db
from ..models import (
AdminUser,
AuditLog,
Campaign,
CtaBanner,
DemandFeatureCard,
DemandForm,
DemandHero,
DemandRecommendation,
DemandRecommendationProduct,
Destination,
DestinationHero,
DestinationRegion,
HeroSlide,
HotelGroup,
Lead,
MapImage,
MediaAsset,
Product,
ProductImage,
RouteSection,
RouteSectionProduct,
SiteVersion,
ThemeCard,
VehicleOption,
utc_now,
)
from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn
from ..schemas import LeadQuery, LeadStatus, LeadStatusIn, LoginIn, SiteConfigPatchIn, SiteConfigReorderIn
from ..seed import create_media, reset_guizhou_content
from ..demand_recommendations import demand_recommendation_dict, demand_recommendation_query, replace_demand_recommendation_products
from ..route_sections import replace_route_section_products, route_section_dict, route_section_query
from ..serializers import admin_product_dict, destination_dict, encode_value, hotel_group_dict, lead_dict, model_dict
from ..serializers import encode_value, lead_dict, model_dict
from .shared import site_config
@@ -68,14 +53,6 @@ SITE_CONFIG_MODULES = {
"none_to_empty": {"image"},
"create_defaults": {"kicker": "", "image": ""},
},
"destinations": {
"model": Destination,
"entity": "destination",
"primary": "name",
"fields": {"name", "slug", "region", "image", "isHot", "isActive", "sortOrder"},
"none_to_empty": set(),
"create_defaults": {"isHot": False},
},
"destinationHero": {
"model": DestinationHero,
"entity": "destination_hero",
@@ -85,15 +62,6 @@ SITE_CONFIG_MODULES = {
"empty_to_none": {"kicker", "image"},
"create_defaults": {"kicker": None, "image": None},
},
"destinationRegions": {
"model": DestinationRegion,
"entity": "destination_region",
"primary": "label",
"fields": {"label", "keyword", "spots", "isActive", "sortOrder"},
"none_to_empty": set(),
"empty_to_none": {"keyword", "spots"},
"create_defaults": {"keyword": None, "spots": None},
},
"demandHero": {
"model": DemandHero,
"entity": "demand_hero",
@@ -142,61 +110,6 @@ SITE_CONFIG_MODULES = {
"ordered": False,
"singleton": True,
},
"demandRecommendations": {
"model": DemandRecommendation,
"entity": "demand_recommendation",
"primary": "title",
"fields": {"title", "subtitle", "productIds", "isActive", "sortOrder"},
"none_to_empty": set(),
"empty_to_none": {"subtitle"},
"create_defaults": {"subtitle": None},
},
"map": {
"model": MapImage,
"entity": "map_image",
"primary": "image",
"fields": {"image", "isActive"},
"none_to_empty": set(),
"create_defaults": {},
"ordered": False,
"singleton": True,
},
"themes": {
"model": ThemeCard,
"entity": "theme_card",
"primary": "label",
"fields": {"label", "image", "targetType", "targetValue", "isActive", "sortOrder"},
"none_to_empty": {"image"},
"create_defaults": {"image": ""},
},
"campaigns": {
"model": Campaign,
"entity": "campaign",
"primary": "title",
"fields": {"title", "slug", "description", "coverImage", "priceAmount", "priceUnit", "tags", "status", "startsAt", "endsAt"},
"none_to_empty": set(),
"empty_to_none": {"description", "coverImage", "priceUnit", "startsAt", "endsAt"},
"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": {},
},
"hotelGroups": {
"model": HotelGroup,
"entity": "hotel_group",
"primary": "title",
"fields": {"title", "description", "image", "coverImage", "priceAmount", "priceUnit", "tags", "status", "isActive", "sortOrder"},
"none_to_empty": set(),
"empty_to_none": {"description", "image", "coverImage", "priceUnit"},
"create_defaults": {"description": None, "image": None, "coverImage": None, "priceAmount": None, "priceUnit": "起/晚", "tags": [], "status": "published"},
},
"vehicleOptions": {
"model": VehicleOption,
"entity": "vehicle_option",
@@ -206,19 +119,8 @@ SITE_CONFIG_MODULES = {
"empty_to_none": {"description", "image"},
"create_defaults": {"description": None, "image": None},
},
"ctaBanners": {
"model": CtaBanner,
"entity": "cta_banner",
"primary": "alt",
"fields": {"alt", "image", "targetType", "targetValue", "isActive", "sortOrder"},
"none_to_empty": {"image", "targetType"},
"create_defaults": {"image": "", "targetType": ""},
},
}
PUBLISH_STATUSES = {"draft", "published"}
def media_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 {}})
@@ -347,34 +249,6 @@ def upload_image_to_oss(file_obj, key: str, mime_type: str, size_bytes: int) ->
return urlunsplit((scheme, host, f"/{quoted_key}", "", ""))
def normalize_images(images):
return [
{"url": image.url.strip(), "alt": image.alt.strip() if image.alt else None, "sortOrder": index}
for index, image in enumerate(images or [])
if image.url.strip()
]
def normalize_detail_sections(sections):
normalized = []
for section in sections or []:
blocks = []
for block in section.blocks:
if block.type == "image":
url = block.url.strip()
if url:
blocks.append({"type": "image", "url": url, "alt": block.alt.strip() if block.alt else None})
else:
text = block.text.strip()
if text:
blocks.append({"type": "text", "text": text})
key = section.key.strip()
label = section.label.strip()
if key and label and blocks:
normalized.append({"key": key, "label": label, "title": section.title.strip() if section.title else None, "blocks": blocks})
return normalized
def audit(db: Session, actor_id: str | None, action: str, entity: str, entity_id: str | None = None, after=None, before=None) -> None:
db.add(
AuditLog(
@@ -399,23 +273,10 @@ def site_module(module: str) -> dict:
return config
def site_slugify(value: str) -> str:
return quote(value, safe="").replace("%", "").lower()
def clean_site_value(value):
return value.strip() if isinstance(value, str) else value
def normalize_campaign_tags(value) -> list[str]:
if value is None:
return []
tags = [item.strip() for item in value if isinstance(item, str) and item.strip()]
if len(tags) > 3:
site_config_error(422, "tags cannot exceed 3", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "tags", "max": 3})
return tags
def normalize_string_list(value) -> list[str]:
if value is None:
return []
@@ -423,8 +284,6 @@ def normalize_string_list(value) -> list[str]:
def site_field_value(config: dict, field: str, value):
if field == "tags":
return normalize_campaign_tags(value)
if field in {"steps", "chips"}:
return normalize_string_list(value)
value = clean_site_value(value)
@@ -435,13 +294,6 @@ def site_field_value(config: dict, field: str, value):
return value
def validate_publish_status(value):
status_value = clean_site_value(value)
if status_value not in PUBLISH_STATUSES:
site_config_error(422, "status must be draft or published", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "status"})
return status_value
def validate_site_primary(config: dict, body: SiteConfigPatchIn) -> str:
primary = config["primary"]
value = clean_site_value(getattr(body, primary))
@@ -468,37 +320,15 @@ def hero_slide_admin_dict(item: HeroSlide) -> dict:
}
def map_image_admin_dict(item: MapImage) -> dict:
return {
"id": item.id,
"image": item.image or None,
"isActive": item.isActive,
"createdAt": encode_value(item.createdAt),
"updatedAt": encode_value(item.updatedAt),
}
def site_item_dict(module: str, item) -> dict:
if module == "heroSlides":
return hero_slide_admin_dict(item)
if module == "map":
return map_image_admin_dict(item)
if module == "routeSections":
return route_section_dict(item)
if module == "demandRecommendations":
return demand_recommendation_dict(item)
if module == "hotelGroups":
return hotel_group_dict(item)
return destination_dict(item) if module == "destinations" else model_dict(item)
return 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 model is DemandRecommendation:
stmt = stmt.options(selectinload(DemandRecommendation.products).selectinload(DemandRecommendationProduct.product))
if config.get("ordered", True):
stmt = stmt.order_by(model.sortOrder.asc())
else:
@@ -516,7 +346,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 and field != "productIds"
if field in fields
}
payload[config["primary"]] = validate_site_primary(config, body)
for field, value in config["create_defaults"].items():
@@ -525,29 +355,6 @@ def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db:
payload["isActive"] = payload.get("isActive", True)
if config.get("ordered", True) and payload.get("sortOrder") is None:
payload["sortOrder"] = next_site_sort_order(db, config["model"])
if module == "destinations":
slug = clean_site_value(payload.get("slug"))
payload["slug"] = slug or site_slugify(payload["name"])
if module == "campaigns":
slug = clean_site_value(payload.get("slug"))
if not slug:
site_config_error(422, "required field is empty", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "slug"})
payload["slug"] = slug
if not payload.get("priceUnit"):
payload["priceUnit"] = "起/人"
payload["status"] = validate_publish_status(payload.get("status", "draft"))
if module == "hotelGroups":
if not payload.get("priceUnit"):
payload["priceUnit"] = "起/晚"
payload["status"] = validate_publish_status(payload.get("status", "published"))
if not payload.get("coverImage") and payload.get("image"):
payload["coverImage"] = payload["image"]
if not payload.get("image") and payload.get("coverImage"):
payload["image"] = payload["coverImage"]
if "status" in fields and "isActive" not in fields:
payload["isActive"] = payload["status"] == "published"
if "isActive" in fields and "status" not in fields:
payload["status"] = "published" if payload.get("isActive") else "draft"
return payload
@@ -556,64 +363,10 @@ 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})
if module == "destinations" and field == "slug" and not value:
site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field})
if module == "campaigns" and field == "slug" and not value:
site_config_error(422, "required field is empty", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field})
if module in {"campaigns", "hotelGroups"} and field == "status":
value = validate_publish_status(value)
setattr(item, field, value)
if module == "hotelGroups":
if "coverImage" in fields and "image" not in fields:
item.image = item.coverImage
if "image" in fields and "coverImage" not in fields:
item.coverImage = item.image
if "status" in fields and "isActive" not in fields:
item.isActive = item.status == "published"
if "isActive" in fields and "status" not in fields:
item.status = "published" if item.isActive else "draft"
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)
.options(selectinload(Product.destination).selectinload(Destination.aliases), selectinload(Product.images))
.where(Product.id == product_id)
).one()
@router.post("/auth/login")
@@ -635,150 +388,26 @@ def me(user: AdminUser = Depends(require_admin)):
@router.get("/dashboard")
def dashboard(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
stats = {
"productCount": db.scalar(select(func.count()).select_from(Product)),
"publishedProductCount": db.scalar(select(func.count()).select_from(Product).where(Product.status == "published")),
"destinationCount": db.scalar(select(func.count()).select_from(Destination).where(Destination.isActive.is_(True))),
"newLeadCount": db.scalar(select(func.count()).select_from(Lead).where(Lead.status == "new")),
"leadCount": db.scalar(select(func.count()).select_from(Lead)),
"campaignCount": db.scalar(select(func.count()).select_from(Campaign)),
}
recent = db.scalars(
select(Lead).options(selectinload(Lead.sourceProduct), selectinload(Lead.assignedUser)).order_by(Lead.createdAt.desc()).limit(5)
select(Lead).options(selectinload(Lead.assignedUser)).order_by(Lead.createdAt.desc()).limit(5)
).all()
return {"stats": stats, "recentLeads": [lead_dict(lead) for lead in recent]}
@router.get("/products")
def list_products(
keyword: str | None = None,
status_value: ProductStatus | None = Query(default=None, alias="status"),
take: int = Query(default=100, ge=1, le=200),
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
query = AdminProductQuery(keyword=keyword, status=status_value, take=take)
stmt = (
select(Product)
.options(selectinload(Product.destination).selectinload(Destination.aliases), selectinload(Product.images))
.order_by(Product.sortWeight.asc(), Product.updatedAt.desc())
.limit(query.take)
)
if query.status:
stmt = stmt.where(Product.status == query.status)
if query.keyword:
pattern = f"%{query.keyword}%"
stmt = stmt.where(or_(Product.title.ilike(pattern), Product.subtitle.ilike(pattern), Product.tags.any(query.keyword)))
products = db.scalars(stmt).unique().all()
return {"items": [admin_product_dict(product) for product in products]}
@router.post("/products", status_code=status.HTTP_201_CREATED)
def create_product(body: ProductCreateIn, request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
images = normalize_images(body.images)
sections = normalize_detail_sections(body.detailSections)
payload = body.model_dump(exclude={"images", "detailSections"})
payload["priceUnit"] = body.priceUnit or "起/人"
payload["detailSections"] = sections or None
payload["publishedAt"] = utc_now() if body.status == "published" else None
product = Product(**payload)
db.add(product)
db.flush()
for image in images:
db.add(ProductImage(productId=product.id, **image))
create_media(db, image["url"], "product-detail", image["alt"] or product.title)
db.flush()
product = load_product(db, product.id)
audit(db, get_actor_id(request), "create", "product", product.id, admin_product_dict(product))
db.commit()
return admin_product_dict(product)
@router.patch("/products/{product_id}")
def update_product(product_id: str, body: ProductUpdateIn, request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
product = db.scalars(
select(Product)
.options(selectinload(Product.images), selectinload(Product.destination).selectinload(Destination.aliases))
.where(Product.id == product_id)
).first()
if not product:
raise HTTPException(status_code=404, detail="线路不存在")
before = admin_product_dict(product)
fields = body.model_fields_set
payload = body.model_dump(exclude={"images", "detailSections"}, exclude_unset=True)
for key, value in payload.items():
setattr(product, key, value)
if "detailSections" in fields:
product.detailSections = normalize_detail_sections(body.detailSections)
if "status" in fields and body.status == "published" and before.get("status") != "published":
product.publishedAt = utc_now()
if "images" in fields:
db.execute(delete(ProductImage).where(ProductImage.productId == product.id))
for image in normalize_images(body.images):
db.add(ProductImage(productId=product.id, **image))
create_media(db, image["url"], "product-detail", image["alt"] or product.title)
db.flush()
product = load_product(db, product.id)
audit(db, get_actor_id(request), "update", "product", product.id, admin_product_dict(product), before)
db.commit()
return admin_product_dict(product)
@router.get("/destinations")
def admin_destinations(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
destinations = db.scalars(
select(Destination).options(selectinload(Destination.aliases)).order_by(Destination.sortOrder.asc())
).all()
counts = {}
if destinations:
rows = db.execute(
select(Product.destinationId, func.count(Product.id))
.where(Product.destinationId.in_([destination.id for destination in destinations]))
.group_by(Product.destinationId)
).all()
counts = {destination_id: count for destination_id, count in rows if destination_id}
return {"items": [destination_dict(destination, include_count=True, product_count=counts.get(destination.id, 0)) for destination in destinations]}
@router.get("/site-config")
def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
destinations = db.scalars(
select(Destination).options(selectinload(Destination.aliases)).order_by(Destination.sortOrder.asc())
).all()
return {
"heroSlides": [
hero_slide_admin_dict(item)
for item in db.scalars(select(HeroSlide).order_by(HeroSlide.sortOrder.asc())).all()
],
"destinations": [destination_dict(item) for item in destinations],
"destinationHero": [
model_dict(item)
for item in db.scalars(select(DestinationHero).order_by(DestinationHero.sortOrder.asc())).all()
],
"destinationRegions": [
model_dict(item)
for item in db.scalars(select(DestinationRegion).order_by(DestinationRegion.sortOrder.asc())).all()
],
"map": [
map_image_admin_dict(item)
for item in db.scalars(select(MapImage).order_by(MapImage.createdAt.asc())).all()
],
"themes": [
model_dict(item)
for item in db.scalars(select(ThemeCard).order_by(ThemeCard.sortOrder.asc())).all()
],
"ctaBanners": [
model_dict(item)
for item in db.scalars(select(CtaBanner).order_by(CtaBanner.sortOrder.asc())).all()
],
"campaigns": [
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()],
"hotelGroups": [
hotel_group_dict(item)
for item in db.scalars(select(HotelGroup).order_by(HotelGroup.sortOrder.asc())).all()
],
"vehicleOptions": [
model_dict(item)
for item in db.scalars(select(VehicleOption).order_by(VehicleOption.sortOrder.asc())).all()
@@ -795,7 +424,6 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D
model_dict(item)
for item in db.scalars(select(DemandForm).order_by(DemandForm.createdAt.asc())).all()
],
"demandRecommendations": [demand_recommendation_dict(item) for item in db.scalars(demand_recommendation_query()).all()],
}
@@ -808,20 +436,13 @@ 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):
if module == "map":
site_config_error(409, "地图图片已存在", "MAP_IMAGE_ALREADY_EXISTS", {"module": module})
site_config_error(409, "单例配置已存在", "MODULE_CONFIG_SINGLETON_EXISTS", {"module": module})
item = config["model"](**site_create_payload(module, config, body, db))
db.add(item)
db.flush()
if module == "demandRecommendations" and "productIds" in body.model_fields_set:
replace_demand_recommendation_products(db, item, body.productIds or [])
db.flush()
after = site_item_dict(module, item)
audit(db, get_actor_id(request), "create", config["entity"], item.id, after)
db.commit()
@@ -874,10 +495,6 @@ 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 [])
if module == "demandRecommendations" and "productIds" in body.model_fields_set:
replace_demand_recommendation_products(db, item, body.productIds or [])
db.flush()
after = site_item_dict(module, item)
audit(db, get_actor_id(request), "update", config["entity"], item.id, after, before)
@@ -899,10 +516,6 @@ def delete_site_config(
item = db.get(config["model"], item_id)
if not item:
site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id})
if module == "destinations":
product_count = db.scalar(select(func.count()).select_from(Product).where(Product.destinationId == item.id))
if product_count:
site_config_error(409, "配置项仍被商品引用,不能删除", "MODULE_CONFIG_CONFLICT", {"module": module, "id": item_id})
before = site_item_dict(module, item)
db.delete(item)
db.flush()
@@ -935,7 +548,7 @@ def list_leads(
)
stmt = (
select(Lead)
.options(selectinload(Lead.sourceProduct), selectinload(Lead.assignedUser))
.options(selectinload(Lead.assignedUser))
.order_by(Lead.createdAt.desc())
.limit(query.take)
)
@@ -949,18 +562,13 @@ def list_leads(
stmt = stmt.where(Lead.createdAt < query.createdTo)
if query.keyword and query.keyword.strip():
pattern = f"%{query.keyword.strip()}%"
stmt = (
stmt.outerjoin(Lead.sourceProduct)
.where(
or_(
Lead.phone.ilike(pattern),
Lead.destination.ilike(pattern),
Lead.note.ilike(pattern),
Product.title.ilike(pattern),
)
stmt = stmt.where(
or_(
Lead.phone.ilike(pattern),
Lead.destination.ilike(pattern),
Lead.note.ilike(pattern),
)
.distinct()
)
).distinct()
leads = db.scalars(stmt).all()
return {"items": [lead_dict(lead) for lead in leads]}