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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user