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 from .menu_rules import normalize_menu_values LeadStatus = Literal["new", "assigned", "contacted", "planning", "won", "invalid"] LeadType = Literal["general", "vehicle"] VehicleServiceType = Literal["charter", "transfer"] CharterDuration = Literal["halfDay", "fullDay"] BrowseHistoryType = Literal["wanfa-route", "team-building", "wild-archive"] PriceUnit = Literal["person", "day", "group"] class LoginIn(BaseModel): email: EmailStr password: str = Field(min_length=6) captchaId: str = Field(min_length=1, max_length=64) captchaCode: str = Field(min_length=4, max_length=8) rememberMe: bool = False @field_validator("captchaId") @classmethod def normalize_captcha_id(cls, value: str) -> str: normalized = value.strip() if not normalized: raise ValueError("验证码不能为空") return normalized @field_validator("captchaCode") @classmethod def normalize_captcha_code(cls, value: str) -> str: normalized = value.strip().upper() if not normalized: raise ValueError("验证码不能为空") return normalized 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 BrowseHistoryCreateIn(BaseModel): itemType: BrowseHistoryType itemId: str = Field(min_length=1, max_length=128) @field_validator("itemId") @classmethod def normalize_item_id(cls, value: str) -> str: normalized = value.strip() if not normalized: raise ValueError("内容 ID 不能为空") return normalized 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 def normalize_optional_price_text(value: str | None) -> str | None: if value is None: return None normalized = normalize_optional_text(value) return normalized or None 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) priceStartingValue: float | None = Field(default=None, ge=0) priceUnit: PriceUnit | None = None pricePeopleRange: str | None = Field(default=None, max_length=80) priceDescription: str | None = Field(default=None, max_length=500) 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("pricePeopleRange", "priceDescription", mode="before") @classmethod def normalize_price_text(cls, value: str | None) -> str | None: return normalize_optional_price_text(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 priceStartingValue: float | None = Field(default=None, ge=0) priceUnit: PriceUnit | None = None pricePeopleRange: str | None = Field(default=None, max_length=80) priceDescription: str | None = Field(default=None, max_length=500) 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("pricePeopleRange", "priceDescription", mode="before") @classmethod def normalize_optional_price_text_fields(cls, value: str | None) -> str | None: return normalize_optional_price_text(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 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] DataScope = Literal["all", "dept", "dept_and_children", "custom_dept", "self"] MenuType = Literal["directory", "page", "button"] class AdminRoleCreate(BaseModel): name: str = Field(min_length=1, max_length=80) code: str = Field(min_length=1, max_length=80, pattern=r"^[a-z][a-z0-9_:-]*$") dataScope: DataScope = "self" description: str | None = Field(default=None, max_length=500) isActive: bool = True menuIds: list[str] = Field(default_factory=list, max_length=500) deptIds: list[str] = Field(default_factory=list, max_length=500) class AdminRolePatch(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=80) code: str | None = Field(default=None, min_length=1, max_length=80, pattern=r"^[a-z][a-z0-9_:-]*$") dataScope: DataScope | None = None description: str | None = Field(default=None, max_length=500) isActive: bool | None = None menuIds: list[str] | None = Field(default=None, max_length=500) deptIds: list[str] | None = Field(default=None, max_length=500) class AdminMenuCreate(BaseModel): parentId: str | None = None name: str = Field(min_length=1, max_length=80) type: MenuType = "page" path: str | None = Field(default=None, max_length=200) componentKey: str | None = Field(default=None, max_length=120) permissionCode: str | None = Field(default=None, max_length=120) icon: str | None = Field(default=None, max_length=80) sortOrder: int = Field(default=0, ge=0) isVisible: bool = True isActive: bool = True @model_validator(mode="after") def validate_menu_fields(self): normalized = normalize_menu_values(self.model_dump()) for field, value in normalized.items(): setattr(self, field, value) return self class AdminMenuPatch(BaseModel): parentId: str | None = None name: str | None = Field(default=None, min_length=1, max_length=80) type: MenuType | None = None path: str | None = Field(default=None, max_length=200) componentKey: str | None = Field(default=None, max_length=120) permissionCode: str | None = Field(default=None, max_length=120) icon: str | None = Field(default=None, max_length=80) sortOrder: int | None = Field(default=None, ge=0) isVisible: bool | None = None isActive: bool | None = None @model_validator(mode="after") def normalize_menu_fields(self): normalized = normalize_menu_values(self.model_dump(exclude_unset=True), partial=True) for field, value in normalized.items(): setattr(self, field, value) return self class AdminDepartmentCreate(BaseModel): parentId: str | None = None name: str = Field(min_length=1, max_length=120) sortOrder: int = Field(default=0, ge=0) isActive: bool = True class AdminDepartmentPatch(BaseModel): parentId: str | None = None name: str | None = Field(default=None, min_length=1, max_length=120) sortOrder: int | None = Field(default=None, ge=0) isActive: bool | None = None class AdminUserCreate(BaseModel): email: EmailStr name: str = Field(min_length=1, max_length=80) password: str = Field(min_length=8, max_length=128) isActive: bool = True roleIds: list[str] = Field(default_factory=list, max_length=100) deptIds: list[str] = Field(default_factory=list, max_length=100) class AdminUserPatch(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=80) password: str | None = Field(default=None, min_length=8, max_length=128) isActive: bool | None = None roleIds: list[str] | None = Field(default=None, max_length=100) deptIds: list[str] | None = Field(default=None, max_length=100)