from datetime import datetime, time 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, 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, public_home_team_building_detail_dict, public_home_wanfa_recommendation_dict, public_home_wild_archive_detail_dict, public_home_wild_archive_dict, public_detail_dict, public_wanfa_category_dict, ) from ..wechat import WechatApiError, WechatConfigError, exchange_phone_code router = APIRouter() def mask_phone(phone: str) -> str: normalized = phone.strip() if len(normalized) <= 7: return "***" return f"{normalized[:3]}****{normalized[-4:]}" 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"}) @router.get("/api/public/site-config") def get_site_config(db: Session = Depends(get_db)): return success_response(site_config(db, active_only=True)) @router.get("/api/public/wanfa/categories") def get_public_wanfa_categories(db: Session = Depends(get_db)): categories = db.scalars( select(WanfaCategory) .options(selectinload(WanfaCategory.routes)) .order_by(WanfaCategory.sortOrder.asc()) ).all() return success_response({"categories": [public_wanfa_category_dict(category) for category in categories]}) @router.get("/api/public/details/{detail_key}") def get_public_detail(detail_key: str, db: Session = Depends(get_db)): detail = db.scalar( select(DetailRecord).where( DetailRecord.key == detail_key, DetailRecord.isActive.is_(True), ) ) if not detail: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="详情不存在") advisor = None if detail.conciergeAdvisorId: candidate = db.get(ConciergeAdvisor, detail.conciergeAdvisorId) if candidate and candidate.isActive: advisor = candidate return success_response(public_detail_dict(detail, advisor)) @router.get("/api/public/concierge/advisors") def get_public_concierge_advisors(db: Session = Depends(get_db)): advisors = db.scalars( select(ConciergeAdvisor) .where(ConciergeAdvisor.isActive.is_(True)) .order_by(ConciergeAdvisor.sortOrder.asc()) ).all() return success_response({"advisors": [public_concierge_advisor_dict(advisor) for advisor in advisors]}) @router.get("/api/public/home") def get_public_home(db: Session = Depends(get_db)): team_buildings = db.scalars( select(HomeTeamBuilding) .where(HomeTeamBuilding.isActive.is_(True)) .order_by(HomeTeamBuilding.sortOrder.asc()) ).all() wild_archives = db.scalars( select(HomeWildArchive) .where(HomeWildArchive.isActive.is_(True)) .order_by(HomeWildArchive.sortOrder.asc()) ).all() play_recommendations = db.scalars( select(HomeWanfaRecommendation) .options(selectinload(HomeWanfaRecommendation.category).selectinload(WanfaCategory.routes)) .where(HomeWanfaRecommendation.isActive.is_(True)) .order_by(HomeWanfaRecommendation.sortOrder.asc()) ).all() return success_response( { "experiences": [public_home_wanfa_recommendation_dict(item) for item in play_recommendations], "teamBuildings": [public_home_team_building_dict(item) for item in team_buildings], "wildArchives": [public_home_wild_archive_dict(item) for item in wild_archives], } ) @router.get("/api/public/home/team-buildings/{team_building_id}") def get_public_team_building(team_building_id: str, db: Session = Depends(get_db)): team_building = db.scalar( select(HomeTeamBuilding).where( HomeTeamBuilding.id == team_building_id, HomeTeamBuilding.isActive.is_(True), ) ) if not team_building: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="团队共创不存在") return success_response(public_home_team_building_detail_dict(team_building)) @router.get("/api/public/home/wild-archives") def list_public_wild_archives(db: Session = Depends(get_db)): archives = db.scalars( select(HomeWildArchive) .where(HomeWildArchive.isActive.is_(True)) .order_by(HomeWildArchive.sortOrder.asc()) ).all() return success_response({"items": [public_home_wild_archive_dict(item) for item in archives]}) @router.get("/api/public/home/wild-archives/{archive_id}") def get_public_wild_archive(archive_id: str, db: Session = Depends(get_db)): archive = db.scalar( select(HomeWildArchive).where( HomeWildArchive.id == archive_id, HomeWildArchive.isActive.is_(True), ) ) if not archive: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="客片案例不存在") return success_response(public_home_wild_archive_detail_dict(archive)) @router.post("/api/public/auth/phone-login") def phone_login(body: PhoneLoginIn, db: Session = Depends(get_db)): try: phone = exchange_phone_code(body.code) except WechatConfigError as exc: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc except WechatApiError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc customer = db.scalar(select(Customer).where(Customer.phone == phone)) if not customer: customer = Customer(phone=phone) db.add(customer) db.flush() db.commit() db.refresh(customer) return success_response({"token": create_customer_token(customer), "customer": public_customer_dict(customer)}) @router.get("/api/public/auth/me") 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, customer: Customer | None = Depends(optional_customer), db: Session = Depends(get_db), ): if body.leadType == "vehicle" and customer is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用车需求提交前请先登录") payload = body.model_dump(exclude_none=True, exclude={"vehicleDemand"}) if body.leadType == "vehicle" and body.vehicleDemand: demand = body.vehicleDemand payload.update( { "customerId": customer.id if customer else None, "destination": demand.dropoffLocation, "travelDate": datetime.combine(demand.travelDate, time.min), "peopleCount": demand.peopleCount, "vehicleDemand": demand.model_dump(mode="json"), } ) lead = Lead(**payload, status="new") db.add(lead) db.commit() db.refresh(lead) return success_response({"id": lead.id, "status": lead.status}, status_code=status.HTTP_201_CREATED)