feat: Add WonderQ-MiniAPP Public API documentation

- Introduced a comprehensive API contract for the WonderQ-MiniAPP, detailing endpoints for site configuration, product listings, and lead submissions.
- Defined data types for various entities including HeroSlide, Destination, Theme, CtaBanner, PublicProduct, and more.
- Specified request and response formats, including error handling guidelines.

chore: Update requirements to include python-multipart

- Added python-multipart dependency to requirements.txt for handling file uploads.

test: Implement API contract tests

- Created test suite for API contracts, validating serializers and endpoints for public products and leads.
- Included tests for destination and product serializers, ensuring correct data handling and validation.

test: Add configuration tests for OSS settings

- Implemented tests to verify that OSS settings are correctly loaded from environment variables.
This commit is contained in:
duanshuwen
2026-07-01 16:55:00 +08:00
parent 75f5a5a48b
commit a47b4b5dd0
14 changed files with 2210 additions and 104 deletions

View File

@@ -11,6 +11,10 @@ class Settings(BaseSettings):
log_level: str = Field(default="info")
port: int = Field(default=4000)
cors_origins: str = Field(default="*")
oss_access_key_id: str | None = Field(default=None)
oss_access_key_secret: str | None = Field(default=None)
oss_endpoint: str | None = Field(default=None)
oss_bucket_name: str | None = Field(default=None)
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

View File

@@ -28,6 +28,8 @@ def create_app() -> FastAPI:
@app.exception_handler(HTTPException)
async def http_exception_handler(_request: Request, exc: HTTPException):
if isinstance(exc.detail, dict) and "message" in exc.detail:
return JSONResponse(status_code=exc.status_code, content=exc.detail)
return JSONResponse(status_code=exc.status_code, content={"message": exc.detail})
@app.exception_handler(Exception)

View File

