feat: add campaign model and API integration with price and tags
This commit is contained in:
53
alembic/versions/0003_campaign_display_fields.py
Normal file
53
alembic/versions/0003_campaign_display_fields.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Add campaign display fields.
|
||||
|
||||
Revision ID: 0003_campaign_display_fields
|
||||
Revises: 0002_add_map_image
|
||||
Create Date: 2026-07-02
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "0003_campaign_display_fields"
|
||||
down_revision = "0002_add_map_image"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if "Campaign" not in set(inspect(bind).get_table_names()):
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspect(bind).get_columns("Campaign")}
|
||||
if "priceAmount" not in columns:
|
||||
op.add_column("Campaign", sa.Column("priceAmount", sa.Integer(), nullable=True))
|
||||
if "priceUnit" not in columns:
|
||||
op.add_column("Campaign", sa.Column("priceUnit", sa.String(), nullable=True))
|
||||
if "tags" not in columns:
|
||||
op.add_column(
|
||||
"Campaign",
|
||||
sa.Column(
|
||||
"tags",
|
||||
postgresql.ARRAY(sa.String()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::varchar[]"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if "Campaign" not in set(inspect(bind).get_table_names()):
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspect(bind).get_columns("Campaign")}
|
||||
if "tags" in columns:
|
||||
op.drop_column("Campaign", "tags")
|
||||
if "priceUnit" in columns:
|
||||
op.drop_column("Campaign", "priceUnit")
|
||||
if "priceAmount" in columns:
|
||||
op.drop_column("Campaign", "priceAmount")
|
||||
@@ -173,6 +173,9 @@ class Campaign(Base):
|
||||
title: Mapped[str] = mapped_column(String, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
coverImage: Mapped[str | None] = mapped_column(String)
|
||||
priceAmount: Mapped[int | None] = mapped_column(Integer)
|
||||
priceUnit: Mapped[str | None] = mapped_column(String, default="起/人")
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String, default="published", nullable=False)
|
||||
startsAt: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
endsAt: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
|
||||
@@ -81,6 +81,16 @@ SITE_CONFIG_MODULES = {
|
||||
"none_to_empty": {"image"},
|
||||
"create_defaults": {"image": ""},
|
||||
},
|
||||
"campaigns": {
|
||||
"model": Campaign,
|
||||
"entity": "campaign",
|
||||
"primary": "title",
|
||||
"fields": {"title", "slug", "description", "coverImage", "priceAmount", "priceUnit", "tags", "status", "startsAt", "endsAt"},
|
||||
"none_to_empty": set(),
|
||||
"empty_to_none": {"description", "coverImage", "priceUnit", "startsAt", "endsAt"},
|
||||
"create_defaults": {"description": None, "coverImage": None, "priceAmount": None, "priceUnit": "起/人", "tags": [], "status": "draft", "startsAt": None, "endsAt": None},
|
||||
"ordered": False,
|
||||
},
|
||||
"ctaBanners": {
|
||||
"model": CtaBanner,
|
||||
"entity": "cta_banner",
|
||||
@@ -91,6 +101,8 @@ SITE_CONFIG_MODULES = {
|
||||
},
|
||||
}
|
||||
|
||||
CAMPAIGN_STATUSES = {"draft", "published"}
|
||||
|
||||
|
||||
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 {}})
|
||||
@@ -280,13 +292,33 @@ def clean_site_value(value):
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
|
||||
def normalize_campaign_tags(value) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
tags = [item.strip() for item in value if isinstance(item, str) and item.strip()]
|
||||
if len(tags) > 3:
|
||||
site_config_error(422, "campaign tags cannot exceed 3", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "tags", "max": 3})
|
||||
return tags
|
||||
|
||||
|
||||
def site_field_value(config: dict, field: str, value):
|
||||
if field == "tags":
|
||||
return normalize_campaign_tags(value)
|
||||
value = clean_site_value(value)
|
||||
if value == "" and field in config.get("empty_to_none", set()):
|
||||
return None
|
||||
if value is None and field in config["none_to_empty"]:
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def validate_campaign_status(value):
|
||||
status_value = clean_site_value(value)
|
||||
if status_value not in CAMPAIGN_STATUSES:
|
||||
site_config_error(422, "status must be draft or published", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "status"})
|
||||
return status_value
|
||||
|
||||
|
||||
def validate_site_primary(config: dict, body: SiteConfigPatchIn) -> str:
|
||||
primary = config["primary"]
|
||||
value = clean_site_value(getattr(body, primary))
|
||||
@@ -356,12 +388,21 @@ def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db:
|
||||
payload[config["primary"]] = validate_site_primary(config, body)
|
||||
for field, value in config["create_defaults"].items():
|
||||
payload.setdefault(field, value)
|
||||
if "isActive" in config["fields"]:
|
||||
payload["isActive"] = payload.get("isActive", True)
|
||||
if config.get("ordered", True) and 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"])
|
||||
if module == "campaigns":
|
||||
slug = clean_site_value(payload.get("slug"))
|
||||
if not slug:
|
||||
site_config_error(422, "required field is empty", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "slug"})
|
||||
payload["slug"] = slug
|
||||
if not payload.get("priceUnit"):
|
||||
payload["priceUnit"] = "起/人"
|
||||
payload["status"] = validate_campaign_status(payload.get("status", "draft"))
|
||||
return payload
|
||||
|
||||
|
||||
@@ -375,6 +416,10 @@ def apply_site_patch(module: str, config: dict, item, body: SiteConfigPatchIn) -
|
||||
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})
|
||||
if module == "campaigns" and field == "slug" and not value:
|
||||
site_config_error(422, "required field is empty", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field})
|
||||
if module == "campaigns" and field == "status":
|
||||
value = validate_campaign_status(value)
|
||||
setattr(item, field, value)
|
||||
|
||||
|
||||
@@ -532,6 +577,10 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D
|
||||
model_dict(item)
|
||||
for item in db.scalars(select(CtaBanner).order_by(CtaBanner.sortOrder.asc())).all()
|
||||
],
|
||||
"campaigns": [
|
||||
model_dict(item)
|
||||
for item in db.scalars(select(Campaign).order_by(Campaign.updatedAt.desc())).all()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -127,6 +127,14 @@ class SiteConfigPatchIn(BaseModel):
|
||||
label: str | None = None
|
||||
alt: str | None = None
|
||||
image: str | None = None
|
||||
description: str | None = None
|
||||
coverImage: str | None = None
|
||||
priceAmount: int | None = Field(default=None, ge=0)
|
||||
priceUnit: str | None = None
|
||||
tags: list[str] | None = None
|
||||
status: str | None = None
|
||||
startsAt: datetime | None = None
|
||||
endsAt: datetime | None = None
|
||||
targetType: str | None = None
|
||||
targetValue: str | None = None
|
||||
isHot: bool | None = None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WonderQ-MiniAPP Public API 对接文档
|
||||
|
||||
最后更新:2026-06-30
|
||||
最后更新:2026-07-02
|
||||
|
||||
本文档定义 `WonderQ-MiniAPP` 前台 H5/小程序对接 `WonderQ-Admin` 后端所需的 Public API 契约。当前 MiniAPP 主动调用站点配置、产品列表和线索提交 3 个接口;后端已存在的健康检查、产品详情和目的地列表接口建议继续保留,供后续前台按需接入。
|
||||
|
||||
@@ -60,6 +60,32 @@
|
||||
| `targetValue` | `string \| null` | 否 | 点击目标值 |
|
||||
| `isActive` | `boolean` | 否 | 是否启用 |
|
||||
|
||||
### `Campaign`
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `string` | 是 | 活动 ID |
|
||||
| `slug` | `string` | 是 | 活动标识 |
|
||||
| `title` | `string` | 是 | 活动标题 |
|
||||
| `description` | `string \| null` | 否 | 活动描述 |
|
||||
| `coverImage` | `string \| null` | 否 | 活动封面图 |
|
||||
| `priceAmount` | `number \| null` | 否 | 参考起价,单位按 `priceUnit` 展示 |
|
||||
| `priceUnit` | `string \| null` | 否 | 价格单位文案,默认 `起/人` |
|
||||
| `tags` | `string[]` | 否 | 活动卡片标签,最多 3 个 |
|
||||
| `status` | `string` | 是 | 活动状态;Public API 只返回 `published` |
|
||||
| `startsAt` | `string \| null` | 否 | 活动开始时间 |
|
||||
| `endsAt` | `string \| null` | 否 | 活动结束时间 |
|
||||
|
||||
### `RouteSection`
|
||||
|
||||
`RouteSection` 用于描述首页“精选线路”下的分组。`经典人文打卡线路`、`极限山野户外野咖线路`、`人文+户外综合混搭线路` 等属于“精选线路”的子集,不是独立一级模块。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `string` | 是 | 分组 ID,例如 `routes`、`routes-outdoor`、`routes-mix` |
|
||||
| `title` | `string` | 是 | 分组标题 |
|
||||
| `productIds` | `string[]` | 是 | 该分组包含的产品 ID,产品详情来自 `/api/public/products` 的 `items` |
|
||||
|
||||
### `PublicProduct`
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
@@ -131,10 +157,21 @@
|
||||
| --- | --- | --- |
|
||||
| `heroSlides` | `HeroSlide[]` | 首页顶部轮播 |
|
||||
| `destinations` | `Destination[]` | 首页目的地入口 |
|
||||
| `map` | `Array<{ id: string; image: string; isActive?: boolean }>` | 贵州地图图片;MiniAPP 当前消费 `map[0].image` |
|
||||
| `themes` | `Theme[]` | 主题甄选入口 |
|
||||
| `ctaBanners` | `CtaBanner[]` | 底部 CTA Banner |
|
||||
| `campaigns` | `unknown[]` | 后端现有扩展字段,可保留 |
|
||||
| `routeSections` | `Array<{ id: string; title: string; productIds: string[] }>` | 后端现有扩展字段,可保留 |
|
||||
| `campaigns` | `Campaign[]` | 活动元信息,可用于“特价优惠”入口;当前不包含活动产品结果列表 |
|
||||
| `routeSections` | `RouteSection[]` | “精选线路”子分组定义 |
|
||||
|
||||
#### 首页模块数据归属
|
||||
|
||||
| 首页模块 | 当前接口归属 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 特价优惠 | `site-config.campaigns` + `/api/public/products` | 当前 Public API 只返回活动元信息,不直接返回“特价优惠结果列表”。MiniAPP 若要展示活动线路,可按活动标题、标签或后续扩展的活动产品关联从 `/api/public/products` 中筛选。 |
|
||||
| 精选线路 | `site-config.routeSections` + `/api/public/products` | `routeSections` 只返回分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。 |
|
||||
| 经典人文打卡线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes`。 |
|
||||
| 极限山野户外野咖线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-outdoor`。 |
|
||||
| 人文+户外综合混搭线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-mix`。 |
|
||||
|
||||
#### 响应示例
|
||||
|
||||
@@ -161,8 +198,47 @@
|
||||
"aliases": [{ "id": "alias-1", "alias": "小七孔" }]
|
||||
}
|
||||
],
|
||||
"map": [
|
||||
{
|
||||
"id": "map-1",
|
||||
"image": "/assets/guizhou/guizhou-map.jpg",
|
||||
"isActive": true
|
||||
}
|
||||
],
|
||||
"themes": [],
|
||||
"ctaBanners": []
|
||||
"ctaBanners": [],
|
||||
"campaigns": [
|
||||
{
|
||||
"id": "campaign-1",
|
||||
"slug": "classic-deal",
|
||||
"title": "经典打卡特惠",
|
||||
"description": "经典首游活动",
|
||||
"coverImage": "/assets/guizhou/libo-xiaoqikong.jpg",
|
||||
"priceAmount": 162500,
|
||||
"priceUnit": "起/人",
|
||||
"tags": ["臻藏旅位", "赛事庆典"],
|
||||
"status": "published",
|
||||
"startsAt": null,
|
||||
"endsAt": null
|
||||
}
|
||||
],
|
||||
"routeSections": [
|
||||
{
|
||||
"id": "routes",
|
||||
"title": "经典人文打卡线路",
|
||||
"productIds": ["8a6e7c4f-0000-4000-9000-000000000001"]
|
||||
},
|
||||
{
|
||||
"id": "routes-outdoor",
|
||||
"title": "极限山野户外野咖线路",
|
||||
"productIds": []
|
||||
},
|
||||
{
|
||||
"id": "routes-mix",
|
||||
"title": "人文+户外综合混搭线路",
|
||||
"productIds": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -338,6 +414,8 @@
|
||||
|
||||
- `site-config` 与 `products` 会在应用启动时并行请求;任一请求失败时,MiniAPP 会回退到本地静态内容。
|
||||
- `products.items` 为空时,MiniAPP 会使用本地产品兜底数据。
|
||||
- “精选线路”由 `site-config.routeSections` 定义分组,由 `/api/public/products.items` 提供产品详情;`经典人文打卡线路`、`极限山野户外野咖线路`、`人文+户外综合混搭线路` 是“精选线路”的子集。
|
||||
- “特价优惠”当前没有独立 Public 结果列表字段;`site-config.campaigns` 只提供活动元信息,活动线路需通过产品标签/关键词筛选或后续扩展活动产品关联字段。
|
||||
- 产品搜索当前主要在前端执行,依赖 `title`、`tags`、`destination.name`、`summary`。
|
||||
- 产品详情页当前使用已加载的产品列表数据;后续可改为进入详情页时请求 `GET /api/public/products/{product_id}`。
|
||||
- 收藏、浏览历史和最近咨询记录由 MiniAPP 本地存储处理,不需要后端接口。
|
||||
@@ -346,7 +424,8 @@
|
||||
## 后端验证建议
|
||||
|
||||
- 为 `GET /health` 增加或保留健康检查测试。
|
||||
- 为 `GET /api/public/site-config` 验证返回 JSON 包含 `heroSlides`、`destinations`、`themes`、`ctaBanners` 数组字段。
|
||||
- 为 `GET /api/public/site-config` 验证返回 JSON 包含 `heroSlides`、`destinations`、`map`、`themes`、`ctaBanners`、`campaigns`、`routeSections` 数组字段。
|
||||
- 为 `GET /api/public/site-config` 验证 `routeSections` 表达“精选线路”子分组,并包含 `routes`、`routes-outdoor`、`routes-mix` 三个当前约定分组。
|
||||
- 为 `GET /api/public/products` 验证响应结构为 `{ items: [...] }`,并覆盖 `keyword`、`destinationId`、`status`、`take` 参数。
|
||||
- 为 `GET /api/public/products/{product_id}` 验证 UUID、数字 `sourceId` 和 404 场景。
|
||||
- 为 `GET /api/public/destinations` 验证只返回启用目的地及别名字段。
|
||||
|
||||
@@ -8,9 +8,10 @@ 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, MapImage, MediaAsset, Product, ProductImage, ThemeCard
|
||||
from app.models import AdminUser, Campaign, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MapImage, MediaAsset, Product, ProductImage, ThemeCard
|
||||
from app.routers import admin as admin_router
|
||||
from app.routers.admin import normalize_detail_sections, normalize_images
|
||||
from app.routers.shared import site_config
|
||||
from app.schemas import (
|
||||
AdminProductQuery,
|
||||
LeadCreateIn,
|
||||
@@ -189,6 +190,24 @@ def make_map_image(**overrides):
|
||||
)
|
||||
|
||||
|
||||
def make_campaign(**overrides):
|
||||
return Campaign(
|
||||
id=overrides.get("id", "campaign-test"),
|
||||
slug=overrides.get("slug", "classic-deal"),
|
||||
title=overrides.get("title", "经典打卡特惠"),
|
||||
description=overrides.get("description", "经典首游活动"),
|
||||
coverImage=overrides.get("coverImage", "https://cdn.example.test/campaign.webp"),
|
||||
priceAmount=overrides.get("priceAmount", 162500),
|
||||
priceUnit=overrides.get("priceUnit", "起/人"),
|
||||
tags=overrides.get("tags", ["臻藏旅位", "赛事庆典"]),
|
||||
status=overrides.get("status", "draft"),
|
||||
startsAt=overrides.get("startsAt"),
|
||||
endsAt=overrides.get("endsAt"),
|
||||
createdAt=datetime(2026, 1, 1),
|
||||
updatedAt=datetime(2026, 1, 2),
|
||||
)
|
||||
|
||||
|
||||
def make_admin_user():
|
||||
return AdminUser(
|
||||
id="admin-test",
|
||||
@@ -452,7 +471,7 @@ def test_site_config_create_requires_module_primary_field():
|
||||
|
||||
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], [], [], []])
|
||||
fake_db = FakeDb(scalar_results=[[], [hero_slide], [], [], [], []])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
@@ -470,7 +489,7 @@ def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
|
||||
|
||||
def test_admin_site_config_includes_map_array_with_dedicated_contract():
|
||||
map_image = make_map_image()
|
||||
fake_db = FakeDb(scalar_results=[[], [], [map_image], [], []])
|
||||
fake_db = FakeDb(scalar_results=[[], [], [map_image], [], [], []])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
@@ -494,6 +513,40 @@ def test_admin_site_config_includes_map_array_with_dedicated_contract():
|
||||
assert "targetType" not in body["map"][0]
|
||||
|
||||
|
||||
def test_admin_site_config_includes_campaigns_for_special_offers():
|
||||
campaign = make_campaign(status="published")
|
||||
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign]])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).get("/api/admin/site-config")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["campaigns"] == [
|
||||
{
|
||||
"id": "campaign-test",
|
||||
"slug": "classic-deal",
|
||||
"title": "经典打卡特惠",
|
||||
"description": "经典首游活动",
|
||||
"coverImage": "https://cdn.example.test/campaign.webp",
|
||||
"priceAmount": 162500,
|
||||
"priceUnit": "起/人",
|
||||
"tags": ["臻藏旅位", "赛事庆典"],
|
||||
"status": "published",
|
||||
"startsAt": None,
|
||||
"endsAt": None,
|
||||
"createdAt": "2026-01-01T00:00:00",
|
||||
"updatedAt": "2026-01-02T00:00:00",
|
||||
}
|
||||
]
|
||||
assert "sortOrder" not in body["campaigns"][0]
|
||||
assert "isActive" not in body["campaigns"][0]
|
||||
assert "targetType" not in body["campaigns"][0]
|
||||
|
||||
|
||||
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)
|
||||
@@ -564,6 +617,75 @@ def test_site_config_create_map_image_rejects_duplicate_singleton():
|
||||
assert not fake_db.committed
|
||||
|
||||
|
||||
def test_site_config_create_campaign_defaults_draft_and_audits():
|
||||
fake_db = FakeDb()
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).post(
|
||||
"/api/admin/site-config/campaigns",
|
||||
json={
|
||||
"title": " Classic Deal ",
|
||||
"slug": " classic-deal ",
|
||||
"description": " Deal summary ",
|
||||
"coverImage": " https://cdn.example.test/campaign.webp ",
|
||||
"priceAmount": 188000,
|
||||
"priceUnit": " 起/人 ",
|
||||
"tags": [" 季节限定 ", "", "小众秘境"],
|
||||
"image": "ignored",
|
||||
"isActive": True,
|
||||
"sortOrder": 9,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["title"] == "Classic Deal"
|
||||
assert body["slug"] == "classic-deal"
|
||||
assert body["description"] == "Deal summary"
|
||||
assert body["coverImage"] == "https://cdn.example.test/campaign.webp"
|
||||
assert body["priceAmount"] == 188000
|
||||
assert body["priceUnit"] == "起/人"
|
||||
assert body["tags"] == ["季节限定", "小众秘境"]
|
||||
assert body["status"] == "draft"
|
||||
assert "image" not in body
|
||||
assert "isActive" not in body
|
||||
assert "sortOrder" not in body
|
||||
created = fake_db.added[0]
|
||||
assert isinstance(created, Campaign)
|
||||
assert created.status == "draft"
|
||||
assert created.priceAmount == 188000
|
||||
assert created.priceUnit == "起/人"
|
||||
assert created.tags == ["季节限定", "小众秘境"]
|
||||
assert fake_db.added[-1].entity == "campaign"
|
||||
assert fake_db.committed
|
||||
|
||||
|
||||
def test_site_config_create_campaign_rejects_more_than_three_tags():
|
||||
fake_db = FakeDb()
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).post(
|
||||
"/api/admin/site-config/campaigns",
|
||||
json={
|
||||
"title": "Classic Deal",
|
||||
"slug": "classic-deal",
|
||||
"tags": ["a", "b", "c", "d"],
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["code"] == "MODULE_CONFIG_VALIDATION_ERROR"
|
||||
assert response.json()["details"] == {"field": "tags", "max": 3}
|
||||
assert not fake_db.added
|
||||
assert not fake_db.committed
|
||||
|
||||
|
||||
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)
|
||||
@@ -616,6 +738,81 @@ def test_site_config_patch_map_image_updates_allowed_fields_only():
|
||||
assert fake_db.added[-1].entity == "map_image"
|
||||
|
||||
|
||||
def test_site_config_patch_campaign_updates_contract_fields_only():
|
||||
campaign = make_campaign(status="draft")
|
||||
fake_db = FakeDb(get_result=campaign)
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).patch(
|
||||
"/api/admin/site-config/campaigns/campaign-test",
|
||||
json={
|
||||
"title": " Summer Deal ",
|
||||
"slug": " summer-deal ",
|
||||
"description": None,
|
||||
"coverImage": " https://cdn.example.test/summer.webp ",
|
||||
"priceAmount": 57500,
|
||||
"priceUnit": " 起/人 ",
|
||||
"tags": ["无可比拟", "尊享礼遇", ""],
|
||||
"status": "published",
|
||||
"startsAt": "2026-07-01T08:00:00",
|
||||
"endsAt": None,
|
||||
"image": "ignored",
|
||||
"isActive": False,
|
||||
"sortOrder": 3,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["title"] == "Summer Deal"
|
||||
assert body["slug"] == "summer-deal"
|
||||
assert body["description"] is None
|
||||
assert body["coverImage"] == "https://cdn.example.test/summer.webp"
|
||||
assert body["priceAmount"] == 57500
|
||||
assert body["priceUnit"] == "起/人"
|
||||
assert body["tags"] == ["无可比拟", "尊享礼遇"]
|
||||
assert body["status"] == "published"
|
||||
assert body["startsAt"] == "2026-07-01T08:00:00"
|
||||
assert body["endsAt"] is None
|
||||
assert "image" not in body
|
||||
assert "isActive" not in body
|
||||
assert "sortOrder" not in body
|
||||
assert campaign.status == "published"
|
||||
assert campaign.priceAmount == 57500
|
||||
assert campaign.priceUnit == "起/人"
|
||||
assert campaign.tags == ["无可比拟", "尊享礼遇"]
|
||||
assert fake_db.added[-1].action == "update"
|
||||
assert fake_db.added[-1].entity == "campaign"
|
||||
|
||||
|
||||
def test_public_site_config_campaigns_include_price_and_tags():
|
||||
campaign = make_campaign(status="published", tags=["自然奇景", "小众秘境"])
|
||||
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], []])
|
||||
|
||||
result = site_config(fake_db, active_only=True)
|
||||
|
||||
assert result["campaigns"] == [
|
||||
{
|
||||
"id": "campaign-test",
|
||||
"slug": "classic-deal",
|
||||
"title": "经典打卡特惠",
|
||||
"description": "经典首游活动",
|
||||
"coverImage": "https://cdn.example.test/campaign.webp",
|
||||
"priceAmount": 162500,
|
||||
"priceUnit": "起/人",
|
||||
"tags": ["自然奇景", "小众秘境"],
|
||||
"status": "published",
|
||||
"startsAt": None,
|
||||
"endsAt": None,
|
||||
"createdAt": "2026-01-01T00:00:00",
|
||||
"updatedAt": "2026-01-02T00:00:00",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_site_config_patch_updates_destination_contract_fields():
|
||||
destination = make_destination()
|
||||
fake_db = FakeDb(get_result=destination)
|
||||
@@ -742,6 +939,20 @@ def test_site_config_reorder_map_is_not_supported():
|
||||
assert not fake_db.committed
|
||||
|
||||
|
||||
def test_site_config_reorder_campaigns_is_not_supported():
|
||||
fake_db = FakeDb()
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).patch("/api/admin/site-config/campaigns/reorder", json={"itemIds": ["campaign-test"]})
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["code"] == "MODULE_CONFIG_REORDER_UNSUPPORTED"
|
||||
assert not fake_db.committed
|
||||
|
||||
|
||||
def test_site_config_delete_map_image_returns_json_and_audits_without_reorder():
|
||||
map_image = make_map_image()
|
||||
fake_db = FakeDb(get_result=map_image)
|
||||
@@ -760,6 +971,24 @@ def test_site_config_delete_map_image_returns_json_and_audits_without_reorder():
|
||||
assert fake_db.committed
|
||||
|
||||
|
||||
def test_site_config_delete_campaign_returns_json_and_audits_without_reorder():
|
||||
campaign = make_campaign()
|
||||
fake_db = FakeDb(get_result=campaign)
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).delete("/api/admin/site-config/campaigns/campaign-test")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": "campaign-test"}
|
||||
assert fake_db.deleted == [campaign]
|
||||
assert fake_db.added[-1].action == "delete"
|
||||
assert fake_db.added[-1].entity == "campaign"
|
||||
assert fake_db.committed
|
||||
|
||||
|
||||
def test_admin_media_upload_streams_image_to_oss_records_asset_and_audits(monkeypatch):
|
||||
uploaded = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user