commit 8c0a5f489d8a169619cc884471d7f03fd564e93e Author: 刘涛 Date: Sun Mar 22 22:01:23 2026 +0800 Initial commit: hulai admin API backend Co-Authored-By: Claude Sonnet 4.6 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..b389221 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "hulai-admin-api (FastAPI 后端)", + "runtimeExecutable": "venv/bin/uvicorn", + "runtimeArgs": ["app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"], + "port": 8000 + } + ] +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..667b0cc --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,23 @@ +{ + "permissions": { + "defaultMode": "bypassPermissions", + "allow": [ + "Bash(find /Users/liutao/Desktop/hulai-admin-api -type f -name *.py -o -type d)", + "Bash(source venv/bin/activate)", + "Bash(alembic init:*)", + "Bash(alembic revision:*)", + "Bash(alembic upgrade:*)", + "Bash(find /Users/liutao/Desktop/hulai-admin-api/scripts -type f -name *.py)", + "Bash(find /Users/liutao/Desktop/hulai-admin-api -name *.json -type f)", + "Bash(venv/bin/alembic upgrade:*)", + "Bash(venv/bin/alembic current:*)", + "Bash(wc -l /Users/liutao/Desktop/hulai-admin-api/app/routers/*.py)", + "Bash(venv/bin/python -c \":*)", + "Bash(mysql -u root -e \"SHOW DATABASES;\")", + "Bash(mysql -u root hulai_admin -e \"SHOW TABLES;\")", + "Bash(python -c \"import fastapi, uvicorn, sqlalchemy; print\\(''依赖检查通过''\\)\")", + "Bash(python /tmp/test_api.py)", + "Bash(python3:*)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d1cbcf4 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL=mysql+pymysql://root:password@localhost:3306/hulai_admin?charset=utf8mb4 +JWT_SECRET_KEY=change-this-to-a-random-secret-key +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=480 +NUXT_DATA_PATH=/path/to/hulai-website/data +NUXT_PROJECT_PATH=/path/to/hulai-website +UPLOAD_DIR=/path/to/hulai-website/public/images diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6286d48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Python +venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +.eggs/ + +# Database +*.db +*.sqlite3 + +# Uploads & media +uploads/ + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..77cc285 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,119 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +version_path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..22fb042 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,51 @@ +from __future__ import annotations +from datetime import datetime, timedelta, timezone + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from app.config import settings +from app.database import get_db + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): + from app.models.user import AdminUser + + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的认证凭据", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user = db.query(AdminUser).filter(AdminUser.username == username, AdminUser.is_active == True).first() + if user is None: + raise credentials_exception + return user diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..47a2388 --- /dev/null +++ b/app/config.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str + JWT_SECRET_KEY: str + JWT_ALGORITHM: str = "HS256" + JWT_EXPIRE_MINUTES: int = 480 + NUXT_DATA_PATH: str + NUXT_PROJECT_PATH: str + UPLOAD_DIR: str + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..2f0392f --- /dev/null +++ b/app/database.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase + +from app.config import settings + +engine = create_engine(settings.DATABASE_URL, echo=False, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8002963 --- /dev/null +++ b/app/main.py @@ -0,0 +1,62 @@ +from __future__ import annotations +import os + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.routers import auth, brand, reviews, faq, contact, seo, navigation, products, about, versions, guides, upload, export, site_images +from app.routers.seasonal_products import autumn_router, winter_router +from app.routers import winter_camp, destinations, blog, stories, news, pricing, qualifications, partners, calendar +from app.routers import destinations_detail, gallery, courses, selector, customize + +app = FastAPI(title="呼籁旅行管理后台 API", version="1.0.0", redirect_slashes=False) + +# CORS - 根据环境区分 +_cors_origins = ["https://admin.1814.love"] +if os.getenv("ENV", "development") == "development": + _cors_origins += ["http://localhost:5173", "http://localhost:3001"] + +app.add_middleware( + CORSMiddleware, + allow_origins=_cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Register routers +app.include_router(auth.router) +app.include_router(brand.router) +app.include_router(products.router) +app.include_router(faq.router) +app.include_router(reviews.router) +app.include_router(about.router) +app.include_router(contact.router) +app.include_router(seo.router) +app.include_router(navigation.router) +app.include_router(versions.router) +app.include_router(guides.router) +app.include_router(upload.router) +app.include_router(site_images.router) +app.include_router(export.router) +app.include_router(autumn_router) +app.include_router(winter_router) +app.include_router(winter_camp.router) +app.include_router(destinations.router) +app.include_router(blog.router) +app.include_router(stories.router) +app.include_router(news.router) +app.include_router(pricing.router) +app.include_router(qualifications.router) +app.include_router(partners.router) +app.include_router(calendar.router) +app.include_router(destinations_detail.router) +app.include_router(gallery.router) +app.include_router(courses.router) +app.include_router(selector.router) +app.include_router(customize.router) + + +@app.get("/api/health") +def health_check(): + return {"status": "ok", "service": "hulai-admin-api"} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..8815c1a --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,28 @@ +from app.models.user import AdminUser +from app.models.brand import Brand, BrandTrustStat, BrandDifferentiator +from app.models.product import ProductConfig, ProductVersion, ProductVersionHighlight, SummerCamp, SummerCampPrinciple, SummerCampActivity, SummerCampItinerary, SummerCampFaq, SelectionGuide +from app.models.faq import FaqCategory, FaqQuestion +from app.models.review import ReviewSummary, Review +from app.models.about import AboutStory, AboutSubsidiary, AboutCertification, AboutTrademark, AboutCopyright, AboutGuarantee, AboutXiaohongshu, AboutTeam, AboutCulture, AboutCultureValue +from app.models.contact import ContactChannel, ContactConfig +from app.models.seo import SeoPage +from app.models.navigation import NavHeader, NavFooterGroup, NavFooterLink +from app.models.version import VersionConfig, VersionUpgrade, VersionHighlight, VersionCompare, VersionTimeline, VersionTimelineChange, VersionPhilosophy +from app.models.guide import GuideConfig, GuideSection +from app.models.site_image import SiteImage +from app.models.export_log import ExportLog +from app.models.seasonal_product import SeasonalProductConfig, SeasonalProductVersion, SeasonalProductHighlight, SeasonalProductTimeline +from app.models.winter_camp import WinterCampConfig, WinterCampHotel, WinterCampItinerary, WinterCampFaq +from app.models.destination import DestinationConfig, DestinationItem, DestinationDimension, DestinationHonestItem +from app.models.blog import Blog +from app.models.story import Story +from app.models.news import News +from app.models.pricing import PricingItem +from app.models.qualification import Qualification +from app.models.partner import Partner +from app.models.calendar import CalendarMonth +from app.models.destination_detail import DestinationDetail +from app.models.gallery import GalleryItem +from app.models.courses import CoursesConfig +from app.models.selector import SelectorConfig +from app.models.customize_submission import CustomizeSubmission diff --git a/app/models/about.py b/app/models/about.py new file mode 100644 index 0000000..93a8e6b --- /dev/null +++ b/app/models/about.py @@ -0,0 +1,206 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class AboutStory(Base): + __tablename__ = "about_story" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200)) + content = Column(Text) + founding_moment = Column(String(500)) + totem_description = Column(Text) + totem_tagline = Column(String(500)) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class AboutSubsidiary(Base): + __tablename__ = "about_subsidiaries" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + role = Column(Text) + established = Column(String(20)) + sort_order = Column(Integer, default=0) + + +class AboutCertification(Base): + __tablename__ = "about_certifications" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(255), nullable=False) + detail = Column(String(500)) + sort_order = Column(Integer, default=0) + + +class AboutTrademark(Base): + __tablename__ = "about_trademark" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100)) + scope = Column(String(100)) + holder = Column(String(255)) + description = Column(Text) + + +class AboutCopyright(Base): + __tablename__ = "about_copyrights" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + reg_no = Column(String(100)) + category = Column(String(100)) + holder = Column(String(255)) + date = Column(String(20)) + description = Column(Text) + sort_order = Column(Integer, default=0) + + +class AboutGuarantee(Base): + __tablename__ = "about_guarantees" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(255), nullable=False) + detail = Column(Text) + sort_order = Column(Integer, default=0) + + +class AboutXiaohongshu(Base): + __tablename__ = "about_xiaohongshu" + + id = Column(Integer, primary_key=True, autoincrement=True) + account = Column(String(200)) + verified = Column(Boolean, default=False) + verified_type = Column(String(200)) + followers = Column(String(50)) + likes = Column(String(50)) + awards = Column(JSON) + tagline = Column(String(500)) + tags = Column(JSON) + description = Column(Text) + + +class AboutTeam(Base): + __tablename__ = "about_team" + + id = Column(Integer, primary_key=True, autoincrement=True) + summary = Column(Text) + + +class AboutCulture(Base): + __tablename__ = "about_culture" + + id = Column(Integer, primary_key=True, autoincrement=True) + core = Column(String(200)) + transparency = Column(Text) + trust = Column(String(500)) + grief_award_name = Column(String(100)) + grief_award_description = Column(Text) + + values = relationship("AboutCultureValue", back_populates="culture", cascade="all, delete-orphan", order_by="AboutCultureValue.sort_order") + + +class AboutCultureValue(Base): + __tablename__ = "about_culture_values" + + id = Column(Integer, primary_key=True, autoincrement=True) + culture_id = Column(Integer, ForeignKey("about_culture.id", ondelete="CASCADE"), nullable=False) + name = Column(String(50), nullable=False) + expression = Column(Text) + sort_order = Column(Integer, default=0) + + culture = relationship("AboutCulture", back_populates="values") + + +class AboutFounderDetail(Base): + """单个创始人详细介绍(刘涛)""" + __tablename__ = "about_founder_detail" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100)) + title = Column(String(200)) + brand_founded = Column(Integer) + years_in_hulunbuir = Column(String(50)) + background = Column(Text) + expertise = Column(JSON) # list of strings + media_presence = Column(JSON) # list of strings + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class AboutFounder(Base): + """三位创始人简短介绍(列表)""" + __tablename__ = "about_founders" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + title = Column(String(200)) + story = Column(Text) + sort_order = Column(Integer, default=0) + + +class AboutMilestone(Base): + """品牌成长纪事""" + __tablename__ = "about_milestones" + + id = Column(Integer, primary_key=True, autoincrement=True) + year = Column(Integer, nullable=False) + event = Column(Text, nullable=False) + sort_order = Column(Integer, default=0) + + +class AboutServicePrinciple(Base): + """服务原则(列表字符串)""" + __tablename__ = "about_service_principles" + + id = Column(Integer, primary_key=True, autoincrement=True) + text = Column(Text, nullable=False) + sort_order = Column(Integer, default=0) + + +class AboutDifferentiation(Base): + """差异化竞争力""" + __tablename__ = "about_differentiation" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + detail = Column(Text) + sort_order = Column(Integer, default=0) + + +class AboutServiceJourney(Base): + """服务旅程配置(标题+子标题)""" + __tablename__ = "about_service_journey" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200)) + subtitle = Column(String(500)) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + moments = relationship("AboutServiceMoment", back_populates="journey", cascade="all, delete-orphan", order_by="AboutServiceMoment.sort_order") + + +class AboutServiceMoment(Base): + """服务触点(8个时刻)""" + __tablename__ = "about_service_moments" + + id = Column(Integer, primary_key=True, autoincrement=True) + journey_id = Column(Integer, ForeignKey("about_service_journey.id", ondelete="CASCADE"), nullable=False) + step = Column(Integer, nullable=False) + name = Column(String(100), nullable=False) + detail = Column(Text) + sort_order = Column(Integer, default=0) + + journey = relationship("AboutServiceJourney", back_populates="moments") + + +class AboutStats(Base): + """品牌统计数据(单行JSON)""" + __tablename__ = "about_stats" + + id = Column(Integer, primary_key=True, autoincrement=True) + data = Column(JSON) # flat key-value dict + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/blog.py b/app/models/blog.py new file mode 100644 index 0000000..d463506 --- /dev/null +++ b/app/models/blog.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON +from sqlalchemy.sql import func + +from app.database import Base + + +class Blog(Base): + __tablename__ = "blog_posts" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200), nullable=False) + slug = Column(String(200), unique=True, nullable=False) + cover_image = Column(String(500)) + summary = Column(Text) + content = Column(Text) + author = Column(String(100)) + category = Column(String(100)) + tags = Column(JSON) + published_at = Column(String(50)) + is_visible = Column(Boolean, default=True) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/brand.py b/app/models/brand.py new file mode 100644 index 0000000..c8c04a8 --- /dev/null +++ b/app/models/brand.py @@ -0,0 +1,53 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class Brand(Base): + __tablename__ = "brand" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + full_name = Column(String(255)) + domain = Column(String(100)) + url = Column(String(255)) + slogan_emotional = Column(String(255)) + slogan_functional = Column(String(255)) + icp_entity = Column(String(255)) + icp = Column(String(100)) + icp_url = Column(String(255)) + e_contract = Column(String(255)) + cta_buttons = Column(JSON) # {primary, secondary, pricing, customize, contact} + conversion_path = Column(JSON) # list of {step, label, to} + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + trust_stats = relationship("BrandTrustStat", back_populates="brand", cascade="all, delete-orphan", order_by="BrandTrustStat.sort_order") + differentiators = relationship("BrandDifferentiator", back_populates="brand", cascade="all, delete-orphan", order_by="BrandDifferentiator.sort_order") + + +class BrandTrustStat(Base): + __tablename__ = "brand_trust_stats" + + id = Column(Integer, primary_key=True, autoincrement=True) + brand_id = Column(Integer, ForeignKey("brand.id", ondelete="CASCADE"), nullable=False) + value = Column(String(50), nullable=False) + unit = Column(String(20)) + label = Column(String(100), nullable=False) + sort_order = Column(Integer, default=0) + + brand = relationship("Brand", back_populates="trust_stats") + + +class BrandDifferentiator(Base): + __tablename__ = "brand_differentiators" + + id = Column(Integer, primary_key=True, autoincrement=True) + brand_id = Column(Integer, ForeignKey("brand.id", ondelete="CASCADE"), nullable=False) + title = Column(String(100), nullable=False) + description = Column(Text) + sort_order = Column(Integer, default=0) + + brand = relationship("Brand", back_populates="differentiators") diff --git a/app/models/calendar.py b/app/models/calendar.py new file mode 100644 index 0000000..897e178 --- /dev/null +++ b/app/models/calendar.py @@ -0,0 +1,21 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON, UniqueConstraint +from sqlalchemy.sql import func + +from app.database import Base + + +class CalendarMonth(Base): + __tablename__ = "calendar_months" + __table_args__ = (UniqueConstraint("year", "month", name="uq_calendar_year_month"),) + + id = Column(Integer, primary_key=True, autoincrement=True) + year = Column(Integer, nullable=False) + month = Column(Integer, nullable=False) # 1-12 + title = Column(String(100)) + description = Column(Text) + weather = Column(String(200)) + highlights = Column(JSON) # list of strings + events = Column(JSON) # list of {date, title, description} + is_available = Column(Boolean, default=True) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/contact.py b/app/models/contact.py new file mode 100644 index 0000000..4be6d42 --- /dev/null +++ b/app/models/contact.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, Boolean + +from app.database import Base + + +class ContactChannel(Base): + __tablename__ = "contact_channels" + + id = Column(Integer, primary_key=True, autoincrement=True) + type = Column(String(50), nullable=False) + label = Column(String(100), nullable=False) + value = Column(String(500)) + qr_image = Column(String(500)) + is_primary = Column(Boolean, default=False) + description = Column(Text) + sort_order = Column(Integer, default=0) + + +class ContactConfig(Base): + __tablename__ = "contact_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + security_notice = Column(Text) diff --git a/app/models/courses.py b/app/models/courses.py new file mode 100644 index 0000000..f11f580 --- /dev/null +++ b/app/models/courses.py @@ -0,0 +1,15 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, JSON, DateTime +from sqlalchemy.sql import func + +from app.database import Base + + +class CoursesConfig(Base): + __tablename__ = "courses_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + modules = Column(JSON) # list of course modules + age_groups = Column(JSON) # list of age group configs + faqs = Column(JSON) # list of {question, answer} + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/customize_config.py b/app/models/customize_config.py new file mode 100644 index 0000000..27eddb1 --- /dev/null +++ b/app/models/customize_config.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, JSON, DateTime +from sqlalchemy.sql import func + +from app.database import Base + + +class CustomizeConfig(Base): + """定制表单页面配置(选项列表、流程步骤、联系方式等)""" + __tablename__ = "customize_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + trust_stats = Column(JSON) # [{value, unit, label}] + durations = Column(JSON) # [{value, label}] + activities = Column(JSON) # [{value, label}] + budgets = Column(JSON) # [{value, label}] + process = Column(JSON) # [{title, desc}] + contact = Column(JSON) # {wechat, tip} + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/customize_submission.py b/app/models/customize_submission.py new file mode 100644 index 0000000..85bede0 --- /dev/null +++ b/app/models/customize_submission.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Enum +from sqlalchemy.sql import func + +from app.database import Base + + +class CustomizeSubmission(Base): + __tablename__ = "customize_submissions" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100)) + phone = Column(String(50)) + wechat = Column(String(100)) + adults = Column(Integer, default=0) + children = Column(Integer, default=0) + travel_dates = Column(String(200)) + budget = Column(String(100)) + interests = Column(JSON) # list of interest tags + notes = Column(Text) + source = Column(String(100)) # 来源页面 + status = Column(Enum("pending", "processed"), default="pending") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/destination.py b/app/models/destination.py new file mode 100644 index 0000000..5c5bdf2 --- /dev/null +++ b/app/models/destination.py @@ -0,0 +1,50 @@ +"""Destination comparison (目的地对比) data model.""" +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON +from sqlalchemy.sql import func + +from app.database import Base + + +class DestinationConfig(Base): + __tablename__ = "destination_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200)) + subtitle = Column(String(200)) + intro = Column(Text) + closing_title = Column(String(200)) + closing_text = Column(Text) + data_sources = Column(Text) + honest_title = Column(String(200)) + honest_subtitle = Column(String(200)) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class DestinationItem(Base): + __tablename__ = "destination_items" + + id = Column(Integer, primary_key=True, autoincrement=True) + dest_id = Column(String(50), nullable=False) + name = Column(String(100), nullable=False) + tag = Column(String(200)) + highlight = Column(Boolean, default=False) + sort_order = Column(Integer, default=0) + + +class DestinationDimension(Base): + __tablename__ = "destination_dimensions" + + id = Column(Integer, primary_key=True, autoincrement=True) + label = Column(String(100), nullable=False) + icon = Column(String(10)) + values = Column(JSON) # list of 3 strings, one per destination + sort_order = Column(Integer, default=0) + + +class DestinationHonestItem(Base): + __tablename__ = "destination_honest_items" + + id = Column(Integer, primary_key=True, autoincrement=True) + text = Column(Text, nullable=False) + sort_order = Column(Integer, default=0) diff --git a/app/models/destination_detail.py b/app/models/destination_detail.py new file mode 100644 index 0000000..9c1499f --- /dev/null +++ b/app/models/destination_detail.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON +from sqlalchemy.sql import func + +from app.database import Base + + +class DestinationDetail(Base): + __tablename__ = "destination_details" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + slug = Column(String(200), unique=True, nullable=False) + subtitle = Column(String(500)) + cover_image = Column(String(500)) + description = Column(Text) + location = Column(String(200)) + best_season = Column(String(200)) + duration = Column(String(100)) + highlights = Column(JSON) # list of {icon, title, description} + gallery = Column(JSON) # list of image URLs + tags = Column(JSON) # list of strings + is_visible = Column(Boolean, default=True) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/export_log.py b/app/models/export_log.py new file mode 100644 index 0000000..a7a3e79 --- /dev/null +++ b/app/models/export_log.py @@ -0,0 +1,16 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Enum +from sqlalchemy.sql import func + +from app.database import Base + + +class ExportLog(Base): + __tablename__ = "export_log" + + id = Column(Integer, primary_key=True, autoincrement=True) + exported_by = Column(String(50)) + modules = Column(JSON) + status = Column(Enum("success", "failed"), default="success") + message = Column(Text) + created_at = Column(DateTime, server_default=func.now()) diff --git a/app/models/faq.py b/app/models/faq.py new file mode 100644 index 0000000..721db3b --- /dev/null +++ b/app/models/faq.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, ForeignKey, JSON +from sqlalchemy.orm import relationship + +from app.database import Base + + +class FaqCategory(Base): + __tablename__ = "faq_categories" + + id = Column(Integer, primary_key=True, autoincrement=True) + category_id = Column(String(50), unique=True, nullable=False) + name = Column(String(100), nullable=False) + sort_order = Column(Integer, default=0) + + questions = relationship("FaqQuestion", back_populates="category", cascade="all, delete-orphan", order_by="FaqQuestion.sort_order") + + +class FaqQuestion(Base): + __tablename__ = "faq_questions" + + id = Column(Integer, primary_key=True, autoincrement=True) + category_id = Column(Integer, ForeignKey("faq_categories.id", ondelete="CASCADE"), nullable=False) + question_id = Column(String(50), unique=True, nullable=False) + question = Column(Text, nullable=False) + answer = Column(Text, nullable=False) + related_links = Column(JSON) + sort_order = Column(Integer, default=0) + + category = relationship("FaqCategory", back_populates="questions") diff --git a/app/models/gallery.py b/app/models/gallery.py new file mode 100644 index 0000000..d770adc --- /dev/null +++ b/app/models/gallery.py @@ -0,0 +1,21 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON +from sqlalchemy.sql import func + +from app.database import Base + + +class GalleryItem(Base): + __tablename__ = "gallery_items" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200)) + image = Column(String(500), nullable=False) + photographer = Column(String(100)) + location = Column(String(200)) + description = Column(Text) + tags = Column(JSON) # list of strings + is_visible = Column(Boolean, default=True) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/guide.py b/app/models/guide.py new file mode 100644 index 0000000..b62663f --- /dev/null +++ b/app/models/guide.py @@ -0,0 +1,34 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class GuideConfig(Base): + __tablename__ = "guide_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + page_intro = Column(Text) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + sections = relationship("GuideSection", back_populates="config", cascade="all, delete-orphan", order_by="GuideSection.sort_order") + + +class GuideSection(Base): + """Each section has unique nested structure, stored as JSON columns.""" + __tablename__ = "guide_sections" + + id = Column(Integer, primary_key=True, autoincrement=True) + config_id = Column(Integer, ForeignKey("guide_config.id", ondelete="CASCADE"), nullable=False) + section_id = Column(String(50), unique=True, nullable=False) + title = Column(String(200)) + subtitle = Column(String(500)) + icon = Column(String(10)) + content = Column(Text) + # Flexible JSON columns for varied section structures + data = Column(JSON) + sort_order = Column(Integer, default=0) + + config = relationship("GuideConfig", back_populates="sections") diff --git a/app/models/navigation.py b/app/models/navigation.py new file mode 100644 index 0000000..6968f47 --- /dev/null +++ b/app/models/navigation.py @@ -0,0 +1,37 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, ForeignKey, JSON +from sqlalchemy.orm import relationship + +from app.database import Base + + +class NavHeader(Base): + __tablename__ = "nav_header" + + id = Column(Integer, primary_key=True, autoincrement=True) + text = Column(String(100), nullable=False) + to_path = Column(String(255), nullable=False) + children = Column(JSON) # [{text, to, season}, ...] for dropdown menus + sort_order = Column(Integer, default=0) + + +class NavFooterGroup(Base): + __tablename__ = "nav_footer_groups" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(100), nullable=False) + sort_order = Column(Integer, default=0) + + links = relationship("NavFooterLink", back_populates="group", cascade="all, delete-orphan", order_by="NavFooterLink.sort_order") + + +class NavFooterLink(Base): + __tablename__ = "nav_footer_links" + + id = Column(Integer, primary_key=True, autoincrement=True) + group_id = Column(Integer, ForeignKey("nav_footer_groups.id", ondelete="CASCADE"), nullable=False) + text = Column(String(100), nullable=False) + to_path = Column(String(255), nullable=False) + sort_order = Column(Integer, default=0) + + group = relationship("NavFooterGroup", back_populates="links") diff --git a/app/models/news.py b/app/models/news.py new file mode 100644 index 0000000..9e29c85 --- /dev/null +++ b/app/models/news.py @@ -0,0 +1,22 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime +from sqlalchemy.sql import func + +from app.database import Base + + +class News(Base): + __tablename__ = "news" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200), nullable=False) + cover_image = Column(String(500)) + summary = Column(Text) + content = Column(Text) + source = Column(String(100)) + source_url = Column(String(500)) + published_at = Column(String(50)) + is_visible = Column(Boolean, default=True) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/partner.py b/app/models/partner.py new file mode 100644 index 0000000..559ac77 --- /dev/null +++ b/app/models/partner.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, DateTime +from sqlalchemy.sql import func + +from app.database import Base + + +class Partner(Base): + __tablename__ = "partners" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + logo = Column(String(500)) + website = Column(String(500)) + description = Column(Text) + category = Column(String(100)) + sort_order = Column(Integer, default=0) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/pricing.py b/app/models/pricing.py new file mode 100644 index 0000000..99d95b5 --- /dev/null +++ b/app/models/pricing.py @@ -0,0 +1,22 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON +from sqlalchemy.sql import func + +from app.database import Base + + +class PricingItem(Base): + __tablename__ = "pricing_items" + + id = Column(Integer, primary_key=True, autoincrement=True) + product_name = Column(String(200), nullable=False) + product_slug = Column(String(100)) + price_from = Column(String(50)) + price_unit = Column(String(50)) + price_label = Column(String(100)) + description = Column(Text) + features = Column(JSON) + notes = Column(JSON) + is_visible = Column(Boolean, default=True) + sort_order = Column(Integer, default=0) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..39f01de --- /dev/null +++ b/app/models/product.py @@ -0,0 +1,125 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class ProductConfig(Base): + __tablename__ = "product_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + narrative = Column(Text) + pricing_philosophy = Column(Text) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + versions = relationship("ProductVersion", back_populates="config", cascade="all, delete-orphan", order_by="ProductVersion.sort_order") + summer_camp = relationship("SummerCamp", back_populates="config", uselist=False, cascade="all, delete-orphan") + selection_guide = relationship("SelectionGuide", back_populates="config", uselist=False, cascade="all, delete-orphan") + + +class ProductVersion(Base): + __tablename__ = "product_versions" + + id = Column(Integer, primary_key=True, autoincrement=True) + config_id = Column(Integer, ForeignKey("product_config.id", ondelete="CASCADE"), nullable=False) + version_id = Column(String(50), unique=True, nullable=False) + name = Column(String(200), nullable=False) + days = Column(Integer, nullable=False) + nights = Column(Integer, nullable=False) + audience = Column(String(255)) + description = Column(Text) + tag = Column(String(50)) + sort_order = Column(Integer, default=0) + + config = relationship("ProductConfig", back_populates="versions") + highlights = relationship("ProductVersionHighlight", back_populates="version", cascade="all, delete-orphan", order_by="ProductVersionHighlight.sort_order") + + +class ProductVersionHighlight(Base): + __tablename__ = "product_version_highlights" + + id = Column(Integer, primary_key=True, autoincrement=True) + version_id = Column(Integer, ForeignKey("product_versions.id", ondelete="CASCADE"), nullable=False) + text = Column(Text, nullable=False) + sort_order = Column(Integer, default=0) + + version = relationship("ProductVersion", back_populates="highlights") + + +class SummerCamp(Base): + __tablename__ = "summer_camp" + + id = Column(Integer, primary_key=True, autoincrement=True) + config_id = Column(Integer, ForeignKey("product_config.id", ondelete="CASCADE"), nullable=False) + name = Column(String(200)) + positioning = Column(Text) + days = Column(Integer) + nights = Column(Integer) + sessions_json = Column(JSON) + difference_from_v9 = Column(Text) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + config = relationship("ProductConfig", back_populates="summer_camp") + principles = relationship("SummerCampPrinciple", back_populates="camp", cascade="all, delete-orphan", order_by="SummerCampPrinciple.sort_order") + activities = relationship("SummerCampActivity", back_populates="camp", cascade="all, delete-orphan", order_by="SummerCampActivity.sort_order") + itinerary = relationship("SummerCampItinerary", back_populates="camp", cascade="all, delete-orphan", order_by="SummerCampItinerary.sort_order") + faq = relationship("SummerCampFaq", back_populates="camp", cascade="all, delete-orphan", order_by="SummerCampFaq.sort_order") + + +class SummerCampPrinciple(Base): + __tablename__ = "summer_camp_principles" + + id = Column(Integer, primary_key=True, autoincrement=True) + camp_id = Column(Integer, ForeignKey("summer_camp.id", ondelete="CASCADE"), nullable=False) + text = Column(String(255), nullable=False) + sort_order = Column(Integer, default=0) + + camp = relationship("SummerCamp", back_populates="principles") + + +class SummerCampActivity(Base): + __tablename__ = "summer_camp_activities" + + id = Column(Integer, primary_key=True, autoincrement=True) + camp_id = Column(Integer, ForeignKey("summer_camp.id", ondelete="CASCADE"), nullable=False) + text = Column(Text, nullable=False) + sort_order = Column(Integer, default=0) + + camp = relationship("SummerCamp", back_populates="activities") + + +class SummerCampItinerary(Base): + __tablename__ = "summer_camp_itinerary" + + id = Column(Integer, primary_key=True, autoincrement=True) + camp_id = Column(Integer, ForeignKey("summer_camp.id", ondelete="CASCADE"), nullable=False) + text = Column(Text, nullable=False) + sort_order = Column(Integer, default=0) + + camp = relationship("SummerCamp", back_populates="itinerary") + + +class SummerCampFaq(Base): + __tablename__ = "summer_camp_faq" + + id = Column(Integer, primary_key=True, autoincrement=True) + camp_id = Column(Integer, ForeignKey("summer_camp.id", ondelete="CASCADE"), nullable=False) + question = Column(Text, nullable=False) + answer = Column(Text, nullable=False) + sort_order = Column(Integer, default=0) + + camp = relationship("SummerCamp", back_populates="faq") + + +class SelectionGuide(Base): + __tablename__ = "selection_guide" + + id = Column(Integer, primary_key=True, autoincrement=True) + config_id = Column(Integer, ForeignKey("product_config.id", ondelete="CASCADE"), nullable=False) + by_vacation_length = Column(JSON) + by_child_age = Column(JSON) + by_preference = Column(JSON) + + config = relationship("ProductConfig", back_populates="selection_guide") diff --git a/app/models/qualification.py b/app/models/qualification.py new file mode 100644 index 0000000..4b6afa9 --- /dev/null +++ b/app/models/qualification.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, DateTime +from sqlalchemy.sql import func + +from app.database import Base + + +class Qualification(Base): + __tablename__ = "qualifications" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200), nullable=False) + issuer = Column(String(200)) + year = Column(String(20)) + image = Column(String(500)) + description = Column(Text) + category = Column(String(100)) + sort_order = Column(Integer, default=0) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/review.py b/app/models/review.py new file mode 100644 index 0000000..941be5f --- /dev/null +++ b/app/models/review.py @@ -0,0 +1,32 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON +from sqlalchemy.sql import func + +from app.database import Base + + +class ReviewSummary(Base): + __tablename__ = "review_summary" + + id = Column(Integer, primary_key=True, autoincrement=True) + total_count = Column(Integer, default=0) + approval_rate = Column(String(10)) + keywords = Column(JSON) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class Review(Base): + __tablename__ = "reviews" + + id = Column(Integer, primary_key=True, autoincrement=True) + nickname = Column(String(100), nullable=False) + travel_date = Column(String(50)) + product_version = Column(String(200)) + screenshot = Column(String(500)) + content = Column(Text, nullable=False) + scenes = Column(JSON) + concerns = Column(JSON) + sort_order = Column(Integer, default=0) + is_visible = Column(Boolean, default=True) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/seasonal_product.py b/app/models/seasonal_product.py new file mode 100644 index 0000000..4a1bcc5 --- /dev/null +++ b/app/models/seasonal_product.py @@ -0,0 +1,66 @@ +""" +Seasonal products: autumn (游牧的森林) and winter (嗨冰雪). +Both share the same table structure, distinguished by `season` field. +""" +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, DateTime, JSON +from sqlalchemy.sql import func + +from app.database import Base + + +class SeasonalProductConfig(Base): + __tablename__ = "seasonal_product_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + season = Column(String(20), unique=True, nullable=False) # 'autumn' or 'winter' + narrative = Column(Text) + style = Column(Text) + brand_name = Column(String(100)) + version_label = Column(String(100)) + season_label = Column(String(100)) + selection_guide = Column(JSON) # {byVacationLength: [...]} + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class SeasonalProductVersion(Base): + __tablename__ = "seasonal_product_versions" + + id = Column(Integer, primary_key=True, autoincrement=True) + season = Column(String(20), nullable=False) # 'autumn' or 'winter' + version_id = Column(String(50), unique=True, nullable=False) + name = Column(String(200), nullable=False) + days = Column(Integer, nullable=False) + nights = Column(Integer, nullable=False) + tag = Column(String(50)) + line = Column(String(50)) + route = Column(String(200)) + audience = Column(String(255)) + description = Column(Text) + highlights = Column(JSON) # list of strings + itinerary = Column(JSON) # list of strings + sort_order = Column(Integer, default=0) + + +class SeasonalProductHighlight(Base): + __tablename__ = "seasonal_product_highlights" + + id = Column(Integer, primary_key=True, autoincrement=True) + season = Column(String(20), nullable=False) + category = Column(String(20), nullable=False) # 'shared', 'south', 'north' + title = Column(String(200), nullable=False) + description = Column(Text) + sort_order = Column(Integer, default=0) + + +class SeasonalProductTimeline(Base): + __tablename__ = "seasonal_product_timeline" + + id = Column(Integer, primary_key=True, autoincrement=True) + season = Column(String(20), nullable=False) + version = Column(String(50)) + date = Column(String(50)) + title = Column(String(200)) + changes = Column(JSON) # list of {type, text} + reason = Column(Text) + sort_order = Column(Integer, default=0) diff --git a/app/models/selector.py b/app/models/selector.py new file mode 100644 index 0000000..6fcfb22 --- /dev/null +++ b/app/models/selector.py @@ -0,0 +1,14 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, JSON, DateTime +from sqlalchemy.sql import func + +from app.database import Base + + +class SelectorConfig(Base): + __tablename__ = "selector_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + questions = Column(JSON) # list of {id, text, options: [{value, label}]} + rules = Column(JSON) # list of {conditions, recommendation} + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/seo.py b/app/models/seo.py new file mode 100644 index 0000000..478cc75 --- /dev/null +++ b/app/models/seo.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, DateTime +from sqlalchemy.sql import func + +from app.database import Base + + +class SeoPage(Base): + __tablename__ = "seo_pages" + + id = Column(Integer, primary_key=True, autoincrement=True) + page_key = Column(String(50), unique=True, nullable=False) + title = Column(String(500)) + description = Column(Text) + h1 = Column(String(500)) + keywords = Column(Text) + og_image = Column(String(500)) + tldr = Column(Text) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/site_image.py b/app/models/site_image.py new file mode 100644 index 0000000..caddd5d --- /dev/null +++ b/app/models/site_image.py @@ -0,0 +1,17 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text + +from app.database import Base + + +class SiteImage(Base): + """网站图片配置 - 每张图片一条记录,用 group + key 标识""" + __tablename__ = "site_images" + + id = Column(Integer, primary_key=True, autoincrement=True) + group_name = Column(String(50), nullable=False) # logo, hero, mascot, team, etc. + image_key = Column(String(50), nullable=False) # main, background, front, etc. + image_path = Column(String(500), nullable=False) # /images/logo.png + label = Column(String(100)) # 显示名称:品牌Logo + size_hint = Column(String(100)) # 建议尺寸:200x200px + description = Column(Text) # 说明 diff --git a/app/models/story.py b/app/models/story.py new file mode 100644 index 0000000..e3627e8 --- /dev/null +++ b/app/models/story.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON +from sqlalchemy.sql import func + +from app.database import Base + + +class Story(Base): + __tablename__ = "stories" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200), nullable=False) + cover_image = Column(String(500)) + summary = Column(Text) + content = Column(Text) + customer_name = Column(String(100)) + avatar = Column(String(500)) + travel_date = Column(String(50)) + product_name = Column(String(200)) + scenes = Column(JSON) + is_visible = Column(Boolean, default=True) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..09558ac --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum +from sqlalchemy.sql import func + +from app.database import Base + + +class AdminUser(Base): + __tablename__ = "admin_users" + + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String(50), unique=True, nullable=False) + password_hash = Column(String(255), nullable=False) + display_name = Column(String(100)) + role = Column(Enum("admin", "editor"), default="editor") + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/app/models/version.py b/app/models/version.py new file mode 100644 index 0000000..23c8d43 --- /dev/null +++ b/app/models/version.py @@ -0,0 +1,100 @@ +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Enum +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class VersionConfig(Base): + __tablename__ = "version_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + stats_iterations = Column(Integer) + stats_years = Column(Integer) + stats_guests = Column(String(50)) + quote_text = Column(Text) + quote_author = Column(String(200)) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + upgrades = relationship("VersionUpgrade", back_populates="config", cascade="all, delete-orphan", order_by="VersionUpgrade.sort_order") + highlights = relationship("VersionHighlight", back_populates="config", cascade="all, delete-orphan", order_by="VersionHighlight.sort_order") + compare = relationship("VersionCompare", back_populates="config", uselist=False, cascade="all, delete-orphan") + timeline = relationship("VersionTimeline", back_populates="config", cascade="all, delete-orphan", order_by="VersionTimeline.sort_order") + philosophy = relationship("VersionPhilosophy", back_populates="config", cascade="all, delete-orphan", order_by="VersionPhilosophy.sort_order") + + +class VersionUpgrade(Base): + __tablename__ = "version_upgrades" + + id = Column(Integer, primary_key=True, autoincrement=True) + config_id = Column(Integer, ForeignKey("version_config.id", ondelete="CASCADE"), nullable=False) + tag = Column(String(50)) + name = Column(String(200)) + description = Column(Text) + reason = Column(Text) + sort_order = Column(Integer, default=0) + + config = relationship("VersionConfig", back_populates="upgrades") + + +class VersionHighlight(Base): + __tablename__ = "version_highlights" + + id = Column(Integer, primary_key=True, autoincrement=True) + config_id = Column(Integer, ForeignKey("version_config.id", ondelete="CASCADE"), nullable=False) + label = Column(String(50)) + text = Column(Text) + sort_order = Column(Integer, default=0) + + config = relationship("VersionConfig", back_populates="highlights") + + +class VersionCompare(Base): + __tablename__ = "version_compare" + + id = Column(Integer, primary_key=True, autoincrement=True) + config_id = Column(Integer, ForeignKey("version_config.id", ondelete="CASCADE"), nullable=False) + headers = Column(JSON) + rows = Column(JSON) + + config = relationship("VersionConfig", back_populates="compare") + + +class VersionTimeline(Base): + __tablename__ = "version_timeline" + + id = Column(Integer, primary_key=True, autoincrement=True) + config_id = Column(Integer, ForeignKey("version_config.id", ondelete="CASCADE"), nullable=False) + version = Column(String(20), nullable=False) + date = Column(String(20)) + title = Column(String(200)) + reason = Column(Text) + sort_order = Column(Integer, default=0) + + config = relationship("VersionConfig", back_populates="timeline") + changes = relationship("VersionTimelineChange", back_populates="timeline", cascade="all, delete-orphan", order_by="VersionTimelineChange.sort_order") + + +class VersionTimelineChange(Base): + __tablename__ = "version_timeline_changes" + + id = Column(Integer, primary_key=True, autoincrement=True) + timeline_id = Column(Integer, ForeignKey("version_timeline.id", ondelete="CASCADE"), nullable=False) + type = Column(Enum("add", "update", "remove"), nullable=False) + text = Column(Text, nullable=False) + sort_order = Column(Integer, default=0) + + timeline = relationship("VersionTimeline", back_populates="changes") + + +class VersionPhilosophy(Base): + __tablename__ = "version_philosophy" + + id = Column(Integer, primary_key=True, autoincrement=True) + config_id = Column(Integer, ForeignKey("version_config.id", ondelete="CASCADE"), nullable=False) + label = Column(String(50)) + text = Column(Text) + sort_order = Column(Integer, default=0) + + config = relationship("VersionConfig", back_populates="philosophy") diff --git a/app/models/winter_camp.py b/app/models/winter_camp.py new file mode 100644 index 0000000..5212860 --- /dev/null +++ b/app/models/winter_camp.py @@ -0,0 +1,62 @@ +"""Winter camp (小蒙马冬季亲子营) data model.""" +from __future__ import annotations +from sqlalchemy import Column, Integer, String, Text, DateTime, JSON +from sqlalchemy.sql import func + +from app.database import Base + + +class WinterCampConfig(Base): + __tablename__ = "winter_camp_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200)) + positioning = Column(Text) + days = Column(Integer) + nights = Column(Integer) + max_families = Column(Integer) + total_sessions = Column(Integer) + age_range = Column(String(50)) + deposit = Column(Integer) + season = Column(String(100)) + route = Column(String(200)) + why_hulunbuir = Column(Text) + closing_note = Column(Text) + photographer = Column(JSON) # {name, description, specialDay2} + winter_clothing = Column(JSON) # {upper, lower, shoes, accessories} + camp_advantages = Column(JSON) # list of strings + service_config = Column(JSON) # list of strings + camp_essentials = Column(JSON) # list of strings + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class WinterCampHotel(Base): + __tablename__ = "winter_camp_hotels" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + star = Column(String(50)) + nights = Column(String(50)) + description = Column(Text) + sort_order = Column(Integer, default=0) + + +class WinterCampItinerary(Base): + __tablename__ = "winter_camp_itinerary" + + id = Column(Integer, primary_key=True, autoincrement=True) + day = Column(Integer, nullable=False) + title = Column(String(200)) + summary = Column(Text) + highlights = Column(JSON) # list of strings + hotel = Column(String(200)) + sort_order = Column(Integer, default=0) + + +class WinterCampFaq(Base): + __tablename__ = "winter_camp_faq" + + id = Column(Integer, primary_key=True, autoincrement=True) + question = Column(Text, nullable=False) + answer = Column(Text, nullable=False) + sort_order = Column(Integer, default=0) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/about.py b/app/routers/about.py new file mode 100644 index 0000000..7ce94e4 --- /dev/null +++ b/app/routers/about.py @@ -0,0 +1,361 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.about import ( + AboutStory, AboutSubsidiary, AboutCertification, AboutTrademark, + AboutCopyright, AboutGuarantee, AboutXiaohongshu, AboutTeam, + AboutCulture, AboutCultureValue, + AboutFounderDetail, AboutFounder, AboutMilestone, + AboutServicePrinciple, AboutDifferentiation, + AboutServiceJourney, AboutServiceMoment, AboutStats, +) +from app.schemas.about import ( + StorySchema, SubsidiarySchema, CertificationSchema, TrademarkSchema, + CopyrightSchema, GuaranteeSchema, XiaohongshuSchema, TeamSchema, + CultureSchema, FounderDetailSchema, FounderSchema, MilestoneSchema, + ServicePrincipleSchema, DifferentiationSchema, + ServiceJourneySchema, ServiceMomentSchema, AboutStatsSchema, +) + +router = APIRouter(prefix="/api/about", tags=["关于我们"], dependencies=[Depends(get_current_user)]) + + +# --- Story --- +@router.get("/story", response_model=StorySchema) +def get_story(db: Session = Depends(get_db)): + story = db.query(AboutStory).first() + if not story: + return StorySchema() + return StorySchema( + title=story.title, content=story.content, founding_moment=story.founding_moment, + totem_description=story.totem_description, totem_tagline=story.totem_tagline, + ) + + +@router.put("/story") +def update_story(req: StorySchema, db: Session = Depends(get_db)): + story = db.query(AboutStory).first() + if not story: + story = AboutStory() + db.add(story) + story.title = req.title + story.content = req.content + story.founding_moment = req.founding_moment + story.totem_description = req.totem_description + story.totem_tagline = req.totem_tagline + db.commit() + return {"message": "更新成功"} + + +# --- Subsidiaries --- +@router.get("/subsidiaries", response_model=list[SubsidiarySchema]) +def get_subsidiaries(db: Session = Depends(get_db)): + items = db.query(AboutSubsidiary).order_by(AboutSubsidiary.sort_order).all() + return [SubsidiarySchema(name=s.name, role=s.role, established=s.established) for s in items] + + +@router.put("/subsidiaries") +def update_subsidiaries(req: list[SubsidiarySchema], db: Session = Depends(get_db)): + db.query(AboutSubsidiary).delete() + for i, s in enumerate(req): + db.add(AboutSubsidiary(name=s.name, role=s.role, established=s.established, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +# --- Certifications --- +@router.get("/certifications", response_model=list[CertificationSchema]) +def get_certifications(db: Session = Depends(get_db)): + items = db.query(AboutCertification).order_by(AboutCertification.sort_order).all() + return [CertificationSchema(title=c.title, detail=c.detail) for c in items] + + +@router.put("/certifications") +def update_certifications(req: list[CertificationSchema], db: Session = Depends(get_db)): + db.query(AboutCertification).delete() + for i, c in enumerate(req): + db.add(AboutCertification(title=c.title, detail=c.detail, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +# --- Trademark --- +@router.get("/trademark", response_model=TrademarkSchema) +def get_trademark(db: Session = Depends(get_db)): + tm = db.query(AboutTrademark).first() + if not tm: + return TrademarkSchema() + return TrademarkSchema(name=tm.name, scope=tm.scope, holder=tm.holder, description=tm.description) + + +@router.put("/trademark") +def update_trademark(req: TrademarkSchema, db: Session = Depends(get_db)): + tm = db.query(AboutTrademark).first() + if not tm: + tm = AboutTrademark() + db.add(tm) + tm.name = req.name + tm.scope = req.scope + tm.holder = req.holder + tm.description = req.description + db.commit() + return {"message": "更新成功"} + + +# --- Copyrights --- +@router.get("/copyrights", response_model=list[CopyrightSchema]) +def get_copyrights(db: Session = Depends(get_db)): + items = db.query(AboutCopyright).order_by(AboutCopyright.sort_order).all() + return [CopyrightSchema(name=c.name, reg_no=c.reg_no, category=c.category, holder=c.holder, date=c.date, description=c.description) for c in items] + + +@router.put("/copyrights") +def update_copyrights(req: list[CopyrightSchema], db: Session = Depends(get_db)): + db.query(AboutCopyright).delete() + for i, c in enumerate(req): + db.add(AboutCopyright(name=c.name, reg_no=c.reg_no, category=c.category, holder=c.holder, date=c.date, description=c.description, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +# --- Guarantees --- +@router.get("/guarantees", response_model=list[GuaranteeSchema]) +def get_guarantees(db: Session = Depends(get_db)): + items = db.query(AboutGuarantee).order_by(AboutGuarantee.sort_order).all() + return [GuaranteeSchema(title=g.title, detail=g.detail) for g in items] + + +@router.put("/guarantees") +def update_guarantees(req: list[GuaranteeSchema], db: Session = Depends(get_db)): + db.query(AboutGuarantee).delete() + for i, g in enumerate(req): + db.add(AboutGuarantee(title=g.title, detail=g.detail, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +# --- Xiaohongshu --- +@router.get("/xiaohongshu", response_model=XiaohongshuSchema) +def get_xiaohongshu(db: Session = Depends(get_db)): + xhs = db.query(AboutXiaohongshu).first() + if not xhs: + return XiaohongshuSchema() + return XiaohongshuSchema( + account=xhs.account, verified=xhs.verified, verified_type=xhs.verified_type, + followers=xhs.followers, likes=xhs.likes, awards=xhs.awards or [], + tagline=xhs.tagline, tags=xhs.tags or [], description=xhs.description, + ) + + +@router.put("/xiaohongshu") +def update_xiaohongshu(req: XiaohongshuSchema, db: Session = Depends(get_db)): + xhs = db.query(AboutXiaohongshu).first() + if not xhs: + xhs = AboutXiaohongshu() + db.add(xhs) + for key, val in req.model_dump().items(): + setattr(xhs, key, val) + db.commit() + return {"message": "更新成功"} + + +# --- Team --- +@router.get("/team", response_model=TeamSchema) +def get_team(db: Session = Depends(get_db)): + team = db.query(AboutTeam).first() + if not team: + return TeamSchema() + return TeamSchema(summary=team.summary) + + +@router.put("/team") +def update_team(req: TeamSchema, db: Session = Depends(get_db)): + team = db.query(AboutTeam).first() + if not team: + team = AboutTeam() + db.add(team) + team.summary = req.summary + db.commit() + return {"message": "更新成功"} + + +# --- Culture --- +@router.get("/culture", response_model=CultureSchema) +def get_culture(db: Session = Depends(get_db)): + culture = db.query(AboutCulture).first() + if not culture: + return CultureSchema() + return CultureSchema( + core=culture.core, + transparency=culture.transparency, + trust=culture.trust, + grief_award_name=culture.grief_award_name, + grief_award_description=culture.grief_award_description, + values=[{"name": v.name, "expression": v.expression} for v in culture.values], + ) + + +@router.put("/culture") +def update_culture(req: CultureSchema, db: Session = Depends(get_db)): + culture = db.query(AboutCulture).first() + if not culture: + culture = AboutCulture() + db.add(culture) + db.flush() + culture.core = req.core + culture.transparency = req.transparency + culture.trust = req.trust + culture.grief_award_name = req.grief_award_name + culture.grief_award_description = req.grief_award_description + + db.query(AboutCultureValue).filter_by(culture_id=culture.id).delete() + for i, v in enumerate(req.values): + db.add(AboutCultureValue(culture_id=culture.id, name=v.name, expression=v.expression, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +# --- Founder Detail (单个创始人详细) --- +@router.get("/founder-detail", response_model=FounderDetailSchema) +def get_founder_detail(db: Session = Depends(get_db)): + fd = db.query(AboutFounderDetail).first() + if not fd: + return FounderDetailSchema() + return FounderDetailSchema( + name=fd.name, title=fd.title, brand_founded=fd.brand_founded, + years_in_hulunbuir=fd.years_in_hulunbuir, background=fd.background, + expertise=fd.expertise or [], media_presence=fd.media_presence or [], + ) + + +@router.put("/founder-detail") +def update_founder_detail(req: FounderDetailSchema, db: Session = Depends(get_db)): + fd = db.query(AboutFounderDetail).first() + if not fd: + fd = AboutFounderDetail() + db.add(fd) + fd.name = req.name + fd.title = req.title + fd.brand_founded = req.brand_founded + fd.years_in_hulunbuir = req.years_in_hulunbuir + fd.background = req.background + fd.expertise = req.expertise + fd.media_presence = req.media_presence + db.commit() + return {"message": "更新成功"} + + +# --- Founders (三位创始人简介列表) --- +@router.get("/founders", response_model=list[FounderSchema]) +def get_founders(db: Session = Depends(get_db)): + items = db.query(AboutFounder).order_by(AboutFounder.sort_order).all() + return [FounderSchema(name=f.name, title=f.title, story=f.story) for f in items] + + +@router.put("/founders") +def update_founders(req: list[FounderSchema], db: Session = Depends(get_db)): + db.query(AboutFounder).delete() + for i, f in enumerate(req): + db.add(AboutFounder(name=f.name, title=f.title, story=f.story, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +# --- Milestones --- +@router.get("/milestones", response_model=list[MilestoneSchema]) +def get_milestones(db: Session = Depends(get_db)): + items = db.query(AboutMilestone).order_by(AboutMilestone.sort_order).all() + return [MilestoneSchema(year=m.year, event=m.event) for m in items] + + +@router.put("/milestones") +def update_milestones(req: list[MilestoneSchema], db: Session = Depends(get_db)): + db.query(AboutMilestone).delete() + for i, m in enumerate(req): + db.add(AboutMilestone(year=m.year, event=m.event, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +# --- Service Principles --- +@router.get("/service-principles", response_model=list[ServicePrincipleSchema]) +def get_service_principles(db: Session = Depends(get_db)): + items = db.query(AboutServicePrinciple).order_by(AboutServicePrinciple.sort_order).all() + return [ServicePrincipleSchema(text=p.text) for p in items] + + +@router.put("/service-principles") +def update_service_principles(req: list[ServicePrincipleSchema], db: Session = Depends(get_db)): + db.query(AboutServicePrinciple).delete() + for i, p in enumerate(req): + db.add(AboutServicePrinciple(text=p.text, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +# --- Differentiation --- +@router.get("/differentiation", response_model=list[DifferentiationSchema]) +def get_differentiation(db: Session = Depends(get_db)): + items = db.query(AboutDifferentiation).order_by(AboutDifferentiation.sort_order).all() + return [DifferentiationSchema(name=d.name, detail=d.detail) for d in items] + + +@router.put("/differentiation") +def update_differentiation(req: list[DifferentiationSchema], db: Session = Depends(get_db)): + db.query(AboutDifferentiation).delete() + for i, d in enumerate(req): + db.add(AboutDifferentiation(name=d.name, detail=d.detail, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +# --- Service Journey --- +@router.get("/service-journey", response_model=ServiceJourneySchema) +def get_service_journey(db: Session = Depends(get_db)): + sj = db.query(AboutServiceJourney).first() + if not sj: + return ServiceJourneySchema() + return ServiceJourneySchema( + title=sj.title, + subtitle=sj.subtitle, + moments=[ServiceMomentSchema(step=m.step, name=m.name, detail=m.detail) for m in sj.moments], + ) + + +@router.put("/service-journey") +def update_service_journey(req: ServiceJourneySchema, db: Session = Depends(get_db)): + sj = db.query(AboutServiceJourney).first() + if not sj: + sj = AboutServiceJourney() + db.add(sj) + db.flush() + sj.title = req.title + sj.subtitle = req.subtitle + db.query(AboutServiceMoment).filter_by(journey_id=sj.id).delete() + for i, m in enumerate(req.moments): + db.add(AboutServiceMoment(journey_id=sj.id, step=m.step, name=m.name, detail=m.detail, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +# --- Stats --- +@router.get("/stats", response_model=AboutStatsSchema) +def get_stats(db: Session = Depends(get_db)): + s = db.query(AboutStats).first() + if not s: + return AboutStatsSchema() + return AboutStatsSchema(data=s.data or {}) + + +@router.put("/stats") +def update_stats(req: AboutStatsSchema, db: Session = Depends(get_db)): + s = db.query(AboutStats).first() + if not s: + s = AboutStats() + db.add(s) + s.data = req.data + db.commit() + return {"message": "更新成功"} diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..d5f568e --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,104 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import verify_password, create_access_token, hash_password, get_current_user +from app.models.user import AdminUser +from app.schemas.user import LoginRequest, LoginResponse, UserInfo, ChangePasswordRequest, CreateUserRequest, UpdateUserRequest, ResetPasswordRequest, UserListResponse + +router = APIRouter(prefix="/api/auth", tags=["认证"]) + + +@router.post("/login", response_model=LoginResponse) +def login(req: LoginRequest, db: Session = Depends(get_db)): + user = db.query(AdminUser).filter(AdminUser.username == req.username).first() + if not user or not verify_password(req.password, user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误") + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用") + token = create_access_token({"sub": user.username, "role": user.role}) + return LoginResponse(access_token=token) + + +@router.get("/me", response_model=UserInfo) +def get_me(current_user: AdminUser = Depends(get_current_user)): + return current_user + + +@router.put("/password") +def change_password(req: ChangePasswordRequest, current_user: AdminUser = Depends(get_current_user), db: Session = Depends(get_db)): + if not verify_password(req.old_password, current_user.password_hash): + raise HTTPException(status_code=400, detail="原密码错误") + current_user.password_hash = hash_password(req.new_password) + db.commit() + return {"message": "密码修改成功"} + + +# ===== 用户管理(仅 admin 角色可用)===== + +def require_admin(current_user: AdminUser = Depends(get_current_user)): + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="仅管理员可操作") + return current_user + + +@router.get("/users", response_model=list[UserListResponse]) +def list_users(db: Session = Depends(get_db), _: AdminUser = Depends(require_admin)): + users = db.query(AdminUser).order_by(AdminUser.id).all() + return [UserListResponse.model_validate(u) for u in users] + + +@router.post("/users", response_model=UserListResponse, status_code=201) +def create_user(req: CreateUserRequest, db: Session = Depends(get_db), _: AdminUser = Depends(require_admin)): + if db.query(AdminUser).filter(AdminUser.username == req.username).first(): + raise HTTPException(status_code=400, detail="用户名已存在") + user = AdminUser( + username=req.username, + password_hash=hash_password(req.password), + display_name=req.display_name, + role=req.role, + is_active=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return UserListResponse.model_validate(user) + + +@router.put("/users/{user_id}", response_model=UserListResponse) +def update_user(user_id: int, req: UpdateUserRequest, db: Session = Depends(get_db), _: AdminUser = Depends(require_admin)): + user = db.query(AdminUser).get(user_id) + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if req.display_name is not None: + user.display_name = req.display_name + if req.role is not None: + user.role = req.role + if req.is_active is not None: + user.is_active = req.is_active + db.commit() + db.refresh(user) + return UserListResponse.model_validate(user) + + +@router.put("/users/{user_id}/reset-password") +def reset_password(user_id: int, req: ResetPasswordRequest, db: Session = Depends(get_db), _: AdminUser = Depends(require_admin)): + user = db.query(AdminUser).get(user_id) + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + user.password_hash = hash_password(req.new_password) + db.commit() + return {"message": f"已重置 {user.username} 的密码"} + + +@router.delete("/users/{user_id}") +def delete_user(user_id: int, db: Session = Depends(get_db), current_user: AdminUser = Depends(require_admin)): + user = db.query(AdminUser).get(user_id) + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if user.id == current_user.id: + raise HTTPException(status_code=400, detail="不能删除自己") + db.delete(user) + db.commit() + return {"message": "删除成功"} diff --git a/app/routers/blog.py b/app/routers/blog.py new file mode 100644 index 0000000..ac29fdd --- /dev/null +++ b/app/routers/blog.py @@ -0,0 +1,74 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func as sqlfunc + +from app.database import get_db +from app.auth import get_current_user +from app.models.blog import Blog +from app.schemas.blog import BlogSchema, BlogResponse, BlogListResponse + +router = APIRouter(prefix="/api/blog", tags=["博客文章"], dependencies=[Depends(get_current_user)]) + + +@router.get("/", response_model=BlogListResponse) +def list_blogs( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query("", description="搜索关键词"), + db: Session = Depends(get_db), +): + query = db.query(Blog) + if keyword: + query = query.filter(Blog.title.contains(keyword) | Blog.summary.contains(keyword)) + total = query.count() + items = query.order_by(Blog.sort_order, Blog.id.desc()).offset((page - 1) * page_size).limit(page_size).all() + return BlogListResponse( + items=[BlogResponse.model_validate(b) for b in items], + total=total, + ) + + +@router.post("/", response_model=BlogResponse, status_code=201) +def create_blog(req: BlogSchema, db: Session = Depends(get_db)): + if db.query(Blog).filter(Blog.slug == req.slug).first(): + raise HTTPException(status_code=400, detail="该 slug 已存在") + max_order = db.query(sqlfunc.max(Blog.sort_order)).scalar() or 0 + blog = Blog(**req.model_dump(), sort_order=max_order + 1) + db.add(blog) + db.commit() + db.refresh(blog) + return BlogResponse.model_validate(blog) + + +@router.put("/{blog_id}", response_model=BlogResponse) +def update_blog(blog_id: int, req: BlogSchema, db: Session = Depends(get_db)): + blog = db.query(Blog).get(blog_id) + if not blog: + raise HTTPException(status_code=404, detail="文章不存在") + existing = db.query(Blog).filter(Blog.slug == req.slug, Blog.id != blog_id).first() + if existing: + raise HTTPException(status_code=400, detail="该 slug 已被其他文章使用") + for key, val in req.model_dump().items(): + setattr(blog, key, val) + db.commit() + db.refresh(blog) + return BlogResponse.model_validate(blog) + + +@router.delete("/{blog_id}") +def delete_blog(blog_id: int, db: Session = Depends(get_db)): + blog = db.query(Blog).get(blog_id) + if not blog: + raise HTTPException(status_code=404, detail="文章不存在") + db.delete(blog) + db.commit() + return {"message": "删除成功"} + + +@router.put("/reorder/batch") +def reorder_blogs(ids: list[int], db: Session = Depends(get_db)): + for i, bid in enumerate(ids): + db.query(Blog).filter(Blog.id == bid).update({"sort_order": i}) + db.commit() + return {"message": "排序更新成功"} diff --git a/app/routers/brand.py b/app/routers/brand.py new file mode 100644 index 0000000..6f34166 --- /dev/null +++ b/app/routers/brand.py @@ -0,0 +1,59 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.brand import Brand, BrandTrustStat, BrandDifferentiator +from app.schemas.brand import BrandFullSchema, BrandUpdateRequest + +router = APIRouter(prefix="/api/brand", tags=["品牌"], dependencies=[Depends(get_current_user)]) + + +@router.get("/", response_model=BrandFullSchema) +def get_brand(db: Session = Depends(get_db)): + brand = db.query(Brand).first() + if not brand: + raise HTTPException(status_code=404, detail="品牌信息未初始化") + return BrandFullSchema( + id=brand.id, + name=brand.name, + full_name=brand.full_name, + domain=brand.domain, + url=brand.url, + slogan_emotional=brand.slogan_emotional, + slogan_functional=brand.slogan_functional, + icp_entity=brand.icp_entity, + icp=brand.icp, + icp_url=brand.icp_url, + e_contract=brand.e_contract, + trust_stats=[{"value": s.value, "unit": s.unit, "label": s.label} for s in brand.trust_stats], + differentiators=[{"title": d.title, "description": d.description} for d in brand.differentiators], + cta_buttons=brand.cta_buttons, + conversion_path=brand.conversion_path, + ) + + +@router.put("/") +def update_brand(req: BrandUpdateRequest, db: Session = Depends(get_db)): + brand = db.query(Brand).first() + if not brand: + brand = Brand() + db.add(brand) + + for key, val in req.brand.model_dump().items(): + setattr(brand, key, val) + db.flush() + + # Replace trust stats + db.query(BrandTrustStat).filter(BrandTrustStat.brand_id == brand.id).delete() + for i, stat in enumerate(req.trust_stats): + db.add(BrandTrustStat(brand_id=brand.id, value=stat.value, unit=stat.unit, label=stat.label, sort_order=i)) + + # Replace differentiators + db.query(BrandDifferentiator).filter(BrandDifferentiator.brand_id == brand.id).delete() + for i, diff in enumerate(req.differentiators): + db.add(BrandDifferentiator(brand_id=brand.id, title=diff.title, description=diff.description, sort_order=i)) + + db.commit() + return {"message": "品牌信息更新成功"} diff --git a/app/routers/calendar.py b/app/routers/calendar.py new file mode 100644 index 0000000..e7d954a --- /dev/null +++ b/app/routers/calendar.py @@ -0,0 +1,65 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.calendar import CalendarMonth +from app.schemas.calendar import CalendarMonthSchema, CalendarMonthResponse + +router = APIRouter(prefix="/api/calendar", tags=["行程日历"], dependencies=[Depends(get_current_user)]) + +MONTH_NAMES = ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"] + + +@router.get("/", response_model=list[CalendarMonthResponse]) +def list_calendar( + year: int = Query(..., description="年份,如 2025"), + db: Session = Depends(get_db), +): + items = db.query(CalendarMonth).filter(CalendarMonth.year == year).order_by(CalendarMonth.month).all() + # 如果该年份还没有数据,自动初始化12个月份 + if not items: + for m in range(1, 13): + month_record = CalendarMonth( + year=year, + month=m, + title=MONTH_NAMES[m - 1], + highlights=[], + events=[], + is_available=False, + ) + db.add(month_record) + db.commit() + items = db.query(CalendarMonth).filter(CalendarMonth.year == year).order_by(CalendarMonth.month).all() + return [CalendarMonthResponse.model_validate(item) for item in items] + + +@router.put("/{year}/{month}", response_model=CalendarMonthResponse) +def update_calendar_month(year: int, month: int, req: CalendarMonthSchema, db: Session = Depends(get_db)): + item = db.query(CalendarMonth).filter(CalendarMonth.year == year, CalendarMonth.month == month).first() + if not item: + raise HTTPException(status_code=404, detail="日历月份不存在") + for key, val in req.model_dump().items(): + setattr(item, key, val) + db.commit() + db.refresh(item) + return CalendarMonthResponse.model_validate(item) + + +@router.post("/init") +def init_calendar_year(year: int = Query(..., description="要初始化的年份"), db: Session = Depends(get_db)): + existing = db.query(CalendarMonth).filter(CalendarMonth.year == year).count() + if existing > 0: + return {"message": f"{year}年日历已存在,共{existing}条记录"} + for m in range(1, 13): + db.add(CalendarMonth( + year=year, + month=m, + title=MONTH_NAMES[m - 1], + highlights=[], + events=[], + is_available=False, + )) + db.commit() + return {"message": f"{year}年12个月份初始化成功"} diff --git a/app/routers/contact.py b/app/routers/contact.py new file mode 100644 index 0000000..e68d1fc --- /dev/null +++ b/app/routers/contact.py @@ -0,0 +1,68 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import func as sqlfunc + +from app.database import get_db +from app.auth import get_current_user +from app.models.contact import ContactChannel, ContactConfig +from app.schemas.contact import ContactChannelSchema, ContactChannelResponse, ContactConfigSchema + +router = APIRouter(prefix="/api/contact", tags=["联系方式"], dependencies=[Depends(get_current_user)]) + + +@router.get("/channels", response_model=list[ContactChannelResponse]) +def list_channels(db: Session = Depends(get_db)): + channels = db.query(ContactChannel).order_by(ContactChannel.sort_order).all() + return [ContactChannelResponse.model_validate(c) for c in channels] + + +@router.post("/channels", response_model=ContactChannelResponse, status_code=201) +def create_channel(req: ContactChannelSchema, db: Session = Depends(get_db)): + max_order = db.query(sqlfunc.max(ContactChannel.sort_order)).scalar() or 0 + ch = ContactChannel(**req.model_dump(), sort_order=max_order + 1) + db.add(ch) + db.commit() + db.refresh(ch) + return ContactChannelResponse.model_validate(ch) + + +@router.put("/channels/{ch_id}", response_model=ContactChannelResponse) +def update_channel(ch_id: int, req: ContactChannelSchema, db: Session = Depends(get_db)): + ch = db.query(ContactChannel).get(ch_id) + if not ch: + raise HTTPException(status_code=404, detail="联系方式不存在") + for key, val in req.model_dump().items(): + setattr(ch, key, val) + db.commit() + db.refresh(ch) + return ContactChannelResponse.model_validate(ch) + + +@router.delete("/channels/{ch_id}") +def delete_channel(ch_id: int, db: Session = Depends(get_db)): + ch = db.query(ContactChannel).get(ch_id) + if not ch: + raise HTTPException(status_code=404, detail="联系方式不存在") + db.delete(ch) + db.commit() + return {"message": "删除成功"} + + +@router.get("/config", response_model=ContactConfigSchema) +def get_config(db: Session = Depends(get_db)): + config = db.query(ContactConfig).first() + if not config: + return ContactConfigSchema() + return ContactConfigSchema(security_notice=config.security_notice) + + +@router.put("/config") +def update_config(req: ContactConfigSchema, db: Session = Depends(get_db)): + config = db.query(ContactConfig).first() + if not config: + config = ContactConfig() + db.add(config) + config.security_notice = req.security_notice + db.commit() + return {"message": "更新成功"} diff --git a/app/routers/courses.py b/app/routers/courses.py new file mode 100644 index 0000000..07c9b12 --- /dev/null +++ b/app/routers/courses.py @@ -0,0 +1,39 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.courses import CoursesConfig +from app.schemas.courses import CoursesConfigSchema, CoursesConfigResponse + +router = APIRouter( + prefix="/api/courses", + tags=["研学课程"], + dependencies=[Depends(get_current_user)], +) + + +def _get_or_create(db: Session) -> CoursesConfig: + config = db.query(CoursesConfig).first() + if not config: + config = CoursesConfig(modules=[], age_groups=[], faqs=[]) + db.add(config) + db.commit() + return config + + +@router.get("/", response_model=CoursesConfigResponse) +def get_config(db: Session = Depends(get_db)): + return CoursesConfigResponse.model_validate(_get_or_create(db)) + + +@router.put("/", response_model=CoursesConfigResponse) +def update_config(req: CoursesConfigSchema, db: Session = Depends(get_db)): + config = _get_or_create(db) + config.modules = req.modules + config.age_groups = req.age_groups + config.faqs = req.faqs + db.commit() + db.refresh(config) + return CoursesConfigResponse.model_validate(config) diff --git a/app/routers/customize.py b/app/routers/customize.py new file mode 100644 index 0000000..ef26722 --- /dev/null +++ b/app/routers/customize.py @@ -0,0 +1,104 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.customize_submission import CustomizeSubmission +from app.models.customize_config import CustomizeConfig +from app.schemas.customize_submission import ( + CustomizeSubmissionResponse, + CustomizeSubmissionListResponse, + CustomizeStatusUpdate, +) +from app.schemas.customize_config import CustomizeConfigSchema + +router = APIRouter( + prefix="/api/customize", + tags=["定制表单"], + dependencies=[Depends(get_current_user)], +) + + +@router.get("/submissions", response_model=CustomizeSubmissionListResponse) +def list_submissions( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query("", description="搜索姓名/电话/微信"), + status: str = Query("", description="状态筛选: pending/processed"), + db: Session = Depends(get_db), +): + query = db.query(CustomizeSubmission) + if keyword: + query = query.filter( + CustomizeSubmission.name.contains(keyword) + | CustomizeSubmission.phone.contains(keyword) + | CustomizeSubmission.wechat.contains(keyword) + ) + if status in ("pending", "processed"): + query = query.filter(CustomizeSubmission.status == status) + total = query.count() + items = ( + query.order_by(CustomizeSubmission.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return CustomizeSubmissionListResponse( + items=[CustomizeSubmissionResponse.model_validate(i) for i in items], + total=total, + ) + + +@router.get("/submissions/{submission_id}", response_model=CustomizeSubmissionResponse) +def get_submission(submission_id: int, db: Session = Depends(get_db)): + item = db.query(CustomizeSubmission).get(submission_id) + if not item: + raise HTTPException(status_code=404, detail="记录不存在") + return CustomizeSubmissionResponse.model_validate(item) + + +@router.put("/submissions/{submission_id}/status") +def update_status( + submission_id: int, + req: CustomizeStatusUpdate, + db: Session = Depends(get_db), +): + item = db.query(CustomizeSubmission).get(submission_id) + if not item: + raise HTTPException(status_code=404, detail="记录不存在") + item.status = req.status + db.commit() + return {"message": "状态更新成功"} + + +# --- Customize Config --- +@router.get("/config", response_model=CustomizeConfigSchema) +def get_config(db: Session = Depends(get_db)): + cfg = db.query(CustomizeConfig).first() + if not cfg: + return CustomizeConfigSchema() + return CustomizeConfigSchema( + trust_stats=cfg.trust_stats or [], + durations=cfg.durations or [], + activities=cfg.activities or [], + budgets=cfg.budgets or [], + process=cfg.process or [], + contact=cfg.contact or {}, + ) + + +@router.put("/config") +def update_config(req: CustomizeConfigSchema, db: Session = Depends(get_db)): + cfg = db.query(CustomizeConfig).first() + if not cfg: + cfg = CustomizeConfig() + db.add(cfg) + cfg.trust_stats = req.trust_stats + cfg.durations = req.durations + cfg.activities = req.activities + cfg.budgets = req.budgets + cfg.process = req.process + cfg.contact = req.contact + db.commit() + return {"message": "更新成功"} diff --git a/app/routers/destinations.py b/app/routers/destinations.py new file mode 100644 index 0000000..7a7b414 --- /dev/null +++ b/app/routers/destinations.py @@ -0,0 +1,118 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.destination import DestinationConfig, DestinationItem, DestinationDimension, DestinationHonestItem +from app.schemas.destination import ( + DestinationConfigSchema, DestinationItemSchema, DestinationItemResponse, + DestinationDimensionSchema, DestinationDimensionResponse, + DestinationHonestItemSchema, DestinationHonestItemResponse, +) + +router = APIRouter(prefix="/api/destinations", tags=["目的地对比"], dependencies=[Depends(get_current_user)]) + + +def _get_config(db: Session) -> DestinationConfig: + config = db.query(DestinationConfig).first() + if not config: + config = DestinationConfig() + db.add(config) + db.flush() + return config + + +@router.get("/config", response_model=DestinationConfigSchema) +def get_config(db: Session = Depends(get_db)): + c = _get_config(db) + return DestinationConfigSchema( + title=c.title, subtitle=c.subtitle, intro=c.intro, + closing_title=c.closing_title, closing_text=c.closing_text, + data_sources=c.data_sources, honest_title=c.honest_title, honest_subtitle=c.honest_subtitle, + ) + + +@router.put("/config") +def update_config(req: DestinationConfigSchema, db: Session = Depends(get_db)): + c = _get_config(db) + c.title = req.title + c.subtitle = req.subtitle + c.intro = req.intro + c.closing_title = req.closing_title + c.closing_text = req.closing_text + c.data_sources = req.data_sources + c.honest_title = req.honest_title + c.honest_subtitle = req.honest_subtitle + db.commit() + return {"message": "更新成功"} + + +# --- Destination items --- +@router.get("/items", response_model=list[DestinationItemResponse]) +def list_items(db: Session = Depends(get_db)): + rows = db.query(DestinationItem).order_by(DestinationItem.sort_order).all() + return [DestinationItemResponse(id=d.id, dest_id=d.dest_id, name=d.name, tag=d.tag, highlight=d.highlight, sort_order=d.sort_order) for d in rows] + + +@router.put("/items") +def update_items(items: list[DestinationItemSchema], db: Session = Depends(get_db)): + db.query(DestinationItem).delete() + for i, d in enumerate(items): + db.add(DestinationItem(dest_id=d.dest_id, name=d.name, tag=d.tag, highlight=d.highlight, sort_order=i)) + db.commit() + return {"message": "目的地更新成功"} + + +# --- Dimensions --- +@router.get("/dimensions", response_model=list[DestinationDimensionResponse]) +def list_dimensions(db: Session = Depends(get_db)): + rows = db.query(DestinationDimension).order_by(DestinationDimension.sort_order).all() + return [DestinationDimensionResponse(id=d.id, label=d.label, icon=d.icon, values=d.values or [], sort_order=d.sort_order) for d in rows] + + +@router.post("/dimensions", response_model=DestinationDimensionResponse, status_code=201) +def create_dimension(req: DestinationDimensionSchema, db: Session = Depends(get_db)): + d = DestinationDimension(label=req.label, icon=req.icon, values=req.values) + db.add(d) + db.commit() + db.refresh(d) + return DestinationDimensionResponse(id=d.id, label=d.label, icon=d.icon, values=d.values or [], sort_order=d.sort_order) + + +@router.put("/dimensions/{d_id}") +def update_dimension(d_id: int, req: DestinationDimensionSchema, db: Session = Depends(get_db)): + d = db.query(DestinationDimension).get(d_id) + if not d: + raise HTTPException(status_code=404, detail="对比维度不存在") + d.label = req.label + d.icon = req.icon + d.values = req.values + db.commit() + return {"message": "更新成功"} + + +@router.delete("/dimensions/{d_id}") +def delete_dimension(d_id: int, db: Session = Depends(get_db)): + d = db.query(DestinationDimension).get(d_id) + if not d: + raise HTTPException(status_code=404, detail="对比维度不存在") + db.delete(d) + db.commit() + return {"message": "删除成功"} + + +# --- Honest items --- +@router.get("/honest-items", response_model=list[DestinationHonestItemResponse]) +def list_honest_items(db: Session = Depends(get_db)): + rows = db.query(DestinationHonestItem).order_by(DestinationHonestItem.sort_order).all() + return [DestinationHonestItemResponse(id=h.id, text=h.text, sort_order=h.sort_order) for h in rows] + + +@router.put("/honest-items") +def update_honest_items(items: list[DestinationHonestItemSchema], db: Session = Depends(get_db)): + db.query(DestinationHonestItem).delete() + for i, h in enumerate(items): + db.add(DestinationHonestItem(text=h.text, sort_order=i)) + db.commit() + return {"message": "坦诚说明更新成功"} diff --git a/app/routers/destinations_detail.py b/app/routers/destinations_detail.py new file mode 100644 index 0000000..a5b3fb8 --- /dev/null +++ b/app/routers/destinations_detail.py @@ -0,0 +1,92 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func as sqlfunc + +from app.database import get_db +from app.auth import get_current_user +from app.models.destination_detail import DestinationDetail +from app.schemas.destination_detail import ( + DestinationDetailSchema, + DestinationDetailResponse, + DestinationDetailListResponse, +) + +router = APIRouter( + prefix="/api/destinations-detail", + tags=["景点详情"], + dependencies=[Depends(get_current_user)], +) + + +@router.get("/", response_model=DestinationDetailListResponse) +def list_items( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query("", description="搜索关键词"), + db: Session = Depends(get_db), +): + query = db.query(DestinationDetail) + if keyword: + query = query.filter( + DestinationDetail.name.contains(keyword) + | DestinationDetail.location.contains(keyword) + ) + total = query.count() + items = ( + query.order_by(DestinationDetail.sort_order, DestinationDetail.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return DestinationDetailListResponse( + items=[DestinationDetailResponse.model_validate(i) for i in items], + total=total, + ) + + +@router.post("/", response_model=DestinationDetailResponse, status_code=201) +def create_item(req: DestinationDetailSchema, db: Session = Depends(get_db)): + if db.query(DestinationDetail).filter(DestinationDetail.slug == req.slug).first(): + raise HTTPException(status_code=400, detail="该 slug 已存在") + max_order = db.query(sqlfunc.max(DestinationDetail.sort_order)).scalar() or 0 + item = DestinationDetail(**req.model_dump(), sort_order=max_order + 1) + db.add(item) + db.commit() + db.refresh(item) + return DestinationDetailResponse.model_validate(item) + + +@router.put("/{item_id}", response_model=DestinationDetailResponse) +def update_item(item_id: int, req: DestinationDetailSchema, db: Session = Depends(get_db)): + item = db.query(DestinationDetail).get(item_id) + if not item: + raise HTTPException(status_code=404, detail="景点不存在") + existing = db.query(DestinationDetail).filter( + DestinationDetail.slug == req.slug, DestinationDetail.id != item_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="该 slug 已被其他景点使用") + for key, val in req.model_dump().items(): + setattr(item, key, val) + db.commit() + db.refresh(item) + return DestinationDetailResponse.model_validate(item) + + +@router.delete("/{item_id}") +def delete_item(item_id: int, db: Session = Depends(get_db)): + item = db.query(DestinationDetail).get(item_id) + if not item: + raise HTTPException(status_code=404, detail="景点不存在") + db.delete(item) + db.commit() + return {"message": "删除成功"} + + +@router.put("/reorder/batch") +def reorder_items(ids: list[int], db: Session = Depends(get_db)): + for i, iid in enumerate(ids): + db.query(DestinationDetail).filter(DestinationDetail.id == iid).update({"sort_order": i}) + db.commit() + return {"message": "排序更新成功"} diff --git a/app/routers/export.py b/app/routers/export.py new file mode 100644 index 0000000..6bd00a0 --- /dev/null +++ b/app/routers/export.py @@ -0,0 +1,60 @@ +from __future__ import annotations +import subprocess + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.config import settings +from app.models.user import AdminUser +from app.models.export_log import ExportLog +from app.schemas.export import ExportRequest, ExportLogResponse +from app.services.export_service import export_modules + +router = APIRouter(prefix="/api/export", tags=["导出"], dependencies=[Depends(get_current_user)]) + + +@router.post("/") +def do_export(req: ExportRequest, db: Session = Depends(get_db), current_user: AdminUser = Depends(get_current_user)): + results = export_modules(db, req.modules) + all_success = all(v == "success" for v in results.values()) + + # Optionally trigger rebuild + rebuild_msg = "" + if req.trigger_rebuild and all_success: + try: + subprocess.run( + ["npx", "nuxi", "generate"], + cwd=settings.NUXT_PROJECT_PATH, + timeout=300, + capture_output=True, + ) + rebuild_msg = " | 静态站已重新构建" + except Exception as e: + rebuild_msg = f" | 构建失败: {str(e)}" + + # Log + log = ExportLog( + exported_by=current_user.username, + modules=req.modules if "all" not in req.modules else ["all"], + status="success" if all_success else "failed", + message=str(results) + rebuild_msg, + ) + db.add(log) + db.commit() + + return {"results": results, "rebuild": rebuild_msg} + + +@router.get("/log", response_model=list[ExportLogResponse]) +def get_export_log(db: Session = Depends(get_db)): + logs = db.query(ExportLog).order_by(ExportLog.created_at.desc()).limit(50).all() + return [ + ExportLogResponse( + id=l.id, exported_by=l.exported_by, modules=l.modules, + status=l.status, message=l.message, + created_at=l.created_at.isoformat() if l.created_at else None, + ) + for l in logs + ] diff --git a/app/routers/faq.py b/app/routers/faq.py new file mode 100644 index 0000000..f5aa8b0 --- /dev/null +++ b/app/routers/faq.py @@ -0,0 +1,90 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import func as sqlfunc + +from app.database import get_db +from app.auth import get_current_user +from app.models.faq import FaqCategory, FaqQuestion +from app.schemas.faq import FaqCategorySchema, FaqCategoryResponse, FaqQuestionSchema, FaqQuestionResponse + +router = APIRouter(prefix="/api/faq", tags=["常见问答"], dependencies=[Depends(get_current_user)]) + + +@router.get("/categories", response_model=list[FaqCategoryResponse]) +def list_categories(db: Session = Depends(get_db)): + cats = db.query(FaqCategory).order_by(FaqCategory.sort_order).all() + return [FaqCategoryResponse.model_validate(c) for c in cats] + + +@router.post("/categories", response_model=FaqCategoryResponse, status_code=201) +def create_category(req: FaqCategorySchema, db: Session = Depends(get_db)): + max_order = db.query(sqlfunc.max(FaqCategory.sort_order)).scalar() or 0 + cat = FaqCategory(category_id=req.category_id, name=req.name, sort_order=max_order + 1) + db.add(cat) + db.commit() + db.refresh(cat) + return FaqCategoryResponse.model_validate(cat) + + +@router.put("/categories/{cat_id}") +def update_category(cat_id: int, req: FaqCategorySchema, db: Session = Depends(get_db)): + cat = db.query(FaqCategory).get(cat_id) + if not cat: + raise HTTPException(status_code=404, detail="分类不存在") + cat.category_id = req.category_id + cat.name = req.name + db.commit() + return {"message": "更新成功"} + + +@router.delete("/categories/{cat_id}") +def delete_category(cat_id: int, db: Session = Depends(get_db)): + cat = db.query(FaqCategory).get(cat_id) + if not cat: + raise HTTPException(status_code=404, detail="分类不存在") + db.delete(cat) + db.commit() + return {"message": "删除成功"} + + +@router.post("/questions", response_model=FaqQuestionResponse, status_code=201) +def create_question(req: FaqQuestionSchema, category_id: int = None, db: Session = Depends(get_db)): + if not category_id: + raise HTTPException(status_code=400, detail="必须指定分类") + max_order = db.query(sqlfunc.max(FaqQuestion.sort_order)).filter(FaqQuestion.category_id == category_id).scalar() or 0 + q = FaqQuestion( + category_id=category_id, + question_id=req.question_id, + question=req.question, + answer=req.answer, + related_links=[link.model_dump() for link in req.related_links], + sort_order=max_order + 1, + ) + db.add(q) + db.commit() + db.refresh(q) + return FaqQuestionResponse.model_validate(q) + + +@router.put("/questions/{q_id}") +def update_question(q_id: int, req: FaqQuestionSchema, db: Session = Depends(get_db)): + q = db.query(FaqQuestion).get(q_id) + if not q: + raise HTTPException(status_code=404, detail="问题不存在") + q.question_id = req.question_id + q.question = req.question + q.answer = req.answer + q.related_links = [link.model_dump() for link in req.related_links] + db.commit() + return {"message": "更新成功"} + + +@router.delete("/questions/{q_id}") +def delete_question(q_id: int, db: Session = Depends(get_db)): + q = db.query(FaqQuestion).get(q_id) + if not q: + raise HTTPException(status_code=404, detail="问题不存在") + db.delete(q) + db.commit() + return {"message": "删除成功"} diff --git a/app/routers/gallery.py b/app/routers/gallery.py new file mode 100644 index 0000000..ea7d299 --- /dev/null +++ b/app/routers/gallery.py @@ -0,0 +1,82 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func as sqlfunc + +from app.database import get_db +from app.auth import get_current_user +from app.models.gallery import GalleryItem +from app.schemas.gallery import GalleryItemSchema, GalleryItemResponse, GalleryListResponse + +router = APIRouter( + prefix="/api/gallery", + tags=["旅拍作品"], + dependencies=[Depends(get_current_user)], +) + + +@router.get("/", response_model=GalleryListResponse) +def list_items( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query("", description="搜索关键词"), + db: Session = Depends(get_db), +): + query = db.query(GalleryItem) + if keyword: + query = query.filter( + GalleryItem.title.contains(keyword) + | GalleryItem.location.contains(keyword) + | GalleryItem.photographer.contains(keyword) + ) + total = query.count() + items = ( + query.order_by(GalleryItem.sort_order, GalleryItem.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return GalleryListResponse( + items=[GalleryItemResponse.model_validate(i) for i in items], + total=total, + ) + + +@router.post("/", response_model=GalleryItemResponse, status_code=201) +def create_item(req: GalleryItemSchema, db: Session = Depends(get_db)): + max_order = db.query(sqlfunc.max(GalleryItem.sort_order)).scalar() or 0 + item = GalleryItem(**req.model_dump(), sort_order=max_order + 1) + db.add(item) + db.commit() + db.refresh(item) + return GalleryItemResponse.model_validate(item) + + +@router.put("/{item_id}", response_model=GalleryItemResponse) +def update_item(item_id: int, req: GalleryItemSchema, db: Session = Depends(get_db)): + item = db.query(GalleryItem).get(item_id) + if not item: + raise HTTPException(status_code=404, detail="作品不存在") + for key, val in req.model_dump().items(): + setattr(item, key, val) + db.commit() + db.refresh(item) + return GalleryItemResponse.model_validate(item) + + +@router.delete("/{item_id}") +def delete_item(item_id: int, db: Session = Depends(get_db)): + item = db.query(GalleryItem).get(item_id) + if not item: + raise HTTPException(status_code=404, detail="作品不存在") + db.delete(item) + db.commit() + return {"message": "删除成功"} + + +@router.put("/reorder/batch") +def reorder_items(ids: list[int], db: Session = Depends(get_db)): + for i, iid in enumerate(ids): + db.query(GalleryItem).filter(GalleryItem.id == iid).update({"sort_order": i}) + db.commit() + return {"message": "排序更新成功"} diff --git a/app/routers/guides.py b/app/routers/guides.py new file mode 100644 index 0000000..bb65190 --- /dev/null +++ b/app/routers/guides.py @@ -0,0 +1,55 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.guide import GuideConfig, GuideSection +from app.schemas.guide import GuideConfigSchema, GuideSectionSchema, GuideSectionResponse + +router = APIRouter(prefix="/api/guides", tags=["出行指南"], dependencies=[Depends(get_current_user)]) + + +def _get_config(db: Session) -> GuideConfig: + config = db.query(GuideConfig).first() + if not config: + config = GuideConfig() + db.add(config) + db.flush() + return config + + +@router.get("/config", response_model=GuideConfigSchema) +def get_config(db: Session = Depends(get_db)): + c = _get_config(db) + return GuideConfigSchema(page_intro=c.page_intro) + + +@router.put("/config") +def update_config(req: GuideConfigSchema, db: Session = Depends(get_db)): + c = _get_config(db) + c.page_intro = req.page_intro + db.commit() + return {"message": "更新成功"} + + +@router.get("/sections", response_model=list[GuideSectionResponse]) +def list_sections(db: Session = Depends(get_db)): + c = _get_config(db) + sections = db.query(GuideSection).filter_by(config_id=c.id).order_by(GuideSection.sort_order).all() + return [GuideSectionResponse.model_validate(s) for s in sections] + + +@router.put("/sections/{section_id}") +def update_section(section_id: str, req: GuideSectionSchema, db: Session = Depends(get_db)): + c = _get_config(db) + section = db.query(GuideSection).filter_by(config_id=c.id, section_id=section_id).first() + if not section: + raise HTTPException(status_code=404, detail=f"章节 {section_id} 不存在") + section.title = req.title + section.subtitle = req.subtitle + section.icon = req.icon + section.content = req.content + section.data = req.data + db.commit() + return {"message": "更新成功"} diff --git a/app/routers/navigation.py b/app/routers/navigation.py new file mode 100644 index 0000000..d20b877 --- /dev/null +++ b/app/routers/navigation.py @@ -0,0 +1,44 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.navigation import NavHeader, NavFooterGroup, NavFooterLink +from app.schemas.navigation import NavigationSchema + +router = APIRouter(prefix="/api/navigation", tags=["导航菜单"], dependencies=[Depends(get_current_user)]) + + +@router.get("/", response_model=NavigationSchema) +def get_navigation(db: Session = Depends(get_db)): + headers = db.query(NavHeader).order_by(NavHeader.sort_order).all() + groups = db.query(NavFooterGroup).order_by(NavFooterGroup.sort_order).all() + return NavigationSchema( + header=[{"text": h.text, "to": h.to_path} for h in headers], + footer=[ + {"title": g.title, "links": [{"text": l.text, "to": l.to_path} for l in g.links]} + for g in groups + ], + ) + + +@router.put("/") +def update_navigation(req: NavigationSchema, db: Session = Depends(get_db)): + # Replace header + db.query(NavHeader).delete() + for i, h in enumerate(req.header): + db.add(NavHeader(text=h.text, to_path=h.to, sort_order=i)) + + # Replace footer + db.query(NavFooterLink).delete() + db.query(NavFooterGroup).delete() + for i, g in enumerate(req.footer): + group = NavFooterGroup(title=g.title, sort_order=i) + db.add(group) + db.flush() + for j, link in enumerate(g.links): + db.add(NavFooterLink(group_id=group.id, text=link.text, to_path=link.to, sort_order=j)) + + db.commit() + return {"message": "导航更新成功"} diff --git a/app/routers/news.py b/app/routers/news.py new file mode 100644 index 0000000..02a92e9 --- /dev/null +++ b/app/routers/news.py @@ -0,0 +1,69 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func as sqlfunc + +from app.database import get_db +from app.auth import get_current_user +from app.models.news import News +from app.schemas.news import NewsSchema, NewsResponse, NewsListResponse + +router = APIRouter(prefix="/api/news", tags=["新闻动态"], dependencies=[Depends(get_current_user)]) + + +@router.get("/", response_model=NewsListResponse) +def list_news( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query("", description="搜索关键词"), + db: Session = Depends(get_db), +): + query = db.query(News) + if keyword: + query = query.filter(News.title.contains(keyword) | News.summary.contains(keyword)) + total = query.count() + items = query.order_by(News.sort_order, News.id.desc()).offset((page - 1) * page_size).limit(page_size).all() + return NewsListResponse( + items=[NewsResponse.model_validate(n) for n in items], + total=total, + ) + + +@router.post("/", response_model=NewsResponse, status_code=201) +def create_news(req: NewsSchema, db: Session = Depends(get_db)): + max_order = db.query(sqlfunc.max(News.sort_order)).scalar() or 0 + news = News(**req.model_dump(), sort_order=max_order + 1) + db.add(news) + db.commit() + db.refresh(news) + return NewsResponse.model_validate(news) + + +@router.put("/{news_id}", response_model=NewsResponse) +def update_news(news_id: int, req: NewsSchema, db: Session = Depends(get_db)): + news = db.query(News).get(news_id) + if not news: + raise HTTPException(status_code=404, detail="新闻不存在") + for key, val in req.model_dump().items(): + setattr(news, key, val) + db.commit() + db.refresh(news) + return NewsResponse.model_validate(news) + + +@router.delete("/{news_id}") +def delete_news(news_id: int, db: Session = Depends(get_db)): + news = db.query(News).get(news_id) + if not news: + raise HTTPException(status_code=404, detail="新闻不存在") + db.delete(news) + db.commit() + return {"message": "删除成功"} + + +@router.put("/reorder/batch") +def reorder_news(ids: list[int], db: Session = Depends(get_db)): + for i, nid in enumerate(ids): + db.query(News).filter(News.id == nid).update({"sort_order": i}) + db.commit() + return {"message": "排序更新成功"} diff --git a/app/routers/partners.py b/app/routers/partners.py new file mode 100644 index 0000000..ee3c301 --- /dev/null +++ b/app/routers/partners.py @@ -0,0 +1,57 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import func as sqlfunc + +from app.database import get_db +from app.auth import get_current_user +from app.models.partner import Partner +from app.schemas.partner import PartnerSchema, PartnerResponse + +router = APIRouter(prefix="/api/partners", tags=["合作伙伴"], dependencies=[Depends(get_current_user)]) + + +@router.get("/", response_model=list[PartnerResponse]) +def list_partners(db: Session = Depends(get_db)): + items = db.query(Partner).order_by(Partner.sort_order).all() + return [PartnerResponse.model_validate(item) for item in items] + + +@router.post("/", response_model=PartnerResponse, status_code=201) +def create_partner(req: PartnerSchema, db: Session = Depends(get_db)): + max_order = db.query(sqlfunc.max(Partner.sort_order)).scalar() or 0 + item = Partner(**req.model_dump(), sort_order=max_order + 1) + db.add(item) + db.commit() + db.refresh(item) + return PartnerResponse.model_validate(item) + + +@router.put("/{item_id}", response_model=PartnerResponse) +def update_partner(item_id: int, req: PartnerSchema, db: Session = Depends(get_db)): + item = db.query(Partner).get(item_id) + if not item: + raise HTTPException(status_code=404, detail="合作伙伴不存在") + for key, val in req.model_dump().items(): + setattr(item, key, val) + db.commit() + db.refresh(item) + return PartnerResponse.model_validate(item) + + +@router.delete("/{item_id}") +def delete_partner(item_id: int, db: Session = Depends(get_db)): + item = db.query(Partner).get(item_id) + if not item: + raise HTTPException(status_code=404, detail="合作伙伴不存在") + db.delete(item) + db.commit() + return {"message": "删除成功"} + + +@router.put("/reorder/batch") +def reorder_partners(ids: list[int], db: Session = Depends(get_db)): + for i, pid in enumerate(ids): + db.query(Partner).filter(Partner.id == pid).update({"sort_order": i}) + db.commit() + return {"message": "排序更新成功"} diff --git a/app/routers/pricing.py b/app/routers/pricing.py new file mode 100644 index 0000000..38a9205 --- /dev/null +++ b/app/routers/pricing.py @@ -0,0 +1,57 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import func as sqlfunc + +from app.database import get_db +from app.auth import get_current_user +from app.models.pricing import PricingItem +from app.schemas.pricing import PricingItemSchema, PricingItemResponse + +router = APIRouter(prefix="/api/pricing", tags=["价格信息"], dependencies=[Depends(get_current_user)]) + + +@router.get("/", response_model=list[PricingItemResponse]) +def list_pricing(db: Session = Depends(get_db)): + items = db.query(PricingItem).order_by(PricingItem.sort_order).all() + return [PricingItemResponse.model_validate(item) for item in items] + + +@router.post("/", response_model=PricingItemResponse, status_code=201) +def create_pricing(req: PricingItemSchema, db: Session = Depends(get_db)): + max_order = db.query(sqlfunc.max(PricingItem.sort_order)).scalar() or 0 + item = PricingItem(**req.model_dump(), sort_order=max_order + 1) + db.add(item) + db.commit() + db.refresh(item) + return PricingItemResponse.model_validate(item) + + +@router.put("/{item_id}", response_model=PricingItemResponse) +def update_pricing(item_id: int, req: PricingItemSchema, db: Session = Depends(get_db)): + item = db.query(PricingItem).get(item_id) + if not item: + raise HTTPException(status_code=404, detail="价格信息不存在") + for key, val in req.model_dump().items(): + setattr(item, key, val) + db.commit() + db.refresh(item) + return PricingItemResponse.model_validate(item) + + +@router.delete("/{item_id}") +def delete_pricing(item_id: int, db: Session = Depends(get_db)): + item = db.query(PricingItem).get(item_id) + if not item: + raise HTTPException(status_code=404, detail="价格信息不存在") + db.delete(item) + db.commit() + return {"message": "删除成功"} + + +@router.put("/reorder/batch") +def reorder_pricing(ids: list[int], db: Session = Depends(get_db)): + for i, pid in enumerate(ids): + db.query(PricingItem).filter(PricingItem.id == pid).update({"sort_order": i}) + db.commit() + return {"message": "排序更新成功"} diff --git a/app/routers/products.py b/app/routers/products.py new file mode 100644 index 0000000..f7f3b97 --- /dev/null +++ b/app/routers/products.py @@ -0,0 +1,187 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.product import ( + ProductConfig, ProductVersion, ProductVersionHighlight, + SummerCamp, SummerCampPrinciple, SummerCampActivity, SummerCampItinerary, SummerCampFaq, + SelectionGuide, +) +from app.schemas.product import ( + ProductConfigSchema, ProductVersionSchema, ProductVersionResponse, + SummerCampSchema, SelectionGuideSchema, +) + +router = APIRouter(prefix="/api/products", tags=["产品管理"], dependencies=[Depends(get_current_user)]) + + +def _get_config(db: Session) -> ProductConfig: + config = db.query(ProductConfig).first() + if not config: + config = ProductConfig() + db.add(config) + db.flush() + return config + + +@router.get("/config", response_model=ProductConfigSchema) +def get_config(db: Session = Depends(get_db)): + config = _get_config(db) + return ProductConfigSchema(narrative=config.narrative, pricing_philosophy=config.pricing_philosophy) + + +@router.put("/config") +def update_config(req: ProductConfigSchema, db: Session = Depends(get_db)): + config = _get_config(db) + config.narrative = req.narrative + config.pricing_philosophy = req.pricing_philosophy + db.commit() + return {"message": "更新成功"} + + +@router.get("/versions", response_model=list[ProductVersionResponse]) +def list_versions(db: Session = Depends(get_db)): + config = _get_config(db) + versions = db.query(ProductVersion).filter_by(config_id=config.id).order_by(ProductVersion.sort_order).all() + result = [] + for v in versions: + resp = ProductVersionResponse( + id=v.id, version_id=v.version_id, name=v.name, days=v.days, nights=v.nights, + audience=v.audience, description=v.description, tag=v.tag, sort_order=v.sort_order, + highlights=[h.text for h in v.highlights], + ) + result.append(resp) + return result + + +@router.post("/versions", response_model=ProductVersionResponse, status_code=201) +def create_version(req: ProductVersionSchema, db: Session = Depends(get_db)): + config = _get_config(db) + v = ProductVersion( + config_id=config.id, version_id=req.version_id, name=req.name, + days=req.days, nights=req.nights, audience=req.audience, + description=req.description, tag=req.tag, + ) + db.add(v) + db.flush() + for i, h in enumerate(req.highlights): + db.add(ProductVersionHighlight(version_id=v.id, text=h, sort_order=i)) + db.commit() + db.refresh(v) + return ProductVersionResponse( + id=v.id, version_id=v.version_id, name=v.name, days=v.days, nights=v.nights, + audience=v.audience, description=v.description, tag=v.tag, sort_order=v.sort_order, + highlights=[h.text for h in v.highlights], + ) + + +@router.put("/versions/{v_id}") +def update_version(v_id: int, req: ProductVersionSchema, db: Session = Depends(get_db)): + v = db.query(ProductVersion).get(v_id) + if not v: + raise HTTPException(status_code=404, detail="版本不存在") + v.version_id = req.version_id + v.name = req.name + v.days = req.days + v.nights = req.nights + v.audience = req.audience + v.description = req.description + v.tag = req.tag + # Replace highlights + db.query(ProductVersionHighlight).filter_by(version_id=v.id).delete() + for i, h in enumerate(req.highlights): + db.add(ProductVersionHighlight(version_id=v.id, text=h, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +@router.delete("/versions/{v_id}") +def delete_version(v_id: int, db: Session = Depends(get_db)): + v = db.query(ProductVersion).get(v_id) + if not v: + raise HTTPException(status_code=404, detail="版本不存在") + db.delete(v) + db.commit() + return {"message": "删除成功"} + + +@router.get("/summer-camp", response_model=SummerCampSchema) +def get_summer_camp(db: Session = Depends(get_db)): + config = _get_config(db) + camp = db.query(SummerCamp).filter_by(config_id=config.id).first() + if not camp: + return SummerCampSchema() + return SummerCampSchema( + name=camp.name, positioning=camp.positioning, days=camp.days, nights=camp.nights, + sessions=camp.sessions_json, difference_from_v9=camp.difference_from_v9, + principles=[p.text for p in camp.principles], + activities=[a.text for a in camp.activities], + itinerary=[it.text for it in camp.itinerary], + faq=[{"question": f.question, "answer": f.answer} for f in camp.faq], + ) + + +@router.put("/summer-camp") +def update_summer_camp(req: SummerCampSchema, db: Session = Depends(get_db)): + config = _get_config(db) + camp = db.query(SummerCamp).filter_by(config_id=config.id).first() + if not camp: + camp = SummerCamp(config_id=config.id) + db.add(camp) + db.flush() + + camp.name = req.name + camp.positioning = req.positioning + camp.days = req.days + camp.nights = req.nights + camp.sessions_json = req.sessions + camp.difference_from_v9 = req.difference_from_v9 + + # Replace sub-collections + db.query(SummerCampPrinciple).filter_by(camp_id=camp.id).delete() + for i, p in enumerate(req.principles): + db.add(SummerCampPrinciple(camp_id=camp.id, text=p, sort_order=i)) + + db.query(SummerCampActivity).filter_by(camp_id=camp.id).delete() + for i, a in enumerate(req.activities): + db.add(SummerCampActivity(camp_id=camp.id, text=a, sort_order=i)) + + db.query(SummerCampItinerary).filter_by(camp_id=camp.id).delete() + for i, it in enumerate(req.itinerary): + db.add(SummerCampItinerary(camp_id=camp.id, text=it, sort_order=i)) + + db.query(SummerCampFaq).filter_by(camp_id=camp.id).delete() + for i, f in enumerate(req.faq): + db.add(SummerCampFaq(camp_id=camp.id, question=f.question, answer=f.answer, sort_order=i)) + + db.commit() + return {"message": "夏令营更新成功"} + + +@router.get("/selection-guide", response_model=SelectionGuideSchema) +def get_selection_guide(db: Session = Depends(get_db)): + config = _get_config(db) + guide = db.query(SelectionGuide).filter_by(config_id=config.id).first() + if not guide: + return SelectionGuideSchema() + return SelectionGuideSchema( + by_vacation_length=guide.by_vacation_length or [], + by_child_age=guide.by_child_age or [], + by_preference=guide.by_preference or [], + ) + + +@router.put("/selection-guide") +def update_selection_guide(req: SelectionGuideSchema, db: Session = Depends(get_db)): + config = _get_config(db) + guide = db.query(SelectionGuide).filter_by(config_id=config.id).first() + if not guide: + guide = SelectionGuide(config_id=config.id) + db.add(guide) + guide.by_vacation_length = req.by_vacation_length + guide.by_child_age = req.by_child_age + guide.by_preference = req.by_preference + db.commit() + return {"message": "选品指南更新成功"} diff --git a/app/routers/qualifications.py b/app/routers/qualifications.py new file mode 100644 index 0000000..6cd3ef5 --- /dev/null +++ b/app/routers/qualifications.py @@ -0,0 +1,57 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import func as sqlfunc + +from app.database import get_db +from app.auth import get_current_user +from app.models.qualification import Qualification +from app.schemas.qualification import QualificationSchema, QualificationResponse + +router = APIRouter(prefix="/api/qualifications", tags=["资质荣誉"], dependencies=[Depends(get_current_user)]) + + +@router.get("/", response_model=list[QualificationResponse]) +def list_qualifications(db: Session = Depends(get_db)): + items = db.query(Qualification).order_by(Qualification.sort_order).all() + return [QualificationResponse.model_validate(item) for item in items] + + +@router.post("/", response_model=QualificationResponse, status_code=201) +def create_qualification(req: QualificationSchema, db: Session = Depends(get_db)): + max_order = db.query(sqlfunc.max(Qualification.sort_order)).scalar() or 0 + item = Qualification(**req.model_dump(), sort_order=max_order + 1) + db.add(item) + db.commit() + db.refresh(item) + return QualificationResponse.model_validate(item) + + +@router.put("/{item_id}", response_model=QualificationResponse) +def update_qualification(item_id: int, req: QualificationSchema, db: Session = Depends(get_db)): + item = db.query(Qualification).get(item_id) + if not item: + raise HTTPException(status_code=404, detail="资质信息不存在") + for key, val in req.model_dump().items(): + setattr(item, key, val) + db.commit() + db.refresh(item) + return QualificationResponse.model_validate(item) + + +@router.delete("/{item_id}") +def delete_qualification(item_id: int, db: Session = Depends(get_db)): + item = db.query(Qualification).get(item_id) + if not item: + raise HTTPException(status_code=404, detail="资质信息不存在") + db.delete(item) + db.commit() + return {"message": "删除成功"} + + +@router.put("/reorder/batch") +def reorder_qualifications(ids: list[int], db: Session = Depends(get_db)): + for i, qid in enumerate(ids): + db.query(Qualification).filter(Qualification.id == qid).update({"sort_order": i}) + db.commit() + return {"message": "排序更新成功"} diff --git a/app/routers/reviews.py b/app/routers/reviews.py new file mode 100644 index 0000000..39f1540 --- /dev/null +++ b/app/routers/reviews.py @@ -0,0 +1,100 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func as sqlfunc + +from app.database import get_db +from app.auth import get_current_user +from app.models.review import Review, ReviewSummary +from app.schemas.review import ReviewSchema, ReviewResponse, ReviewListResponse, ReviewSummarySchema + +router = APIRouter(prefix="/api/reviews", tags=["客户评价"], dependencies=[Depends(get_current_user)]) + + +@router.get("/summary", response_model=ReviewSummarySchema) +def get_summary(db: Session = Depends(get_db)): + summary = db.query(ReviewSummary).first() + if not summary: + return ReviewSummarySchema() + return ReviewSummarySchema(total_count=summary.total_count, approval_rate=summary.approval_rate, keywords=summary.keywords or []) + + +@router.put("/summary") +def update_summary(req: ReviewSummarySchema, db: Session = Depends(get_db)): + summary = db.query(ReviewSummary).first() + if not summary: + summary = ReviewSummary() + db.add(summary) + summary.total_count = req.total_count + summary.approval_rate = req.approval_rate + summary.keywords = req.keywords + db.commit() + return {"message": "评价概要更新成功"} + + +@router.get("/", response_model=ReviewListResponse) +def list_reviews( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query("", description="搜索关键词"), + db: Session = Depends(get_db), +): + query = db.query(Review) + if keyword: + query = query.filter(Review.nickname.contains(keyword) | Review.content.contains(keyword)) + total = query.count() + items = query.order_by(Review.sort_order, Review.id).offset((page - 1) * page_size).limit(page_size).all() + return ReviewListResponse( + items=[ReviewResponse.model_validate(r) for r in items], + total=total, + ) + + +@router.post("/", response_model=ReviewResponse, status_code=201) +def create_review(req: ReviewSchema, db: Session = Depends(get_db)): + max_order = db.query(sqlfunc.max(Review.sort_order)).scalar() or 0 + review = Review( + nickname=req.nickname, + travel_date=req.travel_date, + product_version=req.product_version, + screenshot=req.screenshot, + content=req.content, + scenes=req.scenes, + concerns=req.concerns, + is_visible=req.is_visible, + sort_order=max_order + 1, + ) + db.add(review) + db.commit() + db.refresh(review) + return ReviewResponse.model_validate(review) + + +@router.put("/{review_id}", response_model=ReviewResponse) +def update_review(review_id: int, req: ReviewSchema, db: Session = Depends(get_db)): + review = db.query(Review).get(review_id) + if not review: + raise HTTPException(status_code=404, detail="评价不存在") + for key, val in req.model_dump().items(): + setattr(review, key, val) + db.commit() + db.refresh(review) + return ReviewResponse.model_validate(review) + + +@router.delete("/{review_id}") +def delete_review(review_id: int, db: Session = Depends(get_db)): + review = db.query(Review).get(review_id) + if not review: + raise HTTPException(status_code=404, detail="评价不存在") + db.delete(review) + db.commit() + return {"message": "删除成功"} + + +@router.put("/reorder/batch") +def reorder_reviews(ids: list[int], db: Session = Depends(get_db)): + for i, rid in enumerate(ids): + db.query(Review).filter(Review.id == rid).update({"sort_order": i}) + db.commit() + return {"message": "排序更新成功"} diff --git a/app/routers/seasonal_products.py b/app/routers/seasonal_products.py new file mode 100644 index 0000000..78519d7 --- /dev/null +++ b/app/routers/seasonal_products.py @@ -0,0 +1,174 @@ +""" +Seasonal products router: serves both autumn (游牧的森林) and winter (嗨冰雪). +Mounted twice at /api/autumn-products and /api/winter-products. +""" +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.seasonal_product import ( + SeasonalProductConfig, SeasonalProductVersion, + SeasonalProductHighlight, SeasonalProductTimeline, +) +from app.schemas.seasonal_product import ( + SeasonalConfigSchema, SeasonalVersionSchema, SeasonalVersionResponse, + SeasonalHighlightSchema, SeasonalHighlightResponse, + SeasonalTimelineSchema, SeasonalTimelineResponse, +) + + +def create_seasonal_router(season: str, label: str) -> APIRouter: + router = APIRouter(prefix=f"/api/{season}-products", tags=[f"{label}产品"], dependencies=[Depends(get_current_user)]) + + def _get_config(db: Session) -> SeasonalProductConfig: + config = db.query(SeasonalProductConfig).filter_by(season=season).first() + if not config: + config = SeasonalProductConfig(season=season) + db.add(config) + db.flush() + return config + + @router.get("/config", response_model=SeasonalConfigSchema) + def get_config(db: Session = Depends(get_db)): + c = _get_config(db) + return SeasonalConfigSchema( + narrative=c.narrative, style=c.style, brand_name=c.brand_name, + version_label=c.version_label, season_label=c.season_label, + ) + + @router.put("/config") + def update_config(req: SeasonalConfigSchema, db: Session = Depends(get_db)): + c = _get_config(db) + c.narrative = req.narrative + c.style = req.style + c.brand_name = req.brand_name + c.version_label = req.version_label + c.season_label = req.season_label + db.commit() + return {"message": "更新成功"} + + # --- Versions --- + @router.get("/versions", response_model=list[SeasonalVersionResponse]) + def list_versions(db: Session = Depends(get_db)): + rows = db.query(SeasonalProductVersion).filter_by(season=season).order_by(SeasonalProductVersion.sort_order).all() + return [ + SeasonalVersionResponse( + id=v.id, version_id=v.version_id, name=v.name, days=v.days, nights=v.nights, + tag=v.tag, line=v.line, route=v.route, audience=v.audience, + description=v.description, highlights=v.highlights or [], itinerary=v.itinerary or [], + sort_order=v.sort_order, + ) + for v in rows + ] + + @router.post("/versions", response_model=SeasonalVersionResponse, status_code=201) + def create_version(req: SeasonalVersionSchema, db: Session = Depends(get_db)): + v = SeasonalProductVersion( + season=season, version_id=req.version_id, name=req.name, + days=req.days, nights=req.nights, tag=req.tag, line=req.line, + route=req.route, audience=req.audience, description=req.description, + highlights=req.highlights, itinerary=req.itinerary, + ) + db.add(v) + db.commit() + db.refresh(v) + return SeasonalVersionResponse( + id=v.id, version_id=v.version_id, name=v.name, days=v.days, nights=v.nights, + tag=v.tag, line=v.line, route=v.route, audience=v.audience, + description=v.description, highlights=v.highlights or [], itinerary=v.itinerary or [], + sort_order=v.sort_order, + ) + + @router.put("/versions/{v_id}") + def update_version(v_id: int, req: SeasonalVersionSchema, db: Session = Depends(get_db)): + v = db.query(SeasonalProductVersion).get(v_id) + if not v or v.season != season: + raise HTTPException(status_code=404, detail="版本不存在") + v.version_id = req.version_id + v.name = req.name + v.days = req.days + v.nights = req.nights + v.tag = req.tag + v.line = req.line + v.route = req.route + v.audience = req.audience + v.description = req.description + v.highlights = req.highlights + v.itinerary = req.itinerary + db.commit() + return {"message": "更新成功"} + + @router.delete("/versions/{v_id}") + def delete_version(v_id: int, db: Session = Depends(get_db)): + v = db.query(SeasonalProductVersion).get(v_id) + if not v or v.season != season: + raise HTTPException(status_code=404, detail="版本不存在") + db.delete(v) + db.commit() + return {"message": "删除成功"} + + # --- Highlights --- + @router.get("/highlights", response_model=list[SeasonalHighlightResponse]) + def list_highlights(category: str = None, db: Session = Depends(get_db)): + q = db.query(SeasonalProductHighlight).filter_by(season=season) + if category: + q = q.filter_by(category=category) + return [ + SeasonalHighlightResponse(id=h.id, category=h.category, title=h.title, description=h.description, sort_order=h.sort_order) + for h in q.order_by(SeasonalProductHighlight.sort_order).all() + ] + + @router.put("/highlights") + def update_highlights(items: list[SeasonalHighlightSchema], db: Session = Depends(get_db)): + db.query(SeasonalProductHighlight).filter_by(season=season).delete() + for i, h in enumerate(items): + db.add(SeasonalProductHighlight(season=season, category=h.category, title=h.title, description=h.description, sort_order=i)) + db.commit() + return {"message": "亮点更新成功"} + + # --- Timeline --- + @router.get("/timeline", response_model=list[SeasonalTimelineResponse]) + def list_timeline(db: Session = Depends(get_db)): + rows = db.query(SeasonalProductTimeline).filter_by(season=season).order_by(SeasonalProductTimeline.sort_order).all() + return [ + SeasonalTimelineResponse(id=t.id, version=t.version, date=t.date, title=t.title, changes=t.changes or [], reason=t.reason, sort_order=t.sort_order) + for t in rows + ] + + @router.post("/timeline", response_model=SeasonalTimelineResponse, status_code=201) + def create_timeline(req: SeasonalTimelineSchema, db: Session = Depends(get_db)): + t = SeasonalProductTimeline(season=season, version=req.version, date=req.date, title=req.title, changes=req.changes, reason=req.reason) + db.add(t) + db.commit() + db.refresh(t) + return SeasonalTimelineResponse(id=t.id, version=t.version, date=t.date, title=t.title, changes=t.changes or [], reason=t.reason, sort_order=t.sort_order) + + @router.put("/timeline/{t_id}") + def update_timeline(t_id: int, req: SeasonalTimelineSchema, db: Session = Depends(get_db)): + t = db.query(SeasonalProductTimeline).get(t_id) + if not t or t.season != season: + raise HTTPException(status_code=404, detail="时间线不存在") + t.version = req.version + t.date = req.date + t.title = req.title + t.changes = req.changes + t.reason = req.reason + db.commit() + return {"message": "更新成功"} + + @router.delete("/timeline/{t_id}") + def delete_timeline(t_id: int, db: Session = Depends(get_db)): + t = db.query(SeasonalProductTimeline).get(t_id) + if not t or t.season != season: + raise HTTPException(status_code=404, detail="时间线不存在") + db.delete(t) + db.commit() + return {"message": "删除成功"} + + return router + + +autumn_router = create_seasonal_router("autumn", "秋季") +winter_router = create_seasonal_router("winter", "冬季") diff --git a/app/routers/selector.py b/app/routers/selector.py new file mode 100644 index 0000000..af847f3 --- /dev/null +++ b/app/routers/selector.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.selector import SelectorConfig +from app.schemas.selector import SelectorConfigSchema, SelectorConfigResponse + +router = APIRouter( + prefix="/api/selector", + tags=["选版工具"], + dependencies=[Depends(get_current_user)], +) + + +def _get_or_create(db: Session) -> SelectorConfig: + config = db.query(SelectorConfig).first() + if not config: + config = SelectorConfig(questions=[], rules=[]) + db.add(config) + db.commit() + return config + + +@router.get("/", response_model=SelectorConfigResponse) +def get_config(db: Session = Depends(get_db)): + return SelectorConfigResponse.model_validate(_get_or_create(db)) + + +@router.put("/", response_model=SelectorConfigResponse) +def update_config(req: SelectorConfigSchema, db: Session = Depends(get_db)): + config = _get_or_create(db) + config.questions = req.questions + config.rules = req.rules + db.commit() + db.refresh(config) + return SelectorConfigResponse.model_validate(config) diff --git a/app/routers/seo.py b/app/routers/seo.py new file mode 100644 index 0000000..3d73f99 --- /dev/null +++ b/app/routers/seo.py @@ -0,0 +1,32 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.seo import SeoPage +from app.schemas.seo import SeoPageSchema, SeoPageResponse + +router = APIRouter(prefix="/api/seo", tags=["SEO配置"], dependencies=[Depends(get_current_user)]) + + +@router.get("/pages", response_model=list[SeoPageResponse]) +def list_pages(db: Session = Depends(get_db)): + pages = db.query(SeoPage).all() + return [SeoPageResponse.model_validate(p) for p in pages] + + +@router.put("/pages/{page_key}", response_model=SeoPageResponse) +def update_page(page_key: str, req: SeoPageSchema, db: Session = Depends(get_db)): + page = db.query(SeoPage).filter(SeoPage.page_key == page_key).first() + if not page: + raise HTTPException(status_code=404, detail=f"页面 {page_key} 不存在") + page.title = req.title + page.description = req.description + page.h1 = req.h1 + page.keywords = req.keywords + page.og_image = req.og_image + page.tldr = req.tldr + db.commit() + db.refresh(page) + return SeoPageResponse.model_validate(page) diff --git a/app/routers/site_images.py b/app/routers/site_images.py new file mode 100644 index 0000000..6f572e6 --- /dev/null +++ b/app/routers/site_images.py @@ -0,0 +1,49 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.site_image import SiteImage +from app.schemas.site_image import SiteImageSchema, SiteImageResponse + +router = APIRouter(prefix="/api/site-images", tags=["网站图片"], dependencies=[Depends(get_current_user)]) + + +@router.get("/", response_model=list[SiteImageResponse]) +def list_images(db: Session = Depends(get_db)): + images = db.query(SiteImage).order_by(SiteImage.group_name, SiteImage.image_key).all() + return [SiteImageResponse.model_validate(img) for img in images] + + +@router.put("/{image_id}", response_model=SiteImageResponse) +def update_image(image_id: int, req: SiteImageSchema, db: Session = Depends(get_db)): + img = db.query(SiteImage).get(image_id) + if not img: + raise HTTPException(status_code=404, detail="图片不存在") + img.image_path = req.image_path + img.label = req.label + img.size_hint = req.size_hint + img.description = req.description + db.commit() + db.refresh(img) + return SiteImageResponse.model_validate(img) + + +@router.post("/", response_model=SiteImageResponse, status_code=201) +def create_image(req: SiteImageSchema, db: Session = Depends(get_db)): + img = SiteImage(**req.model_dump()) + db.add(img) + db.commit() + db.refresh(img) + return SiteImageResponse.model_validate(img) + + +@router.delete("/{image_id}") +def delete_image(image_id: int, db: Session = Depends(get_db)): + img = db.query(SiteImage).get(image_id) + if not img: + raise HTTPException(status_code=404, detail="图片不存在") + db.delete(img) + db.commit() + return {"message": "删除成功"} diff --git a/app/routers/stories.py b/app/routers/stories.py new file mode 100644 index 0000000..9284ed4 --- /dev/null +++ b/app/routers/stories.py @@ -0,0 +1,69 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func as sqlfunc + +from app.database import get_db +from app.auth import get_current_user +from app.models.story import Story +from app.schemas.story import StorySchema, StoryResponse, StoryListResponse + +router = APIRouter(prefix="/api/stories", tags=["客户故事"], dependencies=[Depends(get_current_user)]) + + +@router.get("/", response_model=StoryListResponse) +def list_stories( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query("", description="搜索关键词"), + db: Session = Depends(get_db), +): + query = db.query(Story) + if keyword: + query = query.filter(Story.title.contains(keyword) | Story.customer_name.contains(keyword)) + total = query.count() + items = query.order_by(Story.sort_order, Story.id.desc()).offset((page - 1) * page_size).limit(page_size).all() + return StoryListResponse( + items=[StoryResponse.model_validate(s) for s in items], + total=total, + ) + + +@router.post("/", response_model=StoryResponse, status_code=201) +def create_story(req: StorySchema, db: Session = Depends(get_db)): + max_order = db.query(sqlfunc.max(Story.sort_order)).scalar() or 0 + story = Story(**req.model_dump(), sort_order=max_order + 1) + db.add(story) + db.commit() + db.refresh(story) + return StoryResponse.model_validate(story) + + +@router.put("/{story_id}", response_model=StoryResponse) +def update_story(story_id: int, req: StorySchema, db: Session = Depends(get_db)): + story = db.query(Story).get(story_id) + if not story: + raise HTTPException(status_code=404, detail="故事不存在") + for key, val in req.model_dump().items(): + setattr(story, key, val) + db.commit() + db.refresh(story) + return StoryResponse.model_validate(story) + + +@router.delete("/{story_id}") +def delete_story(story_id: int, db: Session = Depends(get_db)): + story = db.query(Story).get(story_id) + if not story: + raise HTTPException(status_code=404, detail="故事不存在") + db.delete(story) + db.commit() + return {"message": "删除成功"} + + +@router.put("/reorder/batch") +def reorder_stories(ids: list[int], db: Session = Depends(get_db)): + for i, sid in enumerate(ids): + db.query(Story).filter(Story.id == sid).update({"sort_order": i}) + db.commit() + return {"message": "排序更新成功"} diff --git a/app/routers/upload.py b/app/routers/upload.py new file mode 100644 index 0000000..761ec10 --- /dev/null +++ b/app/routers/upload.py @@ -0,0 +1,36 @@ +from __future__ import annotations +import os +import uuid + +from fastapi import APIRouter, Depends, UploadFile, File, HTTPException + +from app.auth import get_current_user +from app.config import settings + +router = APIRouter(prefix="/api/upload", tags=["文件上传"], dependencies=[Depends(get_current_user)]) + +ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} +MAX_SIZE = 5 * 1024 * 1024 # 5MB + + +@router.post("/image", status_code=201) +async def upload_image(file: UploadFile = File(...), subdir: str = "uploads"): + ext = os.path.splitext(file.filename)[1].lower() + if ext not in ALLOWED_EXTENSIONS: + raise HTTPException(status_code=400, detail=f"不支持的文件格式: {ext}") + + content = await file.read() + if len(content) > MAX_SIZE: + raise HTTPException(status_code=400, detail="文件大小不能超过5MB") + + target_dir = os.path.join(settings.UPLOAD_DIR, subdir) + os.makedirs(target_dir, exist_ok=True) + + filename = f"{uuid.uuid4().hex}{ext}" + filepath = os.path.join(target_dir, filename) + + with open(filepath, "wb") as f: + f.write(content) + + relative_path = f"/images/{subdir}/{filename}" + return {"url": relative_path, "filename": filename} diff --git a/app/routers/versions.py b/app/routers/versions.py new file mode 100644 index 0000000..6964ad5 --- /dev/null +++ b/app/routers/versions.py @@ -0,0 +1,172 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.version import ( + VersionConfig, VersionUpgrade, VersionHighlight, VersionCompare, + VersionTimeline, VersionTimelineChange, VersionPhilosophy, +) +from app.schemas.version import ( + VersionConfigSchema, VersionUpgradeSchema, VersionHighlightSchema, + VersionCompareSchema, VersionTimelineSchema, VersionTimelineResponse, + VersionPhilosophySchema, +) + +router = APIRouter(prefix="/api/versions", tags=["版本历史"], dependencies=[Depends(get_current_user)]) + + +def _get_config(db: Session) -> VersionConfig: + config = db.query(VersionConfig).first() + if not config: + config = VersionConfig() + db.add(config) + db.flush() + return config + + +@router.get("/config", response_model=VersionConfigSchema) +def get_config(db: Session = Depends(get_db)): + c = _get_config(db) + return VersionConfigSchema( + stats_iterations=c.stats_iterations, stats_years=c.stats_years, + stats_guests=c.stats_guests, quote_text=c.quote_text, quote_author=c.quote_author, + ) + + +@router.put("/config") +def update_config(req: VersionConfigSchema, db: Session = Depends(get_db)): + c = _get_config(db) + for key, val in req.model_dump().items(): + setattr(c, key, val) + db.commit() + return {"message": "更新成功"} + + +@router.get("/upgrades", response_model=list[VersionUpgradeSchema]) +def get_upgrades(db: Session = Depends(get_db)): + c = _get_config(db) + items = db.query(VersionUpgrade).filter_by(config_id=c.id).order_by(VersionUpgrade.sort_order).all() + return [VersionUpgradeSchema(tag=u.tag, name=u.name, description=u.description, reason=u.reason) for u in items] + + +@router.put("/upgrades") +def update_upgrades(req: list[VersionUpgradeSchema], db: Session = Depends(get_db)): + c = _get_config(db) + db.query(VersionUpgrade).filter_by(config_id=c.id).delete() + for i, u in enumerate(req): + db.add(VersionUpgrade(config_id=c.id, tag=u.tag, name=u.name, description=u.description, reason=u.reason, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +@router.get("/highlights", response_model=list[VersionHighlightSchema]) +def get_highlights(db: Session = Depends(get_db)): + c = _get_config(db) + items = db.query(VersionHighlight).filter_by(config_id=c.id).order_by(VersionHighlight.sort_order).all() + return [VersionHighlightSchema(label=h.label, text=h.text) for h in items] + + +@router.put("/highlights") +def update_highlights(req: list[VersionHighlightSchema], db: Session = Depends(get_db)): + c = _get_config(db) + db.query(VersionHighlight).filter_by(config_id=c.id).delete() + for i, h in enumerate(req): + db.add(VersionHighlight(config_id=c.id, label=h.label, text=h.text, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +@router.get("/compare", response_model=VersionCompareSchema) +def get_compare(db: Session = Depends(get_db)): + c = _get_config(db) + cmp = db.query(VersionCompare).filter_by(config_id=c.id).first() + if not cmp: + return VersionCompareSchema() + return VersionCompareSchema(headers=cmp.headers or [], rows=cmp.rows or []) + + +@router.put("/compare") +def update_compare(req: VersionCompareSchema, db: Session = Depends(get_db)): + c = _get_config(db) + cmp = db.query(VersionCompare).filter_by(config_id=c.id).first() + if not cmp: + cmp = VersionCompare(config_id=c.id) + db.add(cmp) + cmp.headers = req.headers + cmp.rows = req.rows + db.commit() + return {"message": "更新成功"} + + +@router.get("/timeline", response_model=list[VersionTimelineResponse]) +def get_timeline(db: Session = Depends(get_db)): + c = _get_config(db) + items = db.query(VersionTimeline).filter_by(config_id=c.id).order_by(VersionTimeline.sort_order).all() + return [ + VersionTimelineResponse( + id=t.id, version=t.version, date=t.date, title=t.title, reason=t.reason, sort_order=t.sort_order, + changes=[{"type": ch.type, "text": ch.text} for ch in t.changes], + ) + for t in items + ] + + +@router.post("/timeline", response_model=VersionTimelineResponse, status_code=201) +def create_timeline(req: VersionTimelineSchema, db: Session = Depends(get_db)): + c = _get_config(db) + t = VersionTimeline(config_id=c.id, version=req.version, date=req.date, title=req.title, reason=req.reason) + db.add(t) + db.flush() + for i, ch in enumerate(req.changes): + db.add(VersionTimelineChange(timeline_id=t.id, type=ch.type, text=ch.text, sort_order=i)) + db.commit() + db.refresh(t) + return VersionTimelineResponse( + id=t.id, version=t.version, date=t.date, title=t.title, reason=t.reason, sort_order=t.sort_order, + changes=[{"type": ch.type, "text": ch.text} for ch in t.changes], + ) + + +@router.put("/timeline/{t_id}") +def update_timeline(t_id: int, req: VersionTimelineSchema, db: Session = Depends(get_db)): + t = db.query(VersionTimeline).get(t_id) + if not t: + raise HTTPException(status_code=404, detail="时间线条目不存在") + t.version = req.version + t.date = req.date + t.title = req.title + t.reason = req.reason + db.query(VersionTimelineChange).filter_by(timeline_id=t.id).delete() + for i, ch in enumerate(req.changes): + db.add(VersionTimelineChange(timeline_id=t.id, type=ch.type, text=ch.text, sort_order=i)) + db.commit() + return {"message": "更新成功"} + + +@router.delete("/timeline/{t_id}") +def delete_timeline(t_id: int, db: Session = Depends(get_db)): + t = db.query(VersionTimeline).get(t_id) + if not t: + raise HTTPException(status_code=404, detail="时间线条目不存在") + db.delete(t) + db.commit() + return {"message": "删除成功"} + + +@router.get("/philosophy", response_model=list[VersionPhilosophySchema]) +def get_philosophy(db: Session = Depends(get_db)): + c = _get_config(db) + items = db.query(VersionPhilosophy).filter_by(config_id=c.id).order_by(VersionPhilosophy.sort_order).all() + return [VersionPhilosophySchema(label=p.label, text=p.text) for p in items] + + +@router.put("/philosophy") +def update_philosophy(req: list[VersionPhilosophySchema], db: Session = Depends(get_db)): + c = _get_config(db) + db.query(VersionPhilosophy).filter_by(config_id=c.id).delete() + for i, p in enumerate(req): + db.add(VersionPhilosophy(config_id=c.id, label=p.label, text=p.text, sort_order=i)) + db.commit() + return {"message": "更新成功"} diff --git a/app/routers/winter_camp.py b/app/routers/winter_camp.py new file mode 100644 index 0000000..d872c64 --- /dev/null +++ b/app/routers/winter_camp.py @@ -0,0 +1,133 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.auth import get_current_user +from app.models.winter_camp import WinterCampConfig, WinterCampHotel, WinterCampItinerary, WinterCampFaq +from app.schemas.winter_camp import ( + WinterCampConfigSchema, WinterCampHotelSchema, WinterCampHotelResponse, + WinterCampItinerarySchema, WinterCampItineraryResponse, + WinterCampFaqSchema, WinterCampFaqResponse, +) + +router = APIRouter(prefix="/api/winter-camp", tags=["冬季营"], dependencies=[Depends(get_current_user)]) + + +def _get_config(db: Session) -> WinterCampConfig: + config = db.query(WinterCampConfig).first() + if not config: + config = WinterCampConfig() + db.add(config) + db.flush() + return config + + +@router.get("/config", response_model=WinterCampConfigSchema) +def get_config(db: Session = Depends(get_db)): + c = _get_config(db) + return WinterCampConfigSchema( + name=c.name, positioning=c.positioning, days=c.days, nights=c.nights, + max_families=c.max_families, total_sessions=c.total_sessions, + age_range=c.age_range, deposit=c.deposit, season=c.season, route=c.route, + why_hulunbuir=c.why_hulunbuir, closing_note=c.closing_note, + photographer=c.photographer, winter_clothing=c.winter_clothing, + camp_advantages=c.camp_advantages or [], service_config=c.service_config or [], + camp_essentials=c.camp_essentials or [], + ) + + +@router.put("/config") +def update_config(req: WinterCampConfigSchema, db: Session = Depends(get_db)): + c = _get_config(db) + c.name = req.name + c.positioning = req.positioning + c.days = req.days + c.nights = req.nights + c.max_families = req.max_families + c.total_sessions = req.total_sessions + c.age_range = req.age_range + c.deposit = req.deposit + c.season = req.season + c.route = req.route + c.why_hulunbuir = req.why_hulunbuir + c.closing_note = req.closing_note + c.photographer = req.photographer + c.winter_clothing = req.winter_clothing + c.camp_advantages = req.camp_advantages + c.service_config = req.service_config + c.camp_essentials = req.camp_essentials + db.commit() + return {"message": "冬季营配置更新成功"} + + +# --- Hotels --- +@router.get("/hotels", response_model=list[WinterCampHotelResponse]) +def list_hotels(db: Session = Depends(get_db)): + rows = db.query(WinterCampHotel).order_by(WinterCampHotel.sort_order).all() + return [WinterCampHotelResponse(id=h.id, name=h.name, star=h.star, nights=h.nights, description=h.description, sort_order=h.sort_order) for h in rows] + + +@router.put("/hotels") +def update_hotels(items: list[WinterCampHotelSchema], db: Session = Depends(get_db)): + db.query(WinterCampHotel).delete() + for i, h in enumerate(items): + db.add(WinterCampHotel(name=h.name, star=h.star, nights=h.nights, description=h.description, sort_order=i)) + db.commit() + return {"message": "酒店信息更新成功"} + + +# --- Itinerary --- +@router.get("/itinerary", response_model=list[WinterCampItineraryResponse]) +def list_itinerary(db: Session = Depends(get_db)): + rows = db.query(WinterCampItinerary).order_by(WinterCampItinerary.sort_order).all() + return [ + WinterCampItineraryResponse(id=d.id, day=d.day, title=d.title, summary=d.summary, highlights=d.highlights or [], hotel=d.hotel, sort_order=d.sort_order) + for d in rows + ] + + +@router.put("/itinerary") +def update_itinerary(items: list[WinterCampItinerarySchema], db: Session = Depends(get_db)): + db.query(WinterCampItinerary).delete() + for i, d in enumerate(items): + db.add(WinterCampItinerary(day=d.day, title=d.title, summary=d.summary, highlights=d.highlights, hotel=d.hotel, sort_order=i)) + db.commit() + return {"message": "行程更新成功"} + + +# --- FAQ --- +@router.get("/faq", response_model=list[WinterCampFaqResponse]) +def list_faq(db: Session = Depends(get_db)): + rows = db.query(WinterCampFaq).order_by(WinterCampFaq.sort_order).all() + return [WinterCampFaqResponse(id=f.id, question=f.question, answer=f.answer, sort_order=f.sort_order) for f in rows] + + +@router.post("/faq", response_model=WinterCampFaqResponse, status_code=201) +def create_faq(req: WinterCampFaqSchema, db: Session = Depends(get_db)): + f = WinterCampFaq(question=req.question, answer=req.answer) + db.add(f) + db.commit() + db.refresh(f) + return WinterCampFaqResponse(id=f.id, question=f.question, answer=f.answer, sort_order=f.sort_order) + + +@router.put("/faq/{f_id}") +def update_faq(f_id: int, req: WinterCampFaqSchema, db: Session = Depends(get_db)): + f = db.query(WinterCampFaq).get(f_id) + if not f: + raise HTTPException(status_code=404, detail="FAQ不存在") + f.question = req.question + f.answer = req.answer + db.commit() + return {"message": "更新成功"} + + +@router.delete("/faq/{f_id}") +def delete_faq(f_id: int, db: Session = Depends(get_db)): + f = db.query(WinterCampFaq).get(f_id) + if not f: + raise HTTPException(status_code=404, detail="FAQ不存在") + db.delete(f) + db.commit() + return {"message": "删除成功"} diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/about.py b/app/schemas/about.py new file mode 100644 index 0000000..8cf849f --- /dev/null +++ b/app/schemas/about.py @@ -0,0 +1,119 @@ +from __future__ import annotations +from pydantic import BaseModel +from typing import Any + + +class StorySchema(BaseModel): + title: str | None = None + content: str | None = None + founding_moment: str | None = None + totem_description: str | None = None + totem_tagline: str | None = None + + +class SubsidiarySchema(BaseModel): + name: str + role: str | None = None + established: str | None = None + + +class CertificationSchema(BaseModel): + title: str + detail: str | None = None + + +class TrademarkSchema(BaseModel): + name: str | None = None + scope: str | None = None + holder: str | None = None + description: str | None = None + + +class CopyrightSchema(BaseModel): + name: str + reg_no: str | None = None + category: str | None = None + holder: str | None = None + date: str | None = None + description: str | None = None + + +class GuaranteeSchema(BaseModel): + title: str + detail: str | None = None + + +class XiaohongshuSchema(BaseModel): + account: str | None = None + verified: bool = False + verified_type: str | None = None + followers: str | None = None + likes: str | None = None + awards: list[str] = [] + tagline: str | None = None + tags: list[str] = [] + description: str | None = None + + +class TeamSchema(BaseModel): + summary: str | None = None + + +class CultureValueSchema(BaseModel): + name: str + expression: str | None = None + + +class CultureSchema(BaseModel): + core: str | None = None + transparency: str | None = None + trust: str | None = None + grief_award_name: str | None = None + grief_award_description: str | None = None + values: list[CultureValueSchema] = [] + + +class FounderDetailSchema(BaseModel): + name: str | None = None + title: str | None = None + brand_founded: int | None = None + years_in_hulunbuir: str | None = None + background: str | None = None + expertise: list[str] = [] + media_presence: list[str] = [] + + +class FounderSchema(BaseModel): + name: str + title: str | None = None + story: str | None = None + + +class MilestoneSchema(BaseModel): + year: int + event: str + + +class ServicePrincipleSchema(BaseModel): + text: str + + +class DifferentiationSchema(BaseModel): + name: str + detail: str | None = None + + +class ServiceMomentSchema(BaseModel): + step: int + name: str + detail: str | None = None + + +class ServiceJourneySchema(BaseModel): + title: str | None = None + subtitle: str | None = None + moments: list[ServiceMomentSchema] = [] + + +class AboutStatsSchema(BaseModel): + data: dict[str, Any] = {} diff --git a/app/schemas/blog.py b/app/schemas/blog.py new file mode 100644 index 0000000..c1f072d --- /dev/null +++ b/app/schemas/blog.py @@ -0,0 +1,29 @@ +from __future__ import annotations +from pydantic import BaseModel +from typing import Any + + +class BlogSchema(BaseModel): + title: str + slug: str + cover_image: str | None = None + summary: str | None = None + content: str | None = None + author: str | None = None + category: str | None = None + tags: list[str] = [] + published_at: str | None = None + is_visible: bool = True + + +class BlogResponse(BlogSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class BlogListResponse(BaseModel): + items: list[BlogResponse] + total: int diff --git a/app/schemas/brand.py b/app/schemas/brand.py new file mode 100644 index 0000000..1bbed24 --- /dev/null +++ b/app/schemas/brand.py @@ -0,0 +1,44 @@ +from __future__ import annotations +from pydantic import BaseModel +from typing import Any + + +class TrustStatSchema(BaseModel): + value: str + unit: str | None = None + label: str + + +class DifferentiatorSchema(BaseModel): + title: str + description: str | None = None + + +class BrandSchema(BaseModel): + name: str + full_name: str | None = None + domain: str | None = None + url: str | None = None + slogan_emotional: str | None = None + slogan_functional: str | None = None + icp_entity: str | None = None + icp: str | None = None + icp_url: str | None = None + e_contract: str | None = None + cta_buttons: dict[str, Any] | None = None + conversion_path: list[Any] | None = None + + +class BrandFullSchema(BrandSchema): + id: int + trust_stats: list[TrustStatSchema] = [] + differentiators: list[DifferentiatorSchema] = [] + + class Config: + from_attributes = True + + +class BrandUpdateRequest(BaseModel): + brand: BrandSchema + trust_stats: list[TrustStatSchema] = [] + differentiators: list[DifferentiatorSchema] = [] diff --git a/app/schemas/calendar.py b/app/schemas/calendar.py new file mode 100644 index 0000000..2930579 --- /dev/null +++ b/app/schemas/calendar.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class CalendarEventSchema(BaseModel): + date: str | None = None + title: str + description: str | None = None + + +class CalendarMonthSchema(BaseModel): + year: int + month: int + title: str | None = None + description: str | None = None + weather: str | None = None + highlights: list[str] = [] + events: list[CalendarEventSchema] = [] + is_available: bool = True + + +class CalendarMonthResponse(CalendarMonthSchema): + id: int + + class Config: + from_attributes = True diff --git a/app/schemas/contact.py b/app/schemas/contact.py new file mode 100644 index 0000000..be872cd --- /dev/null +++ b/app/schemas/contact.py @@ -0,0 +1,23 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class ContactChannelSchema(BaseModel): + type: str + label: str + value: str | None = None + qr_image: str | None = None + is_primary: bool = False + description: str | None = None + + +class ContactChannelResponse(ContactChannelSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class ContactConfigSchema(BaseModel): + security_notice: str | None = None diff --git a/app/schemas/courses.py b/app/schemas/courses.py new file mode 100644 index 0000000..e11f1dc --- /dev/null +++ b/app/schemas/courses.py @@ -0,0 +1,15 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class CoursesConfigSchema(BaseModel): + modules: list = [] + age_groups: list = [] + faqs: list = [] + + +class CoursesConfigResponse(CoursesConfigSchema): + id: int + + class Config: + from_attributes = True diff --git a/app/schemas/customize_config.py b/app/schemas/customize_config.py new file mode 100644 index 0000000..2bc1c0d --- /dev/null +++ b/app/schemas/customize_config.py @@ -0,0 +1,12 @@ +from __future__ import annotations +from pydantic import BaseModel +from typing import Any + + +class CustomizeConfigSchema(BaseModel): + trust_stats: list[Any] = [] + durations: list[Any] = [] + activities: list[Any] = [] + budgets: list[Any] = [] + process: list[Any] = [] + contact: dict[str, Any] = {} diff --git a/app/schemas/customize_submission.py b/app/schemas/customize_submission.py new file mode 100644 index 0000000..8be8bc0 --- /dev/null +++ b/app/schemas/customize_submission.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from pydantic import BaseModel +from typing import Literal + + +class CustomizeSubmissionResponse(BaseModel): + id: int + name: str | None = None + phone: str | None = None + wechat: str | None = None + adults: int = 0 + children: int = 0 + travel_dates: str | None = None + budget: str | None = None + interests: list = [] + notes: str | None = None + source: str | None = None + status: str = "pending" + created_at: str | None = None + + class Config: + from_attributes = True + + @classmethod + def model_validate(cls, obj, **kwargs): + data = super().model_validate(obj, **kwargs) + if obj.created_at: + data.created_at = obj.created_at.strftime("%Y-%m-%d %H:%M:%S") + return data + + +class CustomizeSubmissionListResponse(BaseModel): + items: list[CustomizeSubmissionResponse] + total: int + + +class CustomizeStatusUpdate(BaseModel): + status: Literal["pending", "processed"] diff --git a/app/schemas/destination.py b/app/schemas/destination.py new file mode 100644 index 0000000..4d10aed --- /dev/null +++ b/app/schemas/destination.py @@ -0,0 +1,54 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class DestinationConfigSchema(BaseModel): + title: str | None = None + subtitle: str | None = None + intro: str | None = None + closing_title: str | None = None + closing_text: str | None = None + data_sources: str | None = None + honest_title: str | None = None + honest_subtitle: str | None = None + + +class DestinationItemSchema(BaseModel): + dest_id: str + name: str + tag: str | None = None + highlight: bool = False + + +class DestinationItemResponse(DestinationItemSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class DestinationDimensionSchema(BaseModel): + label: str + icon: str | None = None + values: list[str] = [] + + +class DestinationDimensionResponse(DestinationDimensionSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class DestinationHonestItemSchema(BaseModel): + text: str + + +class DestinationHonestItemResponse(DestinationHonestItemSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True diff --git a/app/schemas/destination_detail.py b/app/schemas/destination_detail.py new file mode 100644 index 0000000..67c0aed --- /dev/null +++ b/app/schemas/destination_detail.py @@ -0,0 +1,35 @@ +from __future__ import annotations +from pydantic import BaseModel, field_validator + + +class DestinationDetailSchema(BaseModel): + name: str + slug: str + subtitle: str | None = None + cover_image: str | None = None + description: str | None = None + location: str | None = None + best_season: str | None = None + duration: str | None = None + highlights: list = [] + gallery: list = [] + tags: list = [] + is_visible: bool = True + + @field_validator('highlights', 'gallery', 'tags', mode='before') + @classmethod + def coerce_none_to_list(cls, v): + return v if v is not None else [] + + +class DestinationDetailResponse(DestinationDetailSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class DestinationDetailListResponse(BaseModel): + items: list[DestinationDetailResponse] + total: int diff --git a/app/schemas/export.py b/app/schemas/export.py new file mode 100644 index 0000000..9929664 --- /dev/null +++ b/app/schemas/export.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class ExportRequest(BaseModel): + modules: list[str] = ["all"] + trigger_rebuild: bool = False + + +class ExportLogResponse(BaseModel): + id: int + exported_by: str | None + modules: list[str] | None + status: str + message: str | None + created_at: str | None + + class Config: + from_attributes = True diff --git a/app/schemas/faq.py b/app/schemas/faq.py new file mode 100644 index 0000000..7c59735 --- /dev/null +++ b/app/schemas/faq.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from pydantic import BaseModel +from typing import Any + + +class RelatedLink(BaseModel): + text: str + url: str + + +class FaqQuestionSchema(BaseModel): + question_id: str + question: str + answer: str + related_links: list[RelatedLink] = [] + + +class FaqQuestionResponse(FaqQuestionSchema): + id: int + category_id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class FaqCategorySchema(BaseModel): + category_id: str + name: str + + +class FaqCategoryResponse(FaqCategorySchema): + id: int + sort_order: int = 0 + questions: list[FaqQuestionResponse] = [] + + class Config: + from_attributes = True diff --git a/app/schemas/gallery.py b/app/schemas/gallery.py new file mode 100644 index 0000000..b246f78 --- /dev/null +++ b/app/schemas/gallery.py @@ -0,0 +1,25 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class GalleryItemSchema(BaseModel): + title: str | None = None + image: str + photographer: str | None = None + location: str | None = None + description: str | None = None + tags: list = [] + is_visible: bool = True + + +class GalleryItemResponse(GalleryItemSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class GalleryListResponse(BaseModel): + items: list[GalleryItemResponse] + total: int diff --git a/app/schemas/guide.py b/app/schemas/guide.py new file mode 100644 index 0000000..23f3ffa --- /dev/null +++ b/app/schemas/guide.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from pydantic import BaseModel +from typing import Any + + +class GuideConfigSchema(BaseModel): + page_intro: str | None = None + + +class GuideSectionSchema(BaseModel): + section_id: str + title: str | None = None + subtitle: str | None = None + icon: str | None = None + content: str | None = None + data: Any = None + + +class GuideSectionResponse(GuideSectionSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True diff --git a/app/schemas/navigation.py b/app/schemas/navigation.py new file mode 100644 index 0000000..cf3470c --- /dev/null +++ b/app/schemas/navigation.py @@ -0,0 +1,22 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class NavLinkSchema(BaseModel): + text: str + to: str + + +class NavFooterLinkSchema(BaseModel): + text: str + to: str + + +class NavFooterGroupSchema(BaseModel): + title: str + links: list[NavFooterLinkSchema] = [] + + +class NavigationSchema(BaseModel): + header: list[NavLinkSchema] = [] + footer: list[NavFooterGroupSchema] = [] diff --git a/app/schemas/news.py b/app/schemas/news.py new file mode 100644 index 0000000..8314747 --- /dev/null +++ b/app/schemas/news.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class NewsSchema(BaseModel): + title: str + cover_image: str | None = None + summary: str | None = None + content: str | None = None + source: str | None = None + source_url: str | None = None + published_at: str | None = None + is_visible: bool = True + + +class NewsResponse(NewsSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class NewsListResponse(BaseModel): + items: list[NewsResponse] + total: int diff --git a/app/schemas/partner.py b/app/schemas/partner.py new file mode 100644 index 0000000..73a9d1f --- /dev/null +++ b/app/schemas/partner.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class PartnerSchema(BaseModel): + name: str + logo: str | None = None + website: str | None = None + description: str | None = None + category: str | None = None + + +class PartnerResponse(PartnerSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True diff --git a/app/schemas/pricing.py b/app/schemas/pricing.py new file mode 100644 index 0000000..ffca0c0 --- /dev/null +++ b/app/schemas/pricing.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from pydantic import BaseModel, field_validator + + +class PricingItemSchema(BaseModel): + product_name: str + product_slug: str | None = None + price_from: str | None = None + price_unit: str | None = None + price_label: str | None = None + description: str | None = None + features: list[str] = [] + notes: list[str] = [] + is_visible: bool = True + + @field_validator('features', 'notes', mode='before') + @classmethod + def coerce_none_to_list(cls, v): + return v if v is not None else [] + + +class PricingItemResponse(PricingItemSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..f59179d --- /dev/null +++ b/app/schemas/product.py @@ -0,0 +1,51 @@ +from __future__ import annotations +from pydantic import BaseModel +from typing import Any + + +class ProductVersionSchema(BaseModel): + version_id: str + name: str + days: int + nights: int + audience: str | None = None + description: str | None = None + highlights: list[str] = [] + tag: str | None = None + + +class ProductVersionResponse(ProductVersionSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class SummerCampFaqSchema(BaseModel): + question: str + answer: str + + +class SummerCampSchema(BaseModel): + name: str | None = None + positioning: str | None = None + days: int | None = None + nights: int | None = None + sessions: dict | None = None + difference_from_v9: str | None = None + principles: list[str] = [] + activities: list[str] = [] + itinerary: list[str] = [] + faq: list[SummerCampFaqSchema] = [] + + +class SelectionGuideSchema(BaseModel): + by_vacation_length: list[Any] = [] + by_child_age: list[Any] = [] + by_preference: list[Any] = [] + + +class ProductConfigSchema(BaseModel): + narrative: str | None = None + pricing_philosophy: str | None = None diff --git a/app/schemas/qualification.py b/app/schemas/qualification.py new file mode 100644 index 0000000..13d4806 --- /dev/null +++ b/app/schemas/qualification.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class QualificationSchema(BaseModel): + title: str + issuer: str | None = None + year: str | None = None + image: str | None = None + description: str | None = None + category: str | None = None + + +class QualificationResponse(QualificationSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True diff --git a/app/schemas/review.py b/app/schemas/review.py new file mode 100644 index 0000000..fd7d8ec --- /dev/null +++ b/app/schemas/review.py @@ -0,0 +1,32 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class ReviewSummarySchema(BaseModel): + total_count: int = 0 + approval_rate: str | None = None + keywords: list[str] = [] + + +class ReviewSchema(BaseModel): + nickname: str + travel_date: str | None = None + product_version: str | None = None + screenshot: str | None = None + content: str + scenes: list[str] = [] + concerns: list[str] = [] + is_visible: bool = True + + +class ReviewResponse(ReviewSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class ReviewListResponse(BaseModel): + items: list[ReviewResponse] + total: int diff --git a/app/schemas/seasonal_product.py b/app/schemas/seasonal_product.py new file mode 100644 index 0000000..10d4be1 --- /dev/null +++ b/app/schemas/seasonal_product.py @@ -0,0 +1,63 @@ +from __future__ import annotations +from pydantic import BaseModel +from typing import Any + + +class SeasonalConfigSchema(BaseModel): + narrative: str | None = None + style: str | None = None + brand_name: str | None = None + version_label: str | None = None + season_label: str | None = None + + +class SeasonalVersionSchema(BaseModel): + version_id: str + name: str + days: int + nights: int + tag: str | None = None + line: str | None = None + route: str | None = None + audience: str | None = None + description: str | None = None + highlights: list[str] = [] + itinerary: list[str] = [] + + +class SeasonalVersionResponse(SeasonalVersionSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class SeasonalHighlightSchema(BaseModel): + category: str # shared, south, north + title: str + description: str | None = None + + +class SeasonalHighlightResponse(SeasonalHighlightSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class SeasonalTimelineSchema(BaseModel): + version: str | None = None + date: str | None = None + title: str | None = None + changes: list[dict] = [] + reason: str | None = None + + +class SeasonalTimelineResponse(SeasonalTimelineSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True diff --git a/app/schemas/selector.py b/app/schemas/selector.py new file mode 100644 index 0000000..10ba1dc --- /dev/null +++ b/app/schemas/selector.py @@ -0,0 +1,23 @@ +from __future__ import annotations +from pydantic import BaseModel, field_validator + + +class SelectorConfigSchema(BaseModel): + questions: list = [] + rules: list = [] + + @field_validator('questions', 'rules', mode='before') + @classmethod + def coerce_to_list(cls, v): + if v is None: + return [] + if isinstance(v, list): + return v + return [] # dict等旧格式数据降级为空列表 + + +class SelectorConfigResponse(SelectorConfigSchema): + id: int + + class Config: + from_attributes = True diff --git a/app/schemas/seo.py b/app/schemas/seo.py new file mode 100644 index 0000000..a3e7d03 --- /dev/null +++ b/app/schemas/seo.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class SeoPageSchema(BaseModel): + title: str | None = None + description: str | None = None + h1: str | None = None + keywords: str | None = None + og_image: str | None = None + tldr: str | None = None + + +class SeoPageResponse(SeoPageSchema): + id: int + page_key: str + + class Config: + from_attributes = True diff --git a/app/schemas/site_image.py b/app/schemas/site_image.py new file mode 100644 index 0000000..ce7fadd --- /dev/null +++ b/app/schemas/site_image.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class SiteImageSchema(BaseModel): + group_name: str + image_key: str + image_path: str + label: str | None = None + size_hint: str | None = None + description: str | None = None + + +class SiteImageResponse(SiteImageSchema): + id: int + + class Config: + from_attributes = True diff --git a/app/schemas/story.py b/app/schemas/story.py new file mode 100644 index 0000000..a2e3dcd --- /dev/null +++ b/app/schemas/story.py @@ -0,0 +1,28 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class StorySchema(BaseModel): + title: str + cover_image: str | None = None + summary: str | None = None + content: str | None = None + customer_name: str | None = None + avatar: str | None = None + travel_date: str | None = None + product_name: str | None = None + scenes: list[str] = [] + is_visible: bool = True + + +class StoryResponse(StorySchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class StoryListResponse(BaseModel): + items: list[StoryResponse] + total: int diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..d97de44 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,55 @@ +from __future__ import annotations +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class UserInfo(BaseModel): + id: int + username: str + display_name: str | None + role: str + + class Config: + from_attributes = True + + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + + +class CreateUserRequest(BaseModel): + username: str + password: str + display_name: str | None = None + role: str = "editor" + + +class UpdateUserRequest(BaseModel): + display_name: str | None = None + role: str | None = None + is_active: bool | None = None + + +class ResetPasswordRequest(BaseModel): + new_password: str + + +class UserListResponse(BaseModel): + id: int + username: str + display_name: str | None + role: str + is_active: bool + + class Config: + from_attributes = True diff --git a/app/schemas/version.py b/app/schemas/version.py new file mode 100644 index 0000000..bd6872c --- /dev/null +++ b/app/schemas/version.py @@ -0,0 +1,54 @@ +from __future__ import annotations +from pydantic import BaseModel +from typing import Any + + +class VersionUpgradeSchema(BaseModel): + tag: str | None = None + name: str | None = None + description: str | None = None + reason: str | None = None + + +class VersionHighlightSchema(BaseModel): + label: str | None = None + text: str | None = None + + +class VersionCompareSchema(BaseModel): + headers: list[str] = [] + rows: list[list[str]] = [] + + +class TimelineChangeSchema(BaseModel): + type: str # add, update, remove + text: str + + +class VersionTimelineSchema(BaseModel): + version: str + date: str | None = None + title: str | None = None + changes: list[TimelineChangeSchema] = [] + reason: str | None = None + + +class VersionTimelineResponse(VersionTimelineSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class VersionPhilosophySchema(BaseModel): + label: str | None = None + text: str | None = None + + +class VersionConfigSchema(BaseModel): + stats_iterations: int | None = None + stats_years: int | None = None + stats_guests: str | None = None + quote_text: str | None = None + quote_author: str | None = None diff --git a/app/schemas/winter_camp.py b/app/schemas/winter_camp.py new file mode 100644 index 0000000..497a8eb --- /dev/null +++ b/app/schemas/winter_camp.py @@ -0,0 +1,67 @@ +from __future__ import annotations +from pydantic import BaseModel +from typing import Any + + +class WinterCampConfigSchema(BaseModel): + name: str | None = None + positioning: str | None = None + days: int | None = None + nights: int | None = None + max_families: int | None = None + total_sessions: int | None = None + age_range: str | None = None + deposit: int | None = None + season: str | None = None + route: str | None = None + why_hulunbuir: str | None = None + closing_note: str | None = None + photographer: dict | None = None + winter_clothing: dict | None = None + camp_advantages: list[str] = [] + service_config: list[str] = [] + camp_essentials: list[str] = [] + + +class WinterCampHotelSchema(BaseModel): + name: str + star: str | None = None + nights: str | None = None + description: str | None = None + + +class WinterCampHotelResponse(WinterCampHotelSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class WinterCampItinerarySchema(BaseModel): + day: int + title: str | None = None + summary: str | None = None + highlights: list[str] = [] + hotel: str | None = None + + +class WinterCampItineraryResponse(WinterCampItinerarySchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True + + +class WinterCampFaqSchema(BaseModel): + question: str + answer: str + + +class WinterCampFaqResponse(WinterCampFaqSchema): + id: int + sort_order: int = 0 + + class Config: + from_attributes = True diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/export_service.py b/app/services/export_service.py new file mode 100644 index 0000000..89d8f98 --- /dev/null +++ b/app/services/export_service.py @@ -0,0 +1,803 @@ +from __future__ import annotations +import json +import os + +from sqlalchemy.orm import Session + +from app.config import settings +from app.models.brand import Brand, BrandTrustStat, BrandDifferentiator +from app.models.product import ProductConfig, ProductVersion, ProductVersionHighlight, SummerCamp, SelectionGuide +from app.models.faq import FaqCategory, FaqQuestion +from app.models.review import ReviewSummary, Review +from app.models.about import ( + AboutStory, AboutSubsidiary, AboutCertification, AboutTrademark, + AboutCopyright, AboutGuarantee, AboutXiaohongshu, AboutTeam, AboutCulture, + AboutFounderDetail, AboutFounder, AboutMilestone, + AboutServicePrinciple, AboutDifferentiation, + AboutServiceJourney, AboutStats, +) +from app.models.customize_config import CustomizeConfig +from app.models.contact import ContactChannel, ContactConfig +from app.models.seo import SeoPage +from app.models.navigation import NavHeader, NavFooterGroup +from app.models.version import VersionConfig, VersionUpgrade, VersionHighlight, VersionCompare, VersionTimeline, VersionPhilosophy +from app.models.guide import GuideConfig, GuideSection +from app.models.site_image import SiteImage +from app.models.seasonal_product import SeasonalProductConfig, SeasonalProductVersion, SeasonalProductHighlight, SeasonalProductTimeline +from app.models.winter_camp import WinterCampConfig, WinterCampHotel, WinterCampItinerary, WinterCampFaq +from app.models.destination import DestinationConfig, DestinationItem, DestinationDimension, DestinationHonestItem +from app.models.blog import Blog +from app.models.story import Story +from app.models.news import News +from app.models.pricing import PricingItem +from app.models.qualification import Qualification +from app.models.partner import Partner +from app.models.gallery import GalleryItem +from app.models.destination_detail import DestinationDetail +from app.models.selector import SelectorConfig +from app.models.courses import CoursesConfig + + +def _write_json(filename: str, data): + path = os.path.join(settings.NUXT_DATA_PATH, filename) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def export_brand(db: Session): + brand = db.query(Brand).first() + if not brand: + return + data = { + "name": brand.name, + "fullName": brand.full_name, + "domain": brand.domain, + "url": brand.url, + "slogan": { + "emotional": brand.slogan_emotional, + "functional": brand.slogan_functional, + }, + "trustStats": [ + {"value": s.value, "unit": s.unit, "label": s.label} + for s in brand.trust_stats + ], + "differentiators": [ + {"title": d.title, "description": d.description} + for d in brand.differentiators + ], + "icpEntity": brand.icp_entity, + "icp": brand.icp, + "icpUrl": brand.icp_url, + "eContract": brand.e_contract, + } + if brand.cta_buttons: + data["ctaButtons"] = brand.cta_buttons + if brand.conversion_path: + data["conversionPath"] = brand.conversion_path + _write_json("brand.json", data) + + +def export_products(db: Session): + config = db.query(ProductConfig).first() + if not config: + return + versions = db.query(ProductVersion).filter_by(config_id=config.id).order_by(ProductVersion.sort_order).all() + + data = { + "narrative": config.narrative, + "versions": [], + } + + for v in versions: + data["versions"].append({ + "id": v.version_id, + "name": v.name, + "days": v.days, + "nights": v.nights, + "audience": v.audience, + "description": v.description, + "highlights": [h.text for h in (v.highlights or [])], + "tag": v.tag, + }) + + # Summer camp + camp = db.query(SummerCamp).filter_by(config_id=config.id).first() + if camp: + data["summerCamp"] = { + "name": camp.name, + "positioning": camp.positioning, + "days": camp.days, + "nights": camp.nights, + "sessions": camp.sessions_json or {}, + "principles": [p.text for p in (camp.principles or [])], + "coreActivities": [a.text for a in (camp.activities or [])], + "itinerary": [it.text for it in (camp.itinerary or [])], + "faq": [{"question": f.question, "answer": f.answer} for f in (camp.faq or [])], + "differenceFromV9": camp.difference_from_v9, + } + + # Selection guide + guide = db.query(SelectionGuide).filter_by(config_id=config.id).first() + if guide: + data["selectionGuide"] = { + "byVacationLength": guide.by_vacation_length or [], + "byChildAge": guide.by_child_age or [], + "byPreference": guide.by_preference or [], + } + + if config.pricing_philosophy: + data["pricingPhilosophy"] = config.pricing_philosophy + + _write_json("products.json", data) + + +def export_faq(db: Session): + categories = db.query(FaqCategory).order_by(FaqCategory.sort_order).all() + data = { + "categories": [ + { + "id": cat.category_id, + "name": cat.name, + "questions": [ + { + "id": q.question_id, + "question": q.question, + "answer": q.answer, + "relatedLinks": q.related_links or [], + } + for q in cat.questions + ], + } + for cat in categories + ] + } + _write_json("faq.json", data) + + +def export_reviews(db: Session): + summary = db.query(ReviewSummary).first() + reviews = db.query(Review).filter(Review.is_visible == True).order_by(Review.sort_order, Review.id).all() + + data = { + "summary": { + "totalCount": summary.total_count if summary else 0, + "approvalRate": summary.approval_rate if summary else "98%", + "keywords": summary.keywords if summary else [], + }, + "items": [ + { + "id": r.id, + "nickname": r.nickname, + "travelDate": r.travel_date, + "productVersion": r.product_version, + "screenshot": r.screenshot, + "content": r.content, + "scenes": r.scenes or [], + "concerns": r.concerns or [], + } + for r in reviews + ], + } + _write_json("reviews.json", data) + + +def export_about(db: Session): + story = db.query(AboutStory).first() + subs = db.query(AboutSubsidiary).order_by(AboutSubsidiary.sort_order).all() + certs = db.query(AboutCertification).order_by(AboutCertification.sort_order).all() + tm = db.query(AboutTrademark).first() + copyrights = db.query(AboutCopyright).order_by(AboutCopyright.sort_order).all() + guarantees = db.query(AboutGuarantee).order_by(AboutGuarantee.sort_order).all() + xhs = db.query(AboutXiaohongshu).first() + team = db.query(AboutTeam).first() + culture = db.query(AboutCulture).first() + founder_detail = db.query(AboutFounderDetail).first() + founders = db.query(AboutFounder).order_by(AboutFounder.sort_order).all() + milestones = db.query(AboutMilestone).order_by(AboutMilestone.sort_order).all() + principles = db.query(AboutServicePrinciple).order_by(AboutServicePrinciple.sort_order).all() + differentiation = db.query(AboutDifferentiation).order_by(AboutDifferentiation.sort_order).all() + sj = db.query(AboutServiceJourney).first() + stats_row = db.query(AboutStats).first() + + data = {} + + if story: + paragraphs = [p.strip() for p in story.content.split("\n\n") if p.strip()] if story.content else [] + story_data = {"title": story.title, "paragraphs": paragraphs} + if story.totem_description or story.totem_tagline: + story_data["totem"] = {"description": story.totem_description, "tagline": story.totem_tagline} + data["story"] = story_data + + data["subsidiaries"] = [{"name": s.name, "role": s.role, "established": s.established} for s in subs] + data["certifications"] = [{"title": c.title, "detail": c.detail} for c in certs] + + if tm: + data["trademark"] = {"name": tm.name, "scope": tm.scope, "holder": tm.holder, "description": tm.description} + + data["copyrights"] = [ + {"name": c.name, "regNo": c.reg_no, "category": c.category, "holder": c.holder, "date": c.date, "description": c.description} + for c in copyrights + ] + + data["guarantees"] = [{"title": g.title, "detail": g.detail} for g in guarantees] + + if xhs: + data["xiaohongshu"] = { + "account": xhs.account, "verified": xhs.verified, "verifiedType": xhs.verified_type, + "followers": xhs.followers, "likes": xhs.likes, "awards": xhs.awards or [], + "tagline": xhs.tagline, "tags": xhs.tags or [], "description": xhs.description, + } + + if team: + data["team"] = {"summary": team.summary} + + if culture: + culture_data = { + "transparency": culture.transparency, + "values": [{"name": v.name, "expression": v.expression} for v in (culture.values or [])], + } + if culture.core: + culture_data["core"] = culture.core + if culture.trust: + culture_data["trust"] = culture.trust + if culture.grief_award_name: + culture_data["griefAward"] = {"name": culture.grief_award_name, "description": culture.grief_award_description} + data["culture"] = culture_data + + if founder_detail: + data["founder"] = { + "name": founder_detail.name, + "title": founder_detail.title, + "brandFounded": founder_detail.brand_founded, + "yearsInHulunbuir": founder_detail.years_in_hulunbuir, + "background": founder_detail.background, + "expertise": founder_detail.expertise or [], + "mediaPresence": founder_detail.media_presence or [], + } + + if founders: + data["founders"] = [{"name": f.name, "title": f.title, "story": f.story} for f in founders] + + if milestones: + data["milestones"] = [{"year": m.year, "event": m.event} for m in milestones] + + if principles: + data["servicePrinciples"] = [p.text for p in principles] + + if differentiation: + data["differentiation"] = [{"name": d.name, "detail": d.detail} for d in differentiation] + + if sj: + data["serviceJourney"] = { + "title": sj.title, + "subtitle": sj.subtitle, + "moments": [{"step": m.step, "name": m.name, "detail": m.detail} for m in (sj.moments or [])], + } + + if stats_row and stats_row.data: + data["stats"] = stats_row.data + + _write_json("about.json", data) + + +def export_customize(db: Session): + cfg = db.query(CustomizeConfig).first() + if not cfg: + return + data = { + "trustStats": cfg.trust_stats or [], + "durations": cfg.durations or [], + "activities": cfg.activities or [], + "budgets": cfg.budgets or [], + "process": cfg.process or [], + "contact": cfg.contact or {}, + } + _write_json("customize.json", data) + + +def export_contact(db: Session): + channels = db.query(ContactChannel).order_by(ContactChannel.sort_order).all() + config = db.query(ContactConfig).first() + + data = { + "channels": [ + { + "type": c.type, "label": c.label, "value": c.value, + **({"qrImage": c.qr_image} if c.qr_image else {}), + **({"primary": True} if c.is_primary else {}), + "description": c.description, + } + for c in channels + ], + "securityNotice": config.security_notice if config else "", + } + _write_json("contact.json", data) + + +def export_seo(db: Session): + pages = db.query(SeoPage).all() + data = {"pages": {}} + for p in pages: + page_data = { + "title": p.title, + "description": p.description, + "h1": p.h1, + "ogImage": p.og_image, + } + if p.keywords: + page_data["keywords"] = p.keywords + data["pages"][p.page_key] = page_data + _write_json("seo.json", data) + + +def export_navigation(db: Session): + headers = db.query(NavHeader).order_by(NavHeader.sort_order).all() + groups = db.query(NavFooterGroup).order_by(NavFooterGroup.sort_order).all() + + header_list = [] + for h in headers: + item = {"text": h.text, "to": h.to_path} + if h.children: + item["children"] = h.children + header_list.append(item) + + data = { + "header": header_list, + "footer": [ + {"title": g.title, "links": [{"text": l.text, "to": l.to_path} for l in (g.links or [])]} + for g in groups + ], + } + _write_json("navigation.json", data) + + +def export_versions(db: Session): + config = db.query(VersionConfig).first() + if not config: + return + + upgrades = db.query(VersionUpgrade).filter_by(config_id=config.id).order_by(VersionUpgrade.sort_order).all() + highlights = db.query(VersionHighlight).filter_by(config_id=config.id).order_by(VersionHighlight.sort_order).all() + compare = db.query(VersionCompare).filter_by(config_id=config.id).first() + timeline = db.query(VersionTimeline).filter_by(config_id=config.id).order_by(VersionTimeline.sort_order).all() + philosophy = db.query(VersionPhilosophy).filter_by(config_id=config.id).order_by(VersionPhilosophy.sort_order).all() + + data = { + "stats": { + "iterations": config.stats_iterations, + "years": config.stats_years, + "guests": config.stats_guests, + }, + "upgrades2026": [ + {"tag": u.tag, "name": u.name, "description": u.description, "reason": u.reason} + for u in upgrades + ], + "highlights": [{"label": h.label, "text": h.text} for h in highlights], + } + + if compare: + data["compareV8V9"] = {"headers": compare.headers or [], "rows": compare.rows or []} + + data["timeline"] = [ + { + "version": t.version, "date": t.date, "title": t.title, + "changes": [{"type": ch.type, "text": ch.text} for ch in (t.changes or [])], + "reason": t.reason, + } + for t in timeline + ] + + if philosophy: + data["philosophy"] = [{"label": p.label, "text": p.text} for p in philosophy] + + if config.quote_text: + data["quote"] = {"text": config.quote_text, "author": config.quote_author} + + _write_json("versions.json", data) + + +def export_guides(db: Session): + config = db.query(GuideConfig).first() + if not config: + return + + sections = db.query(GuideSection).filter_by(config_id=config.id).order_by(GuideSection.sort_order).all() + + data = { + "pageIntro": config.page_intro, + "sections": [], + } + + for s in sections: + section_data = { + "id": s.section_id, + "title": s.title, + "subtitle": s.subtitle, + "icon": s.icon, + "content": s.content, + } + # Merge the JSON data fields back into the section + if s.data and isinstance(s.data, dict): + section_data.update(s.data) + data["sections"].append(section_data) + + _write_json("guides.json", data) + + +def export_images(db: Session): + images = db.query(SiteImage).order_by(SiteImage.group_name, SiteImage.image_key).all() + data = {} + for img in images: + if img.group_name not in data: + data[img.group_name] = {} + data[img.group_name][img.image_key] = img.image_path + _write_json("images.json", data) + + +def _export_seasonal(db: Session, season: str, filename: str): + config = db.query(SeasonalProductConfig).filter_by(season=season).first() + versions = db.query(SeasonalProductVersion).filter_by(season=season).order_by(SeasonalProductVersion.sort_order).all() + highlights = db.query(SeasonalProductHighlight).filter_by(season=season).order_by(SeasonalProductHighlight.sort_order).all() + timeline = db.query(SeasonalProductTimeline).filter_by(season=season).order_by(SeasonalProductTimeline.sort_order).all() + + data = {} + if config: + if config.brand_name: + data["brand"] = config.brand_name + if config.version_label: + data["version"] = config.version_label + if config.season_label: + data["season"] = config.season_label + data["narrative"] = config.narrative + data["style"] = config.style + + data["versions"] = [ + { + "id": v.version_id, "name": v.name, "days": v.days, "nights": v.nights, + "tag": v.tag, "line": v.line, "route": v.route, "audience": v.audience, + "description": v.description, "highlights": v.highlights or [], "itinerary": v.itinerary or [], + } + for v in versions + ] + + shared = [h for h in highlights if h.category == "shared"] + south = [h for h in highlights if h.category == "south"] + north = [h for h in highlights if h.category == "north"] + data["highlights"] = [{"title": h.title, "description": h.description} for h in shared] + data["southHighlights"] = [{"title": h.title, "description": h.description} for h in south] + data["northHighlights"] = [{"title": h.title, "description": h.description} for h in north] + + data["timeline"] = [ + { + "version": t.version, "date": t.date, "title": t.title, + "changes": t.changes or [], "reason": t.reason, + } + for t in timeline + ] + + if config and config.selection_guide: + data["selectionGuide"] = config.selection_guide + + _write_json(filename, data) + + +def export_autumn_products(db: Session): + _export_seasonal(db, "autumn", "autumn-products.json") + + +def export_winter_products(db: Session): + _export_seasonal(db, "winter", "winter-products.json") + + +def export_winter_camp(db: Session): + config = db.query(WinterCampConfig).first() + if not config: + return + hotels = db.query(WinterCampHotel).order_by(WinterCampHotel.sort_order).all() + itinerary = db.query(WinterCampItinerary).order_by(WinterCampItinerary.sort_order).all() + faq = db.query(WinterCampFaq).order_by(WinterCampFaq.sort_order).all() + + data = { + "name": config.name, + "positioning": config.positioning, + "days": config.days, + "nights": config.nights, + "maxFamilies": config.max_families, + "totalSessions": config.total_sessions, + "ageRange": config.age_range, + "deposit": config.deposit, + "season": config.season, + "route": config.route, + "whyHulunbuir": config.why_hulunbuir, + "closingNote": config.closing_note, + "photographer": config.photographer or {}, + "winterClothing": config.winter_clothing or {}, + "campAdvantages": config.camp_advantages or [], + "serviceConfig": config.service_config or [], + "campEssentials": config.camp_essentials or [], + "hotels": [{"name": h.name, "star": h.star, "nights": h.nights, "description": h.description} for h in hotels], + "itinerary": [ + {"day": d.day, "title": d.title, "summary": d.summary, "highlights": d.highlights or [], "hotel": d.hotel} + for d in itinerary + ], + "faq": [{"q": f.question, "a": f.answer} for f in faq], + } + _write_json("winter-camp.json", data) + + +def export_destinations(db: Session): + config = db.query(DestinationConfig).first() + if not config: + return + items = db.query(DestinationItem).order_by(DestinationItem.sort_order).all() + dims = db.query(DestinationDimension).order_by(DestinationDimension.sort_order).all() + honest = db.query(DestinationHonestItem).order_by(DestinationHonestItem.sort_order).all() + + data = { + "title": config.title, + "subtitle": config.subtitle, + "intro": config.intro, + "destinations": [ + {**({"highlight": True} if d.highlight else {}), "id": d.dest_id, "name": d.name, "tag": d.tag} + for d in items + ], + "dimensions": [{"label": d.label, "icon": d.icon, "values": d.values or []} for d in dims], + "honestNote": { + "title": config.honest_title, + "subtitle": config.honest_subtitle, + "items": [h.text for h in honest], + }, + "closing": {"title": config.closing_title, "text": config.closing_text}, + "dataSources": config.data_sources, + } + _write_json("destinations.json", data) + + +def export_blog(db: Session): + posts = db.query(Blog).filter(Blog.is_visible == True).order_by(Blog.sort_order, Blog.id).all() + data = { + "articles": [ + { + "id": p.slug, + "title": p.title, + "subtitle": p.summary, + "author": p.author, + "date": p.published_at, + "category": p.category, + "summary": p.summary, + "coverImage": p.cover_image, + "tags": p.tags or [], + "content": p.content or "", + } + for p in posts + ] + } + _write_json("blog.json", data) + + +def export_stories(db: Session): + stories = db.query(Story).filter(Story.is_visible == True).order_by(Story.sort_order, Story.id).all() + data = { + "stories": [ + { + "id": f"story-{s.id}", + "title": s.title, + "subtitle": s.summary, + "coverImage": s.cover_image, + "familyType": s.customer_name, + "travelDate": s.travel_date, + "product": s.product_name, + "tags": s.scenes or [], + "summary": s.summary, + "content": s.content or "", + } + for s in stories + ] + } + _write_json("stories.json", data) + + +def export_news(db: Session): + articles = db.query(News).filter(News.is_visible == True).order_by(News.sort_order, News.id).all() + data = { + "articles": [ + { + "id": f"news-{n.id}", + "title": n.title, + "date": n.published_at, + "category": n.source, + "summary": n.summary, + "content": n.content or "", + } + for n in articles + ] + } + _write_json("news.json", data) + + +def export_pricing(db: Session): + items = db.query(PricingItem).filter(PricingItem.is_visible == True).order_by(PricingItem.sort_order).all() + data = { + "products": [ + { + "id": p.product_slug or f"product-{p.id}", + "name": p.product_name, + "priceLabel": p.price_label, + "priceNote": p.description, + "highlights": p.features or [], + "url": f"/products/{p.product_slug}" if p.product_slug else None, + } + for p in items + ] + } + _write_json("pricing.json", data) + + +def export_qualifications(db: Session): + items = db.query(Qualification).order_by(Qualification.sort_order).all() + + licenses = [ + {"title": q.title, "issuer": q.issuer, "description": q.description} + for q in items if q.category == "license" + ] + insurance_items = [ + {"title": q.title, "issuer": q.issuer, "description": q.description} + for q in items if q.category == "insurance" + ] + awards = [ + {"title": q.title, "issuer": q.issuer, "year": int(q.year) if q.year and q.year.isdigit() else q.year, + "description": q.description} + for q in items if q.category == "award" + ] + heritage_items = [ + {"title": q.title, "description": q.description} + for q in items if q.category == "heritage" + ] + + data = { + "licenses": licenses, + "insurance": insurance_items[0] if insurance_items else {}, + "awards": awards, + "heritage": heritage_items[0] if heritage_items else {}, + } + _write_json("qualifications.json", data) + + +def export_partners(db: Session): + partners = db.query(Partner).order_by(Partner.sort_order).all() + + cat_map = { + "政府合作": "govPartners", + "行业协会": "associations", + "校企合作": "academicCoops", + "景区合作": "scenicPartners", + "酒店合作": "hotelPartners", + "平台认证": "platformEndorsements", + "媒体报道": "mediaReports", + } + + data = {v: [] for v in cat_map.values()} + for p in partners: + key = cat_map.get(p.category, "govPartners") + data[key].append({ + "name": p.name, + "description": p.description, + "website": p.website, + "verified": True, + }) + _write_json("partners.json", data) + + +def export_gallery(db: Session): + items = db.query(GalleryItem).filter(GalleryItem.is_visible == True).order_by(GalleryItem.sort_order, GalleryItem.id).all() + data = { + "works": [ + { + "id": f"gallery-{str(g.id).zfill(3)}", + "title": g.title, + "category": (g.tags or [None])[0] if g.tags else None, + "location": g.location, + "description": g.description, + "image": g.image, + } + for g in items + ] + } + _write_json("gallery.json", data) + + +def export_destinations_detail(db: Session): + items = db.query(DestinationDetail).filter(DestinationDetail.is_visible == True).order_by(DestinationDetail.sort_order).all() + data = { + "destinations": [ + { + "id": d.slug, + "name": d.name, + "subtitle": d.subtitle, + "tag": (d.tags or [None])[0] if d.tags else None, + "description": d.description, + "highlights": [ + h.get("text", h) if isinstance(h, dict) else h + for h in (d.highlights or []) + ], + "bestSeason": {"primary": d.best_season} if d.best_season else {}, + "heroImage": d.cover_image, + } + for d in items + ] + } + _write_json("destinations-detail.json", data) + + +def export_selector(db: Session): + cfg = db.query(SelectorConfig).first() + if not cfg: + return + rules = cfg.rules or {} + data = { + "questions": cfg.questions or [], + "scoring": rules.get("scoring", {}), + "matchReasons": rules.get("matchReasons", {}), + } + _write_json("selector.json", data) + + +def export_courses(db: Session): + cfg = db.query(CoursesConfig).first() + if not cfg: + return + data = { + "modules": cfg.modules or [], + "ageGroups": cfg.age_groups or [], + "faq": cfg.faqs or [], + } + _write_json("courses.json", data) + + +# Module name -> export function mapping +EXPORT_FUNCTIONS = { + "brand": export_brand, + "products": export_products, + "faq": export_faq, + "reviews": export_reviews, + "about": export_about, + "contact": export_contact, + "seo": export_seo, + "navigation": export_navigation, + "versions": export_versions, + "guides": export_guides, + "images": export_images, + "autumn-products": export_autumn_products, + "winter-products": export_winter_products, + "winter-camp": export_winter_camp, + "destinations": export_destinations, + "customize": export_customize, + "blog": export_blog, + "stories": export_stories, + "news": export_news, + "pricing": export_pricing, + "qualifications": export_qualifications, + "partners": export_partners, + "gallery": export_gallery, + "destinations-detail": export_destinations_detail, + "selector": export_selector, + "courses": export_courses, +} + + +def export_modules(db: Session, modules: list[str]) -> dict: + if "all" in modules: + modules = list(EXPORT_FUNCTIONS.keys()) + + results = {} + for module in modules: + fn = EXPORT_FUNCTIONS.get(module) + if fn: + try: + fn(db) + results[module] = "success" + except Exception as e: + results[module] = f"failed: {str(e)}" + else: + results[module] = "unknown module" + return results diff --git a/app/services/image_service.py b/app/services/image_service.py new file mode 100644 index 0000000..a669bea --- /dev/null +++ b/app/services/image_service.py @@ -0,0 +1,2 @@ +from __future__ import annotations +"""Image upload and processing utilities.""" diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..fd4c99b --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,67 @@ +from logging.config import fileConfig +import os +import sys + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# 确保项目根目录在 Python 路径中 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# 导入所有模型,使 autogenerate 能检测到所有表 +from app.database import Base +import app.models # noqa: F401 - 导入所有模型 + +target_metadata = Base.metadata + +# 从环境变量读取数据库 URL +from app.config import settings +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/2bcfcc62f578_add_blog_stories_news_pricing_.py b/migrations/versions/2bcfcc62f578_add_blog_stories_news_pricing_.py new file mode 100644 index 0000000..bc19676 --- /dev/null +++ b/migrations/versions/2bcfcc62f578_add_blog_stories_news_pricing_.py @@ -0,0 +1,137 @@ +"""add blog stories news pricing qualifications partners calendar + +Revision ID: 2bcfcc62f578 +Revises: +Create Date: 2026-03-20 20:35:59.466044 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2bcfcc62f578' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('blog_posts', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('slug', sa.String(length=200), nullable=False), + sa.Column('cover_image', sa.String(length=500), nullable=True), + sa.Column('summary', sa.Text(), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('author', sa.String(length=100), nullable=True), + sa.Column('category', sa.String(length=100), nullable=True), + sa.Column('tags', sa.JSON(), nullable=True), + sa.Column('published_at', sa.String(length=50), nullable=True), + sa.Column('is_visible', sa.Boolean(), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('slug') + ) + op.create_table('calendar_months', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('year', sa.Integer(), nullable=False), + sa.Column('month', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=100), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('weather', sa.String(length=200), nullable=True), + sa.Column('highlights', sa.JSON(), nullable=True), + sa.Column('events', sa.JSON(), nullable=True), + sa.Column('is_available', sa.Boolean(), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('year', 'month', name='uq_calendar_year_month') + ) + op.create_table('news', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('cover_image', sa.String(length=500), nullable=True), + sa.Column('summary', sa.Text(), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('source', sa.String(length=100), nullable=True), + sa.Column('source_url', sa.String(length=500), nullable=True), + sa.Column('published_at', sa.String(length=50), nullable=True), + sa.Column('is_visible', sa.Boolean(), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('partners', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('logo', sa.String(length=500), nullable=True), + sa.Column('website', sa.String(length=500), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('category', sa.String(length=100), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('pricing_items', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('product_name', sa.String(length=200), nullable=False), + sa.Column('product_slug', sa.String(length=100), nullable=True), + sa.Column('price_from', sa.String(length=50), nullable=True), + sa.Column('price_unit', sa.String(length=50), nullable=True), + sa.Column('price_label', sa.String(length=100), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('features', sa.JSON(), nullable=True), + sa.Column('notes', sa.JSON(), nullable=True), + sa.Column('is_visible', sa.Boolean(), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('qualifications', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('issuer', sa.String(length=200), nullable=True), + sa.Column('year', sa.String(length=20), nullable=True), + sa.Column('image', sa.String(length=500), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('category', sa.String(length=100), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('stories', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('cover_image', sa.String(length=500), nullable=True), + sa.Column('summary', sa.Text(), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('customer_name', sa.String(length=100), nullable=True), + sa.Column('avatar', sa.String(length=500), nullable=True), + sa.Column('travel_date', sa.String(length=50), nullable=True), + sa.Column('product_name', sa.String(length=200), nullable=True), + sa.Column('scenes', sa.JSON(), nullable=True), + sa.Column('is_visible', sa.Boolean(), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('stories') + op.drop_table('qualifications') + op.drop_table('pricing_items') + op.drop_table('partners') + op.drop_table('news') + op.drop_table('calendar_months') + op.drop_table('blog_posts') + # ### end Alembic commands ### diff --git a/migrations/versions/a3f9c1d8e256_add_destinations_detail_gallery_courses_selector_customize.py b/migrations/versions/a3f9c1d8e256_add_destinations_detail_gallery_courses_selector_customize.py new file mode 100644 index 0000000..2b6b6ec --- /dev/null +++ b/migrations/versions/a3f9c1d8e256_add_destinations_detail_gallery_courses_selector_customize.py @@ -0,0 +1,112 @@ +"""add destination_details gallery_items courses_config selector_config customize_submissions seo_tldr + +Revision ID: a3f9c1d8e256 +Revises: 2bcfcc62f578 +Create Date: 2026-03-20 21:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'a3f9c1d8e256' +down_revision: Union[str, None] = '2bcfcc62f578' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # SEO: add tldr column + op.add_column('seo_pages', sa.Column('tldr', sa.Text(), nullable=True)) + + # destination_details + op.create_table( + 'destination_details', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('slug', sa.String(length=200), nullable=False), + sa.Column('subtitle', sa.String(length=500), nullable=True), + sa.Column('cover_image', sa.String(length=500), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('location', sa.String(length=200), nullable=True), + sa.Column('best_season', sa.String(length=200), nullable=True), + sa.Column('duration', sa.String(length=100), nullable=True), + sa.Column('highlights', sa.JSON(), nullable=True), + sa.Column('gallery', sa.JSON(), nullable=True), + sa.Column('tags', sa.JSON(), nullable=True), + sa.Column('is_visible', sa.Boolean(), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('slug'), + ) + + # gallery_items + op.create_table( + 'gallery_items', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(length=200), nullable=True), + sa.Column('image', sa.String(length=500), nullable=False), + sa.Column('photographer', sa.String(length=100), nullable=True), + sa.Column('location', sa.String(length=200), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('tags', sa.JSON(), nullable=True), + sa.Column('is_visible', sa.Boolean(), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + + # courses_config + op.create_table( + 'courses_config', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('modules', sa.JSON(), nullable=True), + sa.Column('age_groups', sa.JSON(), nullable=True), + sa.Column('faqs', sa.JSON(), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + + # selector_config + op.create_table( + 'selector_config', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('questions', sa.JSON(), nullable=True), + sa.Column('rules', sa.JSON(), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + + # customize_submissions + op.create_table( + 'customize_submissions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=True), + sa.Column('phone', sa.String(length=50), nullable=True), + sa.Column('wechat', sa.String(length=100), nullable=True), + sa.Column('adults', sa.Integer(), nullable=True), + sa.Column('children', sa.Integer(), nullable=True), + sa.Column('travel_dates', sa.String(length=200), nullable=True), + sa.Column('budget', sa.String(length=100), nullable=True), + sa.Column('interests', sa.JSON(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('source', sa.String(length=100), nullable=True), + sa.Column('status', sa.Enum('pending', 'processed'), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + + +def downgrade() -> None: + op.drop_table('customize_submissions') + op.drop_table('selector_config') + op.drop_table('courses_config') + op.drop_table('gallery_items') + op.drop_table('destination_details') + op.drop_column('seo_pages', 'tldr') diff --git a/migrations/versions/b1e2f3a4c5d6_add_about_new_fields_brand_cta_customize_config.py b/migrations/versions/b1e2f3a4c5d6_add_about_new_fields_brand_cta_customize_config.py new file mode 100644 index 0000000..8fe604f --- /dev/null +++ b/migrations/versions/b1e2f3a4c5d6_add_about_new_fields_brand_cta_customize_config.py @@ -0,0 +1,154 @@ +"""add about new fields, brand cta buttons, customize config + +Revision ID: b1e2f3a4c5d6 +Revises: a3f9c1d8e256 +Create Date: 2026-03-21 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'b1e2f3a4c5d6' +down_revision: Union[str, None] = 'a3f9c1d8e256' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # --- about_story: add totem fields --- + op.add_column('about_story', sa.Column('totem_description', sa.Text(), nullable=True)) + op.add_column('about_story', sa.Column('totem_tagline', sa.String(length=500), nullable=True)) + + # --- about_culture: add core/trust/griefAward fields --- + op.add_column('about_culture', sa.Column('core', sa.String(length=200), nullable=True)) + op.add_column('about_culture', sa.Column('trust', sa.String(length=500), nullable=True)) + op.add_column('about_culture', sa.Column('grief_award_name', sa.String(length=100), nullable=True)) + op.add_column('about_culture', sa.Column('grief_award_description', sa.Text(), nullable=True)) + + # --- about_founder_detail --- + op.create_table( + 'about_founder_detail', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=True), + sa.Column('title', sa.String(length=200), nullable=True), + sa.Column('brand_founded', sa.Integer(), nullable=True), + sa.Column('years_in_hulunbuir', sa.String(length=50), nullable=True), + sa.Column('background', sa.Text(), nullable=True), + sa.Column('expertise', sa.JSON(), nullable=True), + sa.Column('media_presence', sa.JSON(), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + + # --- about_founders --- + op.create_table( + 'about_founders', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('title', sa.String(length=200), nullable=True), + sa.Column('story', sa.Text(), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + + # --- about_milestones --- + op.create_table( + 'about_milestones', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('year', sa.Integer(), nullable=False), + sa.Column('event', sa.Text(), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + + # --- about_service_principles --- + op.create_table( + 'about_service_principles', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('text', sa.Text(), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + + # --- about_differentiation --- + op.create_table( + 'about_differentiation', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('detail', sa.Text(), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + + # --- about_service_journey --- + op.create_table( + 'about_service_journey', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(length=200), nullable=True), + sa.Column('subtitle', sa.String(length=500), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + + # --- about_service_moments --- + op.create_table( + 'about_service_moments', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('journey_id', sa.Integer(), nullable=False), + sa.Column('step', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('detail', sa.Text(), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['journey_id'], ['about_service_journey.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + + # --- about_stats --- + op.create_table( + 'about_stats', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + + # --- brand: add cta_buttons, conversion_path --- + op.add_column('brand', sa.Column('cta_buttons', sa.JSON(), nullable=True)) + op.add_column('brand', sa.Column('conversion_path', sa.JSON(), nullable=True)) + + # --- customize_config --- + op.create_table( + 'customize_config', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('trust_stats', sa.JSON(), nullable=True), + sa.Column('durations', sa.JSON(), nullable=True), + sa.Column('activities', sa.JSON(), nullable=True), + sa.Column('budgets', sa.JSON(), nullable=True), + sa.Column('process', sa.JSON(), nullable=True), + sa.Column('contact', sa.JSON(), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + + +def downgrade() -> None: + op.drop_table('customize_config') + op.drop_column('brand', 'conversion_path') + op.drop_column('brand', 'cta_buttons') + op.drop_table('about_stats') + op.drop_table('about_service_moments') + op.drop_table('about_service_journey') + op.drop_table('about_differentiation') + op.drop_table('about_service_principles') + op.drop_table('about_milestones') + op.drop_table('about_founders') + op.drop_table('about_founder_detail') + op.drop_column('about_culture', 'grief_award_description') + op.drop_column('about_culture', 'grief_award_name') + op.drop_column('about_culture', 'trust') + op.drop_column('about_culture', 'core') + op.drop_column('about_story', 'totem_tagline') + op.drop_column('about_story', 'totem_description') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3a046e8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.36 +pymysql==1.1.1 +alembic==1.14.1 +pydantic==2.10.4 +pydantic-settings==2.7.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.20 +Pillow==11.1.0 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/create_admin_user.py b/scripts/create_admin_user.py new file mode 100644 index 0000000..5e66ee6 --- /dev/null +++ b/scripts/create_admin_user.py @@ -0,0 +1,41 @@ +from __future__ import annotations +"""创建初始管理员账号 +Usage: cd hulai-admin-api && python -m scripts.create_admin_user +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.database import SessionLocal, engine, Base +from app.models.user import AdminUser +from app.auth import hash_password + + +def main(): + # Create tables if not exist + Base.metadata.create_all(bind=engine) + + db = SessionLocal() + try: + existing = db.query(AdminUser).filter(AdminUser.username == "admin").first() + if existing: + print("管理员账号已存在") + return + + user = AdminUser( + username="admin", + password_hash=hash_password("admin123"), + display_name="管理员", + role="admin", + is_active=True, + ) + db.add(user) + db.commit() + print("管理员创建成功: admin / admin123") + print("⚠️ 请登录后立即修改密码!") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/import_json_data.py b/scripts/import_json_data.py new file mode 100644 index 0000000..a5c708b --- /dev/null +++ b/scripts/import_json_data.py @@ -0,0 +1,540 @@ +from __future__ import annotations +"""把前端 JSON 文件中的数据导入到后端数据库。 +UPSERT 逻辑:已存在则更新所有字段,不存在则插入。 +重新运行脚本即可将 JSON 的最新数据同步到数据库。 + +Usage: cd hulai-admin-api && python -m scripts.import_json_data +""" +import json +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.config import settings +from app.database import SessionLocal, engine, Base +from app.models import * + + +def load_json(filename): + path = os.path.join(settings.NUXT_DATA_PATH, filename) + if not os.path.exists(path): + print(f" ⚠️ 文件不存在: {path},跳过") + return None + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +# ─── Blog ──────────────────────────────────────────────────────────────────── + +def import_blog(db): + data = load_json("blog.json") + if not data: + return + articles = data.get("articles", []) + added = updated = 0 + for i, a in enumerate(articles): + slug = a.get("id") or a.get("slug") + if not slug: + print(f" ⚠️ 博文缺少 id/slug,跳过: {a.get('title', '')[:30]}") + continue + # 章节内容合并成 HTML + content_parts = [] + for ch in a.get("sections", []) or a.get("chapters", []): + if isinstance(ch, dict): + if ch.get("title"): + content_parts.append(f"

{ch['title']}

") + if ch.get("content"): + content_parts.append(ch["content"]) + if ch.get("body"): + content_parts.append(ch["body"]) + content = "\n".join(content_parts) if content_parts else a.get("content", "") + + fields = dict( + title=a["title"], + cover_image=a.get("coverImage") or a.get("cover_image"), + summary=a.get("summary") or a.get("subtitle"), + content=content, + author=a.get("author"), + category=a.get("category"), + tags=a.get("tags"), + published_at=a.get("date") or a.get("publishedAt"), + is_visible=True, + sort_order=i, + ) + + existing = db.query(Blog).filter_by(slug=slug).first() + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + updated += 1 + else: + db.add(Blog(slug=slug, **fields)) + added += 1 + print(f" blog_posts: 新增 {added} 条,更新 {updated} 条(共 {len(articles)} 条)") + + +# ─── Stories ───────────────────────────────────────────────────────────────── + +def import_stories(db): + data = load_json("stories.json") + if not data: + return + stories = data.get("stories", []) + added = updated = 0 + for i, s in enumerate(stories): + slug = s.get("slug") or s.get("id") + title = s.get("title", "") + # 章节内容合并 + content_parts = [] + for ch in s.get("chapters", []): + if isinstance(ch, dict): + if ch.get("title"): + content_parts.append(f"

{ch['title']}

") + body = ch.get("content") or ch.get("body", "") + if body: + content_parts.append(body) + content = "\n".join(content_parts) if content_parts else s.get("content", "") + + fields = dict( + title=title, + cover_image=s.get("coverImage") or s.get("cover_image"), + summary=s.get("summary") or s.get("subtitle"), + content=content, + customer_name=s.get("customerName") or s.get("familyType"), + avatar=s.get("avatar"), + travel_date=s.get("travelDate"), + product_name=s.get("product"), + scenes=s.get("tags"), + is_visible=True, + sort_order=i, + ) + + # 优先按 slug 匹配,无 slug 则按 title + existing = db.query(Story).filter_by(title=title).first() if title else None + + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + updated += 1 + else: + db.add(Story(**fields)) + added += 1 + print(f" stories: 新增 {added} 条,更新 {updated} 条(共 {len(stories)} 条)") + + +# ─── News ───────────────────────────────────────────────────────────────────── + +def import_news(db): + data = load_json("news.json") + if not data: + return + articles = data.get("articles", []) + added = updated = 0 + for i, a in enumerate(articles): + slug = a.get("slug") or a.get("id") + title = a.get("title", "") + + fields = dict( + title=title, + cover_image=a.get("coverImage") or a.get("cover_image"), + summary=a.get("summary"), + content=a.get("content", ""), + source=a.get("source"), + source_url=a.get("sourceUrl") or a.get("url"), + published_at=a.get("date") or a.get("publishedAt"), + is_visible=True, + sort_order=i, + ) + + existing = db.query(News).filter_by(title=title).first() if title else None + + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + updated += 1 + else: + db.add(News(**fields)) + added += 1 + print(f" news: 新增 {added} 条,更新 {updated} 条(共 {len(articles)} 条)") + + +# ─── Pricing ───────────────────────────────────────────────────────────────── + +def import_pricing(db): + data = load_json("pricing.json") + if not data: + return + products = data.get("products", []) + added = updated = 0 + for i, p in enumerate(products): + product_id = p.get("id") or p.get("slug") + + fields = dict( + product_name=p.get("name", ""), + price_from=None, + price_unit=None, + price_label=p.get("priceLabel"), + description=p.get("priceNote") or p.get("audience"), + features=p.get("highlights"), + notes=None, + is_visible=True, + sort_order=i, + ) + + existing = None + if product_id: + existing = db.query(PricingItem).filter_by(product_slug=product_id).first() + + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + updated += 1 + else: + db.add(PricingItem(product_slug=product_id, **fields)) + added += 1 + print(f" pricing_items: 新增 {added} 条,更新 {updated} 条(共 {len(products)} 条)") + + +# ─── Qualifications ────────────────────────────────────────────────────────── + +def import_qualifications(db): + data = load_json("qualifications.json") + if not data: + return + added = updated = 0 + sort = 0 + + def upsert_qual(name, **fields): + nonlocal added, updated, sort + existing = db.query(Qualification).filter_by(title=name).first() + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + updated += 1 + else: + db.add(Qualification(title=name, sort_order=sort, **fields)) + added += 1 + sort += 1 + + # 旅游经营许可证 + for lic in data.get("licenses", []): + title = lic.get("title", "旅游经营许可证") + upsert_qual( + title, + issuer=lic.get("issuer"), + year=None, + image=None, + description=( + f"证件号:{lic.get('licenseNo', '')}|" + f"持证主体:{lic.get('holder', '')}|" + f"法人:{lic.get('legalPerson', '')}|" + f"经营范围:{lic.get('businessScope', '')}|" + f"统一信用代码:{lic.get('creditCode', '')}|" + f"{lic.get('note', '')}" + ), + category="license", + ) + + # 旅游责任险 + insurance = data.get("insurance") + if insurance: + policies = insurance.get("policies", []) + policy_desc = ";".join([ + f"{p.get('insured','')}·{p.get('insurer','')}·保单号{p.get('policyNo','')}·保额{p.get('coverage','')}·有效期{p.get('period','')}" + for p in policies + ]) + upsert_qual( + insurance.get("title", "旅游责任险"), + issuer=policies[0].get("insurer") if policies else None, + year=None, + image=None, + description=f"{insurance.get('description', '')}|{policy_desc}", + category="insurance", + ) + + # 荣誉奖项 + for award in data.get("awards", []): + title = award.get("title", "") + if not title: + continue + upsert_qual( + title, + issuer=award.get("issuer") or award.get("platform"), + year=str(award.get("year", "")) if award.get("year") else None, + image=None, + description=award.get("description") or award.get("detail"), + category="award", + ) + + # 品牌传承 + heritage = data.get("heritage") + if heritage: + milestones_text = ";".join([ + f"{m.get('year')}年:{m.get('event', '')}" + for m in heritage.get("milestones", []) + ]) + upsert_qual( + f"品牌资历:{heritage.get('brandYears', '')}年深耕呼伦贝尔", + issuer=None, + year=str(heritage.get("brandFounded")) if heritage.get("brandFounded") else None, + image=None, + description=f"{heritage.get('note', '')}|{milestones_text}", + category="heritage", + ) + + print(f" qualifications: 新增 {added} 条,更新 {updated} 条") + + +# ─── Partners ──────────────────────────────────────────────────────────────── + +def import_partners(db): + data = load_json("partners.json") + if not data: + return + added = updated = 0 + sort = 0 + + cat_map = { + "govPartners": "政府合作", + "associations": "行业协会", + "academicCoops": "校企合作", + "scenicPartners": "景区合作", + "hotelPartners": "酒店合作", + "platformEndorsements": "平台认证", + "mediaReports": "媒体报道", + } + + for cat_key, cat_label in cat_map.items(): + for item in data.get(cat_key, []): + name = item.get("name") or item.get("platform") or item.get("outlet", "") + if not name or name.startswith("⚠️"): + continue + if not item.get("verified", True): + continue + # 构建描述 + desc_parts = [] + for field in ["type", "relationship", "description", "detail", "status", "summary"]: + val = item.get(field) + if val and not str(val).startswith("⚠️"): + desc_parts.append(str(val)) + if item.get("followers"): + desc_parts.append(f"粉丝:{item['followers']}") + if item.get("account"): + desc_parts.append(f"账号:{item['account']}") + if item.get("since"): + desc_parts.append(f"合作始于:{item['since']}") + + fields = dict( + logo=None, + website=item.get("url"), + description="|".join(desc_parts), + category=cat_label, + sort_order=sort, + ) + + existing = db.query(Partner).filter_by(name=name).first() + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + updated += 1 + else: + db.add(Partner(name=name, **fields)) + added += 1 + sort += 1 + + print(f" partners: 新增 {added} 条,更新 {updated} 条") + + +# ─── Gallery ───────────────────────────────────────────────────────────────── + +def import_gallery(db): + data = load_json("gallery.json") + if not data: + return + works = data.get("works", []) + added = updated = 0 + for i, w in enumerate(works): + title = w.get("title") + tags = [] + if w.get("category"): + tags.append(w["category"]) + if w.get("season"): + tags.append(w["season"]) + if w.get("costumeType"): + tags.append(w["costumeType"]) + + fields = dict( + image=w.get("image", ""), + photographer=None, + location=w.get("location"), + description=w.get("description"), + tags=tags if tags else None, + is_visible=True, + sort_order=i, + ) + + existing = db.query(GalleryItem).filter_by(title=title).first() if title else None + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + updated += 1 + else: + db.add(GalleryItem(title=title, **fields)) + added += 1 + print(f" gallery_items: 新增 {added} 条,更新 {updated} 条(共 {len(works)} 条)") + + +# ─── Destination Details ────────────────────────────────────────────────────── + +def import_destination_details(db): + data = load_json("destinations-detail.json") + if not data: + return + destinations = data.get("destinations", []) + added = updated = 0 + for i, d in enumerate(destinations): + slug = d.get("id", "") + if not slug: + continue + # highlights 可能是 string list 或 dict list + raw_highlights = d.get("highlights", []) + if raw_highlights and isinstance(raw_highlights[0], str): + highlights = [{"text": h} for h in raw_highlights] + else: + highlights = raw_highlights + tags = [] + if d.get("tag"): + tags.append(d["tag"]) + + fields = dict( + name=d.get("name", ""), + subtitle=d.get("subtitle"), + cover_image=d.get("heroImage") or d.get("coverImage"), + description=d.get("description", ""), + location=None, + best_season=( + d["bestSeason"].get("primary") if isinstance(d.get("bestSeason"), dict) + else d.get("bestSeason") + ), + duration=None, + highlights=highlights, + gallery=None, + tags=tags if tags else None, + is_visible=True, + sort_order=i, + ) + + existing = db.query(DestinationDetail).filter_by(slug=slug).first() + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + updated += 1 + else: + db.add(DestinationDetail(slug=slug, **fields)) + added += 1 + print(f" destination_details: 新增 {added} 条,更新 {updated} 条(共 {len(destinations)} 条)") + + +# ─── Selector Config ───────────────────────────────────────────────────────── + +def import_selector(db): + data = load_json("selector.json") + if not data: + return + rules = { + "scoring": data.get("scoring", {}), + "matchReasons": data.get("matchReasons", {}), + } + existing = db.query(SelectorConfig).first() + if existing: + existing.questions = data.get("questions", []) + existing.rules = rules + print(" selector_config: ✓ 已更新") + else: + db.add(SelectorConfig( + questions=data.get("questions", []), + rules=rules, + )) + print(" selector_config: ✓ 新增 1 条") + + +# ─── Courses Config ─────────────────────────────────────────────────────────── + +def import_courses(db): + data = load_json("courses.json") + if not data: + return + existing = db.query(CoursesConfig).first() + if existing: + existing.modules = data.get("modules", []) + existing.age_groups = data.get("ageGroups", []) + existing.faqs = data.get("faq", []) or data.get("faqs", []) + print(" courses_config: ✓ 已更新") + else: + db.add(CoursesConfig( + modules=data.get("modules", []), + age_groups=data.get("ageGroups", []), + faqs=data.get("faq", []) or data.get("faqs", []), + )) + print(" courses_config: ✓ 新增 1 条") + + +# ─── Main ───────────────────────────────────────────────────────────────────── + +def main(): + print("确保数据库表存在...") + Base.metadata.create_all(bind=engine) + + db = SessionLocal() + try: + print("\n开始 UPSERT JSON 数据到数据库:") + print("-" * 50) + import_blog(db) + import_stories(db) + import_news(db) + import_pricing(db) + import_qualifications(db) + import_partners(db) + import_gallery(db) + import_destination_details(db) + import_selector(db) + import_courses(db) + + db.commit() + print("-" * 50) + print("\n✓ 数据同步完成!") + + # 验证 + print("\n数据库记录数验证:") + from app.models import (Blog, Story, News, PricingItem, Qualification, + Partner, GalleryItem, DestinationDetail, + SelectorConfig, CoursesConfig) + checks = [ + ("blog_posts", Blog), + ("stories", Story), + ("news", News), + ("pricing_items", PricingItem), + ("qualifications", Qualification), + ("partners", Partner), + ("gallery_items", GalleryItem), + ("destination_details", DestinationDetail), + ("selector_config", SelectorConfig), + ("courses_config", CoursesConfig), + ] + for name, model in checks: + cnt = db.query(model).count() + status = "✓" if cnt > 0 else "⚠ 空" + print(f" {status} {name}: {cnt}") + + except Exception as e: + db.rollback() + print(f"\n✗ 导入失败: {e}") + import traceback + traceback.print_exc() + raise + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_json_to_db.py b/scripts/migrate_json_to_db.py new file mode 100644 index 0000000..e0f513a --- /dev/null +++ b/scripts/migrate_json_to_db.py @@ -0,0 +1,524 @@ +from __future__ import annotations +"""将现有 JSON 数据导入 MySQL +Usage: cd hulai-admin-api && python -m scripts.migrate_json_to_db +""" +import json +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.config import settings +from app.database import SessionLocal, engine, Base +from app.models import * + + +def load_json(filename): + path = os.path.join(settings.NUXT_DATA_PATH, filename) + if not os.path.exists(path): + print(f" ⚠️ 文件不存在: {path},跳过") + return None + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def migrate_brand(db): + data = load_json("brand.json") + if not data: + return + if db.query(Brand).first(): + print(" brand: 已存在,跳过") + return + + brand = Brand( + name=data["name"], full_name=data.get("fullName"), domain=data.get("domain"), + url=data.get("url"), slogan_emotional=data.get("slogan", {}).get("emotional"), + slogan_functional=data.get("slogan", {}).get("functional"), + icp_entity=data.get("icpEntity"), icp=data.get("icp"), + icp_url=data.get("icpUrl"), e_contract=data.get("eContract"), + ) + db.add(brand) + db.flush() + + for i, s in enumerate(data.get("trustStats", [])): + db.add(BrandTrustStat(brand_id=brand.id, value=s["value"], unit=s.get("unit"), label=s["label"], sort_order=i)) + for i, d in enumerate(data.get("differentiators", [])): + db.add(BrandDifferentiator(brand_id=brand.id, title=d["title"], description=d.get("description"), sort_order=i)) + print(" brand: ✓") + + +def migrate_products(db): + data = load_json("products.json") + if not data: + return + if db.query(ProductConfig).first(): + print(" products: 已存在,跳过") + return + + config = ProductConfig(narrative=data.get("narrative"), pricing_philosophy=data.get("pricingPhilosophy")) + db.add(config) + db.flush() + + for i, v in enumerate(data.get("versions", [])): + pv = ProductVersion( + config_id=config.id, version_id=v["id"], name=v["name"], + days=v["days"], nights=v["nights"], audience=v.get("audience"), + description=v.get("description"), tag=v.get("tag"), sort_order=i, + ) + db.add(pv) + db.flush() + for j, h in enumerate(v.get("highlights", [])): + db.add(ProductVersionHighlight(version_id=pv.id, text=h, sort_order=j)) + + camp_data = data.get("summerCamp") + if camp_data: + camp = SummerCamp( + config_id=config.id, name=camp_data.get("name"), + positioning=camp_data.get("positioning"), + days=camp_data.get("days"), nights=camp_data.get("nights"), + sessions_json=camp_data.get("sessions"), + difference_from_v9=camp_data.get("differenceFromV9"), + ) + db.add(camp) + db.flush() + for i, p in enumerate(camp_data.get("principles", [])): + db.add(SummerCampPrinciple(camp_id=camp.id, text=p, sort_order=i)) + for i, a in enumerate(camp_data.get("activities", [])): + db.add(SummerCampActivity(camp_id=camp.id, text=a, sort_order=i)) + for i, it in enumerate(camp_data.get("itinerary", [])): + db.add(SummerCampItinerary(camp_id=camp.id, text=it, sort_order=i)) + for i, f in enumerate(camp_data.get("faq", [])): + db.add(SummerCampFaq(camp_id=camp.id, question=f.get("q") or f.get("question", ""), answer=f.get("a") or f.get("answer", ""), sort_order=i)) + + guide_data = data.get("selectionGuide") + if guide_data: + db.add(SelectionGuide( + config_id=config.id, + by_vacation_length=guide_data.get("byVacationLength"), + by_child_age=guide_data.get("byChildAge"), + by_preference=guide_data.get("byPreference"), + )) + + print(" products: ✓") + + +def migrate_faq(db): + data = load_json("faq.json") + if not data: + return + if db.query(FaqCategory).first(): + print(" faq: 已存在,跳过") + return + + for i, cat in enumerate(data.get("categories", [])): + c = FaqCategory(category_id=cat["id"], name=cat["name"], sort_order=i) + db.add(c) + db.flush() + for j, q in enumerate(cat.get("questions", [])): + db.add(FaqQuestion( + category_id=c.id, question_id=q["id"], question=q["question"], + answer=q["answer"], related_links=q.get("relatedLinks"), sort_order=j, + )) + print(" faq: ✓") + + +def migrate_reviews(db): + data = load_json("reviews.json") + if not data: + return + if db.query(Review).first(): + print(" reviews: 已存在,跳过") + return + + summary = data.get("summary", {}) + db.add(ReviewSummary( + total_count=summary.get("totalCount", 0), + approval_rate=summary.get("approvalRate"), + keywords=summary.get("keywords"), + )) + + for i, r in enumerate(data.get("items", [])): + db.add(Review( + nickname=r["nickname"], travel_date=r.get("travelDate"), + product_version=r.get("productVersion"), screenshot=r.get("screenshot"), + content=r["content"], scenes=r.get("scenes"), concerns=r.get("concerns"), + sort_order=i, is_visible=True, + )) + print(" reviews: ✓") + + +def migrate_about(db): + data = load_json("about.json") + if not data: + return + if db.query(AboutStory).first(): + print(" about: 已存在,跳过") + return + + story = data.get("story", {}) + db.add(AboutStory(title=story.get("title"), content=story.get("content"), founding_moment=story.get("foundingMoment"))) + + for i, s in enumerate(data.get("subsidiaries", [])): + db.add(AboutSubsidiary(name=s["name"], role=s.get("role"), established=s.get("established"), sort_order=i)) + + for i, c in enumerate(data.get("certifications", [])): + db.add(AboutCertification(title=c["title"], detail=c.get("detail"), sort_order=i)) + + tm = data.get("trademark", {}) + if tm: + db.add(AboutTrademark(name=tm.get("name"), scope=tm.get("scope"), holder=tm.get("holder"), description=tm.get("description"))) + + for i, c in enumerate(data.get("copyrights", [])): + db.add(AboutCopyright( + name=c["name"], reg_no=c.get("regNo"), category=c.get("category"), + holder=c.get("holder"), date=c.get("date"), description=c.get("description"), sort_order=i, + )) + + for i, g in enumerate(data.get("guarantees", [])): + db.add(AboutGuarantee(title=g["title"], detail=g.get("detail"), sort_order=i)) + + xhs = data.get("xiaohongshu", {}) + if xhs: + db.add(AboutXiaohongshu( + account=xhs.get("account"), verified=xhs.get("verified", False), + verified_type=xhs.get("verifiedType"), followers=xhs.get("followers"), + likes=xhs.get("likes"), awards=xhs.get("awards"), tagline=xhs.get("tagline"), + tags=xhs.get("tags"), description=xhs.get("description"), + )) + + team = data.get("team", {}) + if team: + db.add(AboutTeam(summary=team.get("summary"))) + + culture = data.get("culture", {}) + if culture: + c = AboutCulture(transparency=culture.get("transparency")) + db.add(c) + db.flush() + for i, v in enumerate(culture.get("values", [])): + db.add(AboutCultureValue(culture_id=c.id, name=v["name"], expression=v.get("expression"), sort_order=i)) + + print(" about: ✓") + + +def migrate_contact(db): + data = load_json("contact.json") + if not data: + return + if db.query(ContactChannel).first(): + print(" contact: 已存在,跳过") + return + + for i, ch in enumerate(data.get("channels", [])): + db.add(ContactChannel( + type=ch["type"], label=ch["label"], value=ch.get("value"), + qr_image=ch.get("qrImage"), is_primary=ch.get("primary", False), + description=ch.get("description"), sort_order=i, + )) + + notice = data.get("securityNotice") + if notice: + db.add(ContactConfig(security_notice=notice)) + + print(" contact: ✓") + + +def migrate_seo(db): + data = load_json("seo.json") + if not data: + return + if db.query(SeoPage).first(): + print(" seo: 已存在,跳过") + return + + for key, page in data.get("pages", {}).items(): + db.add(SeoPage( + page_key=key, title=page.get("title"), description=page.get("description"), + h1=page.get("h1"), og_image=page.get("ogImage"), + )) + print(" seo: ✓") + + +def migrate_navigation(db): + data = load_json("navigation.json") + if not data: + return + if db.query(NavHeader).first(): + print(" navigation: 已存在,跳过") + return + + for i, h in enumerate(data.get("header", [])): + db.add(NavHeader(text=h["text"], to_path=h["to"], sort_order=i)) + + for i, g in enumerate(data.get("footer", [])): + group = NavFooterGroup(title=g["title"], sort_order=i) + db.add(group) + db.flush() + for j, l in enumerate(g.get("links", [])): + db.add(NavFooterLink(group_id=group.id, text=l["text"], to_path=l["to"], sort_order=j)) + + print(" navigation: ✓") + + +def migrate_versions(db): + data = load_json("versions.json") + if not data: + return + if db.query(VersionConfig).first(): + print(" versions: 已存在,跳过") + return + + stats = data.get("stats", {}) + quote = data.get("quote", {}) + config = VersionConfig( + stats_iterations=stats.get("iterations"), stats_years=stats.get("years"), + stats_guests=stats.get("guests"), + quote_text=quote.get("text") if quote else None, + quote_author=quote.get("author") if quote else None, + ) + db.add(config) + db.flush() + + for i, u in enumerate(data.get("upgrades2026", [])): + db.add(VersionUpgrade(config_id=config.id, tag=u.get("tag"), name=u.get("name"), description=u.get("description"), reason=u.get("reason"), sort_order=i)) + + for i, h in enumerate(data.get("highlights", [])): + db.add(VersionHighlight(config_id=config.id, label=h.get("label"), text=h.get("text"), sort_order=i)) + + cmp = data.get("compareV8V9", {}) + if cmp: + db.add(VersionCompare(config_id=config.id, headers=cmp.get("headers"), rows=cmp.get("rows"))) + + for i, t in enumerate(data.get("timeline", [])): + tl = VersionTimeline( + config_id=config.id, version=t["version"], date=t.get("date"), + title=t.get("title"), reason=t.get("reason"), sort_order=i, + ) + db.add(tl) + db.flush() + for j, ch in enumerate(t.get("changes", [])): + db.add(VersionTimelineChange(timeline_id=tl.id, type=ch["type"], text=ch["text"], sort_order=j)) + + for i, p in enumerate(data.get("philosophy", [])): + db.add(VersionPhilosophy(config_id=config.id, label=p.get("label"), text=p.get("text"), sort_order=i)) + + print(" versions: ✓") + + +def migrate_guides(db): + data = load_json("guides.json") + if not data: + return + if db.query(GuideConfig).first(): + print(" guides: 已存在,跳过") + return + + config = GuideConfig(page_intro=data.get("pageIntro")) + db.add(config) + db.flush() + + for i, s in enumerate(data.get("sections", [])): + # Extract common fields, store the rest as JSON data + common_keys = {"id", "title", "subtitle", "icon", "content"} + extra_data = {k: v for k, v in s.items() if k not in common_keys} + + db.add(GuideSection( + config_id=config.id, section_id=s["id"], title=s.get("title"), + subtitle=s.get("subtitle"), icon=s.get("icon"), content=s.get("content"), + data=extra_data if extra_data else None, sort_order=i, + )) + + print(" guides: ✓") + + +def migrate_images(db): + data = load_json("images.json") + if not data: + return + if db.query(SiteImage).first(): + print(" images: 已存在,跳过") + return + + # 图片元数据:label 和建议尺寸 + meta = { + ("logo", "main"): ("品牌Logo", "200x200px(正方形透明PNG)", "网站头部和底部的品牌标识"), + ("logo", "square"): ("方形Logo", "200x200px(正方形)", "社交媒体头像等场景"), + ("hero", "background"): ("首页大图", "1920x1080px(16:9横图)", "首页顶部全屏背景图"), + ("brandStory", "photo"): ("品牌故事配图", "800x600px(4:3横图)", "关于我们-品牌故事旁配图"), + ("mascot", "front"): ("IP形象正面", "560x760px(竖版透明PNG)", "关于我们-IP展示正面形象"), + ("mascot", "banner"): ("IP场景横幅", "1600x600px(宽横幅)", "关于我们-IP草原场景插画"), + ("mascot", "stickerQr"): ("表情包二维码", "320x320px(正方形)", "微信表情包扫码下载"), + ("mascot", "xiaomengma"): ("小蒙马IP", "400x400px(正方形透明PNG)", "夏令营相关页面的小蒙马形象"), + ("team", "guideGroup"): ("领队合影", "800x600px(4:3横图)", "团队介绍-领队团队合影"), + ("team", "routeSurvey"): ("踩线照片", "800x600px(4:3横图)", "团队介绍-线路踩线工作照"), + ("team", "guideTraining"): ("培训照片", "800x600px(4:3横图)", "团队介绍-领队培训照片"), + ("team", "ranchStore"): ("牧场团队", "800x600px(4:3横图)", "团队介绍-牧场商贸团队照"), + ("xiaohongshu", "storefront"): ("小红书店铺", "1200x400px(3:1横幅)", "关于我们-小红书店铺展示横幅"), + ("summerCamp", "river"): ("夏令营河景", "800x600px(4:3横图)", "夏令营页面-河景团照"), + ("summerCamp", "grassland"): ("夏令营草原", "800x600px(4:3横图)", "夏令营页面-草原活动照"), + ("summerCamp", "birch"): ("夏令营白桦林", "800x600px(4:3横图)", "夏令营页面-白桦林研学照"), + ("summerCamp", "graduation"): ("夏令营结营", "800x600px(4:3横图)", "夏令营页面-结营合影"), + } + + for group_name, items in data.items(): + if isinstance(items, dict): + for key, path in items.items(): + m = meta.get((group_name, key), (f"{group_name}.{key}", "", "")) + db.add(SiteImage( + group_name=group_name, image_key=key, image_path=path, + label=m[0], size_hint=m[1], description=m[2], + )) + + print(" images: ✓") + + +def _migrate_seasonal(db, season, filename): + data = load_json(filename) + if not data: + return + if db.query(SeasonalProductConfig).filter_by(season=season).first(): + print(f" {filename}: 已存在,跳过") + return + + config = SeasonalProductConfig( + season=season, narrative=data.get("narrative"), style=data.get("style"), + ) + db.add(config) + db.flush() + + for i, v in enumerate(data.get("versions", [])): + db.add(SeasonalProductVersion( + season=season, version_id=v["id"], name=v["name"], + days=v["days"], nights=v["nights"], tag=v.get("tag"), + line=v.get("line"), route=v.get("route"), audience=v.get("audience"), + description=v.get("description"), highlights=v.get("highlights", []), + itinerary=v.get("itinerary", []), sort_order=i, + )) + + for cat_key in ["highlights", "southHighlights", "northHighlights"]: + cat_map = {"highlights": "shared", "southHighlights": "south", "northHighlights": "north"} + cat = cat_map[cat_key] + for i, h in enumerate(data.get(cat_key, [])): + db.add(SeasonalProductHighlight( + season=season, category=cat, title=h["title"], + description=h.get("description"), sort_order=i, + )) + + for i, t in enumerate(data.get("timeline", [])): + db.add(SeasonalProductTimeline( + season=season, version=t.get("version"), date=t.get("date"), + title=t.get("title"), changes=t.get("changes", []), + reason=t.get("reason"), sort_order=i, + )) + + print(f" {filename}: ✓") + + +def migrate_autumn_products(db): + _migrate_seasonal(db, "autumn", "autumn-products.json") + + +def migrate_winter_products(db): + _migrate_seasonal(db, "winter", "winter-products.json") + + +def migrate_winter_camp(db): + data = load_json("winter-camp.json") + if not data: + return + if db.query(WinterCampConfig).first(): + print(" winter-camp: 已存在,跳过") + return + + config = WinterCampConfig( + name=data.get("name"), positioning=data.get("positioning"), + days=data.get("days"), nights=data.get("nights"), + max_families=data.get("maxFamilies"), total_sessions=data.get("totalSessions"), + age_range=data.get("ageRange"), deposit=data.get("deposit"), + season=data.get("season"), route=data.get("route"), + why_hulunbuir=data.get("whyHulunbuir"), closing_note=data.get("closingNote"), + photographer=data.get("photographer"), winter_clothing=data.get("winterClothing"), + camp_advantages=data.get("campAdvantages", []), + service_config=data.get("serviceConfig", []), + camp_essentials=data.get("campEssentials", []), + ) + db.add(config) + db.flush() + + for i, h in enumerate(data.get("hotels", [])): + db.add(WinterCampHotel(name=h["name"], star=h.get("star"), nights=h.get("nights"), description=h.get("description"), sort_order=i)) + + for i, d in enumerate(data.get("itinerary", [])): + db.add(WinterCampItinerary(day=d["day"], title=d.get("title"), summary=d.get("summary"), highlights=d.get("highlights", []), hotel=d.get("hotel"), sort_order=i)) + + for i, f in enumerate(data.get("faq", [])): + db.add(WinterCampFaq(question=f["q"], answer=f["a"], sort_order=i)) + + print(" winter-camp: ✓") + + +def migrate_destinations(db): + data = load_json("destinations.json") + if not data: + return + if db.query(DestinationConfig).first(): + print(" destinations: 已存在,跳过") + return + + closing = data.get("closing", {}) + honest = data.get("honestNote", {}) + config = DestinationConfig( + title=data.get("title"), subtitle=data.get("subtitle"), intro=data.get("intro"), + closing_title=closing.get("title"), closing_text=closing.get("text"), + data_sources=data.get("dataSources"), + honest_title=honest.get("title"), honest_subtitle=honest.get("subtitle"), + ) + db.add(config) + db.flush() + + for i, d in enumerate(data.get("destinations", [])): + db.add(DestinationItem(dest_id=d.get("id", f"dest-{i}"), name=d["name"], tag=d.get("tag"), highlight=d.get("highlight", False), sort_order=i)) + + for i, dim in enumerate(data.get("dimensions", [])): + db.add(DestinationDimension(label=dim["label"], icon=dim.get("icon"), values=dim.get("values", []), sort_order=i)) + + for i, h in enumerate(honest.get("items", [])): + db.add(DestinationHonestItem(text=h, sort_order=i)) + + print(" destinations: ✓") + + +def main(): + print("创建数据库表...") + Base.metadata.create_all(bind=engine) + + db = SessionLocal() + try: + print("\n开始迁移数据:") + migrate_brand(db) + migrate_products(db) + migrate_faq(db) + migrate_reviews(db) + migrate_about(db) + migrate_contact(db) + migrate_seo(db) + migrate_navigation(db) + migrate_versions(db) + migrate_guides(db) + migrate_images(db) + migrate_autumn_products(db) + migrate_winter_products(db) + migrate_winter_camp(db) + migrate_destinations(db) + + db.commit() + print("\n✓ 数据迁移完成!") + except Exception as e: + db.rollback() + print(f"\n✗ 迁移失败: {e}") + raise + finally: + db.close() + + +if __name__ == "__main__": + main()