@@ -1,7 +1,17 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
import base64
import hashlib
import hmac
import http.client
import os
import re
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.orm import Session, selectinload
from ..auth import create_token, get_actor_id, require_admin, verify_password
from ..config import get_settings
from ..database import get_db
from ..models import (
AdminUser,
@@ -18,18 +28,190 @@ from ..models import (
ThemeCard,
utc_now,
)
from ..schemas import AdminProductQuery, LeadQuery, LeadStatusIn, LoginIn, ProductCreateIn, ProductUpdateIn, SiteConfigPatchIn
from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn
from ..seed import create_media, reset_guizhou_content
from ..serializers import destination_dict, encode_value, lead_dict, model_dict, product_dict
from ..serializers import admin_product_dict, destination_dict, encode_value, lead_dict, model_dict
from .shared import site_config
router = APIRouter(prefix="/api/admin")
MEDIA_UPLOAD_MAX_BYTES = 5 * 1024 * 1024
MEDIA_ALLOWED_MIME_TYPES = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
}
SITE_CONFIG_MODULES = {
"heroSlides": {
"model": HeroSlide,
"entity": "hero_slide",
"primary": "title",
"fields": {"title", "kicker", "image", "isActive", "sortOrder"},
"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},
},
"themes": {
"model": ThemeCard,
"entity": "theme_card",
"primary": "label",
"fields": {"label", "image", "targetType", "targetValue", "isActive", "sortOrder"},
"none_to_empty": {"image"},
"create_defaults": {"image": ""},
},
"ctaBanners": {
"model": CtaBanner,
"entity": "cta_banner",
"primary": "alt",
"fields": {"alt", "image", "targetType", "targetValue", "isActive", "sortOrder"},
"none_to_empty": {"image", "targetType"},
"create_defaults": {"image": "", "targetType": ""},
},
}
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 {}})
def normalize_media_group(group: str | None) -> str:
value = (group or "general").strip() or "general"
if len(value) > 32 or not re.fullmatch(r"[A-Za-z0-9_-]+", value):
media_error(400, "素材分组只能包含字母、数字、下划线和中划线", "MEDIA_UPLOAD_INVALID_GROUP", {"group": group})
return value
def safe_media_name(filename: str | None) -> str:
name = os.path.basename((filename or "upload").replace("\\", "/")).strip()
return name[:120] or "upload"
def detect_image_mime(head: bytes) -> str | None:
if head.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if head.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if head.startswith((b"GIF87a", b"GIF89a")):
return "image/gif"
if len(head) >= 12 and head[:4] == b"RIFF" and head[8:12] == b"WEBP":
return "image/webp"
return None
def inspect_upload_image(file: UploadFile) -> tuple[str, int]:
content_type = (file.content_type or "").split(";", 1)[0].strip().lower()
if content_type == "image/jpg":
content_type = "image/jpeg"
if content_type and content_type not in MEDIA_ALLOWED_MIME_TYPES and content_type != "application/octet-stream":
media_error(400, "仅支持上传 JPG、PNG、WebP 或 GIF 图片", "MEDIA_UPLOAD_INVALID_TYPE", {"mimeType": content_type})
file.file.seek(0, os.SEEK_END)
size_bytes = file.file.tell()
file.file.seek(0)
if size_bytes <= 0:
media_error(400, "上传文件不能为空", "MEDIA_UPLOAD_EMPTY_FILE")
if size_bytes > MEDIA_UPLOAD_MAX_BYTES:
media_error(
413,
"图片大小不能超过 5MB",
"MEDIA_UPLOAD_TOO_LARGE",
{"maxBytes": MEDIA_UPLOAD_MAX_BYTES, "sizeBytes": size_bytes},
)
head = file.file.read(512)
file.file.seek(0)
detected = detect_image_mime(head)
if not detected:
media_error(400, "仅支持上传 JPG、PNG、WebP 或 GIF 图片", "MEDIA_UPLOAD_INVALID_TYPE")
if content_type in MEDIA_ALLOWED_MIME_TYPES and content_type != detected:
media_error(
400,
"图片内容与文件类型不一致",
"MEDIA_UPLOAD_TYPE_MISMATCH",
{"mimeType": content_type, "detectedMimeType": detected},
)
return detected, size_bytes
def media_object_key(group: str, mime_type: str) -> str:
extension = MEDIA_ALLOWED_MIME_TYPES[mime_type]
return f"admin/{group}/{utc_now().strftime('%Y/%m/%d')}/{uuid4().hex}.{extension}"
def oss_settings():
settings = get_settings()
values = {
"OSS_ACCESS_KEY_ID": settings.oss_access_key_id,
"OSS_ACCESS_KEY_SECRET": settings.oss_access_key_secret,
"OSS_ENDPOINT": settings.oss_endpoint,
"OSS_BUCKET_NAME": settings.oss_bucket_name,
}
missing = [key for key, value in values.items() if not (value and value.strip())]
if missing:
media_error(503, "OSS 存储配置不完整", "MEDIA_STORAGE_NOT_CONFIGURED", {"missing": missing})
return settings
def normalized_oss_host(endpoint: str, bucket: str) -> tuple[str, str]:
raw = endpoint.strip().rstrip("/")
if "://" not in raw:
raw = f"https://{raw}"
parsed = urlsplit(raw)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
media_error(503, "OSS Endpoint 配置不正确", "MEDIA_STORAGE_INVALID_ENDPOINT")
host = parsed.netloc
if not host.startswith(f"{bucket}."):
host = f"{bucket}.{host}"
return parsed.scheme, host
def upload_image_to_oss(file_obj, key: str, mime_type: str, size_bytes: int) -> str:
settings = oss_settings()
bucket = settings.oss_bucket_name.strip()
scheme, host = normalized_oss_host(settings.oss_endpoint, bucket)
date = formatdate(usegmt=True)
canonical_resource = f"/{bucket}/{key}"
string_to_sign = f"PUT\n\n{mime_type}\n{date}\n{canonical_resource}"
signature = base64.b64encode(
hmac.new(settings.oss_access_key_secret.strip().encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha1).digest()
).decode("ascii")
quoted_key = quote(key, safe="/-_.~")
headers = {
"Authorization": f"OSS {settings.oss_access_key_id.strip()}:{signature}",
"Content-Type": mime_type,
"Content-Length": str(size_bytes),
"Date": date,
}
connection_class = http.client.HTTPSConnection if scheme == "https" else http.client.HTTPConnection
connection = connection_class(host, timeout=20)
try:
file_obj.seek(0)
connection.request("PUT", f"/{quoted_key}", body=file_obj, headers=headers)
response = connection.getresponse()
response.read(2048)
if response.status < 200 or response.status >= 300:
media_error(502, "OSS 上传失败", "MEDIA_STORAGE_UPLOAD_FAILED", {"status": response.status})
except OSError:
media_error(502, "OSS 上传失败", "MEDIA_STORAGE_UPLOAD_FAILED")
finally:
connection.close()
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": image.sortOrder if image.sortOrder is not None else index}
{"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()
]
@@ -68,10 +250,109 @@ def audit(db: Session, actor_id: str | None, action: str, entity: str, entity_id
)
def site_config_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 site_module(module: str) -> dict:
config = SITE_CONFIG_MODULES.get(module)
if not config:
site_config_error(400, "模块不存在或无权限操作", "MODULE_CONFIG_FORBIDDEN", {"module": module})
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 site_field_value(config: dict, field: str, value):
value = clean_site_value(value)
if value is None and field in config["none_to_empty"]:
return ""
return value
def validate_site_primary(config: dict, body: SiteConfigPatchIn) -> str:
primary = config["primary"]
value = clean_site_value(getattr(body, primary))
if not value:
site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": primary})
return value
def next_site_sort_order(db: Session, model) -> int:
highest = db.scalar(select(func.max(model.sortOrder)))
return (highest + 1) if highest is not None else 0
def hero_slide_admin_dict(item: HeroSlide) -> dict:
return {
"id": item.id,
"title": item.title,
"kicker": item.kicker or None,
"image": item.image or None,
"isActive": item.isActive,
"sortOrder": item.sortOrder,
"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)
return destination_dict(item) if module == "destinations" else model_dict(item)
def module_items(db: Session, model) -> list:
return db.scalars(select(model).order_by(model.sortOrder.asc())).all()
def normalize_site_sort_orders(items: list) -> None:
for index, item in enumerate(items):
item.sortOrder = index
def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db: Session) -> dict:
fields = body.model_fields_set
payload = {
field: site_field_value(config, field, getattr(body, field))
for field in config["fields"]
if field in fields
}
payload[config["primary"]] = validate_site_primary(config, body)
for field, value in config["create_defaults"].items():
payload.setdefault(field, value)
payload["isActive"] = payload.get("isActive", True)
if 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"])
return payload
def apply_site_patch(module: str, config: dict, item, body: SiteConfigPatchIn) -> None:
fields = body.model_fields_set
for field in config["fields"]:
if field not in fields:
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})
setattr(item, field, value)
def load_product(db: Session, product_id: str) -> Product:
return db.scalars(
select(Product)
.options(selectinload(Product.destination), selectinload(Product.images))
.options(selectinload(Product.destination).selectinload(Destination.aliases), selectinload(Product.images))
.where(Product.id == product_id)
).one()
@@ -111,20 +392,25 @@ def dashboard(_user: AdminUser = Depends(require_admin), db: Session = Depends(g
@router.get("/products")
def list_products(
keyword: str | None = None,
status_value: str | None = Query(default=None, alias="status"),
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(Product.images)).order_by(Product.sortWeight.asc(), Product.updatedAt.desc()).limit(query.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": [product_dict(product) for product in products]}
return {"items": [admin_product_dict(product) for product in products]}
@router.post("/products", status_code=status.HTTP_201_CREATED)
@@ -143,17 +429,21 @@ def create_product(body: ProductCreateIn, request: Request, _user: AdminUser = D
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, product_dict(product))
audit(db, get_actor_id(request), "create", "product", product.id, admin_product_dict(product))
db.commit()
return product_dict(product)
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)).where(Product.id == product_id)).first()
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 = product_dict(product)
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():
@@ -169,22 +459,94 @@ def update_product(product_id: str, body: ProductUpdateIn, request: Request, _us
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, product_dict(product), before)
audit(db, get_actor_id(request), "update", "product", product.id, admin_product_dict(product), before)
db.commit()
return product_dict(product)
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), selectinload(Destination.products)).order_by(Destination.sortOrder.asc())
select(Destination).options(selectinload(Destination.aliases)).order_by(Destination.sortOrder.asc())
).all()
return {"items": [destination_dict(destination, include_count=True) for destination in destinations]}
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)):
return site_config(db, active_only=False, include_public_extras=False)
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],
"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()
],
}
@router.post("/site-config/{module}", status_code=status.HTTP_201_CREATED)
def create_site_config(
module: str,
body: SiteConfigPatchIn,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
config = site_module(module)
item = config["model"](**site_create_payload(module, config, body, db))
db.add(item)
db.flush()
after = site_item_dict(module, item)
audit(db, get_actor_id(request), "create", config["entity"], item.id, after)
db.commit()
return after
@router.patch("/site-config/{module}/reorder")
def reorder_site_config(
module: str,
body: SiteConfigReorderIn,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
config = site_module(module)
items = module_items(db, config["model"])
current_ids = [item.id for item in items]
requested_ids = body.itemIds
if len(set(requested_ids)) != len(requested_ids) or set(requested_ids) != set(current_ids):
site_config_error(
400,
"排序配置项必须完整且不能重复",
"MODULE_CONFIG_REORDER_INVALID",
{"module": module, "itemIds": requested_ids},
)
items_by_id = {item.id: item for item in items}
ordered_items = [items_by_id[item_id] for item_id in requested_ids]
before = [site_item_dict(module, item) for item in items]
normalize_site_sort_orders(ordered_items)
after = [site_item_dict(module, item) for item in ordered_items]
audit(db, get_actor_id(request), "reorder", config["entity"], module, {"items": after}, {"items": before})
db.commit()
return {"items": after}
@router.patch("/site-config/{module}/{item_id}")
@@ -196,60 +558,48 @@ def update_site_config(
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
model_map = {"heroSlides": HeroSlide, "destinations": Destination, "themes": ThemeCard, "ctaBanners": CtaBanner}
if module not in model_map:
raise HTTPException(status_code=400, detail="维护模块不存在")
item = db.get(model_map[module], item_id)
config = site_module(module)
item = db.get(config["model"], item_id)
if not item:
raise HTTPException(status_code=404, detail="维护项不存在")
before = model_dict(item)
fields = body.model_fields_set
def set_if_present(field: str, attr: str | None = None, ignore_none: bool = False) -> None:
if field not in fields:
return
value = getattr(body, field)
if ignore_none and value is None:
return
setattr(item, attr or field, value)
if module == "heroSlides":
set_if_present("title")
set_if_present("kicker")
set_if_present("image", ignore_none=True)
set_if_present("targetType")
set_if_present("targetValue")
set_if_present("isActive")
entity = "hero_slide"
elif module == "destinations":
set_if_present("name")
set_if_present("image", ignore_none=True)
set_if_present("isActive")
entity = "destination"
elif module == "themes":
set_if_present("label")
set_if_present("image", ignore_none=True)
set_if_present("targetType")
set_if_present("targetValue")
set_if_present("isActive")
entity = "theme_card"
else:
set_if_present("alt")
set_if_present("image", ignore_none=True)
set_if_present("targetType", ignore_none=True)
set_if_present("targetValue")
set_if_present("isActive")
entity = "cta_banner"
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)
db.flush()
audit(db, get_actor_id(request), "update", entity, item.id, model_dict(item), before)
after = site_item_dict(module, item)
audit(db, get_actor_id(request), "update", config["entity"], item.id, after, before)
db.commit()
return model_dict(item)
return after
@router.delete("/site-config/{module}/{item_id}")
def delete_site_config(
module: str,
item_id: str,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
config = site_module(module)
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()
normalize_site_sort_orders(module_items(db, config["model"]))
result = {"id": item_id}
audit(db, get_actor_id(request), "delete", config["entity"], item_id, result, before)
db.commit()
return result
@router.get("/leads")
def list_leads(
status_value: str | None = Query(default=None, alias="status"),
status_value: LeadStatus | 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),
@@ -286,6 +636,33 @@ def list_media_assets(_user: AdminUser = Depends(require_admin), db: Session = D
return {"items": [model_dict(asset) for asset in assets]}
@router.post("/media-assets/upload", status_code=status.HTTP_201_CREATED)
def upload_media_asset(
request: Request,
file: UploadFile = File(...),
group: str | None = Form(default="general"),
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
safe_group = normalize_media_group(group)
mime_type, size_bytes = inspect_upload_image(file)
key = media_object_key(safe_group, mime_type)
url = upload_image_to_oss(file.file, key, mime_type, size_bytes)
asset = MediaAsset(
url=url,
name=safe_media_name(file.filename),
mimeType=mime_type,
sizeBytes=size_bytes,
group=safe_group,
)
db.add(asset)
db.flush()
after = model_dict(asset)
audit(db, get_actor_id(request), "upload", "media_asset", asset.id, after)
db.commit()
return after
@router.post("/reset-guizhou-content")
def reset_content(request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
result = reset_guizhou_content(db)

View File

@@ -4,8 +4,8 @@ from sqlalchemy.orm import Session, selectinload
from .shared import site_config
from ..database import get_db
from ..models import Destination, DestinationAlias, Lead, Product
from ..schemas import LeadCreateIn, ProductQuery
from ..serializers import destination_dict, product_dict
from ..schemas import LeadCreateIn, ProductQuery, ProductStatus
from ..serializers import destination_dict, public_product_dict
router = APIRouter()
@@ -25,7 +25,7 @@ def get_site_config(db: Session = Depends(get_db)):
def list_products(
keyword: str | None = None,
destinationId: str | None = None,
status_value: str = Query(default="published", alias="status"),
status_value: ProductStatus = Query(default="published", alias="status"),
take: int = Query(default=48, ge=1, le=100),
db: Session = Depends(get_db),
):
@@ -56,7 +56,7 @@ def list_products(
.distinct()
)
products = db.scalars(stmt).unique().all()
return {"items": [product_dict(product) for product in products]}
return {"items": [public_product_dict(product) for product in products]}
@router.get("/api/public/products/{product_id}")
@@ -69,7 +69,7 @@ def get_product(product_id: str, db: Session = Depends(get_db)):
product = db.scalars(stmt).first()
if not product:
raise HTTPException(status_code=404, detail="线路不存在")
return product_dict(product)
return public_product_dict(product)
@router.get("/api/public/destinations")
@@ -85,7 +85,7 @@ def list_destinations(db: Session = Depends(get_db)):
@router.post("/api/public/leads", status_code=status.HTTP_201_CREATED)
def create_lead(body: LeadCreateIn, db: Session = Depends(get_db)):
lead = Lead(**body.model_dump(exclude_none=True))
lead = Lead(**body.model_dump(exclude_none=True), status="new")
db.add(lead)
db.commit()
db.refresh(lead)

View File

@@ -3,33 +3,37 @@ from typing import Literal
from pydantic import BaseModel, EmailStr, Field, field_validator
ProductStatus = Literal["draft", "published", "archived"]
LeadStatus = Literal["new", "assigned", "contacted", "planning", "won", "invalid"]
class LoginIn(BaseModel):
email: EmailStr
password: str = Field(min_length=6)
class ProductImageIn(BaseModel):
url: str = Field(min_length=1)
url: str = ""
alt: str | None = None
sortOrder: int = 0
class TextBlock(BaseModel):
type: Literal["text"]
text: str = Field(min_length=1)
text: str = ""
class ImageBlock(BaseModel):
type: Literal["image"]
url: str = Field(min_length=1)
url: str = ""
alt: str | None = None
class ProductDetailSectionIn(BaseModel):
key: str = Field(min_length=1)
label: str = Field(min_length=1)
key: str = ""
label: str = ""
title: str | None = None
blocks: list[TextBlock | ImageBlock] = []
blocks: list[TextBlock | ImageBlock] = Field(default_factory=list)
class ProductCreateIn(BaseModel):
@@ -38,12 +42,12 @@ class ProductCreateIn(BaseModel):
destinationId: str | None = None
priceAmount: int | None = Field(default=None, ge=0)
priceUnit: str | None = None
tags: list[str] = []
tags: list[str] = Field(default_factory=list)
coverImage: str | None = None
summary: str | None = None
images: list[ProductImageIn] | None = None
detailSections: list[ProductDetailSectionIn] | None = None
status: Literal["draft", "published", "archived"] = "draft"
status: ProductStatus = "draft"
sortWeight: int = 0
@@ -58,7 +62,7 @@ class ProductUpdateIn(BaseModel):
summary: str | None = None
images: list[ProductImageIn] | None = None
detailSections: list[ProductDetailSectionIn] | None = None
status: Literal["draft", "published", "archived"] | None = None
status: ProductStatus | None = None
sortWeight: int | None = None
@@ -76,28 +80,41 @@ class LeadCreateIn(BaseModel):
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str) -> str:
return " ".join(value.strip().split())
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: Literal["new", "assigned", "contacted", "planning", "won", "invalid"]
status: LeadStatus
class ProductQuery(BaseModel):
keyword: str | None = None
destinationId: str | None = None
status: str = "published"
status: ProductStatus = "published"
take: int = Field(default=48, ge=1, le=100)
class AdminProductQuery(BaseModel):
keyword: str | None = None
status: str | None = None
status: ProductStatus | None = None
take: int = Field(default=100, ge=1, le=200)
class LeadQuery(BaseModel):
status: str | None = None
status: LeadStatus | None = None
take: int = Field(default=100, ge=1, le=200)
@@ -105,9 +122,17 @@ class SiteConfigPatchIn(BaseModel):
title: str | None = None
kicker: str | None = None
name: str | None = None
slug: str | None = None
region: str | None = None
label: str | None = None
alt: str | None = None
image: str | None = None
targetType: str | None = None
targetValue: str | None = None
isHot: bool | None = None
isActive: bool | None = None
sortOrder: int | None = None
class SiteConfigReorderIn(BaseModel):
itemIds: list[str] = Field(default_factory=list)

View File

@@ -20,20 +20,60 @@ def model_dict(instance, include: dict[str, object] | None = None) -> dict:
return data
def product_dict(product) -> dict:
def _sorted_aliases(destination) -> list:
return sorted(destination.aliases or [], key=lambda item: ((item.alias or "").lower(), item.id or ""))
def _sorted_images(product) -> list:
return sorted(product.images or [], key=lambda item: (item.sortOrder, item.id or "", item.url))
def _product_images(product) -> list[dict]:
return [model_dict(image) for image in _sorted_images(product)]
def _detail_sections(product) -> list:
return encode_value(product.detailSections or [])
def admin_product_dict(product) -> dict:
return model_dict(
product,
{
"destination": model_dict(product.destination) if product.destination else None,
"images": [model_dict(image) for image in sorted(product.images, key=lambda item: item.sortOrder)],
"tags": list(product.tags or []),
"detailSections": _detail_sections(product),
"destination": destination_dict(product.destination) if product.destination else None,
"images": _product_images(product),
},
)
def destination_dict(destination, include_count: bool = False) -> dict:
data = model_dict(destination, {"aliases": [model_dict(alias) for alias in destination.aliases]})
def public_product_dict(product) -> dict:
return {
"id": product.id,
"sourceId": product.sourceId,
"title": product.title,
"subtitle": product.subtitle,
"destination": {"id": product.destination.id, "name": product.destination.name} if product.destination else None,
"priceAmount": product.priceAmount,
"priceUnit": product.priceUnit,
"tags": list(product.tags or []),
"coverImage": product.coverImage,
"summary": product.summary,
"images": _product_images(product),
"detailSections": _detail_sections(product),
"status": product.status,
}
def product_dict(product) -> dict:
return admin_product_dict(product)
def destination_dict(destination, include_count: bool = False, product_count: int | None = None) -> dict:
data = model_dict(destination, {"aliases": [model_dict(alias) for alias in _sorted_aliases(destination)]})
if include_count:
data["_count"] = {"products": len(destination.products)}
data["_count"] = {"products": product_count if product_count is not None else len(destination.products or [])}
return data