Files
WonderQ-Project/WonderQ-Admin/app/schemas.py
duanshuwen c143b67275 feat(route-detail): 实现路线详情独立管理全链路功能
添加`DetailRecord`数据库模型、迁移脚本及完整的CRUD管理接口,将路线详情从玩法路线表解耦,实现独立存储。
在Admin UI的路线编辑器中集成详情配置面板,支持编辑详情文案、图片等展示内容。
完善MiniAPP路线详情页,新增导航函数、API调用及数据归一化逻辑,并添加对应单元测试。
更新所有相关文档,明确领域边界、联调流程及接口契约规范。
调整首页和玩法页的点击跳转逻辑,优先跳转路线详情页而非原需求页。
新增数值格式化工具函数优化内容展示效果。
2026-08-19 21:20:51 +08:00

578 lines
21 KiB
Python

from datetime import datetime, timedelta
from typing import Literal
from urllib.parse import urlsplit
from pydantic import BaseModel, EmailStr, Field, ValidationInfo, field_validator
LeadStatus = Literal["new", "assigned", "contacted", "planning", "won", "invalid"]
class LoginIn(BaseModel):
email: EmailStr
password: str = Field(min_length=6)
class PhoneLoginIn(BaseModel):
code: str = Field(min_length=1, max_length=256)
class LeadCreateIn(BaseModel):
destination: str | None = None
phone: str = Field(min_length=2, max_length=64)
travelDate: datetime | None = None
peopleCount: int | None = Field(default=None, gt=0)
budgetMin: int | None = Field(default=None, ge=0)
budgetMax: int | None = Field(default=None, ge=0)
note: str | None = Field(default=None, max_length=1000)
sourcePage: str | None = None
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str) -> str:
normalized = " ".join(value.strip().split())
if not normalized:
raise ValueError("联系方式不能为空")
return normalized
@field_validator("travelDate", mode="before")
@classmethod
def parse_date_only_travel_date(cls, value):
if isinstance(value, str) and len(value) == 10:
try:
return datetime.strptime(value, "%Y-%m-%d")
except ValueError:
return value
return value
class LeadStatusIn(BaseModel):
status: LeadStatus
class LeadQuery(BaseModel):
status: LeadStatus | None = None
sourcePage: str | None = None
keyword: str | None = None
createdFrom: datetime | None = None
createdTo: datetime | None = None
take: int = Field(default=100, ge=1, le=200)
@field_validator("createdFrom", "createdTo", mode="before")
@classmethod
def parse_date_only_range(cls, value, info: ValidationInfo):
if isinstance(value, str) and len(value) == 10:
try:
parsed = datetime.strptime(value, "%Y-%m-%d")
return parsed + timedelta(days=1) if info.field_name == "createdTo" else parsed
except ValueError:
return value
return value
class SiteConfigPatchIn(BaseModel):
title: str | None = None
kicker: str | None = None
subtitle: str | None = None
image: str | None = None
description: str | None = None
steps: list[str] | None = None
destinationLabel: str | None = None
destinationPlaceholder: str | None = None
phoneLabel: str | None = None
phonePlaceholder: str | None = None
noteLabel: str | None = None
notePlaceholder: str | None = None
submitLabel: str | None = None
chips: list[str] | None = None
targetType: str | None = None
targetValue: str | None = None
isActive: bool | None = None
sortOrder: int | None = None
class SiteConfigReorderIn(BaseModel):
itemIds: list[str] = Field(default_factory=list)
def normalize_required_text(value: str) -> str:
normalized = " ".join(value.strip().split())
if not normalized:
raise ValueError("字段不能为空")
return normalized
def normalize_optional_text(value: str) -> str:
return " ".join(value.strip().split())
def normalize_required_url(value: str) -> str:
normalized = normalize_required_text(value)
parsed = urlsplit(normalized)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("图片地址必须是有效的 HTTP(S) URL")
return normalized
class WanfaCategoryCreate(BaseModel):
label: str = Field(min_length=1, max_length=40)
@field_validator("label")
@classmethod
def normalize_label(cls, value: str) -> str:
return normalize_required_text(value)
class WanfaCategoryPatch(BaseModel):
label: str | None = Field(default=None, min_length=1, max_length=40)
@field_validator("label", mode="before")
@classmethod
def normalize_label(cls, value: str | None) -> str:
if value is None:
raise ValueError("字段不能为空")
return normalize_required_text(value)
class WanfaRouteCreate(BaseModel):
title: str = Field(min_length=1, max_length=80)
subtitle: str = Field(min_length=1, max_length=160)
image: str = Field(min_length=1, max_length=2048)
routeCount: int = Field(ge=0)
demandKeyword: str = Field(min_length=1, max_length=60)
@field_validator("title", "subtitle", "image", "demandKeyword")
@classmethod
def normalize_route_text(cls, value: str) -> str:
return normalize_required_text(value)
class WanfaRoutePatch(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=80)
subtitle: str | None = Field(default=None, min_length=1, max_length=160)
image: str | None = Field(default=None, min_length=1, max_length=2048)
routeCount: int | None = Field(default=None, ge=0)
demandKeyword: str | None = Field(default=None, min_length=1, max_length=60)
@field_validator("title", "subtitle", "image", "demandKeyword", mode="before")
@classmethod
def normalize_route_text(cls, value: str | None) -> str:
if value is None:
raise ValueError("字段不能为空")
return normalize_required_text(value)
class WanfaReorderIn(BaseModel):
itemIds: list[str] = Field(default_factory=list)
@field_validator("itemIds")
@classmethod
def validate_item_ids(cls, value: list[str]) -> list[str]:
if any(not item.strip() for item in value):
raise ValueError("排序 ID 不能为空")
return [item.strip() for item in value]
def normalize_detail_list(value: list[str]) -> list[str]:
return [normalize_required_text(item) for item in value if item.strip()]
def normalize_detail_gallery(value: list[str]) -> list[str]:
normalized = [normalize_required_url(item) for item in value if item.strip()]
return normalized
class DetailCreate(BaseModel):
key: str = Field(min_length=1, max_length=120)
eyebrow: str = Field(min_length=1, max_length=80)
duration: str = Field(min_length=1, max_length=80)
title: str = Field(min_length=1, max_length=160)
subtitle: str = Field(min_length=1, max_length=240)
intro: str = Field(min_length=1, max_length=4000)
highlights: list[str] = Field(default_factory=list)
included: list[str] = Field(default_factory=list)
excluded: list[str] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
gallery: list[str] = Field(default_factory=list)
isActive: bool = True
sortOrder: int | None = Field(default=None, ge=0)
@field_validator("key", "eyebrow", "duration", "title", "subtitle", "intro")
@classmethod
def normalize_detail_text(cls, value: str) -> str:
return normalize_required_text(value)
@field_validator("highlights", "included", "excluded", "notes")
@classmethod
def normalize_detail_lists(cls, value: list[str]) -> list[str]:
return normalize_detail_list(value)
@field_validator("gallery")
@classmethod
def validate_detail_gallery(cls, value: list[str]) -> list[str]:
return normalize_detail_gallery(value)
class DetailPatch(BaseModel):
key: str | None = Field(default=None, min_length=1, max_length=120)
eyebrow: str | None = Field(default=None, min_length=1, max_length=80)
duration: str | None = Field(default=None, min_length=1, max_length=80)
title: str | None = Field(default=None, min_length=1, max_length=160)
subtitle: str | None = Field(default=None, min_length=1, max_length=240)
intro: str | None = Field(default=None, min_length=1, max_length=4000)
highlights: list[str] | None = None
included: list[str] | None = None
excluded: list[str] | None = None
notes: list[str] | None = None
gallery: list[str] | None = None
isActive: bool | None = None
sortOrder: int | None = Field(default=None, ge=0)
@field_validator("key", "eyebrow", "duration", "title", "subtitle", "intro", mode="before")
@classmethod
def normalize_optional_detail_text(cls, value: str | None) -> str | None:
if value is None:
raise ValueError("字段不能为空")
return normalize_required_text(value)
@field_validator("highlights", "included", "excluded", "notes", mode="before")
@classmethod
def normalize_optional_detail_lists(cls, value: list[str] | None) -> list[str] | None:
if value is None:
raise ValueError("字段不能为空")
return normalize_detail_list(value)
@field_validator("gallery", mode="before")
@classmethod
def validate_optional_detail_gallery(cls, value: list[str] | None) -> list[str] | None:
if value is None:
raise ValueError("字段不能为空")
return normalize_detail_gallery(value)
class HomeExperienceCreate(BaseModel):
badge: str = Field(min_length=1, max_length=40)
category: str = Field(min_length=1, max_length=80)
title: str = Field(min_length=1, max_length=120)
englishTitle: str = Field(min_length=1, max_length=120)
image: str = Field(min_length=1, max_length=2048)
demandKeyword: str = Field(min_length=1, max_length=120)
isActive: bool = True
sortOrder: int | None = Field(default=None, ge=0)
@field_validator("badge", "category", "title", "englishTitle", "demandKeyword")
@classmethod
def normalize_experience_text(cls, value: str) -> str:
return normalize_required_text(value)
@field_validator("image")
@classmethod
def normalize_experience_image(cls, value: str) -> str:
return normalize_required_url(value)
class HomeExperiencePatch(BaseModel):
badge: str | None = Field(default=None, min_length=1, max_length=40)
category: str | None = Field(default=None, min_length=1, max_length=80)
title: str | None = Field(default=None, min_length=1, max_length=120)
englishTitle: str | None = Field(default=None, min_length=1, max_length=120)
image: str | None = Field(default=None, min_length=1, max_length=2048)
demandKeyword: str | None = Field(default=None, min_length=1, max_length=120)
isActive: bool | None = None
sortOrder: int | None = Field(default=None, ge=0)
@field_validator("badge", "category", "title", "englishTitle", "demandKeyword", mode="before")
@classmethod
def normalize_experience_patch_text(cls, value: str | None) -> str:
if value is None:
raise ValueError("字段不能为空")
return normalize_required_text(value)
@field_validator("image", mode="before")
@classmethod
def normalize_experience_patch_image(cls, value: str | None) -> str:
if value is None:
raise ValueError("字段不能为空")
return normalize_required_url(value)
@field_validator("isActive", "sortOrder", mode="before")
@classmethod
def reject_experience_patch_null(cls, value):
if value is None:
raise ValueError("字段不能为空")
return value
class HomeTeamBuildingCreate(BaseModel):
tag: str = Field(min_length=1, max_length=40)
title: str = Field(min_length=1, max_length=120)
description: str = Field(min_length=1, max_length=240)
image: str = Field(min_length=1, max_length=2048)
demandKeyword: str = Field(min_length=1, max_length=120)
detailSubtitle: str | None = Field(default=None, max_length=160)
detailParagraphs: list[str] | None = Field(default=None, max_length=30)
isActive: bool = True
sortOrder: int | None = Field(default=None, ge=0)
@field_validator("tag", "title", "description", "demandKeyword")
@classmethod
def normalize_team_text(cls, value: str) -> str:
return normalize_required_text(value)
@field_validator("image")
@classmethod
def normalize_team_image(cls, value: str) -> str:
return normalize_required_url(value)
@field_validator("detailSubtitle", mode="before")
@classmethod
def normalize_team_detail_subtitle(cls, value: str | None) -> str | None:
if value is None:
return None
return normalize_optional_text(value)
@field_validator("detailParagraphs")
@classmethod
def normalize_team_detail_paragraphs(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
normalized = [normalize_required_text(item) for item in value if item.strip()]
return normalized or None
class HomeTeamBuildingPatch(BaseModel):
tag: str | None = Field(default=None, min_length=1, max_length=40)
title: str | None = Field(default=None, min_length=1, max_length=120)
description: str | None = Field(default=None, min_length=1, max_length=240)
image: str | None = Field(default=None, min_length=1, max_length=2048)
demandKeyword: str | None = Field(default=None, min_length=1, max_length=120)
detailSubtitle: str | None = Field(default=None, max_length=160)
detailParagraphs: list[str] | None = Field(default=None, max_length=30)
isActive: bool | None = None
sortOrder: int | None = Field(default=None, ge=0)
@field_validator("tag", "title", "description", "demandKeyword", mode="before")
@classmethod
def normalize_team_patch_text(cls, value: str | None) -> str:
if value is None:
raise ValueError("字段不能为空")
return normalize_required_text(value)
@field_validator("image", mode="before")
@classmethod
def normalize_team_patch_image(cls, value: str | None) -> str:
if value is None:
raise ValueError("字段不能为空")
return normalize_required_url(value)
@field_validator("detailSubtitle", mode="before")
@classmethod
def normalize_team_detail_patch_subtitle(cls, value: str | None) -> str | None:
if value is None:
raise ValueError("字段不能为空")
return normalize_optional_text(value)
@field_validator("detailParagraphs")
@classmethod
def normalize_team_detail_patch_paragraphs(cls, value: list[str] | None) -> list[str] | None:
if value is None:
raise ValueError("字段不能为空")
normalized = [normalize_required_text(item) for item in value if item.strip()]
if not normalized:
raise ValueError("详情正文至少需要一段")
return normalized
@field_validator("isActive", "sortOrder", mode="before")
@classmethod
def reject_team_patch_null(cls, value):
if value is None:
raise ValueError("字段不能为空")
return value
class HomeWildArchiveCreate(BaseModel):
title: str = Field(min_length=1, max_length=120)
image: str = Field(min_length=1, max_length=2048)
images: list[str] | None = Field(default=None, max_length=30)
demandKeyword: str = Field(min_length=1, max_length=120)
isActive: bool = True
sortOrder: int | None = Field(default=None, ge=0)
@field_validator("title", "demandKeyword")
@classmethod
def normalize_archive_text(cls, value: str) -> str:
return normalize_required_text(value)
@field_validator("image")
@classmethod
def normalize_archive_image(cls, value: str) -> str:
return normalize_required_url(value)
@field_validator("images")
@classmethod
def normalize_archive_images(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
normalized = [normalize_required_url(item) for item in value]
if not normalized:
raise ValueError("案例详情至少需要一张图片")
return normalized
class HomeWildArchivePatch(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=120)
image: str | None = Field(default=None, min_length=1, max_length=2048)
images: list[str] | None = Field(default=None, max_length=30)
demandKeyword: str | None = Field(default=None, min_length=1, max_length=120)
isActive: bool | None = None
sortOrder: int | None = Field(default=None, ge=0)
@field_validator("title", "demandKeyword", mode="before")
@classmethod
def normalize_archive_patch_text(cls, value: str | None) -> str:
if value is None:
raise ValueError("字段不能为空")
return normalize_required_text(value)
@field_validator("image", mode="before")
@classmethod
def normalize_archive_patch_image(cls, value: str | None) -> str:
if value is None:
raise ValueError("字段不能为空")
return normalize_required_url(value)
@field_validator("images")
@classmethod
def normalize_archive_patch_images(cls, value: list[str] | None) -> list[str] | None:
if value is None:
raise ValueError("案例详情至少需要一张图片")
normalized = [normalize_required_url(item) for item in value]
if not normalized:
raise ValueError("案例详情至少需要一张图片")
return normalized
@field_validator("isActive", "sortOrder", mode="before")
@classmethod
def reject_archive_patch_null(cls, value):
if value is None:
raise ValueError("字段不能为空")
return value
class HomeWanfaRecommendationCreate(BaseModel):
categoryId: str = Field(min_length=1, max_length=120)
isActive: bool = True
sortOrder: int | None = Field(default=None, ge=0)
@field_validator("categoryId")
@classmethod
def normalize_recommendation_category_id(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("玩法分类不能为空")
return value
class HomeWanfaRecommendationPatch(BaseModel):
categoryId: str | None = Field(default=None, min_length=1, max_length=120)
isActive: bool | None = None
sortOrder: int | None = Field(default=None, ge=0)
@field_validator("categoryId", mode="before")
@classmethod
def normalize_recommendation_category_patch_id(cls, value: str | None) -> str:
if value is None:
raise ValueError("玩法分类不能为空")
value = value.strip()
if not value:
raise ValueError("玩法分类不能为空")
return value
@field_validator("isActive", "sortOrder", mode="before")
@classmethod
def reject_recommendation_patch_null(cls, value):
if value is None:
raise ValueError("字段不能为空")
return value
class HomeReorderIn(BaseModel):
itemIds: list[str] = Field(default_factory=list)
@field_validator("itemIds")
@classmethod
def validate_home_item_ids(cls, value: list[str]) -> list[str]:
if any(not item.strip() for item in value):
raise ValueError("排序 ID 不能为空")
return [item.strip() for item in value]
class ConciergeDetailIn(BaseModel):
icon: str = Field(min_length=1, max_length=40)
label: str = Field(min_length=1, max_length=120)
@field_validator("icon", "label")
@classmethod
def normalize_detail_text(cls, value: str) -> str:
return normalize_required_text(value)
class ConciergeAdvisorCreate(BaseModel):
avatar: str = Field(min_length=1, max_length=2048)
name: str = Field(min_length=1, max_length=80)
role: str = Field(min_length=1, max_length=120)
details: list[dict[str, str]] = Field(default_factory=list, max_length=20)
qrImage: str = Field(min_length=1, max_length=2048)
isActive: bool = True
sortOrder: int | None = Field(default=None, ge=0)
@field_validator("name", "role")
@classmethod
def normalize_advisor_text(cls, value: str) -> str:
return normalize_required_text(value)
@field_validator("avatar", "qrImage")
@classmethod
def normalize_advisor_image_url(cls, value: str) -> str:
return normalize_required_url(value)
@field_validator("details")
@classmethod
def normalize_details(cls, value: list[dict[str, str]]) -> list[dict[str, str]]:
return [ConciergeDetailIn.model_validate(detail).model_dump() for detail in value]
class ConciergeAdvisorPatch(BaseModel):
avatar: str | None = Field(default=None, min_length=1, max_length=2048)
name: str | None = Field(default=None, min_length=1, max_length=80)
role: str | None = Field(default=None, min_length=1, max_length=120)
details: list[dict[str, str]] | None = Field(default=None, max_length=20)
qrImage: str | None = Field(default=None, min_length=1, max_length=2048)
isActive: bool | None = None
sortOrder: int | None = Field(default=None, ge=0)
@field_validator("name", "role", mode="before")
@classmethod
def normalize_patch_text(cls, value: str | None) -> str:
if value is None:
raise ValueError("字段不能为空")
return normalize_required_text(value)
@field_validator("avatar", "qrImage", mode="before")
@classmethod
def normalize_patch_image_url(cls, value: str | None) -> str:
if value is None:
raise ValueError("字段不能为空")
return normalize_required_url(value)
@field_validator("details", "sortOrder", "isActive", mode="before")
@classmethod
def reject_null_patch_values(cls, value):
if value is None:
raise ValueError("字段不能为空")
return value
@field_validator("details")
@classmethod
def normalize_patch_details(cls, value: list[dict[str, str]]) -> list[dict[str, str]]:
return [ConciergeDetailIn.model_validate(detail).model_dump() for detail in value]