Files
WonderQ-Project/WonderQ-Admin/app/schemas.py
duanshuwen d42f69bd47 feat: 新增用车需求服务及线索管理全功能
- 新增后台用车服务配置模块,支持维护服务简介、优势与使用流程
- 新增需求线索管理页面,支持筛选、查看与更新用车线索状态
- 新增小程序用车需求页面,优化登录路径与回跳逻辑
- 优化车型卡片跳转与用车需求提交功能
- 新增服务端API与数据处理逻辑,完善权限校验
- 更新全站配置与API文档,新增相关测试用例
2026-08-22 21:48:08 +08:00

661 lines
25 KiB
Python

from datetime import date, datetime, timedelta
from typing import Literal
from urllib.parse import urlsplit
from pydantic import BaseModel, EmailStr, Field, ValidationInfo, field_validator, model_validator
LeadStatus = Literal["new", "assigned", "contacted", "planning", "won", "invalid"]
LeadType = Literal["general", "vehicle"]
VehicleServiceType = Literal["charter", "transfer"]
CharterDuration = Literal["halfDay", "fullDay"]
class LoginIn(BaseModel):
email: EmailStr
password: str = Field(min_length=6)
class PhoneLoginIn(BaseModel):
code: str = Field(min_length=1, max_length=256)
class VehicleDemandIn(BaseModel):
serviceType: VehicleServiceType
charterDuration: CharterDuration | None = None
travelDate: date
pickupTime: str | None = Field(default=None, max_length=32)
pickupLocation: str = Field(min_length=1, max_length=200)
dropoffLocation: str = Field(min_length=1, max_length=200)
peopleCount: int = Field(gt=0, le=1000)
luggageCount: int | None = Field(default=None, ge=0, le=1000)
vehicleOptionId: str | None = Field(default=None, max_length=128)
vehicleOptionTitle: str | None = Field(default=None, max_length=128)
specialRequirements: str | None = Field(default=None, max_length=1000)
@field_validator("pickupTime", "vehicleOptionId", "vehicleOptionTitle", "specialRequirements", mode="before")
@classmethod
def normalize_optional_text(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = " ".join(value.strip().split())
return normalized or None
@field_validator("pickupLocation", "dropoffLocation")
@classmethod
def normalize_location(cls, value: str) -> str:
normalized = " ".join(value.strip().split())
if not normalized:
raise ValueError("地点不能为空")
return normalized
@model_validator(mode="after")
def validate_charter_duration(self):
if self.serviceType == "charter" and not self.charterDuration:
raise ValueError("包车服务必须选择半天或全天")
if self.serviceType == "transfer":
self.charterDuration = None
return self
class LeadCreateIn(BaseModel):
leadType: LeadType = "general"
contactName: str | None = Field(default=None, max_length=80)
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
vehicleDemand: VehicleDemandIn | None = None
@field_validator("contactName", "destination", "sourcePage", mode="before")
@classmethod
def normalize_optional_text(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = " ".join(value.strip().split())
return normalized or 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
@model_validator(mode="after")
def validate_vehicle_demand(self):
if self.leadType == "vehicle" and self.vehicleDemand is None:
raise ValueError("用车需求不能为空")
if self.leadType == "general":
self.vehicleDemand = None
return self
class LeadStatusIn(BaseModel):
status: LeadStatus
class LeadQuery(BaseModel):
leadType: LeadType | None = None
status: LeadStatus | None = None
sourcePage: str | None = None
keyword: str | None = None
createdFrom: datetime | None = None
createdTo: datetime | None = None
take: int = Field(default=100, ge=1, le=200)
@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
introTitle: str | None = None
intro: str | None = None
serviceSections: list[dict[str, str]] | None = None
advantages: list[str] | None = None
processSteps: list[dict[str, 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)
conciergeAdvisorId: str | None = Field(default=None, max_length=120)
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)
@field_validator("conciergeAdvisorId", mode="before")
@classmethod
def normalize_concierge_advisor_id(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
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
conciergeAdvisorId: str | None = Field(default=None, max_length=120)
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)
@field_validator("conciergeAdvisorId", mode="before")
@classmethod
def normalize_optional_concierge_advisor_id(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
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]