feat: add customer travel records and browse history

This commit is contained in:
duanshuwen committed 2026-08-27 14:19:03 +08:00
1 parent 0139fe6382
commit 6d9a546918
79 files changed
+12362 -112

No files matched your search

@@ -0,0 +1,92 @@
"""Create the customer browse history table for public record pages."""
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from app.migration_compat import baseline_table_names
revision = "0036_customer_browse_history"
down_revision = "0035_concierge_details_jsonb"
branch_labels = None
depends_on = None
TABLE_NAME = "CustomerBrowseHistory"
CUSTOMER_HISTORY_INDEXES = {
"ix_CustomerBrowseHistory_customerId",
"ix_CustomerBrowseHistory_customer_visitedAt",
}
def is_offline_mode() -> bool:
try:
return context.is_offline_mode()
except (NameError, AttributeError):
return False
def table_names() -> set[str]:
if is_offline_mode():
return baseline_table_names()
return set(inspect(op.get_bind()).get_table_names())
def should_create_table(existing_tables: set[str]) -> bool:
return TABLE_NAME not in existing_tables
def upgrade() -> None:
if not should_create_table(table_names()):
return
op.create_table(
TABLE_NAME,
sa.Column("id", sa.String(), nullable=False),
sa.Column("customerId", sa.String(), nullable=False),
sa.Column("itemType", sa.String(), nullable=False),
sa.Column("itemId", sa.String(), nullable=False),
sa.Column("title", sa.String(), nullable=False),
sa.Column("image", sa.String(), nullable=False),
sa.Column("visitedAt", sa.DateTime(), nullable=False),
sa.Column("createdAt", sa.DateTime(), nullable=False),
sa.Column("updatedAt", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["customerId"],
["Customer.id"],
name="fk_CustomerBrowseHistory_customerId_Customer",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"customerId",
"itemType",
"itemId",
name="uq_CustomerBrowseHistory_customer_item",
),
)
op.create_index(
"ix_CustomerBrowseHistory_customerId",
TABLE_NAME,
["customerId"],
)
op.create_index(
"ix_CustomerBrowseHistory_customer_visitedAt",
TABLE_NAME,
["customerId", "visitedAt"],
)
def downgrade() -> None:
if should_create_table(table_names()):
return
op.drop_index(
"ix_CustomerBrowseHistory_customer_visitedAt",
table_name=TABLE_NAME,
)
op.drop_index(
"ix_CustomerBrowseHistory_customerId",
table_name=TABLE_NAME,
)
op.drop_table(TABLE_NAME)
+39 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
@@ -344,6 +344,44 @@ class Customer(Base):
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
orders: Mapped[list["Order"]] = relationship(back_populates="customer")
browseHistory: Mapped[list["CustomerBrowseHistory"]] = relationship(
back_populates="customer",
cascade="all, delete-orphan",
)
class CustomerBrowseHistory(Base):
__tablename__ = "CustomerBrowseHistory"
__table_args__ = (
UniqueConstraint(
"customerId",
"itemType",
"itemId",
name="uq_CustomerBrowseHistory_customer_item",
),
Index(
"ix_CustomerBrowseHistory_customer_visitedAt",
"customerId",
"visitedAt",
),
)
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
customerId: Mapped[str] = mapped_column(
String,
ForeignKey("Customer.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
itemType: Mapped[str] = mapped_column(String, nullable=False)
itemId: Mapped[str] = mapped_column(String, nullable=False)
title: Mapped[str] = mapped_column(String, nullable=False)
image: Mapped[str] = mapped_column(String, nullable=False, default="")
visitedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)
createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
customer: Mapped[Customer] = relationship(back_populates="browseHistory")
class Order(Base):
+158 -4
View File
@@ -1,13 +1,14 @@
from datetime import datetime, time
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from .shared import site_config
from ..auth import create_customer_token, optional_customer, require_customer
from ..api_response import success_response
from ..database import get_db
from ..models import ConciergeAdvisor, Customer, DetailRecord, HomeTeamBuilding, HomeWanfaRecommendation, HomeWildArchive, Lead, WanfaCategory
from ..schemas import LeadCreateIn, PhoneLoginIn
from ..models import ConciergeAdvisor, Customer, CustomerBrowseHistory, DetailRecord, HomeTeamBuilding, HomeWanfaRecommendation, HomeWildArchive, Lead, WanfaCategory, WanfaRoute, utc_now
from ..schemas import BrowseHistoryCreateIn, LeadCreateIn, PhoneLoginIn
from ..media_urls import resolve_media_url
from ..serializers import (
public_concierge_advisor_dict,
public_home_team_building_dict,
@@ -35,6 +36,52 @@ def public_customer_dict(customer: Customer) -> dict:
return {"id": customer.id, "phoneMasked": mask_phone(customer.phone)}
def public_vehicle_demand_dict(lead: Lead) -> dict:
return {
"id": lead.id,
"status": lead.status,
"contactName": lead.contactName,
"phoneMasked": mask_phone(lead.phone),
"destination": lead.destination,
"travelDate": lead.travelDate.isoformat() if lead.travelDate else None,
"peopleCount": lead.peopleCount,
"note": lead.note,
"vehicleDemand": lead.vehicleDemand or {},
"createdAt": lead.createdAt.isoformat(),
"updatedAt": lead.updatedAt.isoformat(),
}
def public_browse_history_dict(item: CustomerBrowseHistory) -> dict:
return {
"id": item.id,
"itemType": item.itemType,
"itemId": item.itemId,
"title": item.title,
"image": resolve_media_url(item.image) or "",
"visitedAt": item.visitedAt.isoformat(),
}
def browse_source(item_type: str, item_id: str, db: Session):
models = {
"wanfa-route": WanfaRoute,
"team-building": HomeTeamBuilding,
"wild-archive": HomeWildArchive,
}
model = models.get(item_type)
if model is None:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="浏览内容类型不支持")
conditions = [model.id == item_id]
if hasattr(model, "isActive"):
conditions.append(model.isActive.is_(True))
source = db.scalar(select(model).where(*conditions))
if not source:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="浏览内容不存在")
return source
@router.get("/health")
def health():
return success_response({"ok": True, "service": "miniapp-api"})
@@ -170,6 +217,113 @@ def current_customer(customer: Customer = Depends(require_customer)):
return success_response(public_customer_dict(customer))
@router.get("/api/public/customer/vehicle-demands")
def list_customer_vehicle_demands(
pageNum: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=1, le=50),
customer: Customer = Depends(require_customer),
db: Session = Depends(get_db),
):
filters = [Lead.customerId == customer.id, Lead.leadType == "vehicle"]
leads = db.scalars(
select(Lead)
.where(*filters)
.order_by(Lead.createdAt.desc())
.offset((pageNum - 1) * pageSize)
.limit(pageSize)
).all()
total = db.scalar(select(func.count()).select_from(Lead).where(*filters)) or 0
return success_response(
{
"items": [public_vehicle_demand_dict(lead) for lead in leads],
"total": total,
"pageNum": pageNum,
"pageSize": pageSize,
}
)
@router.get("/api/public/customer/vehicle-demands/{lead_id}")
def get_customer_vehicle_demand(
lead_id: str,
customer: Customer = Depends(require_customer),
db: Session = Depends(get_db),
):
lead = db.scalar(
select(Lead).where(
Lead.id == lead_id,
Lead.customerId == customer.id,
Lead.leadType == "vehicle",
)
)
if not lead:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用车记录不存在")
return success_response(public_vehicle_demand_dict(lead))
@router.get("/api/public/customer/browse-history")
def list_customer_browse_history(
pageNum: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=1, le=50),
customer: Customer = Depends(require_customer),
db: Session = Depends(get_db),
):
filters = [CustomerBrowseHistory.customerId == customer.id]
items = db.scalars(
select(CustomerBrowseHistory)
.where(*filters)
.order_by(CustomerBrowseHistory.visitedAt.desc())
.offset((pageNum - 1) * pageSize)
.limit(pageSize)
).all()
total = db.scalar(select(func.count()).select_from(CustomerBrowseHistory).where(*filters)) or 0
return success_response(
{
"items": [public_browse_history_dict(item) for item in items],
"total": total,
"pageNum": pageNum,
"pageSize": pageSize,
}
)
@router.post("/api/public/customer/browse-history", status_code=status.HTTP_201_CREATED)
def save_customer_browse_history(
body: BrowseHistoryCreateIn,
customer: Customer = Depends(require_customer),
db: Session = Depends(get_db),
):
source = browse_source(body.itemType, body.itemId, db)
item = db.scalar(
select(CustomerBrowseHistory).where(
CustomerBrowseHistory.customerId == customer.id,
CustomerBrowseHistory.itemType == body.itemType,
CustomerBrowseHistory.itemId == body.itemId,
)
)
now = utc_now()
if item:
item.title = source.title
item.image = source.image or ""
item.visitedAt = now
item.updatedAt = now
else:
item = CustomerBrowseHistory(
customerId=customer.id,
itemType=body.itemType,
itemId=body.itemId,
title=source.title,
image=source.image or "",
visitedAt=now,
createdAt=now,
updatedAt=now,
)
db.add(item)
db.commit()
db.refresh(item)
return success_response(public_browse_history_dict(item), status_code=status.HTTP_201_CREATED)
@router.post("/api/public/leads", status_code=status.HTTP_201_CREATED)
def create_lead(
body: LeadCreateIn,
+14
View File
@@ -10,6 +10,7 @@ LeadStatus = Literal["new", "assigned", "contacted", "planning", "won", "invalid
LeadType = Literal["general", "vehicle"]
VehicleServiceType = Literal["charter", "transfer"]
CharterDuration = Literal["halfDay", "fullDay"]
BrowseHistoryType = Literal["wanfa-route", "team-building", "wild-archive"]
class LoginIn(BaseModel):
@@ -126,6 +127,19 @@ class LeadCreateIn(BaseModel):
return self
class BrowseHistoryCreateIn(BaseModel):
itemType: BrowseHistoryType
itemId: str = Field(min_length=1, max_length=128)
@field_validator("itemId")
@classmethod
def normalize_item_id(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("内容 ID 不能为空")
return normalized
class LeadStatusIn(BaseModel):
status: LeadStatus
@@ -50,6 +50,7 @@ class CurrentSchemaInspector:
"AuditLog",
"ConciergeAdvisor",
"Customer",
"CustomerBrowseHistory",
"DetailRecord",
"HeroSlide",
"HomeTeamBuilding",
@@ -0,0 +1,23 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0036_customer_browse_history.py"
SPEC = spec_from_file_location("customer_browse_history_migration", MIGRATION_PATH)
assert SPEC and SPEC.loader
MIGRATION = module_from_spec(SPEC)
SPEC.loader.exec_module(MIGRATION)
def test_customer_browse_history_migration_is_chained_after_current_head():
assert MIGRATION.revision == "0036_customer_browse_history"
assert MIGRATION.down_revision == "0035_concierge_details_jsonb"
def test_customer_browse_history_table_and_indexes_are_created_only_when_missing():
assert MIGRATION.should_create_table(set()) is True
assert MIGRATION.should_create_table({MIGRATION.TABLE_NAME}) is False
assert MIGRATION.CUSTOMER_HISTORY_INDEXES == {
"ix_CustomerBrowseHistory_customerId",
"ix_CustomerBrowseHistory_customer_visitedAt",
}
@@ -0,0 +1,272 @@
from datetime import datetime
from types import SimpleNamespace
from fastapi.testclient import TestClient
from app.auth import require_customer
from app.database import get_db
from app.main import create_app
from app.models import Customer, Lead, WanfaRoute
class FakeScalarResult:
def __init__(self, items):
self.items = items
def all(self):
return self.items
class FakeDb:
def __init__(self, *, scalar_results=None, scalar_values=None):
self.scalar_results = list(scalar_results or [])
self.scalar_values = list(scalar_values or [])
self.added = []
self.committed = False
def scalars(self, _statement):
return FakeScalarResult(self.scalar_results.pop(0) if self.scalar_results else [])
def scalar(self, _statement):
return self.scalar_values.pop(0) if self.scalar_values else None
def add(self, item):
self.added.append(item)
def flush(self):
for item in self.added:
if not getattr(item, "id", None):
item.id = "browse-history-test"
def commit(self):
self.committed = True
def refresh(self, _item):
return None
def authenticated_app(fake_db, customer):
app = create_app()
app.dependency_overrides[get_db] = lambda: fake_db
app.dependency_overrides[require_customer] = lambda: customer
return app
def make_vehicle_lead(**overrides):
return Lead(
id=overrides.get("id", "lead-1"),
leadType="vehicle",
contactName=overrides.get("contactName", "Damon"),
customerId=overrides.get("customerId", "customer-1"),
destination=overrides.get("destination", "黄果树景区"),
phone=overrides.get("phone", "13800000000"),
travelDate=overrides.get("travelDate", datetime(2026, 8, 25)),
peopleCount=overrides.get("peopleCount", 5),
note=overrides.get("note", "需要儿童座椅"),
vehicleDemand=overrides.get(
"vehicleDemand",
{
"serviceType": "charter",
"charterDuration": "fullDay",
"travelDate": "2026-08-25",
"pickupLocation": "贵阳北站",
"dropoffLocation": "黄果树景区",
"peopleCount": 5,
"luggageCount": 2,
"vehicleOptionTitle": "9座精品商务车",
"specialRequirements": "需要儿童座椅",
},
),
status=overrides.get("status", "new"),
createdAt=overrides.get("createdAt", datetime(2026, 8, 20, 10, 0)),
updatedAt=overrides.get("updatedAt", datetime(2026, 8, 20, 10, 0)),
)
def test_public_vehicle_demands_are_paginated_and_phone_is_masked():
customer = Customer(id="customer-1", phone="13800000000")
lead = make_vehicle_lead()
db = FakeDb(scalar_results=[[lead]], scalar_values=[1])
app = authenticated_app(db, customer)
try:
response = TestClient(app).get(
"/api/public/customer/vehicle-demands?pageNum=2&pageSize=1"
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {
"code": 200,
"msg": "success",
"data": {
"items": [
{
"id": "lead-1",
"status": "new",
"contactName": "Damon",
"phoneMasked": "138****0000",
"destination": "黄果树景区",
"travelDate": "2026-08-25T00:00:00",
"peopleCount": 5,
"note": "需要儿童座椅",
"vehicleDemand": lead.vehicleDemand,
"createdAt": "2026-08-20T10:00:00",
"updatedAt": "2026-08-20T10:00:00",
}
],
"total": 1,
"pageNum": 2,
"pageSize": 1,
},
}
def test_public_vehicle_demand_detail_only_returns_the_current_customer_record():
customer = Customer(id="customer-1", phone="13800000000")
lead = make_vehicle_lead()
db = FakeDb(scalar_values=[lead])
app = authenticated_app(db, customer)
try:
response = TestClient(app).get("/api/public/customer/vehicle-demands/lead-1")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json()["data"]["id"] == "lead-1"
assert response.json()["data"]["phoneMasked"] == "138****0000"
assert "phone" not in response.json()["data"]
def test_public_vehicle_demand_detail_hides_another_customers_record():
customer = Customer(id="customer-1", phone="13800000000")
db = FakeDb(scalar_values=[None])
app = authenticated_app(db, customer)
try:
response = TestClient(app).get("/api/public/customer/vehicle-demands/lead-other")
finally:
app.dependency_overrides.clear()
assert response.status_code == 404
def test_public_browse_history_is_paginated_for_the_current_customer():
customer = Customer(id="customer-1", phone="13800000000")
history = SimpleNamespace(
id="history-1",
customerId="customer-1",
itemType="wanfa-route",
itemId="family-water",
title="亲子玩水",
image="https://example.test/family-water.jpg",
visitedAt=datetime(2026, 8, 26, 12, 0),
)
db = FakeDb(scalar_results=[[history]], scalar_values=[1])
app = authenticated_app(db, customer)
try:
response = TestClient(app).get(
"/api/public/customer/browse-history?pageNum=1&pageSize=20"
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json()["data"] == {
"items": [
{
"id": "history-1",
"itemType": "wanfa-route",
"itemId": "family-water",
"title": "亲子玩水",
"image": "https://example.test/family-water.jpg",
"visitedAt": "2026-08-26T12:00:00",
}
],
"total": 1,
"pageNum": 1,
"pageSize": 20,
}
def test_public_browse_history_upserts_a_valid_route_without_accepting_a_customer_id():
customer = Customer(id="customer-1", phone="13800000000")
route = WanfaRoute(
id="family-water",
categoryId="family-route",
title="亲子玩水",
subtitle="贵州·轻松节奏与自然课堂",
image="https://example.test/family-water.jpg",
routeCount=4,
demandKeyword="亲子玩水",
sortOrder=0,
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
db = FakeDb(scalar_values=[route, None])
app = authenticated_app(db, customer)
try:
response = TestClient(app).post(
"/api/public/customer/browse-history",
json={
"customerId": "customer-other",
"itemType": "wanfa-route",
"itemId": "family-water",
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
history = db.added[0]
assert history.customerId == "customer-1"
assert history.itemType == "wanfa-route"
assert history.itemId == "family-water"
assert history.title == "亲子玩水"
assert history.image == "https://example.test/family-water.jpg"
assert response.json()["data"]["itemId"] == "family-water"
def test_public_browse_history_updates_existing_item_instead_of_creating_a_duplicate():
customer = Customer(id="customer-1", phone="13800000000")
route = WanfaRoute(
id="family-water",
categoryId="family-route",
title="亲子玩水·更新",
subtitle="贵州·轻松节奏与自然课堂",
image="https://example.test/family-water-new.jpg",
routeCount=4,
demandKeyword="亲子玩水",
sortOrder=0,
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
history = SimpleNamespace(
id="history-1",
customerId="customer-1",
itemType="wanfa-route",
itemId="family-water",
title="亲子玩水",
image="https://example.test/family-water-old.jpg",
visitedAt=datetime(2026, 8, 25, 12, 0),
)
db = FakeDb(scalar_values=[route, history])
app = authenticated_app(db, customer)
try:
response = TestClient(app).post(
"/api/public/customer/browse-history",
json={"itemType": "wanfa-route", "itemId": "family-water"},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
assert db.added == []
assert history.title == "亲子玩水·更新"
assert history.image == "https://example.test/family-water-new.jpg"
assert history.visitedAt > datetime(2026, 8, 25, 12, 0)