feat(wanfa): 新增路线详情参考价格配置及展示功能

- 新增数据库迁移脚本,为详情表添加四个价格相关字段
- 完善后端schema校验、序列化逻辑,增加价格相关的数据清理与验证规则
- 新增价格配置校验逻辑,设置起售价时必须选择对应的计价单位
- 在Admin UI详情编辑器中新增价格配置模块,支持配置起售价、计价单位、适用人数范围和价格说明
- 更新小程序端详情页面,支持展示配置的参考价格信息
- 补充相关测试用例,更新API文档与类型定义
This commit is contained in:
duanshuwen committed 2026-08-27 22:02:51 +08:00
1 parent f18971a430
commit feec6ab80f
20 files changed
+351 -17

No files matched your search

@@ -0,0 +1,64 @@
"""Add configurable price presentation fields to detail records."""
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from app.migration_compat import baseline_column_names
revision = "0037_detail_price_config"
down_revision = "0036_customer_browse_history"
branch_labels = None
depends_on = None
PRICE_COLUMNS = {
"priceStartingValue",
"priceUnit",
"pricePeopleRange",
"priceDescription",
}
def missing_price_columns(existing_columns: set[str]) -> set[str]:
return PRICE_COLUMNS.difference(existing_columns)
def is_offline_mode() -> bool:
try:
return context.is_offline_mode()
except (NameError, AttributeError):
return False
def upgrade() -> None:
if is_offline_mode():
columns_to_add = missing_price_columns(baseline_column_names("DetailRecord"))
else:
existing_columns = {
column["name"] for column in inspect(op.get_bind()).get_columns("DetailRecord")
}
columns_to_add = missing_price_columns(existing_columns)
definitions = {
"priceStartingValue": sa.Column("priceStartingValue", sa.Float(), nullable=True),
"priceUnit": sa.Column("priceUnit", sa.String(), nullable=True),
"pricePeopleRange": sa.Column("pricePeopleRange", sa.String(), nullable=True),
"priceDescription": sa.Column("priceDescription", sa.Text(), nullable=True),
}
for name, column in definitions.items():
if name in columns_to_add:
op.add_column("DetailRecord", column)
def downgrade() -> None:
if is_offline_mode():
columns_to_drop = PRICE_COLUMNS.intersection(baseline_column_names("DetailRecord"))
else:
columns_to_drop = {
column["name"] for column in inspect(op.get_bind()).get_columns("DetailRecord")
}.intersection(PRICE_COLUMNS)
for name in ("priceDescription", "pricePeopleRange", "priceUnit", "priceStartingValue"):
if name in columns_to_drop:
op.drop_column("DetailRecord", name)
+5 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
@@ -273,6 +273,10 @@ class DetailRecord(AdminOwnedContent, Base):
excluded: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
notes: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
gallery: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
priceStartingValue: Mapped[float | None] = mapped_column(Float, nullable=True)
priceUnit: Mapped[str | None] = mapped_column(String, nullable=True)
pricePeopleRange: Mapped[str | None] = mapped_column(String, nullable=True)
priceDescription: Mapped[str | None] = mapped_column(Text, nullable=True)
conciergeAdvisorId: Mapped[str | None] = mapped_column(String, nullable=True)
isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False, index=True)
+26
View File
@@ -11,6 +11,7 @@ LeadType = Literal["general", "vehicle"]
VehicleServiceType = Literal["charter", "transfer"]
CharterDuration = Literal["halfDay", "fullDay"]
BrowseHistoryType = Literal["wanfa-route", "team-building", "wild-archive"]
PriceUnit = Literal["person", "day", "group"]
class LoginIn(BaseModel):
@@ -281,6 +282,13 @@ def normalize_detail_gallery(value: list[str]) -> list[str]:
return normalized
def normalize_optional_price_text(value: str | None) -> str | None:
if value is None:
return None
normalized = normalize_optional_text(value)
return normalized or None
class DetailCreate(BaseModel):
key: str = Field(min_length=1, max_length=120)
eyebrow: str = Field(min_length=1, max_length=80)
@@ -293,6 +301,10 @@ class DetailCreate(BaseModel):
excluded: list[str] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
gallery: list[str] = Field(default_factory=list)
priceStartingValue: float | None = Field(default=None, ge=0)
priceUnit: PriceUnit | None = None
pricePeopleRange: str | None = Field(default=None, max_length=80)
priceDescription: str | None = Field(default=None, max_length=500)
conciergeAdvisorId: str | None = Field(default=None, max_length=120)
isActive: bool = True
sortOrder: int | None = Field(default=None, ge=0)
@@ -312,6 +324,11 @@ class DetailCreate(BaseModel):
def validate_detail_gallery(cls, value: list[str]) -> list[str]:
return normalize_detail_gallery(value)
@field_validator("pricePeopleRange", "priceDescription", mode="before")
@classmethod
def normalize_price_text(cls, value: str | None) -> str | None:
return normalize_optional_price_text(value)
@field_validator("conciergeAdvisorId", mode="before")
@classmethod
def normalize_concierge_advisor_id(cls, value: str | None) -> str | None:
@@ -333,6 +350,10 @@ class DetailPatch(BaseModel):
excluded: list[str] | None = None
notes: list[str] | None = None
gallery: list[str] | None = None
priceStartingValue: float | None = Field(default=None, ge=0)
priceUnit: PriceUnit | None = None
pricePeopleRange: str | None = Field(default=None, max_length=80)
priceDescription: str | None = Field(default=None, max_length=500)
conciergeAdvisorId: str | None = Field(default=None, max_length=120)
isActive: bool | None = None
sortOrder: int | None = Field(default=None, ge=0)
@@ -358,6 +379,11 @@ class DetailPatch(BaseModel):
raise ValueError("字段不能为空")
return normalize_detail_gallery(value)
@field_validator("pricePeopleRange", "priceDescription", mode="before")
@classmethod
def normalize_optional_price_text_fields(cls, value: str | None) -> str | None:
return normalize_optional_price_text(value)
@field_validator("conciergeAdvisorId", mode="before")
@classmethod
def normalize_optional_concierge_advisor_id(cls, value: str | None) -> str | None:
+4
View File
@@ -80,6 +80,10 @@ def public_detail_dict(detail, concierge_advisor=None) -> dict:
"excluded": detail.excluded or [],
"notes": detail.notes or [],
"gallery": [resolve_media_url(image) for image in (detail.gallery or [])],
"priceStartingValue": detail.priceStartingValue,
"priceUnit": detail.priceUnit,
"pricePeopleRange": detail.pricePeopleRange,
"priceDescription": detail.priceDescription,
"conciergeAdvisor": public_concierge_advisor_dict(concierge_advisor) if concierge_advisor else None,
}
@@ -0,0 +1,28 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0037_detail_price_config.py"
SPEC = spec_from_file_location("detail_price_config_migration", MIGRATION_PATH)
assert SPEC and SPEC.loader
MIGRATION = module_from_spec(SPEC)
SPEC.loader.exec_module(MIGRATION)
def test_price_columns_are_added_only_when_missing():
assert MIGRATION.missing_price_columns(set()) == {
"priceStartingValue",
"priceUnit",
"pricePeopleRange",
"priceDescription",
}
assert MIGRATION.missing_price_columns({"priceStartingValue", "priceUnit"}) == {
"pricePeopleRange",
"priceDescription",
}
assert MIGRATION.missing_price_columns({
"priceStartingValue",
"priceUnit",
"pricePeopleRange",
"priceDescription",
}) == set()
+36
View File
@@ -77,6 +77,10 @@ def make_detail(**overrides):
"excluded": ["往返大交通"],
"notes": ["请准备轻便雨具。"],
"gallery": ["https://example.test/family-water.jpg"],
"priceStartingValue": 1.68,
"priceUnit": "person",
"pricePeopleRange": "2-4人",
"priceDescription": "价格以最终确认方案为准。",
"isActive": True,
"sortOrder": 0,
"conciergeAdvisorId": None,
@@ -124,11 +128,33 @@ def test_detail_schema_trims_text_and_array_items():
excluded=[" 往返大交通 "],
notes=[" 请准备雨具。 "],
gallery=[" https://example.test/family-water.jpg "],
priceStartingValue=1.68,
priceUnit="person",
pricePeopleRange=" 2-4人 ",
priceDescription=" 价格以最终确认方案为准。 ",
)
assert detail.key == "family-water"
assert detail.highlights == ["核心景观串联", "小团出行"]
assert detail.gallery == ["https://example.test/family-water.jpg"]
assert detail.priceStartingValue == 1.68
assert detail.priceUnit == "person"
assert detail.pricePeopleRange == "2-4人"
assert detail.priceDescription == "价格以最终确认方案为准。"
def test_detail_schema_rejects_unknown_price_unit():
with pytest.raises(ValidationError):
DetailCreate(
key="family-water",
eyebrow="玩法推荐",
duration="5天4晚",
title="亲子玩水",
subtitle="贵州路线",
intro="介绍",
priceStartingValue=1.68,
priceUnit="month",
)
def test_detail_schema_rejects_invalid_gallery_url():
@@ -188,6 +214,10 @@ def test_public_detail_returns_frontend_contract_without_auth():
"excluded": ["往返大交通"],
"notes": ["请准备轻便雨具。"],
"gallery": ["https://example.test/family-water.jpg"],
"priceStartingValue": 1.68,
"priceUnit": "person",
"pricePeopleRange": "2-4人",
"priceDescription": "价格以最终确认方案为准。",
"conciergeAdvisor": None,
},
}
@@ -294,6 +324,10 @@ def test_admin_detail_create_normalizes_key_and_appends():
"excluded": [],
"notes": [],
"gallery": ["https://example.test/family-water.jpg"],
"priceStartingValue": 1.68,
"priceUnit": "person",
"pricePeopleRange": "2-4人",
"priceDescription": "价格以最终确认方案为准。",
},
)
finally:
@@ -301,6 +335,8 @@ def test_admin_detail_create_normalizes_key_and_appends():
assert response.status_code == 201
assert response.json()["data"]["key"] == "family-water"
assert fake_db.added[0].priceStartingValue == 1.68
assert fake_db.added[0].priceUnit == "person"
assert fake_db.added[0].sortOrder == 3