diff --git a/.env.example b/.env.example index d597922..36782c9 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,7 @@ JWT_SECRET="replace-with-a-long-random-secret-before-production" PORT=4000 LOG_LEVEL="info" CORS_ORIGINS="*" +OSS_ACCESS_KEY_ID="your-oss-access-key-id" +OSS_ACCESS_KEY_SECRET="your-oss-access-key-secret" +OSS_ENDPOINT="your-oss-endpoint" +OSS_BUCKET_NAME="your-oss-bucket-name" diff --git a/README.md b/README.md index 6ba4f30..251f42c 100644 --- a/README.md +++ b/README.md @@ -2,46 +2,105 @@ 独立的 WonderQ 后端 API 服务,基于 Python、FastAPI、SQLAlchemy 2、Alembic、PostgreSQL、JWT 和 Pydantic。 -## 本地启动 +## 本地手动启动 -1. 复制环境变量: +以下命令默认在项目根目录执行:`D:\www\znkj\WonderQ-Admin`。 -```bash -cp .env.example .env +### 1. 准备环境变量 + +首次启动先复制环境变量模板: + +```powershell +Copy-Item .env.example .env ``` -2. 创建虚拟环境并安装依赖: +然后编辑 `.env`,至少确认以下配置: -```bash +- `DATABASE_URL`:本地 Docker PostgreSQL 默认使用 `localhost:5433`。 +- `JWT_SECRET`:生产环境必须替换为高强度随机值。 +- `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET_NAME`:填写实际 OSS 配置;真实密钥只放在 `.env` 或部署平台密钥中,不提交到 Git。 + +### 2. 创建并启用 Python 虚拟环境 + +首次启动或 `.venv` 不存在时执行: + +```powershell python -m venv .venv -.venv\Scripts\activate +.\.venv\Scripts\Activate.ps1 pip install -r requirements.txt ``` -3. 启动 PostgreSQL 和 Redis: +如果 PowerShell 阻止执行激活脚本,可临时允许当前进程执行脚本后再激活: -```bash +```powershell +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +.\.venv\Scripts\Activate.ps1 +``` + +后续日常启动只需要重新激活虚拟环境: + +```powershell +.\.venv\Scripts\Activate.ps1 +``` + +### 3. 启动依赖服务 + +启动本地 PostgreSQL 和 Redis: + +```powershell docker compose up -d postgres redis ``` -4. 初始化数据库结构并导入初始内容: +确认容器状态: -```bash +```powershell +docker compose ps +``` + +### 4. 初始化或升级数据库 + +首次启动、迁移变更后执行: + +```powershell alembic upgrade head +``` + +空库首次导入初始化内容时执行: + +```powershell python -m app.seed ``` -5. 启动 API: +注意:`python -m app.seed` 会重置站点内容、产品、目的地、活动和媒体数据;已有业务数据时不要重复执行。只需要确保默认后台账号存在时,用: -```bash +```powershell +python -m app.seed --no-reset +``` + +### 5. 启动 API 服务 + +开发模式启动: + +```powershell uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload ``` -访问: +启动后访问: - 健康检查:http://localhost:4000/health +- OpenAPI 文档:http://localhost:4000/docs - 默认后台账号:admin@example.com / ChangeMe123! +### 日常启动速查 + +数据库已经初始化后,通常只需要: + +```powershell +.\.venv\Scripts\Activate.ps1 +docker compose up -d postgres redis +uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload +``` + ## Docker 部署 完整本地部署: @@ -114,6 +173,7 @@ Python 版保留原有核心路径: - `GET /api/admin/leads` - `PATCH /api/admin/leads/{id}/status` - `GET /api/admin/media-assets` +- `POST /api/admin/media-assets/upload` - `POST /api/admin/reset-guizhou-content` - `POST /api/admin/publish` diff --git a/app/config.py b/app/config.py index e29bbaa..10a20eb 100644 --- a/app/config.py +++ b/app/config.py @@ -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") diff --git a/app/main.py b/app/main.py index 8e23268..1355808 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/routers/admin.py b/app/routers/admin.py index 1659903..a230ab6 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -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) diff --git a/app/routers/public.py b/app/routers/public.py index f25a318..23c0ac0 100644 --- a/app/routers/public.py +++ b/app/routers/public.py @@ -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) diff --git a/app/schemas.py b/app/schemas.py index 19b96e3..2ef03f6 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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) diff --git a/app/serializers.py b/app/serializers.py index 37c28ee..00c2f47 100644 --- a/app/serializers.py +++ b/app/serializers.py @@ -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 diff --git a/docs/README.md b/docs/README.md index 6d743c5..071fbda 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,8 @@ 本目录存放后端 API 相关文档。 - `backend/README.md`:当前 Python + FastAPI 后端运行说明。 +- `admin-ui-api-requirements.md`:后台管理前端当前必需的 Admin API 对接需求与字段契约。 +- `miniapp-public-api.md`:WonderQ-MiniAPP 前台对接 Public API 契约。 - `admin-backend-plan.md`:早期后台建设规划,保留作业务范围和阶段规划参考;其中技术栈建议已被当前 Python 迁移方案取代。 后台管理前端文档位于 `D:\www\znkj\WonderQ-Admin-UI\docs`。 diff --git a/docs/admin-ui-api-requirements.md b/docs/admin-ui-api-requirements.md new file mode 100644 index 0000000..29759dd --- /dev/null +++ b/docs/admin-ui-api-requirements.md @@ -0,0 +1,506 @@ +# WonderQ-Admin-UI Admin API 接口需求 + +本文档用于指导 `WonderQ-Admin` 后端按当前 `WonderQ-Admin-UI` 管理端完成 Admin API 对接。接口需求来源于前端 `src/api.ts` 与 `src/App.tsx` 的实际类型、请求封装和页面调用。 + +## 范围 + +- 本文只覆盖当前后台管理前端必需的 Admin API。 +- 不包含 H5 Public API、订单、媒体库、审计日志、活动专题等后续规划能力。 +- 后端现有 `GET /api/admin/me`、`GET /api/admin/media-assets` 不在当前 UI 必需范围内。 + +## 通用约定 + +- 基础路径:`/api/admin`。 +- 请求与响应均使用 JSON。 +- 除 `POST /api/admin/auth/login` 外,其余接口都需要后台登录态。 +- 登录成功后前端会把返回的 `token` 存入本地,并在后续请求头中发送: + +```http +Authorization: Bearer +Content-Type: application/json +``` + +- 日期时间字段返回 ISO 8601 字符串。 +- ID 字段按字符串处理。 +- 前端错误提示优先读取响应体的 `message` 字段;如后端沿用 FastAPI 默认 `detail`,建议同时兼容输出 `message`,避免管理端展示兜底错误。 + +## 当前对接状态 + +| 接口 | 前端依赖 | 后端现状 | 备注 | +| --- | --- | --- | --- | +| `POST /api/admin/auth/login` | 登录页 | 已覆盖 | 返回 token 和 user | +| `GET /api/admin/dashboard` | 客户端已封装 | 已覆盖 | 当前 UI 暂未展示 | +| `GET /api/admin/products` | 商品维护、结构维护 | 已覆盖 | UI 当前只传 `keyword` | +| `POST /api/admin/products` | 新建商品 | 已覆盖 | 返回完整 Product | +| `PATCH /api/admin/products/{id}` | 编辑商品 | 已覆盖 | 返回完整 Product | +| `GET /api/admin/destinations` | 商品目的地下拉、目的地页 | 已覆盖 | 需要返回别名和商品数 | +| `GET /api/admin/site-config` | 首页/目的地/活动结构维护 | 已覆盖 | 需要包含未启用内容 | +| `PATCH /api/admin/site-config/{module}/{item_id}` | 模块内容编辑 | 已覆盖 | 模块名需保持一致 | +| `GET /api/admin/leads` | 需求线索页 | 已覆盖 | UI 当前不传筛选参数 | +| `PATCH /api/admin/leads/{id}/status` | 线索状态流转 | 已覆盖 | UI 更新后会重新拉列表 | +| `POST /api/admin/publish` | 结构维护发布 | 已覆盖 | UI 使用 `title` 提示发布结果 | +| `POST /api/admin/reset-guizhou-content` | 贵州内容重置 | 已覆盖 | 高风险操作,需鉴权和审计 | + +## 枚举 + +### ProductStatus + +```ts +type ProductStatus = "draft" | "published" | "archived"; +``` + +### LeadStatus + +```ts +type LeadStatus = "new" | "assigned" | "contacted" | "planning" | "won" | "invalid"; +``` + +### SiteModule + +```ts +type SiteModule = "heroSlides" | "destinations" | "themes" | "ctaBanners"; +``` + +## 公共数据结构 + +### Destination + +```ts +type Destination = { + id: string; + name: string; + slug: string; + region?: string | null; + image?: string | null; + isHot: boolean; + isActive: boolean; + sortOrder: number; + aliases?: Array<{ id: string; alias: string }>; + _count?: { products: number }; +}; +``` + +### Product + +```ts +type Product = { + id: string; + sourceId?: number | null; + title: string; + subtitle?: string | null; + priceAmount?: number | null; + priceUnit: string; + tags: string[]; + coverImage?: string | null; + summary?: string | null; + images?: Array<{ id?: string; url: string; alt?: string | null; sortOrder: number }>; + detailSections?: ProductDetailSection[] | null; + status: ProductStatus; + sortWeight: number; + updatedAt: string; + destination?: Destination | null; + destinationId?: string | null; +}; +``` + +### ProductDetailSection + +```ts +type ProductDetailBlock = + | { type: "text"; text: string } + | { type: "image"; url: string; alt?: string | null }; + +type ProductDetailSection = { + key: string; + label: string; + title?: string | null; + blocks: ProductDetailBlock[]; +}; +``` + +### ProductInput + +`POST /products` 与 `PATCH /products/{id}` 复用该结构;`PATCH` 可以只提交需要修改的字段。 + +```ts +type ProductInput = { + title: string; + subtitle?: string; + destinationId?: string | null; + priceAmount?: number | null; + priceUnit?: string; + tags: string[]; + coverImage?: string | null; + summary?: string | null; + images?: Array<{ url: string; alt?: string | null; sortOrder: number }>; + detailSections?: ProductDetailSection[]; + status: ProductStatus; + sortWeight: number; +}; +``` + +字段处理要求: + +- `title` 必填,后端至少应校验非空;当前后端 schema 为最少 2 个字符。 +- `priceAmount` 可为空;不为空时应为大于等于 0 的整数。 +- `priceUnit` 为空时后端默认使用 `起/人`。 +- `images` 保存前按数组顺序重排 `sortOrder`。 +- `detailSections` 中空 key、空 label、空 blocks 的模块不应保存为有效详情模块。 +- 当 `status` 首次变为 `published` 时,后端可写入发布时间。 + +### Lead + +```ts +type Lead = { + id: string; + destination?: string | null; + phone: string; + note?: string | null; + sourcePage?: string | null; + status: LeadStatus; + createdAt: string; + sourceProduct?: { id: string; title: string } | null; + assignedUser?: { id: string; name: string } | null; +}; +``` + +后端可额外返回 `travelDate`、`peopleCount`、`budgetMin`、`budgetMax` 等字段,但以上字段是当前管理端展示所需的最小集合。手机号属于隐私信息,日志、错误和文档示例中不得输出真实号码。 + +### SiteConfig + +```ts +type SiteConfig = { + heroSlides: Array<{ + id: string; + title: string; + kicker?: string | null; + image: string; + targetType?: string | null; + targetValue?: string | null; + isActive: boolean; + }>; + destinations: Destination[]; + themes: Array<{ + id: string; + label: string; + image: string; + targetType?: string | null; + targetValue?: string | null; + isActive: boolean; + }>; + ctaBanners: Array<{ + id: string; + alt: string; + image: string; + targetType: string; + targetValue?: string | null; + isActive: boolean; + }>; +}; +``` + +### SiteItemPatch + +```ts +type SiteItemPatch = { + title?: string; + kicker?: string; + name?: string; + label?: string; + alt?: string; + image?: string | null; + targetType?: string | null; + targetValue?: string | null; + isActive?: boolean; +}; +``` + +模块字段映射: + +| module | 可编辑字段 | +| --- | --- | +| `heroSlides` | `title`、`kicker`、`image`、`targetType`、`targetValue`、`isActive` | +| `destinations` | `name`、`image`、`isActive` | +| `themes` | `label`、`image`、`targetType`、`targetValue`、`isActive` | +| `ctaBanners` | `alt`、`image`、`targetType`、`targetValue`、`isActive` | + +## 接口明细 + +### 登录 + +```http +POST /api/admin/auth/login +``` + +请求体: + +```json +{ + "email": "admin@example.com", + "password": "example-password" +} +``` + +响应体: + +```ts +{ + token: string; + user: { + id?: string; + name: string; + email: string; + role: string; + }; +} +``` + +状态码要求: + +- `200`:登录成功。 +- `401`:账号不存在、密码错误或账号停用。 + +### 工作台统计 + +```http +GET /api/admin/dashboard +``` + +当前前端客户端已封装该接口,但页面暂未展示。后端保持兼容即可。 + +响应体: + +```ts +{ + stats: { + productCount: number; + publishedProductCount: number; + destinationCount: number; + newLeadCount: number; + leadCount: number; + campaignCount: number; + }; + recentLeads: Lead[]; +} +``` + +### 商品列表 + +```http +GET /api/admin/products?keyword= +``` + +查询参数: + +| 参数 | 类型 | 当前 UI 是否使用 | 说明 | +| --- | --- | --- | --- | +| `keyword` | `string` | 是 | 搜索商品标题、短标题或标签 | +| `status` | `ProductStatus` | 否 | 后端可支持状态筛选 | +| `take` | `number` | 否 | 后端当前可限制返回条数 | + +响应体: + +```ts +{ + items: Product[]; +} +``` + +排序建议:`sortWeight` 升序,再按 `updatedAt` 倒序。结构维护页和商品维护页都会读取该接口。 + +### 新建商品 + +```http +POST /api/admin/products +``` + +请求体:`ProductInput` + +响应体:`Product` + +状态码要求: + +- `201`:创建成功。 +- `422`:字段校验失败。 + +### 更新商品 + +```http +PATCH /api/admin/products/{id} +``` + +请求体:`Partial` + +响应体:`Product` + +状态码要求: + +- `200`:更新成功。 +- `404`:商品不存在。 +- `422`:字段校验失败。 + +当前 UI 保存商品后会使用响应体刷新编辑状态,因此后端需要返回完整 Product,而不是只返回成功标记。 + +### 目的地列表 + +```http +GET /api/admin/destinations +``` + +响应体: + +```ts +{ + items: Destination[]; +} +``` + +要求: + +- 返回所有目的地,包括未启用项,便于后台维护。 +- 按 `sortOrder` 升序。 +- 每个目的地包含 `aliases`。 +- 每个目的地建议包含 `_count.products`,用于后台判断关联商品数量。 + +### 站点配置 + +```http +GET /api/admin/site-config +``` + +响应体:`SiteConfig` + +要求: + +- 返回 `heroSlides`、`destinations`、`themes`、`ctaBanners` 四个模块。 +- Admin API 需要返回未启用内容;Public API 才按发布/启用状态过滤。 +- 各模块按 `sortOrder` 升序。 + +### 更新站点配置项 + +```http +PATCH /api/admin/site-config/{module}/{item_id} +``` + +路径参数: + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `module` | `SiteModule` | 只能为 `heroSlides`、`destinations`、`themes`、`ctaBanners` | +| `item_id` | `string` | 对应模块内容项 ID | + +请求体:`SiteItemPatch` + +响应体:更新后的内容项对象。 + +状态码要求: + +- `200`:更新成功。 +- `400`:模块不存在。 +- `404`:内容项不存在。 +- `422`:字段校验失败。 + +当前 UI 保存后会重新调用 `GET /api/admin/site-config` 刷新页面,响应体只需保证是合法 JSON。 + +### 线索列表 + +```http +GET /api/admin/leads +``` + +查询参数: + +| 参数 | 类型 | 当前 UI 是否使用 | 说明 | +| --- | --- | --- | --- | +| `status` | `LeadStatus` | 否 | 后端可支持状态筛选 | +| `take` | `number` | 否 | 后端当前可限制返回条数 | + +响应体: + +```ts +{ + items: Lead[]; +} +``` + +排序建议:`createdAt` 倒序。当前 UI 展示客户手机号、创建时间、目的地/备注、来源商品/来源页面和状态。 + +### 更新线索状态 + +```http +PATCH /api/admin/leads/{id}/status +``` + +请求体: + +```ts +{ + status: LeadStatus; +} +``` + +响应体:更新后的 `Lead`,至少需要包含 `id` 和 `status`。 + +状态码要求: + +- `200`:更新成功。 +- `404`:线索不存在。 +- `422`:状态值非法。 + +当前 UI 更新后会重新调用 `GET /api/admin/leads`,因此响应体不会直接用于渲染列表。 + +### 发布站点配置 + +```http +POST /api/admin/publish +``` + +请求体:空 JSON 对象或无请求体均可兼容。 + +响应体: + +```ts +{ + id: string; + title: string; + publishedAt: string; +} +``` + +当前 UI 只读取 `title` 展示发布结果。后端可额外返回 `status`、`snapshot` 等字段。 + +### 重置贵州内容 + +```http +POST /api/admin/reset-guizhou-content +``` + +请求体:空 JSON 对象或无请求体均可兼容。 + +响应体: + +```ts +{ + heroSlides: number; + destinations: number; + themes: number; + ctaBanners: number; + products: number; +} +``` + +要求: + +- 该接口会重置内容数据,必须走后台鉴权。 +- 后端需要记录审计日志。 +- 生产环境调用前应通过部署流程或权限控制额外确认。 + +## 后端实现注意事项 + +- Admin API 默认使用 `require_admin`,登录接口除外。 +- 所有外部输入通过 Pydantic schema 校验。 +- 变更类接口需要继续写入 `AuditLog`。 +- 响应字段使用 camelCase,以匹配当前前端类型。 +- 允许后端返回额外字段,但不要移除本文列出的前端依赖字段。 +- 当前管理端不会直接上传图片,只维护图片 URL;媒体库接口暂不属于本需求范围。 + diff --git a/docs/miniapp-public-api.md b/docs/miniapp-public-api.md new file mode 100644 index 0000000..7d92ddd --- /dev/null +++ b/docs/miniapp-public-api.md @@ -0,0 +1,353 @@ +# WonderQ-MiniAPP Public API 对接文档 + +最后更新:2026-06-30 + +本文档定义 `WonderQ-MiniAPP` 前台 H5/小程序对接 `WonderQ-Admin` 后端所需的 Public API 契约。当前 MiniAPP 主动调用站点配置、产品列表和线索提交 3 个接口;后端已存在的健康检查、产品详情和目的地列表接口建议继续保留,供后续前台按需接入。 + +## 基础约定 + +- 基础地址由 MiniAPP 环境变量 `VITE_API_BASE_URL` 控制;为空时前台按同源 `/api` 请求。 +- Public API 不要求前台登录认证。 +- 请求和响应均使用 JSON,字符集为 UTF-8。 +- 图片字段应返回可被 H5 和微信小程序访问的 URL;现有前台兼容 `/assets/...` 形式。 +- 列表字段建议返回空数组,不建议返回 `null`;MiniAPP 对站点配置和产品列表有本地兜底内容。 +- 错误响应需提供可展示信息,兼容 `{ "message": "..." }` 或 FastAPI 默认 `{ "detail": "..." }`。不要暴露内部异常、真实环境变量、Token、JWT secret、客服链接或企业 ID。 + +## 数据类型 + +### `HeroSlide` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `id` | `string` | 是 | 轮播图 ID | +| `title` | `string` | 是 | 主标题 | +| `kicker` | `string \| null` | 否 | 辅助短文案 | +| `image` | `string` | 是 | 图片 URL | +| `targetType` | `string \| null` | 否 | 点击目标类型 | +| `targetValue` | `string \| null` | 否 | 点击目标值 | +| `isActive` | `boolean` | 否 | 是否启用 | + +### `Destination` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `id` | `string` | 是 | 目的地 ID | +| `name` | `string` | 是 | 目的地名称 | +| `image` | `string \| null` | 否 | 图片 URL | +| `isHot` | `boolean` | 否 | 是否热门 | +| `isActive` | `boolean` | 否 | 是否启用 | +| `aliases` | `Array<{ id: string; alias: string }>` | 否 | 搜索别名 | + +### `Theme` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `id` | `string` | 是 | 主题 ID | +| `label` | `string` | 是 | 主题名称 | +| `image` | `string` | 是 | 图片 URL | +| `targetType` | `string \| null` | 否 | 点击目标类型 | +| `targetValue` | `string \| null` | 否 | 点击目标值 | +| `isActive` | `boolean` | 否 | 是否启用 | + +### `CtaBanner` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `id` | `string` | 是 | Banner ID | +| `alt` | `string` | 是 | 图片替代文案 | +| `image` | `string` | 是 | 图片 URL | +| `targetType` | `string \| null` | 否 | 点击目标类型 | +| `targetValue` | `string \| null` | 否 | 点击目标值 | +| `isActive` | `boolean` | 否 | 是否启用 | + +### `PublicProduct` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `id` | `string` | 是 | 后端产品 UUID | +| `sourceId` | `number \| null` | 否 | 历史采集产品 ID;前台可用于兼容旧数据 | +| `title` | `string` | 是 | 产品标题 | +| `subtitle` | `string \| null` | 否 | 副标题 | +| `destination` | `{ id: string; name: string } \| null` | 否 | 目的地信息 | +| `priceAmount` | `number \| null` | 否 | 参考起价,单位按后端内容约定 | +| `priceUnit` | `string \| null` | 否 | 价格单位文案 | +| `tags` | `string[]` | 否 | 标签列表 | +| `coverImage` | `string \| null` | 否 | 封面图 URL | +| `summary` | `string \| null` | 否 | 摘要 | +| `images` | `Array` | 否 | 图集 | +| `detailSections` | `ProductDetailSection[] \| null` | 否 | 产品详情分区 | +| `status` | `string` | 否 | 产品状态,前台主要消费 `published` 内容 | + +### `ProductImage` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `id` | `string` | 否 | 图片 ID | +| `url` | `string` | 是 | 图片 URL | +| `alt` | `string \| null` | 否 | 图片说明 | +| `sortOrder` | `number` | 是 | 排序值 | + +### `ProductDetailSection` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `key` | `string` | 是 | 分区 key,例如 `overview`、`itinerary`、`service`、`notice`、`price`、`manager` | +| `label` | `string` | 是 | Tab 展示文案 | +| `title` | `string \| null` | 否 | 分区标题 | +| `blocks` | `ProductDetailBlock[]` | 是 | 内容块 | + +`ProductDetailBlock` 支持两种结构: + +```json +{ "type": "text", "text": "文本内容" } +``` + +```json +{ "type": "image", "url": "/assets/example.jpg", "alt": "图片说明" } +``` + +## 接口清单 + +### `GET /health` + +用于服务健康检查。 + +#### 响应示例 + +```json +{ + "ok": true, + "service": "miniapp-api" +} +``` + +### `GET /api/public/site-config` + +用于首页轮播、目的地、主题入口和底部 CTA 配置。MiniAPP 启动时会和产品列表并行请求该接口;接口不可用或关键数组为空时,前台会回退本地静态内容。 + +#### 响应字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `heroSlides` | `HeroSlide[]` | 首页顶部轮播 | +| `destinations` | `Destination[]` | 首页目的地入口 | +| `themes` | `Theme[]` | 主题甄选入口 | +| `ctaBanners` | `CtaBanner[]` | 底部 CTA Banner | +| `campaigns` | `unknown[]` | 后端现有扩展字段,可保留 | +| `routeSections` | `Array<{ id: string; title: string; productIds: string[] }>` | 后端现有扩展字段,可保留 | + +#### 响应示例 + +```json +{ + "heroSlides": [ + { + "id": "hero-1", + "title": "贵州小包团定制", + "kicker": "万趣,你的小包团首选", + "image": "/assets/guizhou/libo-xiaoqikong.jpg", + "targetType": "search", + "targetValue": "贵州", + "isActive": true + } + ], + "destinations": [ + { + "id": "dest-1", + "name": "荔波小七孔", + "image": "/assets/guizhou/libo-xiaoqikong.jpg", + "isHot": true, + "isActive": true, + "aliases": [{ "id": "alias-1", "alias": "小七孔" }] + } + ], + "themes": [], + "ctaBanners": [] +} +``` + +### `GET /api/public/products` + +用于首页产品分区、搜索结果、活动页、目的地页、详情推荐和预订入口。当前 MiniAPP 一次拉取列表后在前端做搜索、筛选和推荐。 + +#### Query 参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `keyword` | `string` | 否 | 无 | 关键词搜索,建议匹配产品标题、副标题、标签、目的地名称和目的地别名 | +| `destinationId` | `string` | 否 | 无 | 按目的地 ID 筛选 | +| `status` | `string` | 否 | `published` | 产品状态 | +| `take` | `number` | 否 | `48` | 返回数量,后端当前限制 1-100 | + +#### 响应字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `items` | `PublicProduct[]` | 产品列表 | + +#### 响应示例 + +```json +{ + "items": [ + { + "id": "8a6e7c4f-0000-4000-9000-000000000001", + "sourceId": 101, + "title": "黄果树瀑布小包团", + "subtitle": "错峰入园,私家车接送", + "destination": { "id": "dest-anshun", "name": "黄果树" }, + "priceAmount": 398000, + "priceUnit": "起/人", + "tags": ["贵州", "黄果树", "小包团"], + "coverImage": "/assets/guizhou/huangguoshu.jpg", + "summary": "适合首次到贵州的经典线路。", + "images": [ + { + "id": "img-1", + "url": "/assets/guizhou/huangguoshu.jpg", + "alt": "黄果树瀑布", + "sortOrder": 0 + } + ], + "detailSections": [ + { + "key": "overview", + "label": "行程概述", + "title": "小包团专属概览", + "blocks": [{ "type": "text", "text": "按同行人、预算和体力强度重排行程。" }] + } + ], + "status": "published" + } + ] +} +``` + +### `GET /api/public/products/{product_id}` + +后端已存在,建议保留给 MiniAPP 后续详情页按需拉取。当前 MiniAPP 主要通过产品列表缓存进入详情。 + +#### Path 参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `product_id` | `string` | 产品 UUID;如果传入纯数字,后端按 `sourceId` 查询 | + +#### 成功响应 + +返回单个 `PublicProduct`。 + +#### 异常响应 + +| 状态码 | 说明 | +| --- | --- | +| `404` | 产品不存在 | + +示例: + +```json +{ + "detail": "线路不存在" +} +``` + +### `GET /api/public/destinations` + +后端已存在,建议保留给 MiniAPP 后续目的地页独立拉取。当前 MiniAPP 首页目的地来自 `site-config.destinations`。 + +#### 响应示例 + +```json +{ + "items": [ + { + "id": "dest-libo", + "name": "荔波小七孔", + "slug": "libo-xiaoqikong", + "region": "黔南", + "image": "/assets/guizhou/libo-xiaoqikong.jpg", + "isHot": true, + "sortOrder": 0, + "isActive": true, + "aliases": [{ "id": "alias-1", "alias": "小七孔" }] + } + ] +} +``` + +### `POST /api/public/leads` + +用于首页快速定制、搜索页快速定制、需求页和预订咨询页提交线索。 + +#### 请求字段 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `phone` | `string` | 是 | 联系方式。前台输入文案为“手机号 / 微信号”,后端当前会去除首尾空白并压缩连续空格 | +| `destination` | `string` | 否 | 目的地或玩法 | +| `travelDate` | `string` | 否 | 出行日期;MiniAPP 当前传 `YYYY-MM-DD` 字符串 | +| `peopleCount` | `number` | 否 | 出行人数,需大于 0 | +| `budgetMin` | `number` | 否 | 最低预算,需大于等于 0 | +| `budgetMax` | `number` | 否 | 最高预算,需大于等于 0 | +| `note` | `string` | 否 | 补充说明,后端当前限制最长 1000 字符 | +| `sourcePage` | `string` | 否 | 来源页面 | +| `sourceProductId` | `string` | 否 | 来源产品 UUID | + +#### `sourcePage` 当前取值 + +| 值 | 来源 | +| --- | --- | +| `home_inline` | 首页快速定制入口 | +| `search_inline` | 搜索结果页快速定制入口 | +| `demand_page` | 提交需求页 | +| `product_consult` | 产品预订咨询页 | + +#### 请求示例 + +```json +{ + "destination": "荔波小七孔", + "phone": "187 8617 4929", + "travelDate": "2027-01-01", + "peopleCount": 2, + "note": "咨询线路:黄果树瀑布小包团;方案偏好:经典人文", + "sourcePage": "product_consult", + "sourceProductId": "8a6e7c4f-0000-4000-9000-000000000001" +} +``` + +#### 成功响应 + +状态码:`201` + +```json +{ + "id": "lead-uuid", + "status": "new" +} +``` + +#### 常见异常 + +| 状态码 | 场景 | +| --- | --- | +| `422` | 请求体校验失败,例如 `phone` 为空、`peopleCount` 小于等于 0、`note` 超长 | +| `500` | 服务端异常,响应不得暴露内部细节 | + +## MiniAPP 当前依赖说明 + +- `site-config` 与 `products` 会在应用启动时并行请求;任一请求失败时,MiniAPP 会回退到本地静态内容。 +- `products.items` 为空时,MiniAPP 会使用本地产品兜底数据。 +- 产品搜索当前主要在前端执行,依赖 `title`、`tags`、`destination.name`、`summary`。 +- 产品详情页当前使用已加载的产品列表数据;后续可改为进入详情页时请求 `GET /api/public/products/{product_id}`。 +- 收藏、浏览历史和最近咨询记录由 MiniAPP 本地存储处理,不需要后端接口。 +- 企业微信客服由 MiniAPP 环境变量控制,不属于 `WonderQ-Admin` Public API;文档和接口不得写入真实链接或企业 ID。 + +## 后端验证建议 + +- 为 `GET /health` 增加或保留健康检查测试。 +- 为 `GET /api/public/site-config` 验证返回 JSON 包含 `heroSlides`、`destinations`、`themes`、`ctaBanners` 数组字段。 +- 为 `GET /api/public/products` 验证响应结构为 `{ items: [...] }`,并覆盖 `keyword`、`destinationId`、`status`、`take` 参数。 +- 为 `GET /api/public/products/{product_id}` 验证 UUID、数字 `sourceId` 和 404 场景。 +- 为 `GET /api/public/destinations` 验证只返回启用目的地及别名字段。 +- 为 `POST /api/public/leads` 验证成功创建、`phone` 规范化、必填校验、人数/预算边界和备注长度限制。 diff --git a/requirements.txt b/requirements.txt index b116a52..1fcd0cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ fastapi>=0.115,<0.116 +python-multipart>=0.0.18,<0.1 uvicorn[standard]>=0.32,<0.33 SQLAlchemy>=2.0,<2.1 alembic>=1.14,<1.15 diff --git a/tests/test_api_contracts.py b/tests/test_api_contracts.py new file mode 100644 index 0000000..cebf6f5 --- /dev/null +++ b/tests/test_api_contracts.py @@ -0,0 +1,717 @@ +from datetime import datetime + +import pytest +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from app import serializers +from app.auth import hash_password, require_admin +from app.database import get_db +from app.main import create_app +from app.models import AdminUser, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MediaAsset, Product, ProductImage, ThemeCard +from app.routers import admin as admin_router +from app.routers.admin import normalize_detail_sections, normalize_images +from app.schemas import ( + AdminProductQuery, + LeadCreateIn, + LeadQuery, + ProductCreateIn, + ProductImageIn, + ProductQuery, +) + + +class FakeScalarResult: + def __init__(self, items): + self.items = items + + def unique(self): + return self + + def all(self): + return self.items + + def first(self): + return self.items[0] if self.items else None + + def one(self): + return self.items[0] + + +class FakeExecuteResult: + def __init__(self, rows): + self.rows = rows + + def all(self): + return self.rows + + +class FakeDb: + def __init__(self, *, scalar_results=None, scalar_values=None, execute_results=None, get_result=None): + self.scalar_results = list(scalar_results or []) + self.scalar_values = list(scalar_values or []) + self.execute_results = list(execute_results or []) + self.get_result = get_result + self.added = [] + self.deleted = [] + self.committed = False + + def scalars(self, _stmt): + return FakeScalarResult(self.scalar_results.pop(0)) + + def scalar(self, _stmt): + return self.scalar_values.pop(0) + + def execute(self, _stmt): + return FakeExecuteResult(self.execute_results.pop(0)) + + def get(self, _model, _item_id): + return self.get_result + + def add(self, item): + self.added.append(item) + + def delete(self, item): + self.deleted.append(item) + + def flush(self): + for item in self.added: + if not getattr(item, "id", None): + item.id = f"{item.__class__.__name__.lower()}-test-id" + + def commit(self): + self.committed = True + + def refresh(self, item): + if not getattr(item, "id", None): + item.id = "lead-test-id" + + +def make_destination(**overrides): + destination = Destination( + id=overrides.get("id", "dest-test"), + name=overrides.get("name", "测试目的地"), + slug=overrides.get("slug", "test-destination"), + region=overrides.get("region", "测试区域"), + image=overrides.get("image", "/assets/test.jpg"), + isHot=overrides.get("isHot", True), + sortOrder=overrides.get("sortOrder", 1), + isActive=overrides.get("isActive", True), + createdAt=datetime(2026, 1, 1), + updatedAt=datetime(2026, 1, 2), + ) + destination.aliases = [ + DestinationAlias(id="alias-b", alias="别名B", destinationId=destination.id), + DestinationAlias(id="alias-a", alias="别名A", destinationId=destination.id), + ] + destination.products = [] + return destination + + +def make_product(**overrides): + destination = overrides.get("destination", make_destination()) + product = Product( + id=overrides.get("id", "product-test"), + sourceId=overrides.get("sourceId", 101), + title=overrides.get("title", "测试线路"), + subtitle=overrides.get("subtitle", "测试副标题"), + destinationId=destination.id, + priceAmount=overrides.get("priceAmount", 1000), + priceUnit=overrides.get("priceUnit", "起/人"), + tags=overrides.get("tags", ["测试", "线路"]), + coverImage=overrides.get("coverImage", "/assets/cover.jpg"), + summary=overrides.get("summary", "测试摘要"), + detailSections=overrides.get("detailSections", None), + status=overrides.get("status", "published"), + sortWeight=overrides.get("sortWeight", 1), + publishedAt=datetime(2026, 1, 3), + createdAt=datetime(2026, 1, 1), + updatedAt=datetime(2026, 1, 2), + ) + product.destination = destination + product.images = [ + ProductImage(id="img-b", productId=product.id, url="/assets/b.jpg", alt="B", sortOrder=2), + ProductImage(id="img-a", productId=product.id, url="/assets/a.jpg", alt="A", sortOrder=1), + ] + return product + + +def make_hero_slide(**overrides): + return HeroSlide( + id=overrides.get("id", "slide-test"), + title=overrides.get("title", "测试轮播"), + kicker=overrides.get("kicker", "测试副标题"), + image=overrides.get("image", "/assets/slide.jpg"), + targetType=overrides.get("targetType", "campaign"), + targetValue=overrides.get("targetValue", "campaign-test"), + sortOrder=overrides.get("sortOrder", 1), + isActive=overrides.get("isActive", True), + createdAt=datetime(2026, 1, 1), + updatedAt=datetime(2026, 1, 2), + ) + + +def make_theme_card(**overrides): + return ThemeCard( + id=overrides.get("id", "theme-test"), + label=overrides.get("label", "测试主题"), + image=overrides.get("image", "/assets/theme.jpg"), + targetType=overrides.get("targetType", "search"), + targetValue=overrides.get("targetValue", "测试主题"), + sortOrder=overrides.get("sortOrder", 1), + isActive=overrides.get("isActive", True), + createdAt=datetime(2026, 1, 1), + updatedAt=datetime(2026, 1, 2), + ) + + +def make_cta_banner(**overrides): + return CtaBanner( + id=overrides.get("id", "cta-test"), + alt=overrides.get("alt", "测试运营入口"), + image=overrides.get("image", "/assets/cta.jpg"), + targetType=overrides.get("targetType", "lead"), + targetValue=overrides.get("targetValue", "cta-test"), + sortOrder=overrides.get("sortOrder", 1), + isActive=overrides.get("isActive", True), + createdAt=datetime(2026, 1, 1), + updatedAt=datetime(2026, 1, 2), + ) + + +def make_admin_user(): + return AdminUser( + id="admin-test", + email="admin@example.test", + name="Admin", + role="admin", + passwordHash="not-used", + isActive=True, + ) + + +def authenticated_app(fake_db): + app = create_app() + app.dependency_overrides[get_db] = lambda: fake_db + app.dependency_overrides[require_admin] = make_admin_user + return app + + +def test_public_product_serializer_limits_destination_and_stabilizes_arrays(): + data = serializers.public_product_dict(make_product()) + + assert data["destination"] == {"id": "dest-test", "name": "测试目的地"} + assert [image["id"] for image in data["images"]] == ["img-a", "img-b"] + assert data["detailSections"] == [] + + +def test_destination_serializer_uses_precomputed_product_count_and_sorts_aliases(): + destination = make_destination() + + data = serializers.destination_dict(destination, include_count=True, product_count=7) + + assert data["_count"] == {"products": 7} + assert [alias["id"] for alias in data["aliases"]] == ["alias-a", "alias-b"] + + +def test_lead_schema_accepts_date_only_string(): + lead = LeadCreateIn(phone=" contact handle ", travelDate="2027-01-01") + + assert lead.phone == "contact handle" + assert lead.travelDate == datetime(2027, 1, 1) + + +def test_query_status_validation_rejects_unknown_values(): + with pytest.raises(ValidationError): + ProductQuery(status="hidden") + with pytest.raises(ValidationError): + AdminProductQuery(status="hidden") + with pytest.raises(ValidationError): + LeadQuery(status="closed") + + +def test_normalize_images_reassigns_sort_order_by_payload_order(): + images = [ + ProductImageIn(url="/assets/first.jpg", sortOrder=99), + ProductImageIn(url="/assets/second.jpg", sortOrder=10), + ] + + assert [image["sortOrder"] for image in normalize_images(images)] == [0, 1] + + +def test_empty_detail_sections_are_accepted_and_dropped(): + body = ProductCreateIn( + title="测试线路", + detailSections=[{"key": "", "label": "", "blocks": []}], + ) + + assert normalize_detail_sections(body.detailSections) == [] + + +def test_public_products_endpoint_uses_public_product_contract(): + product = make_product() + fake_db = FakeDb(scalar_results=[[product]]) + app = create_app() + app.dependency_overrides[get_db] = lambda: fake_db + + try: + response = TestClient(app).get("/api/public/products") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + item = response.json()["items"][0] + assert item["destination"] == {"id": "dest-test", "name": "测试目的地"} + assert item["images"][0]["url"] == "/assets/a.jpg" + + +def test_public_leads_endpoint_accepts_date_only_and_returns_minimal_response(): + fake_db = FakeDb() + app = create_app() + app.dependency_overrides[get_db] = lambda: fake_db + + try: + response = TestClient(app).post( + "/api/public/leads", + json={ + "phone": " contact handle ", + "travelDate": "2027-01-01", + "peopleCount": 2, + "budgetMin": 0, + "budgetMax": 100, + }, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 201 + assert response.json() == {"id": "lead-test-id", "status": "new"} + assert fake_db.added[0].phone == "contact handle" + assert fake_db.added[0].travelDate == datetime(2027, 1, 1) + + +def test_admin_destinations_endpoint_uses_precomputed_product_counts(): + destination = make_destination() + fake_db = FakeDb(scalar_results=[[destination]], execute_results=[[(destination.id, 7)]]) + app = create_app() + app.dependency_overrides[get_db] = lambda: fake_db + app.dependency_overrides[require_admin] = lambda: AdminUser( + id="admin-test", + email="admin@example.test", + name="Admin", + role="admin", + passwordHash="not-used", + isActive=True, + ) + + try: + response = TestClient(app).get("/api/admin/destinations") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert response.json()["items"][0]["_count"] == {"products": 7} + + +def test_admin_requires_auth_for_protected_endpoint(): + response = TestClient(create_app()).get("/api/admin/products") + + assert response.status_code == 401 + assert response.json() == {"message": "请先登录后台"} + + +def test_admin_login_returns_token_and_user(): + admin_user = AdminUser( + id="admin-test", + email="admin@example.com", + name="Admin", + role="admin", + passwordHash=hash_password("ChangeMe123!", rounds=4), + isActive=True, + ) + fake_db = FakeDb(scalar_values=[admin_user]) + app = create_app() + app.dependency_overrides[get_db] = lambda: fake_db + + try: + response = TestClient(app).post( + "/api/admin/auth/login", + json={"email": "admin@example.com", "password": "ChangeMe123!"}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + body = response.json() + assert body["token"] + assert body["user"] == { + "id": "admin-test", + "email": "admin@example.com", + "name": "Admin", + "role": "admin", + } + + +def test_site_config_patch_ignores_fields_not_allowed_for_module_and_audits(): + destination = make_destination() + fake_db = FakeDb(get_result=destination) + app = create_app() + app.dependency_overrides[get_db] = lambda: fake_db + app.dependency_overrides[require_admin] = lambda: AdminUser( + id="admin-test", + email="admin@example.test", + name="Admin", + role="admin", + passwordHash="not-used", + isActive=True, + ) + + try: + response = TestClient(app).patch( + "/api/admin/site-config/destinations/dest-test", + json={"name": "新目的地", "targetType": "ignored"}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert response.json()["name"] == "新目的地" + assert not hasattr(destination, "targetType") + assert fake_db.committed + assert fake_db.added[-1].entity == "destination" + + +@pytest.mark.parametrize( + ("module", "payload", "expected"), + [ + ("heroSlides", {"title": "新轮播", "image": None, "targetType": None}, {"title": "新轮播", "image": None}), + ("destinations", {"name": "新目的地", "slug": "", "region": None, "isHot": True}, {"name": "新目的地", "slug": "e696b0e79baee79a84e59cb0", "isHot": True}), + ("themes", {"label": "新主题", "image": None}, {"label": "新主题", "image": ""}), + ("ctaBanners", {"alt": "新运营入口", "image": None, "targetType": None}, {"alt": "新运营入口", "image": "", "targetType": ""}), + ], +) +def test_site_config_create_modules_defaults_fields_and_audits(module, payload, expected): + fake_db = FakeDb(scalar_values=[4]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).post(f"/api/admin/site-config/{module}", json=payload) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 201 + body = response.json() + for key, value in expected.items(): + assert body[key] == value + assert body["isActive"] is True + assert body["sortOrder"] == 5 + assert fake_db.committed + assert fake_db.added[-1].action == "create" + + +def test_site_config_invalid_module_returns_structured_error(): + fake_db = FakeDb() + app = authenticated_app(fake_db) + + try: + response = TestClient(app).post("/api/admin/site-config/unknown", json={"title": "测试"}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 400 + assert response.json() == { + "message": "模块不存在或无权限操作", + "code": "MODULE_CONFIG_FORBIDDEN", + "details": {"module": "unknown"}, + } + + +def test_site_config_create_requires_module_primary_field(): + fake_db = FakeDb() + app = authenticated_app(fake_db) + + try: + response = TestClient(app).post("/api/admin/site-config/themes", json={"image": "/assets/theme.jpg"}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 422 + assert response.json()["code"] == "MODULE_CONFIG_VALIDATION_ERROR" + assert response.json()["details"] == {"field": "label"} + + +def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets(): + hero_slide = make_hero_slide(targetType="campaign", targetValue="campaign-test") + fake_db = FakeDb(scalar_results=[[], [hero_slide], [], []]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).get("/api/admin/site-config") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + hero = response.json()["heroSlides"][0] + assert hero["title"] == "测试轮播" + assert hero["image"] == "/assets/slide.jpg" + assert "targetType" not in hero + assert "targetValue" not in hero + + +def test_site_config_create_hero_slide_ignores_target_fields_and_returns_dedicated_contract(): + fake_db = FakeDb(scalar_values=[0]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).post( + "/api/admin/site-config/heroSlides", + json={ + "title": " 新轮播 ", + "kicker": "", + "image": None, + "targetType": "campaign", + "targetValue": "ignored", + }, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 201 + body = response.json() + assert body["title"] == "新轮播" + assert body["image"] is None + assert "targetType" not in body + assert "targetValue" not in body + created = fake_db.added[0] + assert created.targetType is None + assert created.targetValue is None + + +def test_site_config_patch_hero_slide_ignores_target_fields_and_returns_dedicated_contract(): + hero_slide = make_hero_slide(targetType="campaign", targetValue="campaign-test") + fake_db = FakeDb(get_result=hero_slide) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).patch( + "/api/admin/site-config/heroSlides/slide-test", + json={ + "title": "夏日贵州小包团", + "image": None, + "targetType": "search", + "targetValue": "ignored", + }, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + body = response.json() + assert body["title"] == "夏日贵州小包团" + assert body["image"] is None + assert "targetType" not in body + assert "targetValue" not in body + assert hero_slide.targetType == "campaign" + assert hero_slide.targetValue == "campaign-test" + + +def test_site_config_patch_updates_destination_contract_fields(): + destination = make_destination() + fake_db = FakeDb(get_result=destination) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).patch( + "/api/admin/site-config/destinations/dest-test", + json={"slug": "new-slug", "region": None, "image": None, "isHot": False, "sortOrder": 8, "targetType": "ignored"}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + body = response.json() + assert body["slug"] == "new-slug" + assert body["region"] is None + assert body["image"] is None + assert body["isHot"] is False + assert body["sortOrder"] == 8 + assert not hasattr(destination, "targetType") + assert fake_db.added[-1].action == "update" + + +def test_site_config_delete_returns_json_reorders_remaining_items_and_audits(): + delete_item = make_theme_card(id="theme-delete", sortOrder=1) + remaining = [make_theme_card(id="theme-b", sortOrder=5), make_theme_card(id="theme-a", sortOrder=9)] + fake_db = FakeDb(get_result=delete_item, scalar_results=[remaining]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).delete("/api/admin/site-config/themes/theme-delete") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert response.json() == {"id": "theme-delete"} + assert fake_db.deleted == [delete_item] + assert [item.sortOrder for item in remaining] == [0, 1] + assert fake_db.committed + assert fake_db.added[-1].action == "delete" + assert fake_db.added[-1].entity == "theme_card" + + +def test_site_config_delete_destination_with_products_returns_conflict(): + destination = make_destination() + fake_db = FakeDb(get_result=destination, scalar_values=[1]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).delete("/api/admin/site-config/destinations/dest-test") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 409 + assert response.json()["code"] == "MODULE_CONFIG_CONFLICT" + assert not fake_db.deleted + + +def test_site_config_reorder_reassigns_sort_order_and_returns_items(): + items = [ + make_hero_slide(id="slide-1", title="第一张", sortOrder=0), + make_hero_slide(id="slide-2", title="第二张", sortOrder=1), + make_hero_slide(id="slide-3", title="第三张", sortOrder=2), + ] + fake_db = FakeDb(scalar_results=[items]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).patch( + "/api/admin/site-config/heroSlides/reorder", + json={"itemIds": ["slide-2", "slide-1", "slide-3"]}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert [item.id for item in items] == ["slide-1", "slide-2", "slide-3"] + assert {item.id: item.sortOrder for item in items} == {"slide-2": 0, "slide-1": 1, "slide-3": 2} + assert [item["id"] for item in response.json()["items"]] == ["slide-2", "slide-1", "slide-3"] + assert [item["sortOrder"] for item in response.json()["items"]] == [0, 1, 2] + assert fake_db.committed + assert fake_db.added[-1].action == "reorder" + + +@pytest.mark.parametrize( + "item_ids", + [ + ["slide-1", "slide-1", "slide-2"], + ["slide-1", "slide-2"], + ["slide-1", "slide-2", "other-module-id"], + ], +) +def test_site_config_reorder_rejects_duplicate_missing_and_unknown_ids(item_ids): + items = [ + make_hero_slide(id="slide-1"), + make_hero_slide(id="slide-2"), + make_hero_slide(id="slide-3"), + ] + fake_db = FakeDb(scalar_results=[items]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).patch("/api/admin/site-config/heroSlides/reorder", json={"itemIds": item_ids}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 400 + assert response.json()["code"] == "MODULE_CONFIG_REORDER_INVALID" + assert not fake_db.committed + + +def test_admin_media_upload_streams_image_to_oss_records_asset_and_audits(monkeypatch): + uploaded = {} + + def fake_upload(file_obj, key, mime_type, size_bytes): + uploaded["key"] = key + uploaded["mimeType"] = mime_type + uploaded["sizeBytes"] = size_bytes + uploaded["body"] = file_obj.read() + return f"https://cdn.example.test/{key}" + + monkeypatch.setattr(admin_router, "upload_image_to_oss", fake_upload) + fake_db = FakeDb() + app = authenticated_app(fake_db) + content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + + try: + response = TestClient(app).post( + "/api/admin/media-assets/upload", + data={"group": "heroSlides"}, + files={"file": ("hero.png", content, "image/png")}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 201 + body = response.json() + assert body["url"].startswith("https://cdn.example.test/admin/heroSlides/") + assert body["name"] == "hero.png" + assert body["mimeType"] == "image/png" + assert body["sizeBytes"] == len(content) + assert body["group"] == "heroSlides" + assert uploaded["body"] == content + assert uploaded["key"].endswith(".png") + assert isinstance(fake_db.added[0], MediaAsset) + assert fake_db.added[-1].entity == "media_asset" + assert fake_db.committed + + +def test_admin_media_upload_rejects_non_image_file(): + fake_db = FakeDb() + app = authenticated_app(fake_db) + + try: + response = TestClient(app).post( + "/api/admin/media-assets/upload", + data={"group": "docs"}, + files={"file": ("note.txt", b"hello", "text/plain")}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 400 + assert response.json()["code"] == "MEDIA_UPLOAD_INVALID_TYPE" + assert not fake_db.added + assert not fake_db.committed + + +def test_admin_lead_status_update_returns_updated_status_and_audits(): + lead = Lead( + id="lead-test", + destination="测试目的地", + phone="contact handle", + status="new", + createdAt=datetime(2026, 1, 1), + updatedAt=datetime(2026, 1, 2), + ) + fake_db = FakeDb(get_result=lead) + app = create_app() + app.dependency_overrides[get_db] = lambda: fake_db + app.dependency_overrides[require_admin] = lambda: AdminUser( + id="admin-test", + email="admin@example.test", + name="Admin", + role="admin", + passwordHash="not-used", + isActive=True, + ) + + try: + response = TestClient(app).patch("/api/admin/leads/lead-test/status", json={"status": "contacted"}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert response.json()["status"] == "contacted" + assert lead.status == "contacted" + assert fake_db.committed + assert fake_db.added[-1].entity == "lead" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..0e55bbe --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,15 @@ +from app.config import Settings + + +def test_oss_settings_are_loaded_from_prefixed_environment(monkeypatch): + monkeypatch.setenv("OSS_ACCESS_KEY_ID", "example-access-key-id") + monkeypatch.setenv("OSS_ACCESS_KEY_SECRET", "example-access-key-secret") + monkeypatch.setenv("OSS_ENDPOINT", "oss-cn-example.aliyuncs.com") + monkeypatch.setenv("OSS_BUCKET_NAME", "example-bucket") + + settings = Settings(_env_file=None) + + assert settings.oss_access_key_id == "example-access-key-id" + assert settings.oss_access_key_secret == "example-access-key-secret" + assert settings.oss_endpoint == "oss-cn-example.aliyuncs.com" + assert settings.oss_bucket_name == "example-bucket"