feat: add campaign model and API integration with price and tags

This commit is contained in:
duanshuwen
2026-07-02 13:30:09 +08:00
parent 7b329907f9
commit 521e501992
6 changed files with 430 additions and 9 deletions

View 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")

View File

@@ -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)

View File

@@ -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)
payload["isActive"] = payload.get("isActive", True)
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()
],
}

View File

@@ -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

View File

@@ -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` 验证只返回启用目的地及别名字段。

View File

@@ -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 = {}